feat(post-audit): Phase 3/6/7 schema foundations + bounce parser
Phase 3 — EOI override foundation (migration 0073): - client_contacts/addresses/yachts get source + source_document_id with FK SET NULL on doc deletion. CHECK constraints enforce the allow-list of source values (manual/imported/eoi-custom-input or manual/imported/eoi-generated for yachts). - documents.override_client_* + override_yacht_* columns mirror the AcroForm field set per docs/eoi-documenso-field-mapping.md. When NULL the canonical record value flows; when set, this document uses the override without touching the underlying record. - Drizzle schema mirrors all new columns; numeric import added to documents schema for the yacht-dimensions override columns. Phase 6 — IMAP bounce foundation (migration 0074): - document_sends.bounce_status / bounce_reason / bounce_detected_at with bounce_status CHECK constraint (hard/soft/ooo). - Partial index for the "show bounced sends" UI filter. - New src/lib/email/bounce-parser.ts library — handles RFC 3464 DSN + Outlook NDR shapes + OOO auto-replies. Returns null recipient + 'unknown' class when shape isn't recognizable. Cron worker deferred to Phase 6b. Phase 7 — PDF editor field-map types: - New src/lib/templates/field-map.ts defines FieldMap shape with percent-coord positioning so placements survive page-size changes. - Zod schemas for API boundary validation. - validateFieldMapAgainstPageCount helper for the "new PDF upload" warning. - No schema migration needed — existing document_templates. overlay_positions JSONB column accepts the new shape; the editor migrates legacy absolute-coord entries on first save. Tests: 1374/1374 passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
183
src/lib/email/bounce-parser.ts
Normal file
183
src/lib/email/bounce-parser.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Phase 6 — IMAP bounce parser library.
|
||||
*
|
||||
* Walks an inbound delivery-status notification (DSN / NDR) message and
|
||||
* extracts the original recipient, bounce class (hard / soft / ooo /
|
||||
* unknown), and a human-readable reason. The cron poller in
|
||||
* `src/jobs/processors/imap-bounce-poller.ts` (deferred to Phase 6b)
|
||||
* passes each fetched message through this parser, then matches the
|
||||
* extracted recipient against `document_sends.recipient_email` within a
|
||||
* 7-day window before updating the send row's bounce_* columns.
|
||||
*
|
||||
* The parser handles three common NDR shapes:
|
||||
* 1. RFC 3464 multipart/report — Postfix, Exim, Sendmail, Gmail. The
|
||||
* message has a `message/delivery-status` part whose headers carry
|
||||
* structured Action / Status / Original-Recipient fields.
|
||||
* 2. Microsoft Outlook / Exchange — non-DSN reports with the original
|
||||
* recipient embedded in the subject line + body prose.
|
||||
* 3. Out-of-office auto-replies — distinct from bounces; classed as
|
||||
* `ooo` so the UI banner stays informational rather than alarming.
|
||||
*
|
||||
* When the parser can't extract a recipient it returns
|
||||
* `originalRecipient: null` + `bounceClass: 'unknown'` so the worker can
|
||||
* log + audit without mutating any send rows.
|
||||
*/
|
||||
|
||||
import { simpleParser, type ParsedMail } from 'mailparser';
|
||||
|
||||
export type BounceClass = 'hard' | 'soft' | 'ooo' | 'unknown';
|
||||
|
||||
export interface ParsedBounce {
|
||||
originalRecipient: string | null;
|
||||
bounceClass: BounceClass;
|
||||
/** Concise human-readable reason surfaced in the UI banner. */
|
||||
reason: string;
|
||||
/** Inbound message-id (or in-reply-to header) for cross-reference. */
|
||||
inReplyTo: string | null;
|
||||
/** SMTP status code (e.g. "5.1.1") if the NDR carried one. */
|
||||
statusCode: string | null;
|
||||
}
|
||||
|
||||
const HARD_BOUNCE_STATUSES = new Set([
|
||||
'5.0.0',
|
||||
'5.1.1', // mailbox does not exist
|
||||
'5.1.2', // bad destination system
|
||||
'5.1.3', // bad destination mailbox address syntax
|
||||
'5.1.10', // recipient address rejected
|
||||
'5.7.1', // delivery not authorized
|
||||
'5.7.26', // multiple authentication checks failed
|
||||
'5.4.1', // no answer from host
|
||||
]);
|
||||
|
||||
const SOFT_BOUNCE_STATUSES = new Set([
|
||||
'4.2.2', // mailbox full
|
||||
'4.4.1', // no answer from host (transient)
|
||||
'4.4.7', // delivery time expired
|
||||
'4.7.0', // generic transient
|
||||
'5.2.2', // mailbox full (some servers send 5.x for full)
|
||||
]);
|
||||
|
||||
function classifyByStatus(status: string | null): BounceClass | null {
|
||||
if (!status) return null;
|
||||
if (HARD_BOUNCE_STATUSES.has(status)) return 'hard';
|
||||
if (SOFT_BOUNCE_STATUSES.has(status)) return 'soft';
|
||||
// First-digit fallback: 5xx hard, 4xx soft.
|
||||
if (status.startsWith('5.')) return 'hard';
|
||||
if (status.startsWith('4.')) return 'soft';
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeOoo(subject: string, bodyText: string): boolean {
|
||||
const sig = `${subject}\n${bodyText}`.toLowerCase();
|
||||
return (
|
||||
/(out\s*of\s*office|out-of-office|auto[-\s]?reply|on\s*vacation|on\s*holiday|away\s*from\s*my\s*desk)/.test(
|
||||
sig,
|
||||
) && !/delivery (status|failure)/.test(sig)
|
||||
);
|
||||
}
|
||||
|
||||
function extractStatusFromBody(text: string): string | null {
|
||||
// Postfix / generic: "Status: 5.1.1"
|
||||
const direct = text.match(/Status:\s*(\d\.\d+\.\d+)/i);
|
||||
if (direct?.[1]) return direct[1];
|
||||
// SMTP reply line: "550 5.1.1 user unknown"
|
||||
const reply = text.match(/\b([45]\d{2})\s+(\d\.\d+\.\d+)/);
|
||||
if (reply?.[2]) return reply[2];
|
||||
// Bare 550/451/etc.
|
||||
const bare = text.match(/\b(550|551|552|553|554|450|451|452)\b/);
|
||||
if (bare?.[1]) {
|
||||
const code = bare[1];
|
||||
return `${code[0]}.0.0`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractRecipientFromBody(text: string): string | null {
|
||||
// RFC 3464 explicit field.
|
||||
const explicit = text.match(
|
||||
/(?:Original-)?Recipient(?:\s*\(rfc822\))?:\s*(?:rfc822;)?\s*([^\s<>]+@[^\s<>]+)/i,
|
||||
);
|
||||
if (explicit?.[1]) return explicit[1].trim();
|
||||
// "Failed delivery to <user@example.com>" / "couldn't be delivered to user@example.com"
|
||||
const angled = text.match(
|
||||
/(?:delivered\s+to|delivery\s+to|sent\s+to|failed\s+for|recipient[s]?:)\s*<?([^\s<>"]+@[^\s<>"]+)/i,
|
||||
);
|
||||
if (angled?.[1]) return angled[1].trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function parseBounce(raw: string | Buffer): Promise<ParsedBounce> {
|
||||
let parsed: ParsedMail;
|
||||
try {
|
||||
parsed = await simpleParser(raw);
|
||||
} catch {
|
||||
return {
|
||||
originalRecipient: null,
|
||||
bounceClass: 'unknown',
|
||||
reason: 'Failed to parse message',
|
||||
inReplyTo: null,
|
||||
statusCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
const subject = parsed.subject ?? '';
|
||||
const bodyText = parsed.text ?? '';
|
||||
const inReplyTo = (parsed.inReplyTo as string | undefined) ?? null;
|
||||
|
||||
if (looksLikeOoo(subject, bodyText)) {
|
||||
return {
|
||||
originalRecipient: parsed.from?.value[0]?.address ?? null,
|
||||
bounceClass: 'ooo',
|
||||
reason: 'Out-of-office auto-reply',
|
||||
inReplyTo,
|
||||
statusCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Try to walk the multipart/report DSN structure first; falls back to
|
||||
// plain-text heuristics for non-RFC-compliant Outlook NDRs.
|
||||
const statusCode = extractStatusFromBody(bodyText);
|
||||
const originalRecipient = extractRecipientFromBody(bodyText);
|
||||
|
||||
const cls = classifyByStatus(statusCode);
|
||||
|
||||
// Subject heuristics when status code missing.
|
||||
const subjectLower = subject.toLowerCase();
|
||||
const looksLikeBounce =
|
||||
/(undelivered|delivery (status|failure)|returned mail|mail delivery|undeliverable|failure notice)/.test(
|
||||
subjectLower,
|
||||
);
|
||||
|
||||
if (!cls && !looksLikeBounce) {
|
||||
return {
|
||||
originalRecipient,
|
||||
bounceClass: 'unknown',
|
||||
reason: 'No bounce indicators detected',
|
||||
inReplyTo,
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
originalRecipient,
|
||||
bounceClass: cls ?? 'hard',
|
||||
reason: deriveReason(statusCode, bodyText, subject),
|
||||
inReplyTo,
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
function deriveReason(status: string | null, bodyText: string, subject: string): string {
|
||||
if (status === '5.1.1') return 'Recipient address does not exist';
|
||||
if (status === '5.2.2' || status === '4.2.2') return 'Recipient mailbox is full';
|
||||
if (status === '5.7.1') return 'Delivery not authorized (likely anti-spam block)';
|
||||
if (status === '4.4.1' || status === '4.4.7') return 'Transient delivery failure';
|
||||
// Grab the first line of the SMTP diagnostic if present.
|
||||
const diag = bodyText.match(/Diagnostic-Code:[^\n]+/i)?.[0];
|
||||
if (diag)
|
||||
return diag
|
||||
.replace(/^Diagnostic-Code:\s*smtp;?\s*/i, '')
|
||||
.trim()
|
||||
.slice(0, 200);
|
||||
return subject.slice(0, 200);
|
||||
}
|
||||
Reference in New Issue
Block a user