[리드관리 PR3] Resend 발송 기반 (웹훅·수신거부·suppression)

ID: 59abea45-bb6a-4994-904c-3f91cfb6619f

보통 리뷰

계획서: docs/features/admin_lead_management.md (§5, §7-PR3)

목표: 마케팅 발송 인프라. 발송 자체(PR4) 전에 로그/웹훅/컴플라이언스 토대를 만든다. 기존 트랜잭션 메일(Gmail SMTP)은 건드리지 않음.

운영 현황: 9way.co.kr Resend 인증 완료(Tokyo). RESEND_API_KEY 설정 완료. 이 PR에서 RESEND_WEBHOOK_SECRET + MARKETING_FROM_EMAIL(marketing@9way.co.kr) 추가 필요(.env / .kamal/secrets / .env.deploy).

작업:
- Gemfile gem "resend" + config/initializers/resend.rb (Resend.api_key = ENV["RESEND_API_KEY"]). delivery_method 전역 변경 금지.
- 마이그레이션: email_deliveries(campaign_id, user_id, email_address, resend_email_id[unique idx], status, delivered_at/opened_at/clicked_at/bounced_at, open_count/click_count, error_message; idx [campaign_id,status], user_id), email_suppressions(email_address[unique], reason enum, suppressed_at).
- EmailDelivery, EmailSuppression 모델.
- User 스코프 not_suppressed 추가(PR1에서 보류분).
- Webhooks::ResendController#create — POST /webhooks/resend, Svix 서명검증(RESEND_WEBHOOK_SECRET), 멱등. 이벤트→deliveries/카운터: delivered/opened(고유+open_count)/clicked/bounced(→suppression hard_bounce)/complained(→suppression complaint + user.email_subscription=false).
- EmailSubscriptionsController#unsubscribe — GET /email/unsubscribe?token=, message_verifier(:unsubscribe)로 user id 서명(신규 컬럼 불필요). 처리: email_subscription=false + EmailSuppression(unsubscribe). 확인 페이지(ko).
- 브랜드 이메일 레이아웃 partial + unsubscribe 링크 + List-Unsubscribe/List-Unsubscribe-Post 헤더.
- Marketing::TokenRenderer — 화이트리스트 토큰만 치환({{name}},{{funnel_stage}} 우선; {{primary_way}}/{{primary_domain}}는 스파이크 후), HTML 이스케이프.

테스트:
- 웹훅 서명 실패 401 / 성공 200.
- 각 이벤트가 delivery·카운터 갱신, 같은 이벤트 재수신 멱등.
- complained → email_subscription=false + suppression.
- unsubscribe 유효 토큰 해제 / 위조 토큰 거부.
- TokenRenderer 화이트리스트·이스케이프.

완료 기준: 웹훅/unsubscribe/suppression 동작(실제 발송은 PR4). 외부 호출은 테스트에서 stub.

첨부 이미지

이미지 추가 (Ctrl+V로 붙여넣기 또는 클릭)

JPEG, PNG, GIF, WebP / 최대 10MB

담당자: 미배정
생성일: 2026년 06월 10일 06:42

활동 로그

  • C
    claude PR3 Resend 발송 기반(웹훅·수신거부·suppression) TDD 구현 완료 (커밋 c23026c)

    2026년 06월 10일 07:32:27

    상세 보기

    ## 변경 사항
    - 마이그레이션 3종: email_deliveries(campaign_id[FK는 PR4], user, resend_email_id[uniq], status, *_at, open/click_count, error_message), email_suppressions(email_address[uniq], reason, suppressed_at), email_webhook_events(event_id[uniq] 멱등키).
    - 모델: EmailDelivery(mark_delivered!/record_open!/record_click!/mark_bounced!/mark_complained!, PROGRESS 단계 역행 방지), EmailSuppression(normalizes + 멱등 suppress!), EmailWebhookEvent(already_processed?/record!).
    - User: not_suppressed 스코프, unsubscribe_token/find_by_unsubscribe_token(message_verifier).
    - app/services/webhooks/svix_verifier.rb: Svix HMAC-SHA256 직접 검증(svix gem 불필요).
    - app/services/marketing/token_renderer.rb: {{name}}/{{funnel_stage}} 치환 + HTML 이스케이프, 미지원 토큰 원형 유지.
    - Webhooks::ResendController(ActionController::Base 상속 → allow_browser/CSRF/locale 우회, skip_forgery_protection): 서명검증 → 멱등 → 이벤트 처리. bounced→hard_bounce suppression, complained→complaint suppression + email_subscription=false.
    - EmailSubscriptionsController#unsubscribe(allow_unauthenticated_access) + 확인 뷰.
    - 공개 라우트: POST /webhooks/resend, GET /email/unsubscribe.

    ## 설계 결정
    - resend gem + 실제 발송은 PR4로 격리(외부 의존성 최소화). 웹훅 검증은 수동 HMAC.
    - 캠페인 카운터 갱신(delivered_count 등)은 EmailCampaign 생성(PR4) 후 연결.

    ## 테스트
    - 신규 67개: email_delivery(6)/email_suppression(5)/user_funnel 추가(3)/token_renderer(7)/svix_verifier(6, 공식 벡터)/resend_controller(6)/email_subscriptions(2) 등.
    - 웹훅 멱등(같은 svix-id 재전송), 서명 실패 401, complained→unsubscribe 검증.

    ## 운영 TODO (배포 전)
    - RESEND_WEBHOOK_SECRET 를 .env / .kamal/secrets / .env.deploy 에 추가.
    - Resend 대시보드에서 https://9way.co.kr/webhooks/resend 웹훅 등록(이벤트: delivered/opened/clicked/bounced/complained).

    ## 참고
    - 전체 스위트의 vuild 랜딩 테스트 5건 실패는 본 작업과 무관(커밋 fd65e8b가 _timeline/_claude_pro/_faq partial 삭제 후 테스트 미갱신, 증거 확인).

  • C
    claude 상태 변경: 진행 중 → 리뷰

    2026년 06월 10일 07:32:13

  • C
    claude 상태 변경: 할 일 → 진행 중

    2026년 06월 10일 07:18:02