Rinda 이메일 자동화 — 6대 신규 기능 기획서

미응답 후속 · OOO 영업시간 재발송 · 담당자 전달 · 국가별 영업일 발송 · 회사명 정리 · 긍정 답장 알림

작성일 2026-06-01 · 대상 시스템 elysia-server (Elysia+Bun) / admin (React 19) · 기존 코드베이스 분석 반영

재사용 기존 코드 그대로 확장 기존 모듈 수정 신규 새로 구현

0 시스템 현황 요약

6개 기능은 모두 기존 시퀀스·답장 처리 파이프라인 위에 얹힌다. 먼저 재사용 가능한 자산을 정리한다.

영역기존 자산위치
시퀀스 스키마sequences / sequenceSteps(delayDays·scheduledHour) / sequenceEnrollments(status·stopReason·repliedAt) / sequenceStepExecutions(scheduledAt·timezone·status)db/schema/sequences.ts
발송 워커30초 tick 로더 → BullMQ delayed → 6단계 발송 파이프라인. provider별 throttle (SES 10s, SendGrid 1.1s)workers/bullmq/sequence-email-worker/
답장 수신Inbound webhook → emails(direction=inbound, threadId, inReplyTo) + email_replies(intent·sentiment·aiSummary)services/webhook.service.ts
AI 분류gemini-3.1-flash-lite, 7 intents: meeting_request·question·objection·out_of_office·not_interested·positive_interest·neutral + confidenceservices/ai-classification.service.ts
OOO 감지RFC3834 Auto-Submitted, X-Auto-Response-Suppress, no-reply 패턴, 60초 룰 → emails.isAutoReplyutils/auto-reply-detection.ts
답장 자동화replyAutomationConfig JSONB + intent→action(stop/complete/pause/ignore). out_of_office→pause 7일 자동 resume. enqueueFollowUpDraftJob() 존재services/reply-automation.service.ts
리드leads.companyName / foundCompanyName, GIN trgm 인덱스. Bulk 모달은 status/businessType/group만admin/.../leads/BulkActionModal.tsx
알림SendGrid(primary)+SES(fallback) 발송. notifications 테이블 + SSE. email-reply-notification 존재(intent 필터 없음). 알림 설정(preferences) 테이블 없음services/email.service.ts
핵심 갭 3가지 ① 영업시간·국가 공휴일 캘린더 미구현 (timezone offset만 존재) · ② wrong_contact/forward_request intent 부재 · ③ 알림 intent 필터·preferences 부재. 6개 기능의 절반이 이 3개 갭을 메우는 작업이다.

한 장면으로 보는 전체 그림

"수출기업 영업 담당 김 과장이 미국 바이어 200명에게 7일 간격 8차 메일 시퀀스를 돌린다" — 이 한 가지 상황에서 6개 기능이 어떻게 김 과장 대신 일해주는지.

지금(자동화 전)은 이렇게 흘러갑니다 엑셀로 받은 바이어 명단을 올리니 회사명이 (주)ABC Trading Ltd 처럼 지저분합니다. 시퀀스를 켜면 메일이 한국 시간 기준 10초에 한 통씩 나가는데, 미국은 한밤중이고 토요일에도 발송됩니다. 어떤 바이어는 "휴가 중" 자동응답만 돌려보내고, 어떤 바이어는 "저는 담당이 아니에요, 구매팀에 보내세요"라고 답합니다. 8번째 메일까지 다 보냈지만 답 없는 바이어가 절반입니다. 그 사이 진짜 "미팅하고 싶어요" 답장 몇 개는 수십 개의 답장 더미에 묻혀 놓칩니다. — 이 모든 걸 김 과장이 손으로 챙겨야 합니다.
6개 기능을 켜면, 같은 상황이 이렇게 바뀝니다
  1. 명단 업로드 직후 — 버튼 한 번으로 (주)·Ltd 같은 군더더기가 전부 정리됩니다. (기능 5)
  2. 발송 중 — 메일은 미국 바이어의 평일 업무시간(현지 오전 9시~오후 6시)에만 나갑니다. 추수감사절·주말은 알아서 건너뛰고, 200통이 하루를 넘기면 자동으로 다음 영업일로 나눠 보냅니다. (기능 4)
  3. "휴가 중" 자동응답이 오면 → 그 바이어가 복귀하는 날 아침에 같은 메일을 한 번 더 보냅니다. (기능 2)
  4. "담당 아니에요, 구매팀 메일로" 답장이 오면 → 그 주소로 같은 내용을 자동 전달(또는 확인 후 전송)합니다. (기능 3)
  5. "미팅 잡고 싶어요" 긍정 답장이 오면 → 답장 더미에 묻히기 전에 김 과장 메일로 즉시 알림이 옵니다. (기능 6)
  6. 8차까지 다 보냈는데 답 없는 바이어에게는 → 며칠 뒤 "확인되셨을까요?" 후속 메일을 알아서 한 번 더. 아직 시퀀스 진행 중인 사람은 건드리지 않습니다. (기능 1)
한마디로: 명단 정리 → 적절한 시간 발송 → 상황별 답장 자동 대응 → 중요한 답장만 알림 → 놓친 사람 자동 리마인드까지, 손이 가던 일을 시스템이 대신합니다.

1 미응답 자동 후속(Nudge) 메일 신규

보낸 메일에 답장이 없으면 최근 보냈던 내용을 바탕으로 "확인되셨을까요?" 톤의 후속 메일을 자동 발송.

이런 게 편해져요

8번째 메일까지 다 보냈는데도 답이 없는 바이어가 많습니다. "한 번 더 찔러볼까?" 싶지만, 누가 답을 안 했는지 일일이 찾아 한 명씩 다시 쓰는 건 일입니다.

