크라우니메일 외부 발송 — 자립 직접전송(MX-direct) + DKIM 구현
개요
/Users/ef/crowny-mail/크라우니메일서버.한선 (v3, :9610)의 외부(비-crowny.org) 메일 발송이
기존에는 sent:true만 거짓 반환하고 실제 릴레이를 하지 않던 버그를 수정.
사용자 결정("자립 직접전송")대로 VPS에서 수신자 MX로 직접 SMTP 연결(curl 위임, STARTTLS
기회적) + DKIM 서명(셀렉터 crowny, RSA-SHA256, relaxed/relaxed)을 구현했다.
무엇을 했는지
- 외부 발송 감지 + 큐 분기:
POST /api/mail/send에서_크라우니여부(받는이)==0이면
_외부발송큐적재() 호출로 분기.
- 아웃바운드 파일 큐:
{MAIL_DATA}outq/<mid>.eml+<mid>.meta(flat 파일명, 가드레일
mkdir -p 선행(쓰기() silent fail 함정 회피).
- RFC822 조립: From/To/Subject/Date(RFC5322,
LC_TIME=C강제 — 시스템 로케일이 ko_KR라
date가 한글 요일/월을 내는 실측 버그를 발견해 수정)/Message-ID/MIME 헤더 + 원본 본문
(CRLF 정규화만, whitespace 보존).
- DKIM 서명: relaxed 헤더/본문 정규화는 순수 한선씨(전체치환/분리/오른공백제거 등)로 구현
체계("openssl dgst ...")로
위임(작업지시 §2 명시 허용 범위).
- MX 조회 + 릴레이:
체계("dig +short mx ...")→ `curl --url 'smtp://<mx>:25' --mail-from
- 배달상태 원장화: 신규 원장 이벤트
DELIVER_STATUS(mid;상태;코드;상세) + 리플레이 핸들러
GET /api/mail/deliverystatus?id=X.
- 응답 계약 수정: 외부 발송은
{"sent":false,"queued":true,"external":true}만 반환.
sent:true 거짓 반환 완전 제거. 내부 발송 응답은 무변경(회귀 없음, 실측 확인).
- 안전장치(작업지시를 넘어 추가):
MAIL_ALLOW_RELAY=1 환경변수 게이트 — 이게 없으면 DKIM 키가 있어도 실제 네트워크 릴레이를
시도하지 않고 큐 적재만(deferred). 로컬/개발 환경에서 실수로 진짜 메일이 나가는 사고 방지.
- _쉘안전() — 발신자/수신자/MX호스트/파일경로를 모두 셸 단일따옴표 이스케이프 후 체계()에
삽입(사용자 입력 또는 외부 DNS 응답을 통한 셸 인젝션 방지).
- _안전호스트검사() — MX 레코드로 받은 호스트명이 영숫자/점/하이픈만인지 검증 후에만 셸
명령에 사용(악성 DNS 응답 방어).DKIM 검증 방법 (실측)
로컬 openssl로 발신자와 무관하게 독립 재현·검증:
.eml에서 bh= 추출 → 별도로 relaxed 본문(트레일링 공백 제거+내부 CRLF, 말미 빈 줄 제거)을
openssl dgst -sha256 -binary | openssl base64 -A → 정확히 일치.
.eml헤더로 서명 base(h=from:to:subject:date:message-id + dkim-signature, 마지막 줄
즉 이 구현이 만든 DKIM-Signature는 진짜 RFC6376 relaxed/relaxed 서명으로 암호학적으로 유효함을 로컬에서 실측 검증했다(테스트용 기존 키 `/Users/ef/Downloads/CrownyTVM/mail-data/dkim/ crowny.org.private.pem` 사용 — VPS 배치 전 검증 용도로만, 실제 서비스 키 아님).
관련 파일
/Users/ef/crowny-mail/크라우니메일서버.한선— 대상 서버(수정본, 2377→2720줄, 함수 19개 신규)- 참조(재사용 개념만, 직접 import는 안 함 — hanseonc_high는 이 파일이 self-contained):
/Users/ef/Downloads/CrownyTVM/mail-server/{smtp-client,dkim,queue,mime}.한선
- 컴파일 산출:
/tmp/메일v3.toau(CROWNY_STRICT=1 경고 0) - 가드레일:
/Users/ef/.claude/templates/한선씨-가드레일.md
잔여 이슈
- VPS 활성화 전 필요 작업(사장님/메인세션 몫):
/var/crowny/mail/dkim/crowny.org.private.pem에 배치(scp) — 없으면
서명 생략 + deferred로만 표시, 실제 발신 안 함.
- mkdir -p /var/crowny/mail/dkim /var/crowny/mail/outq(코드가 부팅 시 자동 생성하지만
상위 디렉토리 권한/소유자 확인 필요할 수 있음).
- LaunchAgent/systemd 환경변수에 MAIL_ALLOW_RELAY=1 명시(없으면 큐 적재만, 영구 deferred).
- DNS TXT 레코드는 이미 게시되어 있다고 전제(작업지시 §2) — 미게시 시 실제 DKIM 검증은
수신측에서 실패(neutral/none)함. 확인 필요.
- 재시도 루프 없음(설계상 의도, 후속 과제): 1회 즉시 시도 후 deferred/bounced는 큐 파일에
- SMTP 응답 분류는 텍스트 휴리스틱(포함() 3자리 코드 검색) — curl 에러 텍스트 포맷이 버전에
_JSON필드/메일상세v3가 JSON 문자열 이스케이프를 안 함(전부터 있던 기존 특성, 이번
/api/mail/read·/api/mail/list 응답이
깨진 JSON을 낸다(실측 재현). 정상 브라우저 클라이언트는 JSON.stringify가 \n을 이스케이프해
보내므로 실사용 경로에서는 잘 안 걸리지만, 이 파일 전체(내가 손대지 않은 기존 코드 포함)에
공통된 gap이라 별도 과제로 남김 — 이번 작업 범위 밖.
- Gmail 등 실제 외부 MX로의 실전송은 이 세션(맥)에서 시도하지 않았음(작업지시 명시 금지 +
MAIL_ALLOW_RELAY 게이트가 로컬에서 항상 0이라 코드 자체도 실제로 못 나감). VPS에서
MAIL_ALLOW_RELAY=1 + 진짜 키 배치 후 실주소로 1건 실지시험 필요.검증 결과 요약
- CROWNY_STRICT=1 컴파일 경고 0.
- 내부 발송(admin→kim@crowny.org):
{"id":"mail-2","sent":true}— 기존과 동일, 큐 미개입. - 외부 발송(admin→test@example.com 등):
{"sent":false,"queued":true,"external":true}—
outq/<mid>.eml+.meta 생성 확인, DKIM-Signature 헤더 실제로 openssl 재검증 통과.
- 재부팅 리플레이: 킬 후 재기동 → 원장 10건 리플레이, DELIVER_STATUS 포함 정상 복원,
- 레이트리밋: 기존
/api/mail/send20/분 게이트가 모드 분기보다 앞에 있어 외부 발송도 동일
- 라이브 :9610(PID 22312)/게이트웨이/pages/DNS 전부 무접촉 — 별도 임시 디렉토리
/tmp/mail-smtp-test, MAIL_DATA/MAIL_DKIM_DIR env override)에서만 실행·검증.크라우니코드: lookup 0건(MISS, 신규 도메인) / MISS 19건(신규 함수 생성) / learn 추가 1건
(fn_메일_아웃바운드릴레이_MX직접).