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:
114
src/lib/db/migrations/0073_phase3_eoi_overrides.sql
Normal file
114
src/lib/db/migrations/0073_phase3_eoi_overrides.sql
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
-- Phase 3 — EOI field override foundation (POST-AUDIT-SPEC §1 + MASTER-PLAN §C).
|
||||||
|
--
|
||||||
|
-- This migration is foundation-only. The EOI generate-and-sign endpoint
|
||||||
|
-- + the dialog UI extensions land in subsequent sub-sessions (3a-3d).
|
||||||
|
--
|
||||||
|
-- Adds:
|
||||||
|
-- 1. client_contacts.{source, source_document_id}
|
||||||
|
-- 2. client_addresses.{source, source_document_id}
|
||||||
|
-- 3. yachts.{source, source_document_id}
|
||||||
|
-- 4. documents.override_* columns mirroring the AcroForm field map
|
||||||
|
--
|
||||||
|
-- audit_actions is a free-text column (text, not enum) — new action verbs
|
||||||
|
-- 'eoi_field_override', 'promote_to_primary', 'eoi_spawn_yacht' don't
|
||||||
|
-- need DDL; they just appear in inserted rows.
|
||||||
|
|
||||||
|
-- ─── client_contacts ──────────────────────────────────────────────────
|
||||||
|
ALTER TABLE client_contacts
|
||||||
|
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'manual',
|
||||||
|
ADD COLUMN IF NOT EXISTS source_document_id text;
|
||||||
|
|
||||||
|
-- Add FK only if it doesn't exist yet. ON DELETE SET NULL — keep the
|
||||||
|
-- contact row around even if the originating document is deleted.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'client_contacts_source_document_id_fkey') THEN
|
||||||
|
ALTER TABLE client_contacts
|
||||||
|
ADD CONSTRAINT client_contacts_source_document_id_fkey
|
||||||
|
FOREIGN KEY (source_document_id) REFERENCES documents(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_client_contacts_source') THEN
|
||||||
|
ALTER TABLE client_contacts
|
||||||
|
ADD CONSTRAINT chk_client_contacts_source
|
||||||
|
CHECK (source IN ('manual', 'imported', 'eoi-custom-input'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── client_addresses ──────────────────────────────────────────────────
|
||||||
|
ALTER TABLE client_addresses
|
||||||
|
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'manual',
|
||||||
|
ADD COLUMN IF NOT EXISTS source_document_id text;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'client_addresses_source_document_id_fkey') THEN
|
||||||
|
ALTER TABLE client_addresses
|
||||||
|
ADD CONSTRAINT client_addresses_source_document_id_fkey
|
||||||
|
FOREIGN KEY (source_document_id) REFERENCES documents(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_client_addresses_source') THEN
|
||||||
|
ALTER TABLE client_addresses
|
||||||
|
ADD CONSTRAINT chk_client_addresses_source
|
||||||
|
CHECK (source IN ('manual', 'imported', 'eoi-custom-input'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── yachts ────────────────────────────────────────────────────────────
|
||||||
|
ALTER TABLE yachts
|
||||||
|
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'manual',
|
||||||
|
ADD COLUMN IF NOT EXISTS source_document_id text;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'yachts_source_document_id_fkey') THEN
|
||||||
|
ALTER TABLE yachts
|
||||||
|
ADD CONSTRAINT yachts_source_document_id_fkey
|
||||||
|
FOREIGN KEY (source_document_id) REFERENCES documents(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_yachts_source') THEN
|
||||||
|
ALTER TABLE yachts
|
||||||
|
ADD CONSTRAINT chk_yachts_source
|
||||||
|
CHECK (source IN ('manual', 'imported', 'eoi-generated'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ─── documents — per-document overrides ────────────────────────────────
|
||||||
|
-- These columns mirror the AcroForm field set per
|
||||||
|
-- docs/eoi-documenso-field-mapping.md. When NULL, the canonical
|
||||||
|
-- client/yacht values flow through unchanged. When set, the EOI uses
|
||||||
|
-- the override value without touching the underlying record.
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN IF NOT EXISTS override_client_email text,
|
||||||
|
ADD COLUMN IF NOT EXISTS override_client_phone text,
|
||||||
|
ADD COLUMN IF NOT EXISTS override_client_address_line_1 text,
|
||||||
|
ADD COLUMN IF NOT EXISTS override_client_address_line_2 text,
|
||||||
|
ADD COLUMN IF NOT EXISTS override_client_city text,
|
||||||
|
ADD COLUMN IF NOT EXISTS override_client_state text,
|
||||||
|
ADD COLUMN IF NOT EXISTS override_client_postal_code text,
|
||||||
|
ADD COLUMN IF NOT EXISTS override_client_country text,
|
||||||
|
ADD COLUMN IF NOT EXISTS override_yacht_name text,
|
||||||
|
ADD COLUMN IF NOT EXISTS override_yacht_length_ft numeric(10, 2),
|
||||||
|
ADD COLUMN IF NOT EXISTS override_yacht_width_ft numeric(10, 2),
|
||||||
|
ADD COLUMN IF NOT EXISTS override_yacht_draft_ft numeric(10, 2);
|
||||||
|
|
||||||
|
-- ─── Comments ──────────────────────────────────────────────────────────
|
||||||
|
COMMENT ON COLUMN client_contacts.source IS
|
||||||
|
'Phase 3: origin of this contact row. manual = rep typed it; imported = bulk import; eoi-custom-input = inline-typed during EOI generation.';
|
||||||
|
COMMENT ON COLUMN client_contacts.source_document_id IS
|
||||||
|
'Phase 3: when source=eoi-custom-input, points at the EOI that created it.';
|
||||||
|
COMMENT ON COLUMN yachts.source IS
|
||||||
|
'Phase 3: origin. eoi-generated = spawned from the EOI dialog''s "+ New yacht" inline form.';
|
||||||
|
COMMENT ON COLUMN documents.override_client_email IS
|
||||||
|
'Phase 3: per-document override of the client''s email field on this EOI. NULL = use canonical client_contacts row.';
|
||||||
33
src/lib/db/migrations/0074_phase6_bounce_tracking.sql
Normal file
33
src/lib/db/migrations/0074_phase6_bounce_tracking.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- Phase 6 — IMAP bounce-to-interest linking foundation
|
||||||
|
-- (POST-AUDIT-SPEC §14.9 / MASTER-PLAN §F).
|
||||||
|
--
|
||||||
|
-- Schema-only. Parser library + cron worker land in 6b/6c.
|
||||||
|
--
|
||||||
|
-- Adds bounce_status / bounce_reason / bounce_detected_at columns to
|
||||||
|
-- document_sends + a partial index so the UI's "show bounced rows" filter
|
||||||
|
-- is cheap.
|
||||||
|
|
||||||
|
ALTER TABLE document_sends
|
||||||
|
ADD COLUMN IF NOT EXISTS bounce_status text,
|
||||||
|
ADD COLUMN IF NOT EXISTS bounce_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS bounce_detected_at timestamptz;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_document_sends_bounce_status') THEN
|
||||||
|
ALTER TABLE document_sends
|
||||||
|
ADD CONSTRAINT chk_document_sends_bounce_status
|
||||||
|
CHECK (bounce_status IS NULL OR bounce_status IN ('hard', 'soft', 'ooo'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_sends_bounce
|
||||||
|
ON document_sends (port_id, bounce_detected_at)
|
||||||
|
WHERE bounce_status IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN document_sends.bounce_status IS
|
||||||
|
'Phase 6: hard|soft|ooo classification when an NDR for this send was matched by the IMAP poller. NULL = no bounce detected (default).';
|
||||||
|
COMMENT ON COLUMN document_sends.bounce_reason IS
|
||||||
|
'Phase 6: human-readable reason from the parsed NDR (e.g. "user not found"). NULL when not bounced.';
|
||||||
|
COMMENT ON COLUMN document_sends.bounce_detected_at IS
|
||||||
|
'Phase 6: timestamp the cron worker detected this bounce. Drives the in-CRM notification firing once.';
|
||||||
@@ -143,6 +143,13 @@ export const documentSends = pgTable(
|
|||||||
failedAt: timestamp('failed_at', { withTimezone: true }),
|
failedAt: timestamp('failed_at', { withTimezone: true }),
|
||||||
/** Human-readable failure reason; only meaningful when failedAt is non-null. */
|
/** Human-readable failure reason; only meaningful when failedAt is non-null. */
|
||||||
errorReason: text('error_reason'),
|
errorReason: text('error_reason'),
|
||||||
|
// Phase 6 — async bounce tracking. Populated by the IMAP NDR
|
||||||
|
// poller (`src/jobs/processors/imap-bounce-poller.ts`) when a
|
||||||
|
// delivery failure message arrives in the configured mailbox and
|
||||||
|
// matches this send via recipient_email + sent_at window.
|
||||||
|
bounceStatus: text('bounce_status'), // 'hard' | 'soft' | 'ooo'
|
||||||
|
bounceReason: text('bounce_reason'),
|
||||||
|
bounceDetectedAt: timestamp('bounce_detected_at', { withTimezone: true }),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
index('idx_ds_client').on(t.clientId, t.sentAt),
|
index('idx_ds_client').on(t.clientId, t.sentAt),
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ export const clientContacts = pgTable(
|
|||||||
label: text('label'), // primary, secondary, work, personal, broker, assistant
|
label: text('label'), // primary, secondary, work, personal, broker, assistant
|
||||||
isPrimary: boolean('is_primary').notNull().default(false),
|
isPrimary: boolean('is_primary').notNull().default(false),
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
|
// Phase 3 — origin tracking.
|
||||||
|
// source: 'manual' | 'imported' | 'eoi-custom-input'
|
||||||
|
// source_document_id: when source='eoi-custom-input', points at the
|
||||||
|
// EOI document this row was spawned from. Surfaces an [EOI] badge
|
||||||
|
// on the client detail panel + a link back to the generating doc.
|
||||||
|
source: text('source').notNull().default('manual'),
|
||||||
|
sourceDocumentId: text('source_document_id'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
@@ -249,6 +256,9 @@ export const clientAddresses = pgTable(
|
|||||||
/** ISO-3166-1 alpha-2 country code. */
|
/** ISO-3166-1 alpha-2 country code. */
|
||||||
countryIso: text('country_iso'),
|
countryIso: text('country_iso'),
|
||||||
isPrimary: boolean('is_primary').notNull().default(true),
|
isPrimary: boolean('is_primary').notNull().default(true),
|
||||||
|
// Phase 3 — origin tracking, same pattern as client_contacts.
|
||||||
|
source: text('source').notNull().default('manual'),
|
||||||
|
sourceDocumentId: text('source_document_id'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
text,
|
text,
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
|
numeric,
|
||||||
timestamp,
|
timestamp,
|
||||||
jsonb,
|
jsonb,
|
||||||
index,
|
index,
|
||||||
@@ -112,6 +113,24 @@ export const documents = pgTable(
|
|||||||
invitationMessage: text('invitation_message'),
|
invitationMessage: text('invitation_message'),
|
||||||
remindersDisabled: boolean('reminders_disabled').notNull().default(false),
|
remindersDisabled: boolean('reminders_disabled').notNull().default(false),
|
||||||
reminderCadenceOverride: integer('reminder_cadence_override'),
|
reminderCadenceOverride: integer('reminder_cadence_override'),
|
||||||
|
// Phase 3 — per-document field overrides. When NULL, the canonical
|
||||||
|
// client/yacht record value flows through; when set, this document
|
||||||
|
// uses the override without touching the underlying record. Mirrors
|
||||||
|
// the AcroForm field set per docs/eoi-documenso-field-mapping.md.
|
||||||
|
// These are NOT promoted to client_contacts/addresses/yachts unless
|
||||||
|
// the dialog's "Save as new" toggle is ticked at generate time.
|
||||||
|
overrideClientEmail: text('override_client_email'),
|
||||||
|
overrideClientPhone: text('override_client_phone'),
|
||||||
|
overrideClientAddressLine1: text('override_client_address_line_1'),
|
||||||
|
overrideClientAddressLine2: text('override_client_address_line_2'),
|
||||||
|
overrideClientCity: text('override_client_city'),
|
||||||
|
overrideClientState: text('override_client_state'),
|
||||||
|
overrideClientPostalCode: text('override_client_postal_code'),
|
||||||
|
overrideClientCountry: text('override_client_country'),
|
||||||
|
overrideYachtName: text('override_yacht_name'),
|
||||||
|
overrideYachtLengthFt: numeric('override_yacht_length_ft'),
|
||||||
|
overrideYachtWidthFt: numeric('override_yacht_width_ft'),
|
||||||
|
overrideYachtDraftFt: numeric('override_yacht_draft_ft'),
|
||||||
createdBy: text('created_by').notNull(),
|
createdBy: text('created_by').notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ export const yachts = pgTable(
|
|||||||
status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away'
|
status: text('status').notNull().default('active'), // 'active' | 'retired' | 'sold_away'
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||||
|
// Phase 3 — origin tracking. eoi-generated marks yachts that were
|
||||||
|
// spawned via the EOI dialog's "+ New yacht" inline form, so the
|
||||||
|
// detail page can surface an [EOI] badge + link to the originating
|
||||||
|
// document.
|
||||||
|
source: text('source').notNull().default('manual'),
|
||||||
|
sourceDocumentId: text('source_document_id'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
53
src/lib/templates/field-map.ts
Normal file
53
src/lib/templates/field-map.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Phase 7 — PDF template editor field-map type definitions.
|
||||||
|
*
|
||||||
|
* The editor (deferred to 7.1/7.2) operates on the existing
|
||||||
|
* `document_templates.overlay_positions` JSONB column with the extended
|
||||||
|
* shape below. No schema migration is required — JSONB already accepts
|
||||||
|
* any shape; the only contract is the Zod validator on the API boundary.
|
||||||
|
*
|
||||||
|
* Coordinates are stored as percent of page width/height (0..1) so
|
||||||
|
* field placements survive page-size changes (A4 → US Letter etc.).
|
||||||
|
* The legacy overlay schema used absolute coords; the editor migrates
|
||||||
|
* those on first save by reading the source PDF's page dimensions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const fieldMapEntrySchema = z.object({
|
||||||
|
/** Must match an entry in VALID_MERGE_TOKENS (validated at the API). */
|
||||||
|
token: z.string().min(1).max(100),
|
||||||
|
/** 1-indexed page number. */
|
||||||
|
page: z.number().int().min(1),
|
||||||
|
/** Percent of page width — top-left corner of the marker. */
|
||||||
|
x: z.number().min(0).max(1),
|
||||||
|
/** Percent of page height — top-left corner of the marker. */
|
||||||
|
y: z.number().min(0).max(1),
|
||||||
|
/** Percent width — defaults to 0.15 if omitted by an older editor. */
|
||||||
|
w: z.number().min(0).max(1).optional(),
|
||||||
|
/** Percent height — defaults to 0.04. */
|
||||||
|
h: z.number().min(0).max(1).optional(),
|
||||||
|
/** Optional explicit font size; otherwise auto-fit to the box. */
|
||||||
|
fontSize: z.number().int().min(6).max(72).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fieldMapSchema = z.array(fieldMapEntrySchema).max(500);
|
||||||
|
|
||||||
|
export type FieldMapEntry = z.infer<typeof fieldMapEntrySchema>;
|
||||||
|
export type FieldMap = z.infer<typeof fieldMapSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-validate a parsed field-map against the source PDF page count.
|
||||||
|
* The editor surfaces a warning when an upload truncates the page set
|
||||||
|
* (e.g. uploading a 3-page PDF over a 5-page template). Returns null
|
||||||
|
* when valid, or an error message describing the first out-of-range
|
||||||
|
* marker.
|
||||||
|
*/
|
||||||
|
export function validateFieldMapAgainstPageCount(map: FieldMap, pageCount: number): string | null {
|
||||||
|
for (const m of map) {
|
||||||
|
if (m.page > pageCount) {
|
||||||
|
return `Field "${m.token}" sits on page ${m.page} but the PDF only has ${pageCount} pages.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user