지금답 없는 바이어를 직접 추려 한 명씩 리마인드 메일을 손으로 작성·발송.
자동화 후시퀀스를 끝까지 돌렸는데도 답 없는 사람만 골라, 며칠 뒤 "확인되셨을까요?" 후속 메일을 같은 대화 스레드로 알아서 한 번 더 발송.

똑똑한 점: 아직 시퀀스가 진행 중인 사람에게는 보내지 않습니다 — 그건 이미 다음 메일이 예약돼 있어서 이중 발송·스팸이 되기 때문입니다. "다 보냈는데도 조용한" 사람에게만 딱 한 번(최대 2회) 갑니다.

핵심 설계 질문 — 7일 간격 8차 시퀀스는 어떻게?

요청에서 직접 지목한 쟁점이다. 결론부터:

시퀀스 진행 중에는 Nudge를 보내지 않는다. Nudge는 "시퀀스가 끝났는데도 답이 없는" enrollment에만 적용한다. 8차 시퀀스의 각 스텝은 그 자체가 이미 후속(follow-up)이다. 진행 중에 별도 Nudge를 끼우면 동일 스레드에 이중 발송 → 스팸·도달율 악화. 따라서 트리거는 enrollment.status='completed' AND repliedAt IS NULL 단 한 경우다.

트리거 조건 (전부 AND)

발송 내용 생성

  1. 해당 enrollment의 마지막(또는 열람률 가장 높았던) 스텝 콘텐츠를 sequenceStepContents에서 가져온다.
  2. LLM(gemini-3-flash)으로 "짧은 리마인드 톤"으로 재작성 — 원문 요지 1~2줄 인용 + "확인되셨을까요?" CTA. 새 제목 대신 Re: 유지.
  3. 같은 스레드로 발송 — 원본 emails.threadId · messageIdIn-Reply-To/References 헤더에 넣어 받은 편지함에서 기존 대화에 묶이게 한다 (도달율·맥락 ↑).

언어 정책 (beta DB 실측 기반)

"확인되셨을까요?" 본문은 기존 AI 생성 파이프라인(gpt-5-nano/gemini-3-flash)을 그대로 재사용해 생성한다. 문제는 어떤 언어로 보낼지다.

beta DB 실측 — country 기반 언어 추론은 Nudge 에 위험
  • leads.country69.9% 비어있음(849,389건 중 593,808건 NULL)
  • 값도 비정규화 — United States(33,597)와 미국(10,050), Japan일본(5,480)이 따로. 영문 키 매핑만 있어 한글 국가명은 en 으로 잘못 fallback
  • Nudge 대상은 "답장이 없는" 리드 → 답장 텍스트 스크립트 감지(가장 정확한 방법)도 불가
→ country 로 추론하면 미국 바이어에게 한국어 Nudge 가 나갈 수 있다.
해결 — "그 리드에게 이미 발송된 마지막 스텝 콘텐츠의 언어를 그대로 재사용" Nudge 는 원래 보낸 내용의 리마인드이므로, 새로 country 를 추론할 필요가 없다. 해당 enrollment 가 실제 발송한 sequenceStepContents 의 언어를 따르면 — 추론 체인을 건너뛰어 70% NULL·비정규화 문제를 원천 회피하고 가장 정확하다. (beta 실측: 발송 콘텐츠 269,116건 중 한국어 57,725·일본어 24,642·라틴 187,398 으로 다국어 생성은 이미 정상 작동.)

DB 변경

-- sequenceEnrollments 에 컬럼 추가 (또는 별도 sequence_nudges 테이블)
nudge_count        smallint   default 0
last_nudge_at      timestamptz
nudge_state        varchar(16)  -- 'eligible' | 'sent' | 'replied' | 'exhausted'

별도 테이블보다 enrollment 컬럼 추가가 단순. Nudge 발송 자체는 sequenceStepExecutionsstepOrder = -1 같은 가상 스텝으로 기록해 발송 이력/추적을 재사용한다.

실행 (worker)

설정 노출 시퀀스 편집 화면에 "미응답 후속 자동 발송" 토글 + cool-down 일수 + 최대 횟수. 기본 off로 출시 후 opt-in.

난이도 M · 기존 발송/스레드/LLM 인프라 대부분 재사용. 신규는 scanner worker + enrollment 컬럼 + 설정 UI.

2 OOO 영업시간 재발송 확장

주말·영업시간 외 발송 → 자동응답(Out-of-Office) 수신 시, 동일 내용을 수신자 영업시간에 다시 보낸다.

이런 게 편해져요

금요일 밤에 나간 메일에 "휴가 중입니다. 다음 주 월요일에 복귀합니다"라는 자동응답이 옵니다. 정작 바이어가 돌아왔을 때 내 메일은 받은 편지함 한참 아래로 밀려나 있습니다.

지금"휴가 중" 자동응답은 그냥 묻히고, 바이어가 복귀해도 다시 챙겨 보내지 않으면 잊혀짐.
자동화 후자동응답 속 "월요일 복귀" 같은 날짜를 읽어, 그 날 아침(상대 업무시간)에 같은 메일을 다시 발송. 날짜가 없으면 다음 영업일 오전에.

안전장치: 재발송은 한 통당 딱 1회. 그 사이 진짜 사람이 답장하면 재발송은 취소됩니다.

현황과 차이

지금은 out_of_office intent를 감지하면 enrollment를 7일 pause 후 자동 resume 한다. 요청은 "7일 뒤 다음 스텝"이 아니라 "방금 그 메일을, 곧 돌아올 영업시간에 다시" 보내는 것 — 리마인드 성격이 다르다.

