[리드관리 PR4] 캠페인 작성 + 수동 발송 (Resend 배치)

ID: 47c5c109-0bb8-468a-9ae0-0879d9df2d9a

보통 리뷰

계획서: docs/features/admin_lead_management.md (§4.2, §5.2, §6, §7-PR4)
선행: PR2(세그먼트), PR3(발송 기반). MVP 마지막 단계.

목표: 운영자가 세그먼트를 골라 메일을 작성·미리보기·테스트 후 Resend로 발송하고 성과를 확인.

작업:
- 마이그레이션 email_campaigns(uuid): name, subject, preheader, body, from_name(default "9WAY"), reply_to, segment_id(FK), status enum(draft/scheduled/sending/sent/failed), scheduled_at(컬럼만), sent_at, recipients_count + delivered/opened/clicked/bounced/unsubscribed_count, created_by_id.
- EmailCampaign 모델 + 상태 전이.
- Admin::Marketing::CampaignsController CRUD(index/new/create/show) + member post :send_test, post :deliver. 작성 폼(제목/본문 + 토큰 도우미 + 미리보기), 테스트 발송(본인), 발송 트리거. show: status + 성과(발송/도달/오픈/클릭/바운스/수신거부) + deliveries 테이블.
- Marketing::CampaignSender 서비스: 세그먼트 대상 산출 → 게이트 필터(marketing_subscribed & not_suppressed & email 존재) → 수신자별 토큰 렌더 + 레이아웃 래핑 + unsubscribe 주입 → 100명 단위 Resend::Batch.send → 반환 id로 email_deliveries 생성(status=sent). Resend 클라이언트는 주입 가능하게 분리(테스트 mock).
- Marketing::SendCampaignJob (Solid Queue) 비동기. 배치 일부 실패는 delivery=failed + error_message, 캠페인 부분성공 허용.

테스트:
- CampaignSender 게이트: 수신거부·suppression·이메일 없음 제외.
- 100명 초과 시 배치 분할.
- 발송 후 deliveries 생성 + status=sent/recipients_count 일치.
- send_test는 본인에게만.
- 배치 일부 실패 시 해당 delivery=failed, 캠페인 부분성공.
- Resend 호출 stub(테스트에서 실제 발송 금지).

완료 기준: 어드민에서 세그먼트 선택→작성→테스트→발송, show에서 웹훅 기반 성과 갱신 확인. = MVP 완성.

첨부 이미지

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

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

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

활동 로그

  • C
    claude PR4 캠페인 작성·발송(Resend 배치) TDD 구현 완료 — MVP 완성 (커밋 3ccbcf1)

    2026년 06월 10일 07:44:21

    상세 보기

    ## 변경 사항
    - resend gem 1.3.0 + config/initializers/resend.rb (마케팅 전용, delivery_method 전역 변경 X).
    - email_campaigns 테이블 + EmailCampaign 모델(enum status, deliveries 연관, sendable?). email_deliveries.campaign_id FK 연결(on_delete nullify) + EmailDelivery belongs_to :campaign.
    - app/services/marketing/campaign_sender.rb: eligible_recipients(segment.members → marketing_subscribed & not_suppressed & email 존재) → 100명 배치 → build_email(토큰 렌더 + 레이아웃 + List-Unsubscribe 헤더 + unsubscribe URL) → ResendClient.send_batch → EmailDelivery 생성. 배치 예외 시 해당 수신자 failed(부분 성공). send_test(본인, 로그 미기록).
    - app/services/marketing/resend_client.rb: Resend::Batch.send 래퍼(주입형).
    - app/jobs/marketing/send_campaign_job.rb (Solid Queue): deliver/test 분기.
    - Admin::Marketing::CampaignsController: CRUD + send_test/deliver(Job enqueue, 동기 발송 X), sendable? 가드.
    - ⚠️ 네임스페이스 충돌 해결: Admin::Marketing 내부에서 ::Marketing::SendCampaignJob 으로 최상위 명시.
    - Webhooks::ResendController: 캠페인 카운터(delivered/opened/clicked/bounced/unsubscribed) 최초 전이 시에만 증가.
    - 뷰: campaigns index/_form/new/edit/show(성과 6지표 + 발송 로그). 사이드바 "캠페인". 세그먼트 show "캠페인 만들기" 버튼(PR2 보류분). ko.yml i18n.

    ## 테스트
    - 신규: email_campaign(5), campaign_sender(6: 게이트/배치분할/토큰/부분실패/send_test), campaigns_controller(10: CRUD/enqueue 검증/sent 차단), 웹훅 카운터(1).
    - 전체 스위트 2793 runs, 0 errors. 실패 5건은 전부 기존 vuild 랜딩(본 작업 무관, 커밋 fd65e8b 이후 미갱신).
    - 실제 Resend 호출은 FakeClient 주입 / Job enqueue 단언으로 회피(테스트에서 발송 없음).

    ## 운영 TODO (발송 활성화 전)
    - MARKETING_FROM_EMAIL=marketing@9way.co.kr, RESEND_WEBHOOK_SECRET 를 .env / .kamal/secrets / .env.deploy 에 추가.
    - Resend 대시보드에서 https://9way.co.kr/webhooks/resend 웹훅 등록.

    ## Fast-follow (범위 외)
    - 진단 결과 유형(WAY/DOMAIN) 세그먼트 조건 — 결과 점수 저장 위치 스파이크 후 SegmentQuery + TokenRenderer({{primary_way}}) 추가.

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

    2026년 06월 10일 07:44:06

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

    2026년 06월 10일 07:33:55