export type EmailStatusVerdict = | 'send' | 'send_with_caution' | 'verify_next' | 'hold' | 'drop'; export type EmailStatusValue = | 'valid' | 'invalid' | 'catch_all' | 'valid_catch_all' | 'unknown' | 'do_not_mail' | 'spamtrap' | 'abuse' | 'disposable'; export type EmailDeliverability = 'high' | 'medium' | 'low' | 'unknown'; export type EmailMxClass = | 'consumer_mailbox' | 'workspace_mailbox' | 'security_gateway' | 'on_prem' | 'unknown'; export type EmailStatus = { verdict: EmailStatusVerdict; status: EmailStatusValue; verified: boolean; confidence: number | null; reasons: string[]; signals: { catch_all: boolean | null; deliverability: EmailDeliverability; mx_class: EmailMxClass; mx_provider: string | null; mx_record: string | null; fraud_score: number | null; disposable: boolean | null; role_based: boolean | null; free_email: boolean | null; abuse: boolean | null; spamtrap: boolean | null; suspect: boolean | null; valid: boolean | null; }; provider: { name: string; raw_status: string | boolean | number | null; raw_score: number | null; }; }; export type EmailStatusMapEntry = { status: EmailStatusValue; verdict?: EmailStatusVerdict; verified?: boolean; reason?: string; }; export type EmailStatusRule = EmailStatusMapEntry & { when: Record; }; export type EmailStatusExtractorConfig = { provider: string; rawStatus?: string[]; rawScore?: string[]; valid?: string[]; deliverability?: string[]; catchAll?: string[]; mxProvider?: string[]; mxRecord?: string[]; fraudScore?: string[]; disposable?: string[]; roleBased?: string[]; freeEmail?: string[]; abuse?: string[]; spamtrap?: string[]; suspect?: string[]; statusMap?: Record; rules?: EmailStatusRule[]; }; export type EmailStatusBuildInput = { config: EmailStatusExtractorConfig; values: Record; }; // There is intentionally NO shared default status map. Each provider must // declare its own `statusMap` / `rules` for the raw status strings it emits // (see the provider registries under src/lib/integrations/**). A coarse global // map silently coerced provider-specific strings ("deliverable", "accept_all", // etc.) into canonical verdicts and let validators depend on guesses instead of // documented behavior. With it gone, an unmapped raw status falls through to // the typed signal inference below (and ultimately to `unknown`/`hold`) rather // than being normalized by a global lookup — loud and provider-declared over // silent and implicit. function normalizeKey(value: unknown): string | null { if (value == null) return null; if (typeof value === 'boolean') return String(value); const normalized = String(value).trim().toLowerCase().replace(/\s+/g, '_'); return normalized || null; } function boolish(value: unknown): boolean | null { if (typeof value === 'boolean') return value; if (typeof value === 'number') return value === 1 ? true : value === 0 ? false : null; if (typeof value !== 'string') return null; const normalized = value.trim().toLowerCase(); if (['true', 'yes', 'y', '1'].includes(normalized)) return true; if (['false', 'no', 'n', '0'].includes(normalized)) return false; return null; } function numberish(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value !== 'string' || value.trim() === '') return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function stringish(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } function deliverability(value: unknown): EmailDeliverability { const normalized = normalizeKey(value); return normalized === 'high' || normalized === 'medium' || normalized === 'low' ? normalized : 'unknown'; } function mxClass(mxProvider: unknown, mxRecord: unknown): EmailMxClass { const haystack = `${stringish(mxProvider) ?? ''} ${stringish(mxRecord) ?? ''}`.toLowerCase(); if (!haystack.trim()) return 'unknown'; if ( /proofpoint|pphosted|mimecast|barracuda|ess\.barracudanetworks|ironport|cisco|iphmx|messagelabs|symantec/.test( haystack, ) ) { return 'security_gateway'; } if (/aspmx\.l\.google|google|g-suite|google workspace/.test(haystack)) { return 'workspace_mailbox'; } if (/protection\.outlook|office365|microsoft|outlook|exchange online/.test(haystack)) { return 'workspace_mailbox'; } if (/gmail|yahoo|icloud|aol|hotmail/.test(haystack)) return 'consumer_mailbox'; if (/postfix|exim|sendmail|zimbra|plesk|cpanel|mail\./.test(haystack)) return 'on_prem'; return 'unknown'; } function entryForStatus( key: string | null, map: Record | undefined, ): EmailStatusMapEntry | null { if (!key) return null; return map?.[key] ?? null; } function read(values: Record, name: string): unknown { return values[name]; } function matchesRule( rule: EmailStatusRule, values: Record, ): boolean { return Object.entries(rule.when).every(([key, expected]) => { const actual = read(values, key); if (key.endsWith('Lt')) { const source = numberish(read(values, key.slice(0, -2))); return typeof expected === 'number' && source != null && source < expected; } if (typeof expected === 'boolean') return boolish(actual) === expected; if (typeof expected === 'number') return numberish(actual) === expected; return normalizeKey(actual) === normalizeKey(expected); }); } export function buildEmailStatus({ config, values, }: EmailStatusBuildInput): EmailStatus { const rawStatus = read(values, 'rawStatus'); const rawScore = numberish(read(values, 'rawScore')); const valid = boolish(read(values, 'valid')); const catchAll = boolish(read(values, 'catchAll')); const disposable = boolish(read(values, 'disposable')); const abuse = boolish(read(values, 'abuse')); const spamtrap = boolish(read(values, 'spamtrap')); const suspect = boolish(read(values, 'suspect')); const rawKey = normalizeKey(rawStatus); const mapped = config.rules?.find((rule) => matchesRule(rule, values)) ?? entryForStatus(rawKey, config.statusMap) ?? entryForStatus(valid == null ? null : String(valid), config.statusMap); const status = mapped?.status ?? (disposable ? 'disposable' : abuse ? 'abuse' : spamtrap ? 'spamtrap' : catchAll ? 'catch_all' : valid === true ? 'valid' : valid === false ? 'invalid' : 'unknown'); const defaultVerdict: EmailStatusVerdict = status === 'valid' ? 'send' : status === 'valid_catch_all' ? 'send_with_caution' : status === 'catch_all' ? 'verify_next' : status === 'unknown' ? 'hold' : 'drop'; const verdict = mapped?.verdict ?? defaultVerdict; const verified = mapped?.verified ?? (status === 'valid' || status === 'valid_catch_all' || verdict === 'send'); const reasons = [ mapped?.reason, catchAll ? 'catch_all_domain' : null, mxClass(read(values, 'mxProvider'), read(values, 'mxRecord')) === 'security_gateway' ? 'security_gateway_mx' : null, suspect ? 'provider_marked_suspect' : null, ].filter((reason): reason is string => typeof reason === 'string'); return { verdict, status, verified, confidence: rawScore, reasons, signals: { catch_all: catchAll, deliverability: deliverability(read(values, 'deliverability')), mx_class: mxClass(read(values, 'mxProvider'), read(values, 'mxRecord')), mx_provider: stringish(read(values, 'mxProvider')), mx_record: stringish(read(values, 'mxRecord')), fraud_score: numberish(read(values, 'fraudScore')), disposable, role_based: boolish(read(values, 'roleBased')), free_email: boolish(read(values, 'freeEmail')), abuse, spamtrap, suspect, valid, }, provider: { name: config.provider, raw_status: typeof rawStatus === 'string' || typeof rawStatus === 'boolean' || typeof rawStatus === 'number' ? rawStatus : null, raw_score: rawScore, }, }; }