플로우

  1. OOO 감지 — 이미 auto-reply-detection.tsemails.isAutoReply=true + intent out_of_office로 처리. 여기까진 재사용.
  2. 확장 OOO 본문에서 복귀일 파싱. LLM 1콜로 "I'll be back on June 5" → 2026-06-05 추출. 실패 시 null.
  3. 재발송 시각 계산:
    • 복귀일 있으면 → 복귀일 다음 영업일 09:00 (수신자 timezone)
    • 없으면 → 다음 영업일·영업시간 시작 슬롯 (기능 4 로직 재사용)
  4. 원본 step execution을 복제해 sequenceStepExecutionsresend 1건 생성 — 동일 콘텐츠, 같은 스레드.

무한 루프 / 이중 발송 방지

가드 ① resend는 원본 1건당 최대 1회 (resend_of_execution_id 유니크) · ② 재발송분에 또 OOO 오면 무시 · ③ 그 사이 사람이 보낸 진짜 답장 오면 resend 취소(스킵).

DB 변경

-- sequenceStepExecutions
resend_of_execution_id  uuid   null   -- self-ref FK, unique partial idx
trigger_reason          varchar(24)    -- 'ooo_resend' 등 발송 사유 추적

난이도 S~M · OOO 감지·발송 인프라 재사용. 신규는 복귀일 파서 + resend 스케줄 분기. 기능 4 의존(영업일 계산 공유).

3 담당자 아님 → 자동 전달 신규

"저는 담당자가 아니에요, XXX@회사.com 으로 보내세요" 답장 → 그 주소로 동일 내용 자동 전달.

이런 게 편해져요

"저는 담당이 아니고, 구매팀 김 부장(kim@company.com)에게 연락하세요"라는 답장이 옵니다. 좋은 단서지만, 결국 내가 원래 메일을 복사해 새 주소로 다시 써야 합니다.

지금답장에서 새 이메일 주소를 눈으로 찾아, 원래 메일 내용을 복붙해 직접 다시 발송.
자동화 후답장에서 새 담당자 주소를 자동으로 찾아내, 같은 내용을 그 주소로 전달. 새 연락처로 등록까지.

실수 방지: 기본은 "전달 메일을 미리 만들어 두고 내가 확인 후 전송" 모드입니다. 자신 있으면 완전 자동 모드로 전환할 수 있습니다 — 엉뚱한 주소로 잘못 나가는 걸 막기 위함입니다.

현황과 갭

현재 AI 분류에 "담당자 아님" intent가 없다. 이런 답장은 objection이나 question으로 잘못 분류된다. 먼저 intent를 추가해야 한다.

구현 단계

  1. 확장 AI 분류에 intent wrong_contact 추가 — 프롬프트에 정의: "recipient says they are not the right person and points to someone else". 응답에 referred_email·referred_name 필드 추출 추가.
  2. 대체 주소 추출 — 답장 본문 정규식(이메일 패턴) + LLM 교차검증. 둘이 일치할 때만 신뢰.
  3. 전달 실행:
    • 추출 주소를 새 leadContact로 추가
    • 원본 시퀀스 콘텐츠(현재 스텝)를 그 주소로 발송 — 새 enrollment 생성 또는 단발 전달
    • 원본 lead enrollment는 stopped (stopReason wrong_contact)

안전장치 — Auto vs Review

잘못 추출한 주소로 자동 발송하면 콜드메일 평판 리스크. 두 모드를 설정으로 제공:
  • Review (기본) — 전달 draft를 생성해 유저 inbox에 "확인 후 전송" 카드로 노출 (enqueueFollowUpDraftJob 패턴 재사용)
  • Autoconfidence ≥ 0.8 + 주소 정규식·LLM 일치 시에만 즉시 전달

DB 변경

-- email_replies 에 추출 결과 저장 (intent='wrong_contact' 일 때)
referred_email   varchar(255)
referred_name    varchar(255)
forward_status   varchar(16)   -- 'pending' | 'forwarded' | 'review' | 'skipped'
-- enrollment stopReason enum 에 'wrong_contact' 추가

난이도 M · 분류 프롬프트 확장 + 주소 추출 + 전달 발송 + Review 카드 UI. 기능 6과 알림 경로 공유 가능.

4 국가별 영업일 발송 신규

시퀀스 메일을 수신자 국가의 영업일·영업시간 안에서만 발송. 벗어나면 다음 영업 슬롯으로 자동 이월.

이런 게 편해져요

한국 오후 3시에 미국 바이어에게 메일을 보내면, 그쪽은 새벽 1시입니다. 자다 깨서 보는 콜드메일은 열어보지도 않고, 토요일·추수감사절에 도착한 영업 메일은 신뢰를 떨어뜨립니다.

지금한국 시간 기준 10초에 한 통씩 발송 → 상대국 새벽·주말·공휴일에도 그대로 도착. 메일이 수만 건이면 영업시간을 넘겨 밤새 나감.
자동화 후각 바이어 나라의 평일 업무시간(예: 현지 09–18시)에만 발송. 주말·공휴일은 자동으로 건너뜀. 하루치 발송 용량을 넘으면 다음 영업일로 알아서 나눠 보냄.

왜 중요한가: 콜드메일은 "현지 업무시간 도착"이 열람률·응답률에 가장 큰 영향을 줍니다. 잘못된 시간대 발송은 스팸 신고·도달율 하락으로 이어집니다.

현황과 갭

현재 timezoneMode(sender/buyer) + scheduledHour/Minute로 시각만 맞춘다. 영업 요일·공휴일 개념이 없다. 토요일 09:00, 크리스마스 09:00에도 그대로 발송된다.

