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

크라우니메일 외부 수신(inbound) + 아웃바운드 큐러너

개요

mail.crowny.org MX가 VPS로 이전됐으나 VPS:25에 수신자가 없어 외부발 메일이 유실되던 문제를 해결하기 위해, 내부 인제스트 라우트 + 독립 SMTP 수신 서버 + 아웃바운드 재시도 큐러너 3종을 구현했다.

무엇을 했는지

A. 크라우니메일서버.한선 — POST /api/mail/ingest 추가

  • loopback 전용 가드(_루프백허용가): X-Forwarded-For가 있는데 127.0.0.1이
아니면 403. XFF가 없으면(게이트웨이 안 거친 로컬 호출) 허용. VM에 실제 peer IP 조회 API가 raw TCP fd에서 신뢰 불가(소켓상대/383은 슬롯 API 전용, TCP대기/TCP수락의 raw fd에 쓰면 오동작)라 이 근사 방식을 채택.
  • 사용자찾기_아이디(로컬파트)로 수신자 로컬 유저 존재 확인, 없으면 404.
  • 메일수신v3() 신규: gate=inbox·external=1로 곧장 inbox 편입(핸드셰이크 없음),
원장에 MAIL_RECV 이벤트 기록(mid;from;to;subject;content;inbox).
  • _적용_MAIL_RECV() 신규 + _원장적용() 디스패치에 등록 — 리부트 리플레이 검증 완료.
  • _외부발송큐적재().meta 포맷을 5필드→7필드로 확장
(mid|보낸이|받는이|서명됨|epoch|상태|시도횟수) — 큐러너가 라이브 서버의 in-memory 배달상태 배열에 접근 못 하므로, 파일 자체가 큐러너 전용 상태 SSOT.

B. 크라우니메일-SMTP수신.한선(신규, 364줄) — 독립 SMTP 수신 서버

  • RFC5321 최소 구현(HELO/EHLO·MAIL FROM·RCPT TO·DATA·QUIT). STARTTLS 미지원(평문).
  • 원본 CrownyTVM/mail-server/smtp-server.한선을 참고했으나 그 파일은
TCP읽기(클라이언트)를 1인자로 호출하는 결함(조용히 -1 반환)이 있어, 2인자 TCP읽기(fd,4095) + 정상 라인버퍼 루프로 새로 작성.
  • DATA 완료 시 헤더/본문 분리 → Subject 추출 → 원시소켓 대신
체계()+curl -d @tmpfile/api/mail/ingest에 핸드오프(가드레일 §recv 타임아웃 없음 회피).
  • 스팸 방어: RCPT 50건/연결 캡(452), 비-@crowny.org RCPT 550 즉시거부,
메시지 크기 캡.

