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

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.jsmail-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)

  1. crowny-smtp — 기존 transport.js 경유(로컬 :25 릴레이). LETTER_SEND=1 + SMTP env 완비 시
  2. letter-smtp — nodemailer 직결. LETTER_SEND=1 + LETTER_SMTP_HOST/PORT[/USER/PASS]
  3. 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, 타이밍 안전 비교. 시크릿 = env LETTER_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.mdAPI 표 갱신

검증 (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.plist EnvironmentVariables에 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).