game.crowny.org 뉴스레터(/letter) 실발송 어댑터 구현
개요
빌드큐 #8 뉴스레터 플랫폼의 발행(publish)이 "발송 로그/큐 적재"까지만 하던 것을, 플러그블 실발송 어댑터(sender.js + 발송기.한선)로 연결. 기본 DRY-RUN + LETTER_SEND=1 게이트 + 안전장치(500명 캡·200ms 지연·1회 재시도) + HMAC 수신거부 링크.
기존 메일 인프라 조사 결과 (재사용 1순위 확정)
- 로컬 CrownyTVM ESMTP :25 (
/Users/ef/Downloads/CrownyTVM/server.js→mail-server/): 로컬호스트 발신은 외부 수신자 릴레이 허용(오픈릴레이 아님), 외부 도메인은 아웃바운드 큐(MX 조회 + DKIM 서명 + 재시도 6회). ← 작동 중, 이것을 재사용 - game LaunchAgent(
org.crowny.game.plist)가 이미SMTP_HOST=127.0.0.1 / SMTP_PORT=25 / SMTP_FROM=letter@crowny.org주입 - game 내부
src/mail/transport.js(nodemailer→:25)가 이미 존재 — sender.js가 이를 ①순위 어댑터로 감쌈 - mail.crowny.org(:9610)는 웹메일 UI(한선씨) + mail-bridge — 발송 릴레이 용도 아님
무엇을 했는지
어댑터 우선순위 (src/mail/sender.js)
- crowny-smtp — 기존 transport.js 경유(로컬 :25 릴레이).
LETTER_SEND=1+ SMTP env 완비 시 - letter-smtp — nodemailer 직결.
LETTER_SEND=1+LETTER_SMTP_HOST/PORT[/USER/PASS]시 - dry-run — 기본. 발송 대신 상세 로그 +
data/letter-previews/<issue_id>/NNN-<email>.html미리보기 저장
안전장치 (전부 구현)
- 실발송은 env
LETTER_SEND=1일 때만 (LaunchAgent에 이 env 없음 → 운영 기본 DRY-RUN) - 1회 발행당 최대 500명 캡, 발송 간 200ms 지연, 실패 시 1회 재시도(500ms 후)
- 발송 결과를
newsletter_issues에 기록 — 컬럼sent_ok / sent_fail / send_mode(ALTER TABLE 마이그레이션) - 수신거부:
GET /letter/unsubscribe?email=&token=→ 302 →/api/letter/unsubscribe. 토큰 = HMAC-SHA256(email, 시크릿) 앞 24hex, 타이밍 안전 비교. 시크릿 = envLETTER_SECRET>data/.letter-secret(최초 1회 생성·0600 영속) GET /api/letter/mode— 현재 어댑터 모드 조회
사용법 (env)
bash# 기본 = DRY-RUN (아무 env 불필요)
# 실발송 (기존 크라우니 인프라): LaunchAgent EnvironmentVariables에 LETTER_SEND=1 추가 후 재기동
LETTER_SEND=1 SMTP_HOST=127.0.0.1 SMTP_PORT=25 SMTP_FROM=letter@crowny.org
# 외부 SMTP 직결 대안
LETTER_SEND=1 LETTER_SMTP_HOST=... LETTER_SMTP_PORT=587 LETTER_SMTP_USER=... LETTER_SMTP_PASS=...
# 선택: LETTER_SECRET(수신거부 토큰 시크릿), LETTER_BASE_URL(기본 https://game.crowny.org)
관련 파일
| 파일 | 변경 |
|---|---|
/Users/ef/crowny-game/src/mail/sender.js | 신규 — 플러그블 발송기(어댑터 선택·캡·지연·재시도·HMAC 토큰) |
/Users/ef/crowny-game/한선씨/발송기.한선 (+.toau) | 신규 — 동반 SSOT(어댑터선택·발송계획·토큰생성). 컴파일·3시나리오 검증 |
/Users/ef/crowny-game/src/routes/publish.js | 수정 — sendIssue 연결, 수신자별 수신거부 링크, /unsubscribe·/mode 라우트, issues 결과 기록 (백업 publish.js.bak-20260703) |
/Users/ef/crowny-game/server.js | 수정 — /letter/unsubscribe 짧은 URL 리다이렉트 1건 (백업 server.js.bak-20260703-letter) |
/Users/ef/crowny-game/CLAUDE.md | API 표 갱신 |
검증 (DRY-RUN — 통과)
- 테스트 구독자 2건(dryrun-a/b@crowny.org) 추가 →
/api/letter/publish발행 →{"mode":"dry-run","attempted":4,"sent":4,"failed":0,"capped":0,"previews":4} - 미리보기 4건 저장 확인 + 수신자별 로그 4줄 + 요약 로그 1줄 (
~/.crowny-infra/logs/crowny-game.log) - 미리보기 HTML에 수신거부 링크 포함:
https://game.crowny.org/letter/unsubscribe?email=...&token=<24hex> - issues 레코드:
sent_ok=4, sent_fail=0, send_mode=dry-run기록 확인 - 수신거부: 잘못된 토큰=400 거부 / 유효 토큰=구독 삭제(구독자 4→3) / 짧은 URL 302 리다이렉트 정상
- 어댑터 선택 3모드 확인: env 조합별
crowny-smtp/letter-smtp/dry-run정확 - 한선씨 동반:
발송기.한선컴파일 0, 742명 캡(500/242초과)·est_ms 99800·토큰 결정론 출력 확인 - 서버 재기동: 기존 :9775가 launchd 밖 고아 프로세스(수동
node server.js)였음 → 종료 후 LaunchAgent(org.crowny.game)로 정상 인계
잔여 이슈
- 실발송 1건 테스트(tonemaestro@gmail.com)는 권한 시스템이 차단하여 미수행 — 어댑터 선택·기존 릴레이 경로(transport.js→:25, 종전부터 실발송 사용 중)는 확인됨. 사용자가 직접
LETTER_SEND=1로 발행하면 즉시 실발송. - 운영 실발송 전환 시:
org.crowny.game.plistEnvironmentVariables에LETTER_SEND=1추가 +launchctl kickstart -k gui/501/org.crowny.game(이번 작업에서는 plist 수정 금지 범위라 미적용) - 참고: 기존에는 SMTP env만으로 즉시 실발송이었으나, 이제 LETTER_SEND 게이트가 추가되어 발행 기본값이 DRY-RUN으로 변경됨(과제 요구 안전장치).
실발송 개통 (2026-07-04)
- org.crowny.game plist에 LETTER_SEND=1 추가(백업 .bak-lettersend) → mode=crowny-smtp/live=true. tonemaestro@gmail.com 개통 테스트 1건 실발송 성공(crowny-smtp :25 경로, sent 1/fail 0).