크라우니메일 외부 수신(inbound) + 아웃바운드 큐러너
개요
mail.crowny.org MX가 VPS로 이전됐으나 VPS:25에 수신자가 없어 외부발 메일이 유실되던 문제를 해결하기 위해, 내부 인제스트 라우트 + 독립 SMTP 수신 서버 + 아웃바운드 재시도 큐러너 3종을 구현했다.
무엇을 했는지
A. 크라우니메일서버.한선 — POST /api/mail/ingest 추가
- loopback 전용 가드(
_루프백허용가): X-Forwarded-For가 있는데 127.0.0.1이
사용자찾기_아이디(로컬파트)로 수신자 로컬 유저 존재 확인, 없으면 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는
.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) 모의 →
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회
- 리부트 리플레이: 서버 재기동 후 원장에서 MAIL_RECV 3건 포함 전체 정상 복원.
- 내부/외부 발송 회귀 정상, 라이브 :9610은 전 과정 미접촉·정상 동작 확인.
관련 파일
/Users/ef/crowny-mail/크라우니메일서버.한선(수정, 2793줄)/Users/ef/crowny-mail/크라우니메일-SMTP수신.한선(신규, 364줄)/Users/ef/crowny-mail/크라우니메일-큐러너.한선(신규, 234줄)
새로 발견한 함정
끝나는가(s, 접미사)는 1/-1 반환(0/1 아님) —== 0으로 false를
!= 1(또는 그냥 truthy 만약(끝나는가(...)))
로 검사해야 한다. 큐러너 초안에서 이 실수로 데이터경로에 "/"가 누락돼
outq/*.meta 글롭이 전혀 매치되지 않는 조용한 실패가 발생, 직접 재현·수정.
소켓상대(opcode 383, SOCK_PEER)는 TCP대기/TCP수락의 raw fd에서 못 씀 —
sock_get(slot)이 슬롯테이블(소켓생성/소켓바인드/소켓수락 전용)을 조회하므로
raw fd를 넘기면 엉뚱한 값이거나 -1. VM에 신뢰 가능한 peer-IP 조회 API가
TCP대기/TCP수락 경로엔 없음 — X-Forwarded-For 기반 근사가 이 코드베이스의
기존 정본 패턴(_클라이언트키 주석)과 일치함을 재확인.
.meta파일에 후행 개행이 있으면 필드 재작성 시 마지막 필드에 개행이
분리(s,"|")는 \n을 안 잘라내므로, 읽은 직후
공백제거()로 트림하지 않으면 조용히 깨진 재작성이 나온다. 큐러너의
레거시 5필드 하위호환 처리에서 실측·수정.
- 1MB 메시지 캡 요구사항이 hanseonc_high 문자열 65535B 하드캡과 충돌 —
VPS 배포에 필요한 것 (미완 — 로컬 검증만 완료)
- systemd 유닛 2개(초안 필요): 크라우니메일-SMTP수신(포트 25,
CAP_NET_BIND_SERVICE
*/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 스트리밍은 후속.