설계 — Business Calendar 레이어

  1. 신규 워크스페이스(또는 시퀀스)별 영업시간 설정: 영업 요일(기본 월~금), 시작·종료 시각(기본 09:00–18:00), 기준 timezone.
  2. 신규 국가별 공휴일 — date-holidays 라이브러리 또는 정적 public_holidays 테이블(국가코드·날짜). 리드의 국가/timezone으로 조회.
  3. 스케줄 보정 함수 nextBusinessSlot(scheduledAt, calendar): 계산된 scheduledAt이 영업시간 밖이면 → 다음 영업일·영업시간 시작으로 shift. 발송 워커의 로더가 픽업 직전 한 번 더 검증.
scheduledAt 계산 │ ▼ 영업 요일? ──no──► 다음 영업 요일 09:00 으로 이동 │yes ▼ 공휴일? ──yes─► 다음 영업일 09:00 으로 이동 │no ▼ 09:00~18:00? ─no──► (이른 새벽→당일 09:00 / 저녁 이후→익일 09:00) │yes ▼ 그대로 발송

핵심 난제 — Throttle vs 영업시간 용량

요청에서 직접 지적한 이슈. "10초에 1건"이면 하루 영업시간(9h)에 한 계정당 약 3,240건이 상한. 수만 건이면 영업시간을 넘긴다.
대응 우선순위:
  1. 용량 초과분 자동 익일 이월 — 영업 윈도우 capacity = 윈도우초 / throttle. 초과분은 다음 영업일로 밀어 keyset 순서대로 처리.
  2. Provider 전환·다중 계정 — SendGrid는 1.1s/건(≈하루 19,200건)로 SES(10s)보다 9배. 영업일 제약이 빡빡하면 SendGrid 권장 + 계정 분산(per-account throttle은 이미 독립).
  3. 발송 시각 분산 — 영업시간 전체에 균등 stagger(이미 존재)하되 capacity 기준으로 일자 분할.

DB 변경

-- 신규: business_hours (workspace_id 별 설정)
work_days        smallint[]    -- [1,2,3,4,5]
start_minute     smallint      -- 540 (09:00)
end_minute       smallint      -- 1080 (18:00)
calendar_tz      varchar(64)
-- 신규: public_holidays (country_code, holiday_date) 또는 date-holidays 런타임 조회

난이도 L · 6개 중 가장 무겁고 기능 1·2가 의존하는 기반. capacity 이월 로직이 핵심 리스크.

5 리드 데이터 멀티 필드 검토형 정리 확장

회사명뿐 아니라 website·country 등 여러 필드를 한 번에 정리. 단 즉시 반영이 아니라 dry-run 미리보기 → 필드/행 선택 → 확정 시에만 적용.

이런 게 편해져요

엑셀로 받은 명단이 전반적으로 지저분합니다 — 회사명은 (주)ABC Trading Ltd, 국가는 미국/United States 뒤죽박죽, URL은 www.abc.com/처럼 제각각. 한 필드씩 따로 고치는 것도 일입니다.

지금회사명·국가·URL을 각각 수백 건씩 손으로 수정. 어떤 게 바뀌는지 미리 볼 수도 없음.
자동화 후모달에서 정리할 필드를 고르면 "필드별 변경 N건 + 이전→이후 미리보기"를 먼저 보여줌. 적용할 필드/행만 골라 확정. 즉시 DB 반영 아님.

안전장치: dry-run 미리보기 필수 + 확정 시에만 적용 + 직전 값 스냅샷(undo). "정리하면 안 되는" 필드는 후보에서 제외.

어떤 필드를 정리하나 (beta DB 실측 — 정리 대상 비율)

필드채워진 행정리 대상비율판정
website_url 프로토콜무/끝슬래시/www258,585106,32241.1%✅ 효과 최대
country 한글표기(미국/일본)255,60385,94033.6%✅ 기능4·언어추론 동시 개선
company_name 법인접사847,56283,1339.8%✅ 원래 대상
contact_name 직함/괄호22,1073491.6%△ 데이터 적음·파싱 필요
employee_count 비숫자141,949139,11998.0%❌ 정리 금지
핵심 — 모든 필드가 정리 대상이 아니다. employee_count의 98% "비숫자"는 버그가 아니라 "10-50"·"100+" 같은 범위 문자열(정상)이다. 숫자로 "정리"하면 의미가 손실된다. → 정리는 "형식만 바꿔도 의미가 보존되는 필드"로 한정한다. 또한 회사명(9.8%)보다 website_url(41%)·country(33.6%)의 정리 효과가 더 크다.

설계 — 필드별 normalizer 레지스트리 + dry-run 검토

  1. 신규 normalizer 레지스트리 — 필드별 순수함수 모음(기능1에서 bun test 통과한 normalizeCompanyName 패턴 재사용). 각 함수는 (raw) → cleaned 로 부수효과 없음:
    • company_name: 법인접사 제거 (주)·㈜·Ltd·Inc·Co.·GmbH·株式会社 (word boundary로 과도제거 방지)
    • website_url: https:// 보정 + www.·끝 슬래시 제거 → canonical
    • country: 한글/약어 → 표준 영문명 매핑(미국→United States) — 언어추론·영업일(기능4) SSOT 정합
  2. 확장 기존 BulkActionModal.tsx"normalizeFields" 액션 추가. 정리할 필드를 다중 선택(체크박스).
  3. dry-run 미리보기 (DB 미변경) — 선택 필드마다 "변경 N건" 집계 + 이전→이후 샘플. 변화 없는 행/필드는 회색.
  4. 사용자가 적용할 필드/행을 골라 확정할 때만 반영. 대상: 선택 리드 / 현재 필터 전체.

API — dry-run / commit 2단계

현재 /leads/bulk생성 전용, 수정은 단건 PUT만. 검토형 정리를 위해 2개 엔드포인트 신규:
  • POST /leads/normalize/preview{ leadIds[]|filter, fields:['companyName','websiteUrl','country'] }변경 diff·건수·샘플만 반환, DB 미변경
  • POST /leads/normalize/commit → 확정된 필드/행만 keyset 배치 UPDATE + 직전값 스냅샷(undo)
