← 목록
기타 2026-07-25 7KB 읽기 7분

크라우니메일 외부 발송 — 자립 직접전송(MX-direct) + DKIM 구현

개요

/Users/ef/crowny-mail/크라우니메일서버.한선 (v3, :9610)의 외부(비-crowny.org) 메일 발송이 기존에는 sent:true만 거짓 반환하고 실제 릴레이를 하지 않던 버그를 수정. 사용자 결정("자립 직접전송")대로 VPS에서 수신자 MX로 직접 SMTP 연결(curl 위임, STARTTLS 기회적) + DKIM 서명(셀렉터 crowny, RSA-SHA256, relaxed/relaxed)을 구현했다.

무엇을 했는지

  1. 외부 발송 감지 + 큐 분기: POST /api/mail/send에서 _크라우니여부(받는이)==0이면
받는이측 가짜 inbox 레코드를 만들지 않고 "sent" 사본 1건 + _외부발송큐적재() 호출로 분기.
  1. 아웃바운드 파일 큐: {MAIL_DATA}outq/<mid>.eml + <mid>.meta (flat 파일명, 가드레일
준수). 부팅 시 mkdir -p 선행(쓰기() silent fail 함정 회피).
  1. RFC822 조립: From/To/Subject/Date(RFC5322, LC_TIME=C 강제 — 시스템 로케일이 ko_KR라
기본 date가 한글 요일/월을 내는 실측 버그를 발견해 수정)/Message-ID/MIME 헤더 + 원본 본문 (CRLF 정규화만, whitespace 보존).
  1. DKIM 서명: relaxed 헤더/본문 정규화는 순수 한선씨(전체치환/분리/오른공백제거 등)로 구현
— 셸 이스케이프 리스크를 피하려 정규화 로직 자체는 셸에 위임하지 않음. SHA256 본문해시와 RSA 서명(둘 다 raw 바이트 연산이 필요해 순수 한선씨로 불가)만 체계("openssl dgst ...")로 위임(작업지시 §2 명시 허용 범위).
  1. MX 조회 + 릴레이: 체계("dig +short mx ...") → `curl --url 'smtp://<mx>:25' --mail-from
--mail-rcpt --upload-file --ssl`(원시소켓 금지 가드레일 준수). 응답 텍스트+exit코드로 delivered(250)/deferred(4xx·연결실패·MX없음)/bounced(5xx) 판정.
  1. 배달상태 원장화: 신규 원장 이벤트 DELIVER_STATUS(mid;상태;코드;상세) + 리플레이 핸들러
+ GET /api/mail/deliverystatus?id=X.
  1. 응답 계약 수정: 외부 발송은 {"sent":false,"queued":true,"external":true}만 반환.
sent:true 거짓 반환 완전 제거. 내부 발송 응답은 무변경(회귀 없음, 실측 확인).
  1. 안전장치(작업지시를 넘어 추가):
- MAIL_ALLOW_RELAY=1 환경변수 게이트 — 이게 없으면 DKIM 키가 있어도 실제 네트워크 릴레이를 시도하지 않고 큐 적재만(deferred). 로컬/개발 환경에서 실수로 진짜 메일이 나가는 사고 방지. - _쉘안전() — 발신자/수신자/MX호스트/파일경로를 모두 셸 단일따옴표 이스케이프 후 체계()에 삽입(사용자 입력 또는 외부 DNS 응답을 통한 셸 인젝션 방지). - _안전호스트검사() — MX 레코드로 받은 호스트명이 영숫자/점/하이픈만인지 검증 후에만 셸 명령에 사용(악성 DNS 응답 방어).

DKIM 검증 방법 (실측)

로컬 openssl로 발신자와 무관하게 독립 재현·검증:

  1. .eml에서 bh= 추출 → 별도로 relaxed 본문(트레일링 공백 제거+내부 CRLF, 말미 빈 줄 제거)을
손으로 재구성해 openssl dgst -sha256 -binary | openssl base64 -A정확히 일치.
  1. .eml 헤더로 서명 base(h=from:to:subject:date:message-id + dkim-signature, 마지막 줄
트레일링 CRLF 없음)를 손으로 재구성 → `openssl dgst -sha256 -verify <공개키> -signature <b64디코딩> <base>` → "Verified OK".

즉 이 구현이 만든 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

잔여 이슈

  1. VPS 활성화 전 필요 작업(사장님/메인세션 몫):
- DKIM 개인키를 /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. 재시도 루프 없음(설계상 의도, 후속 과제): 1회 즉시 시도 후 deferred/bounced는 큐 파일에
남을 뿐 자동 재시도하는 크론/타이머가 아직 없음.
  1. SMTP 응답 분류는 텍스트 휴리스틱(포함() 3자리 코드 검색) — curl 에러 텍스트 포맷이 버전에
따라 다를 수 있어 100% 정밀하지 않음. exit=0이면 확실히 delivered, 그 외는 근사 분류.
  1. _JSON필드/메일상세v3가 JSON 문자열 이스케이프를 안 함(전부터 있던 기존 특성, 이번
작업으로 발견) — 메일 본문에 raw 개행이 들어가면 /api/mail/read·/api/mail/list 응답이 깨진 JSON을 낸다(실측 재현). 정상 브라우저 클라이언트는 JSON.stringify가 \n을 이스케이프해 보내므로 실사용 경로에서는 잘 안 걸리지만, 이 파일 전체(내가 손대지 않은 기존 코드 포함)에 공통된 gap이라 별도 과제로 남김 — 이번 작업 범위 밖.
  1. 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 포함 정상 복원,
outq 파일도 파일 영속(디스크에 그대로 남음) 확인.
  • 레이트리밋: 기존 /api/mail/send 20/분 게이트가 모드 분기보다 앞에 있어 외부 발송도 동일
적용됨을 코드 확인(런타임 부하테스트는 생략).
  • 라이브 :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직접).