sequence-email-worker/processor.ts 의 step 0~5 + Step 2의 6-gate pipeline + mvFailOpenGate 안 verifyEmail() 의 캐시 흐름.
isUndeliverableEmail (noreply/postmaster/billing 등). info/sales/contact 는 GENERIC_PREFIXES 에 있지만 호출 안 됨verifyEmail() 호출:
ev:<email> 캐시 (50y) → hit 시 즉시 returnbounce:bl:<email> 캐시 (hard 90d / soft 7d)mx:v1:<domain> 캐시 (positive 24h / negative 1h) → DNS resolveMx fallbackundeliverable || (risky && score≤20) → skipStep, 나머지 → pass. CreditsExhausted catch → pass (fail-OPEN).
글로벌 ws lead_emails 33,132건 중 verified=1 단 ~2%. 90.4% 가 source='gtm_beauty_upload' 이고 코드베이스에 grep 매치 0건 = 외부 스크립트/수동 SQL 로 raw insert. 정식 verifyEmailCascade 경로 우회.
현 정책 score≤20 차단 은 jaykim 인시던트 (#4214, 2026-04-09) 의 직접 결과 — "MV unknown 의 79%가 유효". 그래서 score=30 (unknown), score=50 (catch_all) 은 모두 send 허용. 글로벌 ws B2B 도메인은 MV SMTP probe 차단 비율이 높아 unknown/catch_all 응답이 60%+ → 통과율 폭증.
email-event.service.ts:293-343 — SES가 보낸 MAILER-DAEMON NDR이 inbound 메일박스로 와서 direction=inbound, status=bounced 로 emails 테이블에 INSERT. bounce 1건 → DB row 2개 (outbound + inbound NDR). admin UI 카운트가 ~2배로 부풀어보임 (오늘 outbound 195 + inbound 137 = 332).
| MV Score | 분류 | bounced 50 | delivered 50 |
|---|---|---|---|
| 5 | undeliverable (invalid/no_mailbox) | 4 | 0 |
| 20 | Option B fallback (API timeout, MX 있음) | 18 | 10 |
| 30 | risky unknown (ip_blocked/greylisted) | 12 | 2 |
| 50 | risky catch_all | 15 | 10 |
| 95 | deliverable (ok/good) | 1 | 28 |
핵심 관찰: Score 20 (Option B fallback) 은 bounce/delivered 양쪽에 고르게 분포 — MV API timeout 자체는 bounce 신뢰 시그널이 아님. Score 30 unknown 은 bounce 가능성 6배. Score 95 deliverable 중 1건은 MV false-positive (catch-all 도메인의 dead mailbox).
| bounce_type | 건수 | 비율 | 의미 |
|---|---|---|---|
| hard | 163 | 74.8% | 영구 실패 (mailbox 없음) — SES reputation 직격 |
| soft | 55 | 25.2% | 일시 실패 (mailbox full / policy) — 재시도 가능 |
| 시간 | 발송 | bounce | bounce rate |
|---|---|---|---|
| 12시 | 107 | 22 | 20.6% |
| 13시 | 196 | 80 | 40.8% ← peak |
| 14시 | 265 | 45 | 17.0% |
| 15시 | 209 | 45 | 21.5% |
| 16시 | 94 | 26 | 27.7% |
13시 peak에 196건 발송 중 80건 bounce. 5% SES 임계값 초과 후에도 발송 지속 → SES reputation 손상 진행 중. 자동 pause hook 절실.
| ws | 발송 | bounced | rate | 판정 |
|---|---|---|---|---|
| 글로벌 린다세일즈 (오늘만) | 865 | 214 | 24.7% | 🚨 SES 정지 위험 |
| 제이에이치유통 | 2,363 | 199 | 8.42% | ⚠️ 모니터링 필요 |
| aeroway | 2,628 | 58 | 2.21% | 정상범위 |
| 린다세일즈 | 2,896 | 15 | 0.52% | 우수 |
| YS Medi | 6,557 | 21 | 0.32% | 우수 |
| 임계값 | TP (block bounce) | FN (miss bounce) | FP (block delivered) | precision | recall | Youden's J |
|---|---|---|---|---|---|---|
| ≤20 (현재) | 22 | 28 | 10 | 0.69 | 0.44 | 0.24 |
| ≤30 | 34 | 16 | 12 | 0.74 | 0.68 | 0.44 ← 최적 |
| ≤50 | 49 | 1 | 22 | 0.69 | 0.98 | 0.54 |
| ≤70 | 49 | 1 | 22 | 0.69 | 0.98 | 0.54 |
| ≤95 | 50 | 0 | 50 | 0.50 | 1.00 | 0.00 |
| Score | 분류 | 이메일 |
|---|---|---|
| 50 | catch_all | info@thebase.in, info@gentlemenx.com, export@thebeautystorysgp.com, india@velnik.com, info@gblg.com, chicor_com@shinsegae.com, support@lamior.com, info@nbcgroup.kz, sales@arti.trade, steve@vitarain.com |
| 20 | Option B fallback | info@agkujatrading.com, sales@gcc-partners.com, info@octipharma.com, info@alazimperfumes.com, customerservice@beautyonwheels.ae, solution@scanmed.com.sg, export@dreamgeneraltrading.com, contact@alharamainperfumes.in, info@kelam.com.mv, export@medicello.com |
| 30 | unknown | global3@nutricare.co.kr, info@beautyprestige.ae |
같은 도메인의 24시간 발송 결과를 보면 도메인 자체가 bounce 결정적임. 같은 도메인 ≥3건 발송 중:
| 도메인 | 발송 | bounce |
|---|---|---|
| gmail.com | 49 | 0 |
| khaleejtimes.com | 11 | 0 |
| lvmh.com | 4 | 0 |
| beiersdorf.com | 4 | 0 |
| razer.com | 5 | 0 |
| gulfnews.com | 4 | 0 |
| 도메인 | 발송 | bounce |
|---|---|---|
| dosinternational.co.kr | 3 | 3 |
| asbahproducts.com | 3 | 3 |
| emirates-online.net | 3 | 3 |
| atninfo.com | 3 | 3 |
| stayve.me | 3 | 3 |
| nextgenzuae.com | 3 | 3 |
| leaderhealthcare.in | 3 | 3 |
| medwin.ae | 3 | 3 |
패턴 — 캠페인이 도메인마다 info@/sales@/export@ 3 prefix 로 발송. 도메인의 첫 hard bounce 가 발생하면 같은 도메인의 다른 prefix 도 99% 확률로 bounce. 즉 도메인 1번 bounce 시 그 도메인의 나머지 enrollment 즉시 차단 만으로도 effective recall ~70% 달성 가능.
undeliverable || score≤20 → block. 모든 ws 기본값. recall 44%, FP 20%. 변경 없음 (다른 ws의 recall 보호).
rolling 24h bounce_rate < 5% → ≤20 유지 (현재) 5% ≤ rate < 10% → ≤30 강화 (precision 0.74, recall 0.68) rate ≥ 10% → ≤30 + 활성 시퀀스 자동 pause (SES protect)
구현: Redis 캐시 bounce_rate:ws:<id> 5분 TTL. mv-fail-open.gate.ts 에서 getEffectiveScoreThreshold(workspaceId) 호출.
같은 도메인 24h hard bounce ≥ 2건 → 그 도메인 발송 즉시 차단
실측 데이터로 검증: 글로벌 ws 의 도메인 prior 패턴 (100% bounce 도메인 8개+) 만 잡아도 추가 차단 ~50건. Redis domain:bl:<domain> zset, hard 90d / soft 7d. bounce webhook에서 자동 갱신.
33,132 unverified lead 에 verifyEmailCascade batch — 발송 시작 전 사전 검증. 그렇지 않으면 다음 캠페인도 같은 패턴 반복.
mv-fail-open.gate.ts — 임계값 동적화 (Layer 2)// 기존 (hard-coded ≤20):
if (verdict?.result === "risky" && verdict.score <= 20) {
return skipStep("skipped", `Tier2 risky low-score: ${toEmail}`)
}
// 변경:
const threshold = await getEffectiveScoreThreshold(ctx.job.data.workspaceId)
if (verdict?.result === "risky" && verdict.score <= threshold) {
return skipStep("skipped", `Tier2 risky score≤${threshold}: ${toEmail}`)
}
bounce-rate.service.ts (신규) — rolling 24h 계산 + Redis 캐시import { RedisCache } from "./redis-cache.service"
import { db } from "../db"
import { emails } from "../db/schema/emails"
import { and, eq, gte, sql } from "drizzle-orm"
const cache = RedisCache.fromConfig({
enabled: true,
keyPrefix: "bounce_rate:ws:",
ttlMs: 5 * 60 * 1000, // 5min — adaptive 변경 빈도 vs DB 부하 trade-off
timeoutMs: 200,
})
export async function getRollingBounceRate(workspaceId: string): Promise<number> {
const cached = await cache.get<number>(workspaceId)
if (cached !== null) return cached
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000)
const [row] = await db
.select({
sent: sql<number>`COUNT(*) FILTER (WHERE status IN ('delivered','opened','clicked','bounced','sent','spam'))::int`,
bounced: sql<number>`COUNT(*) FILTER (WHERE status = 'bounced')::int`,
})
.from(emails)
.where(and(eq(emails.workspaceId, workspaceId), eq(emails.direction, "outbound"), gte(emails.createdAt, cutoff)))
const rate = row && row.sent > 0 ? row.bounced / row.sent : 0
await cache.set(workspaceId, rate)
return rate
}
export async function getEffectiveScoreThreshold(workspaceId: string): Promise<number> {
const rate = await getRollingBounceRate(workspaceId)
if (rate >= 0.10) {
// Layer 4 trigger — 자동 pause (idempotent)
await autoPauseActiveSequences(workspaceId).catch(() => {})
return 30
}
if (rate >= 0.05) return 30
return 20
}
domain-3-strike.gate.ts (신규) — Layer 3import { RedisCache } from "../../../../../services/redis-cache.service"
import type { Gate } from "../types"
import { pass, skipStep } from "../types"
const domainCache = RedisCache.fromConfig({
enabled: true,
keyPrefix: "domain:bl:",
ttlMs: 90 * 24 * 60 * 60 * 1000, // hard 90d
timeoutMs: 100,
})
export const domain3StrikeGate: Gate = {
name: "domain3Strike",
async run({ ctx, toEmail }) {
const domain = toEmail.split("@")[1]?.toLowerCase()
if (!domain) return pass()
const entry = await domainCache.get<{ count: number; firstAt: string }>(domain)
if (entry && entry.count >= 2) {
return skipStep("skipped", `Domain ${domain} has ${entry.count}+ hard bounces`)
}
return pass()
},
}
// pipeline.ts 에 추가 (mvFailOpen 보다 먼저)
export const DEFAULT_PIPELINE: Gate[] = [
formatGate, roleGate, dummyGate, disposableGate, dedupGate,
domain3StrikeGate, // ← 신규
mvFailOpenGate,
]
bounce-check.service.ts — onBounceDetected 확장 (domain counter)export async function onBounceDetected(toEmail: string, bounceType: "hard" | "soft" | "complaint" = "hard"): Promise<void> {
// 기존: blacklist + cache invalidate + lead_enrichment_state propagation
await Promise.all([addToBounceBlacklist(toEmail, blacklistType), invalidateVerificationCache(toEmail)])
// 신규: domain-level counter (Layer 3)
if (bounceType === "hard") {
const domain = toEmail.split("@")[1]?.toLowerCase()
if (domain) await incrementDomainBounceCounter(domain)
}
// ... (기존 lead_enrichment_state 로직 유지)
}
async function incrementDomainBounceCounter(domain: string): Promise<void> {
const existing = await domainCache.get<{ count: number; firstAt: string }>(domain)
const next = existing
? { count: existing.count + 1, firstAt: existing.firstAt }
: { count: 1, firstAt: new Date().toISOString() }
await domainCache.set(domain, next, 90 * 24 * 60 * 60)
}
sequence-auto-pause.service.ts (신규) — Layer 4const PAUSED_FLAG_KEY = "ws_auto_pause:"
const pauseCache = RedisCache.fromConfig({ enabled: true, keyPrefix: PAUSED_FLAG_KEY, ttlMs: 60_000 })
export async function autoPauseActiveSequences(workspaceId: string): Promise<void> {
// idempotent — 1분 dedup
if (await pauseCache.get<boolean>(workspaceId)) return
await pauseCache.set(workspaceId, true)
await db.update(sequences).set({ isActive: false, pausedReason: "auto_pause_high_bounce_rate" })
.where(and(eq(sequences.workspaceId, workspaceId), eq(sequences.isActive, true)))
// Slack 알림
await slackNotify({ channel: "#alerts-ses", text: `🚨 ws ${workspaceId} bounce rate > 10% — sequences auto-paused` })
}
// workers/bullmq/lead-backfill-verify.worker.ts (신규)
export const leadBackfillVerifyWorker = createWorker("lead-backfill-verify", async (job) => {
const { leadId, email } = job.data
const cascade = await verifyEmailCascade(email, { caller: "backfill" })
if (cascade.deliverable === false) {
await db.update(leadEnrichmentState).set({ status: "unreachable" }).where(eq(leadEnrichmentState.leadId, leadId))
} else if (cascade.deliverable === true) {
await db.update(leadEmails).set({ verified: 1, verifiedAt: new Date() })
.where(and(eq(leadEmails.leadId, leadId), eq(leadEmails.emailLower, email.toLowerCase())))
}
}, { concurrency: 5, limiter: { max: 50, duration: 1000 } }) // MV 144 req/s 의 35% 사용
// 발송 전 1회 호출: source='gtm_beauty_upload' AND verified=0 일괄 enqueue
| 패턴 | 적용 위치 | 이유 |
|---|---|---|
| Per-ws adaptive config | Layer 2 (getEffectiveScoreThreshold) | 다른 ws 의 recall 손실 방지. 전역 hard-coded 대신 측정-기반 자동 조정 |
| Redis-backed counter + TTL | Layer 3 (domain:bl:zset), Layer 4 (ws_auto_pause flag) | 워커 재시작 survive + 다중 인스턴스 SSOT |
| Fail-OPEN 의도적 유지 | mvFailOpen (CreditsExhausted 통과) | 2026-05-15 YS Medi 인시던트 (fail-CLOSED 로 enrollment 영구 stop) 회피 |
| Async observability | Slack alert + Grafana metric | Auto-pause 작동 시 즉시 알림. 사후 분석 가능 |
| Idempotent triggers | autoPauseActiveSequences 1분 dedup | 다중 워커 동시 호출 시 race condition 방지 |
| Backfill via BullMQ | lead-backfill-verify.worker | 33k batch 를 hot path 로 처리하지 않음. concurrency + limiter 로 MV credit 보호 |
| Schema-less domain blacklist | Redis key prefix domain:bl:<domain> | DB index 추가 없이 O(1) 조회. 90d TTL 자동 expire |
| 우선 | 조치 | 예상 효과 | 위험 |
|---|---|---|---|
| 🚨 NOW | 글로벌 ws 활성 시퀀스 즉시 pauseUPDATE sequences SET is_active=false WHERE workspace_id='50a26184...' | bounce 폭주 차단 (8,170건 추가 방지) | 없음 (수동 재개 가능) |
| P0 | Layer 3 (Domain 3-Strike Rule) 추가 | 가장 강력 — 100% bounce 도메인 즉시 차단. precision 0.95+ 예상 | 도메인 첫 mailbox는 통과 (acceptable) |
| P0 | Layer 2 (Adaptive threshold) 추가 | per-ws 정책. 글로벌 ws만 ≤30, 다른 ws 영향 없음 | FP 10→12 (+2건) |
| P1 | 시퀀스 자동 pause @ 10% bounce rate | SES reputation 보호 | 없음 |
| P1 | upstream gtm_beauty_upload backfill batch | 근원 차단. 다음 캠페인 자동 보호 | cascade credit 사용 (~33k credit, MV 잔량 876k 로 충분) |
| P2 | NDR inbound 이중 기록 제거 (email-event.service.ts:299-325) | UI 수치 정확화 (~2배 인플레 해소) | NDR 보관 안 됨 — admin debug 영향 (acceptable) |
emails 테이블 — 2026-06-02 outbound bounce 195건, delivered/opened/clicked 759건elysia-server/src/workers/bullmq/sequence-email-worker/ + services/million-verifier.service.ts06e3c47ce, 2026-03-09, #1978 Cheolhee Lee), Phase 2 (#6594 Ahmed Mansy), Phase 3 (#7787 이예인 cleanup)