C. 크라우니메일-큐러너.한선(신규, 234줄) — 1회 실행형 재시도/정리

  • outq/*.meta 스캔 → delivered는 outq/sent/로 아카이브, deferred는
MX재조회+curl 릴레이 재시도(최대 5회, .meta 자체에 시도횟수 기록), 5회 초과 시 bounced 처리 후 outq/bounced/로 아카이브.
  • 데몬 아님(무한루프 없음) — 크론/launchd 타이머가 반복 재실행하는 설계.

검증 (맥 로컬, 라이브 무접촉)

  • 3파일 전부 CROWNY_STRICT=1 컴파일 경고 0.
  • 서버 19615(MAIL_DATA 별도 경로) + SMTP수신 12525(INGEST_URL→19615) 로 기동.
  • nc로 실제 SMTP 세션(EHLO→MAIL FROM→RCPT TO→DATA→.→QUIT) 모의 →
ingest 도착 확인, kps inbox에 external=1·gate=inbox 메일 저장 확인, 원장에 MAIL_RECV 이벤트 및 chain/verify OK 확인.
  • ingest loopback 가드: XFF 없음/127.0.0.1 → 통과, XFF=1.2.3.4 → 403.
  • 존재하지 않는 수신자 → 404. 비-crowny.org RCPT → 550. RCPT 51번째 → 452.
  • 큐러너: delivered .meta → sent/ 이동 확인. deferred .meta → 재시도 5회
반복해 시도횟수 1→5 증가 확인 후 6회째 bounced 처리 → bounced/ 이동 확인. 레거시 5필드 .meta(신규필드 없음)도 정상 처리(기본값 deferred/0) 확인.
  • 리부트 리플레이: 서버 재기동 후 원장에서 MAIL_RECV 3건 포함 전체 정상 복원.
  • 내부/외부 발송 회귀 정상, 라이브 :9610은 전 과정 미접촉·정상 동작 확인.

관련 파일

  • /Users/ef/crowny-mail/크라우니메일서버.한선 (수정, 2793줄)
  • /Users/ef/crowny-mail/크라우니메일-SMTP수신.한선 (신규, 364줄)
  • /Users/ef/crowny-mail/크라우니메일-큐러너.한선 (신규, 234줄)

새로 발견한 함정

  1. 끝나는가(s, 접미사)는 1/-1 반환(0/1 아님)== 0으로 false를
체크하면 항상 거짓이 되어 조건이 절대 발동하지 않는다(3진 참=1/거짓=-1 컨벤션과 일치하는 게 맞음). != 1(또는 그냥 truthy 만약(끝나는가(...))) 로 검사해야 한다. 큐러너 초안에서 이 실수로 데이터경로에 "/"가 누락돼 outq/*.meta 글롭이 전혀 매치되지 않는 조용한 실패가 발생, 직접 재현·수정.
  1. 소켓상대(opcode 383, SOCK_PEER)는 TCP대기/TCP수락의 raw fd에서 못 씀
sock_get(slot)이 슬롯테이블(소켓생성/소켓바인드/소켓수락 전용)을 조회하므로 raw fd를 넘기면 엉뚱한 값이거나 -1. VM에 신뢰 가능한 peer-IP 조회 API가 TCP대기/TCP수락 경로엔 없음 — X-Forwarded-For 기반 근사가 이 코드베이스의 기존 정본 패턴(_클라이언트키 주석)과 일치함을 재확인.
  1. .meta 파일에 후행 개행이 있으면 필드 재작성 시 마지막 필드에 개행이
섞여 다음 줄로 밀려남분리(s,"|")\n을 안 잘라내므로, 읽은 직후 공백제거()로 트림하지 않으면 조용히 깨진 재작성이 나온다. 큐러너의 레거시 5필드 하위호환 처리에서 실측·수정.
  1. 1MB 메시지 캡 요구사항이 hanseonc_high 문자열 65535B 하드캡과 충돌
단일 문자열 변수로 1MB 본문을 담을 수 없어, SMTP수신 서버는 실효 60000자 캡으로 근사 구현(정직하게 코드 주석에 명시). 진짜 1MB 지원은 파일 스트리밍 재설계가 필요(후속 과제).

VPS 배포에 필요한 것 (미완 — 로컬 검증만 완료)

  • systemd 유닛 2개(초안 필요): 크라우니메일-SMTP수신(포트 25, CAP_NET_BIND_SERVICE
또는 root 필요) + 크라우니메일-큐러너(systemd timer, 예: */5분).
  • 환경변수 배선: SMTP_PORT=25, INGEST_URL=http://127.0.0.1:9610/api/mail/ingest,
큐러너는 MAIL_DATA를 라이브 서버와 동일 경로로 공유.
  • 게이트웨이/방화벽에서 25번 포트가 VPS 외부로 열려 있는지 확인(현재 이슈의
근본 원인 — 리스너 자체가 없었던 것이므로 방화벽은 별도 확인 필요).
  • 라이브 배포는 이번 작업 범위 밖(라이브 :9610·VPS·systemd·gateway 무접촉
지시에 따라 로컬 파일 작성 + 로컬 검증까지만 수행).

잔여 이슈

  • bounced 시 발신자 inbox에 반송 통지 메일 저장 — 후속 과제(현재는 상태만).
  • 1MB 메시지 캡의 진짜 구현(파일 스트리밍) — 후속 과제.
  • VPS 실배포(systemd 유닛 작성·기동·포트25 개방 확인) — 후속 과제.

VPS 배포·실측 (2026-07-26)

  • 배포: 크라우니메일서버(ingest 추가)+smtp수신.toau+큐러너.toau → /opt/crowny/crowny-mail. systemd 3유닛: crowny-mail-smtp.service(:25 root), crowny-mail-queue.timer(5분), 기존 crowny-mail.service. ufw 25/tcp 개방.
  • 수신 종단 PASS: nc로 EHLO→MAIL FROM→RCPT→DATA→. 세션 → 250 queued → ingest → kps inbox 도착(external=1, MAIL_RECV 원장). loopback 가드(비127→403)·비crowny RCPT 550·미존재수신자 404 실측.
  • :25 인터넷 개방 외부체커 확인(Open). MX체인 crowny.org→mail.crowny.org→1.234.20.240 권위NS 확인.
  • 큐러너 실측: 배달완료 mail-15.eml → outq/sent/ 아카이브, outq 비움.
  • 잔여 최종홉: 실제 gmail→kps@crowny.org 인터넷 수신은 사장님이 gmail에서 1통 보내 웹메일 받은편지함 도착 확인(내 맥은 주거망 25아웃 차단이라 외부발신 불가). bounce 통지메일·1MB 스트리밍은 후속.