// 필드별 normalizer 레지스트리 (서버·프론트 미리보기 공유, 전부 순수함수)
const NORMALIZERS = {
  companyName: (s) => s
    .replace(/\(주\)|㈜|주식회사|유한회사|株式会社|有限会社/g, " ")
    .replace(/\b(Inc|LLC|Ltd|Co|Corp|Limited|GmbH|S\.?A|B\.?V)\b\.?/gi, " ")
    .replace(/\s{2,}/g, " ").trim(),
  websiteUrl: (s) => {
    let u = s.trim().replace(/\/+$/, "")
    if (u && !/^https?:\/\//i.test(u)) u = "https://" + u
    return u.replace(/:\/\/www\./i, "://")
  },
  country: (s) => COUNTRY_CANONICAL[s.trim()] ?? s.trim(),  // 미국→United States
}
// ❌ employee_count 등 "범위 문자열" 필드는 레지스트리에 넣지 않음 (의미 손실)
되돌리기·범위 제한 dry-run 미리보기 강제 + 확정 시 직전값 스냅샷 1회 보관(undo). "정리하면 의미가 바뀌는" 필드(employee_count·자유서술 notes)는 레지스트리에서 제외해 실수 자체를 차단.

난이도 M · 회사명 단일(S)에서 멀티 필드+dry-run으로 확장. normalizer는 순수함수라 unit test로 값싸게 커버. 프론트+경량 API로 빠르게 체감되는 기능.

6 긍정·미팅 답장 알림 메일 확장

답장이 positive_interest 또는 meeting_request로 분류되면 담당 유저 이메일로 즉시 알림.

이런 게 편해져요

하루에도 답장이 수십 개씩 옵니다. 대부분은 "관심 없어요"·자동응답인데, 그 사이에 "제품 데모 한번 보고 싶어요" 같은 진짜 기회가 섞여 있습니다. 바빠서 늦게 확인하면 골든타임을 놓칩니다.

지금모든 답장을 일일이 열어 확인. 중요한 답장이 더미에 묻혀 며칠 뒤에야 발견.
자동화 후"긍정·미팅 요청" 답장만 골라 즉시 내 메일로 알림 — 누가·어느 회사·답장 요약·바로가기 링크까지. 기회를 놓치지 않음.

똑똑한 점: 거절·자동응답·중립 답장은 알림하지 않아 피로를 줄임. 같은 대화에 대한 중복 알림도 차단.

현황과 갭

이미 email-reply-notification.service.ts가 답장 알림을 보내지만 intent 필터가 없어 모든 답장에 발송된다. "긍정·미팅만"으로 좁히고 내용을 풍부하게 만드는 작업.

설계

  1. AI 분류(classifyEmailReplyAsync) 완료 후, intent ∈ {positive_interest, meeting_request} AND confidence ≥ 0.7 일 때만 알림 트리거.
  2. 알림 메일 내용: 회사/리드명 · intent 배지 · AI 요약(aiSummary) · 답장 원문 발췌 · "받은 편지함에서 열기" 딥링크.
  3. 발송: 기존 SendGrid 발송 서비스(email.service.ts) 재사용. 수신자 = enrollment 소유 유저(user_email_accounts 또는 user 이메일).
  4. 중복 방지 — 같은 thread 같은 intent는 24h 내 1회만.

알림 설정 (preferences)

현재 알림 preferences 테이블이 없다. 우선 기본 ON으로 출시하고, 이후 경량 notification_preferences(userId, eventType, emailEnabled)를 추가해 끄기 옵션 제공. 기능 3(전달)·기능 1(nudge 답장)도 같은 알림 경로를 공유하므로 함께 설계.
// reply-automation 파이프라인 끝에 1 분기 추가
if ((intent === "positive_interest" || intent === "meeting_request")
    && confidence >= 0.7
    && !alreadyNotified(threadId, intent)) {
  await sendPositiveReplyAlert({ to: ownerEmail, lead, intent, aiSummary, snippet })
}

난이도 S · 발송·분류 인프라 완비. intent 게이트 + 메일 템플릿 + 중복 가드만. 가장 빠른 백엔드 기능.

예외·리스크 분석 & 대처

"리스크 있는 시나리오가 모두 대처 가능한가?"에 대한 답. 각 기능의 실패 시나리오를 발생 영향·대처·테스트 가능 여부로 정리한다.

관통하는 원칙 — "안전한 실패(fail-safe) 방향" 이 기능들은 전부 자동 발송을 늘린다. 따라서 모든 설계는 "애매하면 보내지 않는다 / 사람이 확인한다 / 한 번만 보낸다" 쪽으로 실패하게 만든다. 오발송(스팸 신고·평판 하락)이 미발송(기회 한 번 놓침)보다 훨씬 비싸기 때문이다.

공통 리스크 4가지 (모든 기능 공유)

리스크시나리오대처 (기존 자산 재사용)위험도
중복 발송 워커 재시도·동시 실행으로 같은 메일 2번 발송 idempotency.ts의 Redis marker(2h TTL) + atomic claim(WHERE status='pending') + BullMQ jobId dedup를 모든 신규 발송(Nudge·OOO재발송·전달)이 그대로 통과
AI 오분류 "담당 아님/긍정"을 잘못 판정 → 오발송·오알림 confidence 게이트(0.7~0.8) + 분류 실패 시 neutral·confidence:0 fallback(=동작 안 함) + 기능3은 Review 모드 기본
발송 평판 자동 발송량 증가 → bounce·스팸률 상승 → 도메인 평판 하락 기존 verify-email(MillionVerifier)·bounce_check·suppression 파이프라인을 신규 발송도 전부 통과 + 전 기능 opt-in 기본 off
답장 vs 발송 경쟁 답장이 막 도착한 순간 Nudge/재발송이 동시에 나감 발송 직전 repliedAt 재확인 + Nudge 선정도 conditional UPDATE(nudge_state)로 atomic claim

기능별 핵심 예외 & 대처

기능예외 시나리오대처대처 가능?
1 · Nudge 진행 중인 시퀀스 대상에게 잘못 발송(이중) 트리거를 status='completed' AND repliedAt IS NULL로 제한 — 구조적으로 차단 ✅ 완전
Nudge에도 무응답 → 무한 발송 nudge_count 상한(1~2회) + cool-down + suppression 재검사 ✅ 완전
2 · OOO 재발송 재발송 → 또 OOO → 또 재발송(무한 루프) resend_of_execution_id unique → 원본당 재발송 1회만 ✅ 완전
복귀일 파싱 오류(2099년·과거날짜) 파싱 결과 sanity 범위(now ~ now+90d) 밖이면 버리고 "다음 영업일" fallback ✅ 완전
3 · 전달
(최고위험)
잘못 추출한 주소로 전달 → 무관한 사람에게 콜드메일 정규식+LLM 이중 확인 + confidence≥0.8 + 기본 Review 모드(사람 승인) ⚠️ Review로 완화
전달받은 사람도 "담당 아님" → 재귀 전달 체인 전달 깊이 1로 제한(forward된 발송은 재전달 비활성) ✅ 완전
전달했는데 원본 enrollment가 계속 발송 전달 + 원본 stopped(wrong_contact)한 트랜잭션으로 ✅ 완전
4 · 영업일
(가장 무거움)
타임존·DST(서머타임) 계산 오류 IANA tz DB 기반 라이브러리(luxon / date-fns-tz)로 DST 자동 처리 + 기존 immutable tz snapshot 패턴 ✅ 라이브러리
capacity 이월이 무한 누적 → 영원히 안 나가는 메일(starvation) keyset 공정 순서(오래된 것 우선) + 적체 임계 모니터링·알림 + provider/계정 증설 가이드 ⚠️ 모니터링 필수
lead 국가/timezone 미상 sender(한국) 영업시간으로 명시적 fallback — "모르면 안전한 기본값" ✅ 완전
5 · 회사명 과도 제거("Coca-Cola"→"ca-Cola", "3M Co"의 일부 오인) word boundary(\b) + 접두/접미 위치 한정 + 적용 전 미리보기 필수 + undo 스냅샷 ✅ 미리보기로 차단
6 · 알림 오분류로 알림 폭주(피로) / 한 thread 여러 답장 confidence 게이트 + 24h thread·intent 중복 가드 + 분류 실패 시 무알림(안전한 실패) ✅ 완전
완전 차단이 안 되는 2곳 (잔여 리스크) — 정직하게 명시
  • 기능 3 전달 — 주소 추출은 본질적으로 100% 정확할 수 없다. 그래서 "완전 자동"이 아니라 Review 모드를 기본값으로 출시한다. Auto 모드는 사용자가 위험을 이해하고 켜는 옵션.
  • 기능 4 capacity / Redis fail-open — 발송량이 영업시간 용량을 구조적으로 초과하면 적체는 누적된다(메일 자체가 너무 많은 것이지 버그가 아님). 또 Redis 장애 시 throttle가 fail-open이라 단기 중복 가능 → provider rate limit이 최후 방어선이고, 적체·marker write 실패를 모니터링/알림으로 가시화해 운영이 개입하게 한다.
나머지 시나리오는 코드 구조(unique 제약·트랜잭션·트리거 조건)로 완전 차단 가능하다.

2026 패턴 적합성 · 테스트 검증

"2026 최적 패턴인가" + "테스트 코드로 문제없는지 보장 가능한가"에 대한 답.

2026 최적 패턴 부합 점검

관점2026 베스트 프랙티스본 기획 적용
트리거 방식polling 대신 이벤트 기반(webhook)기능 2·3·6은 inbound webhook 트리거(✓). 기능 1·4는 cron 불가피하나 keyset cursor + partial index로 풀스캔 회피
멱등성모든 발송에 idempotency key, at-least-once 전제기존 executionId Redis marker 패턴을 신규 발송 전부에 적용(✓)
시간/타임존IANA tz DB + DST 자동, 시각은 immutable snapshotluxon/date-fns-tz + 기존 sequenceStepExecutions.timezone snapshot 재사용(✓). Temporal API는 Bun 런타임 성숙도 확인 후 채택(현재는 보류 권장)
LLM 사용structured output(JSON schema), low temperature, confidence 노출, 저비용 모델gemini-3.1-flash-lite + temp 0.1 + confidence 필드 + fallback(✓) — 이미 현행 패턴
프론트(기능 5)React 19, optimistic update, 서버상태=TanStack Query, UI상태=Jotai, 모달=프로젝트 dialog SSOT기존 BulkActionModal·radix dialog·Jotai atom 패턴 그대로 확장(✓) — 자체 SSOT 안 만듦
스키마/DBUUIDv7 PK, keyset 페이지네이션, 무거운 집계는 analyticsDb신규 테이블 UUIDv7, bulk-update·nudge scan 모두 keyset(✓)
결론: 6개 모두 이 저장소의 2026 현행 패턴(이벤트·멱등·keyset·structured LLM·React 19) 위에 얹힌다. 레거시 폴링·OFFSET·자체 SSOT 같은 안티패턴은 없다. 유일한 판단 보류는 Temporal API(런타임 성숙도) — 현재는 검증된 tz 라이브러리 권장.

테스트로 문제없는지 — 현실 평가

먼저 솔직한 현황: 이 저장소는 bun test로 ~200개 테스트 파일이 있지만 webhook·AI 분류에는 테스트가 없고, CI는 테스트를 실행하지 않는다(lint+type-check+build만; 테스트는 로컬·pre-commit). 즉 "기존 테스트가 통과하니 안전"이라고 말할 수 없고, 신규 기능은 테스트를 새로 써야 하며 위험 로직은 CI/pre-commit에 테스트를 추가해야 회귀를 잡는다.
검증 대상테스트 종류커버 가능?
normalizeCompanyName() (기능 5)unit — Coca-Cola·3M·주식회사·株式会社·B.V. 등 엣지 케이스 테이블✅ 쉬움(순수 함수)
nextBusinessSlot() (기능 4)unit — 주말·공휴일·DST 경계·연말·자정 경계 대량 케이스✅ 쉬움(순수 함수)
복귀일 파서 (기능 2)unit — 다양한 OOO 문구/언어/날짜 포맷 + sanity 범위✅ 쉬움
OOO 감지 (기능 2)unit — RFC3834·X-Auto-* 헤더 fixture✅ 쉬움(기존 유틸 확장)
intent 게이트·중복 가드 (기능 1·3·6)unit — 분류 결과 mock → 발송/알림 여부 판정✅ 쉬움(AI는 mock)
Nudge 선정·전달 트랜잭션·enrollment 전이integration — docker compose DB, 동시 상태변경·중복 webhook 재현⚠️ 가능하나 셋업 필요
throttle race / capacity 이월load test — 동시 수천 job⚠️ bun test로 부족 → 부하 테스트
Redis 장애 중복 발송chaos — Redis 강제 다운❌ 테스트보다 모니터링·알림으로
테스트 전략 결론위험 로직 대부분이 순수 함수(회사명 정규화·영업일 계산·복귀일 파싱·intent 게이트)라 unit test로 강하게·값싸게 커버된다 — 여기가 버그가 가장 많이 날 곳이고, 가장 쉽게 막힌다. ② DB·race는 integration test(docker compose)로 핵심 시나리오(중복 webhook·동시 전이·이중 발송) 재현. ③ throttle race·Redis 장애처럼 테스트로 못 막는 잔여 리스크운영 모니터링·알림으로 가시화한다. ④ 신규 위험 로직 테스트는 CI/pre-commit에 편입(현재 CI 미실행이므로)해야 회귀를 잡는다.

설정 위치(UI 배치) · 기본값 정책

"on/off 토글을 어디에 두나, 그리고 처음 켰을 때 기본값은 무엇인가." 설정의 적용 범위(scope)로 위치를 정하고, 평판 리스크로 기본값을 정한다.

위치 결정 기준 — "이 설정은 무엇마다 달라지나?"

적용 범위최적 위치이유
캠페인마다 다름시퀀스 화면공격적/조심스러운 발송을 캠페인별로
사람마다 다름유저 설정"나는 받고 동료는 끄고"
회사 전체 1회워크스페이스 설정영업시간·공휴일 같은 조직 공통 규칙
일회성 행동(토글 아님)데이터 화면의 액션상태가 아니라 한 번의 실행

기본값 결정 4원칙

A. 새 자동 발송 = opt-in(기본 off) — 단, "명확한 트리거 + 강한 무한루프 가드"가 있으면 on 허용
B. 위험 없는 도달율·편의 개선 = 기본 on — 켜는 게 거의 항상 이득이면 기본 on
C. 오발송이 비싸고 되돌리기 어려운 것 = 사람 확인(Review) 기본 — 자동 실행은 명시적 opt-in
D. 점진 롤아웃 — 신규 워크스페이스는 안전 기본, 기존 워크스페이스는 갑작스런 동작 변화를 막기 위해 공지 후 단계 전환

기능별 — 위치 × 기본값 × 핵심 파라미터

기능위치 (scope)기본 상태기본 파라미터 / 근거
1 · Nudge 시퀀스 (캠페인별) OFF 새 자동 발송(원칙 A). 켤 때 기본 cool-down 5영업일·최대 1회. 기존 replyAutomationConfig에 합류
2 · OOO 재발송 시퀀스 (캠페인별, 고급) ON* 트리거가 명확(OOO 응답 수신)하고 1회 제한 가드 → on 허용(원칙 A 예외). *기존 워크스페이스는 공지 후 전환(원칙 D)
3 · 담당자 전달 시퀀스 (답장 자동화) Review 기본
(자동전달 OFF)
최고위험(오발송)→ 사람 확인 기본(원칙 C). Auto 모드는 confidence≥0.8 이해 후 opt-in
4 · 영업일 발송 워크스페이스(기본) + 시퀀스 override ON 도달율에 명백히 이득·위험 없음(원칙 B). 기본 캘린더 월~금·현지 09–18시·국가 공휴일 skip. 기존 워크스페이스는 발송 패턴 변화 있으므로 단계 적용(원칙 D)
5 · 회사명 정리 leads 화면 (액션) 해당 없음 토글이 아닌 일회성 액션. "기본값"은 미리보기 후 적용 강제 + 제거 규칙 체크박스 기본 선택
6 · 긍정 알림 유저 설정 (사람별) ON 유용·피로 낮음(긍정/미팅만, 원칙 B). confidence≥0.7·24h 중복가드. notification_preferences 없으므로 우선 무조건 on→이후 끄기 제공

기존 워크스페이스 마이그레이션 (원칙 D 상세)

"기본 on"이라도 이미 운영 중인 워크스페이스에 조용히 켜면 안 된다.
  • 기능 4(영업일) — 켜는 순간 발송 시간대·속도가 바뀐다(밤샘 발송 → 영업시간 집중). 적체 가능성 있으므로 인앱 공지 + 명시적 활성화 후 적용. 신규 워크스페이스만 자동 on.
  • 기능 2(OOO 재발송) — 발송량이 늘 수 있어 출시 초기엔 off로 시작 → 안정 확인 후 신규 기본 on.
  • 기능 6(알림) — preferences 테이블 신설 전까지는 전원 on. 끄기 UI가 준비된 뒤에 개별 제어.
원칙: 신규 = 안전한 권장 기본값, 기존 = 동작이 바뀌는 것만 공지·opt-in.
메일함(inbox)에는 설정 토글을 두지 않는다. inbox는 결과를 보고 개별 대응하는 운영 화면 — 정책(설정)과 액션(개별 실행)을 분리한다. 단 개별 액션은 inbox에 노출: "이 답장 전달하기"(기능 3), "이 thread 알림 끄기"(기능 6). 설정 scope가 불명확해지는 토글만 금지.

헥사고날 설계 · 영향도 분석

코드 반영 전 단계에서 2026 헥사고날(ports & adapters)로 설계. 도메인 로직을 순수하게 격리하고 부수효과(DB·이메일·AI·시계)는 port 뒤로 — 인프라 없이 유스케이스를 테스트한다.

적용 전략 = Surgical (Big-bang 금지) 기존 코드는 전역 싱글톤 + 직접 import(3-layer)지만, ai-gateway·circuit-breaker·agent/repositories에 이미 port 패턴이 있다. 600+ 라우트를 한 번에 갈아엎지 않고, 신규 6기능만 domain/port/adapter 로 짜고 기존 service 는 점진 마이그레이션한다.

4계층 구조

계층역할 (부수효과)이번 기획의 매핑
domain순수 함수 (부수효과 0)normalizeCompanyName·nextBusinessSlot·parseReturnDate·각 게이트 — 이미 작성·테스트 완료
port인터페이스EmailSender·EnrollmentRepo·ReplyClassifier·Clock·HolidayCalendar·ContentGenerator·Notifier·LeadRepo·Scheduler
adapterport 구현 (기존 코드 재사용)email.service→EmailSender · sequence-lifecycle.service→EnrollmentRepo · ai-classification.service→ReplyClassifier · ai-gateway→ContentGenerator
usecaseport 조합 + domain 호출 (인프라 0)scanForNudges·handleOOOReply·forwardWrongContact·scheduleWithBusinessHours·previewNormalize/commit·notifyPositiveReply
핵심 이점 — 인프라 없이 시나리오 테스트 usecase 는 인프라를 모르므로 fake port(인메모리)로 모든 시나리오를 검증할 수 있다. DB·Redis·SendGrid·Gemini 없이 총 57개 테스트 통과(domain 31 + 중복방지 7 + beta 실데이터 5 + usecase 시나리오 14). 예: "Nudge 후보 3종 중 eligible 1건만 발송", "OOO 중 사람 답장 도착 시 재발송 취소", "전달 Review 기본 → 미발송", "dry-run preview 시 DB 미변경".

영향도 분석 (기능별 touch points)

기능[수정] 기존[신규]
1 재참여 (Nudge 개명)sequences.ts(enrollment에 replied_at·nudge 컬럼)·worker.ts 등록scanner worker + usecase + EnrollmentRepo adapter. ⚠️ 기존 nudge(업데이트알림)와 이름 충돌 → re-engagement
2 OOO 재발송webhook.service·reply-automation.service·emails.ts(is_ooo)ooo-detection + handleOOOReply usecase + Scheduler adapter
3 전달ai-classification.service(wrong_contact intent)·emails.ts(forwarded_*)forwardWrongContact usecase
4 영업일sequence-email-worker/steps/send-email.ts·pipeline 게이트business-hours.gate + HolidayCalendar adapter + public_holidays
5 멀티필드 정리leads.routes.ts·lead.servicepreview/commit usecase + LeadRepo adapter + LeadNormalizationModal.tsx·hook
6 알림email-reply-notification.service·webhook.servicenotifyPositiveReply usecase + Notifier adapter + 템플릿
머지 게이트 (CLAUDE.md) 신규 라우트마다 auth 매크로 + check:routes · DB migration(enrollment replied_at 등) → Slack 공지 필수 · sh send-ci.sh · 신규 위험 로직 테스트를 CI/pre-commit 편입(현 CI 는 unit test 미실행). 기능별 작은 PR 로 분리, 기능 5(migration 없음)부터.

통합 우선순위 · 로드맵

기능유형난이도의존우선순위
6 · 긍정·미팅 알림확장S없음P0 (즉시)
5 · 회사명 정리 모달확장S~M없음P0 (즉시)
4 · 국가별 영업일신규L없음(기반)P1 (기반)
3 · 담당자 자동 전달신규M알림(6)P1
2 · OOO 재발송확장S~M영업일(4)P2
1 · 미응답 Nudge신규M영업일(4)P2

권장 진행 순서

  1. Wave 1 (빠른 가치) — 기능 6 + 기능 5. 의존 없음, 며칠 내 체감. 사용자 만족·신뢰 즉시 확보.
  2. Wave 2 (기반) — 기능 4 영업일 캘린더. 가장 무겁지만 기능 1·2가 이 위에 선다. capacity 이월 로직을 여기서 한 번 제대로.
  3. Wave 3 (지능형 자동화) — 기능 3(전달) → 기능 2(OOO 재발송) → 기능 1(Nudge). 모두 답장 intent·영업일 기반을 재사용.
설계 일관성 원칙 ① 모든 자동 발송(Nudge·OOO 재발송·전달)은 기능 4 영업일 게이트를 반드시 통과 · ② 모든 자동 알림(전달·긍정)은 단일 알림 경로 + 중복 가드 공유 · ③ intent 확장은 ai-classification.service.ts 한 곳에서 (SSOT) · ④ 자동 발송은 전부 opt-in 기본 off로 출시해 평판 리스크 차단.