feat(post-audit): Phase 4 polish + Phase 2 wiring + Phase 6 cron + CLAUDE.md

Three of the master plan's "suggested execution order" items shipped this
session; Phase 3b (EOI dialog overrides) deferred — estimate exceeded the
remaining session time.

- Phase 4 polish: yachtId field on <ReminderForm> via the existing
  YachtPicker, Ship-icon subtitle on <ReminderCard>, listReminders filter
  by yachtId, getReminder joins the yacht relation.
- Phase 2 risk-signal data wiring: getInterestById derives the 3 dates
  (dateDocumentDeclined / dateReservationCancelled / dateBerthSoldToOther)
  from document_events / berth_reservations / cross-interest interest_berths
  in parallel — chosen over new schema columns to keep the master plan's
  "no new tables" promise. Threaded through to DealPulseChip.
- Phase 6 cron + UI: src/jobs/processors/imap-bounce-poller.ts polls the
  configured IMAP mailbox (IMAP_* env), matches NDRs to recent
  document_sends rows via recipient + 7-day window, idempotent via
  bounceDetectedAt, fires email_bounced notifications on hard/soft
  (skips OOO). State persisted to system_settings.bounce_poller_state.
  Wired into maintenance queue at */15 * * * *. Admin /admin/sends page
  surfaces the bounce badge + reason inline.
- CLAUDE.md: trimmed 27KB → ~19.5KB (~28% smaller bytes). Prose-heavy
  Documenso webhook / v1-v2 routing / Document folders sections rewritten
  as scannable bullets. Added a new "Working in this repo — skills, MCPs,
  agents" section promoting brainstorming/TDD/debugging/frontend-design
  skills, Context7/Playwright/Serena MCPs, and the Explore/feature-dev
  agents. Documented Phase 2 derivation choice in the data-model section.

Quality gates: 1374/1374 vitest pass, tsc --noEmit clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:38:37 +02:00
parent a6e79231f3
commit 503207ef68
13 changed files with 561 additions and 123 deletions

View File

@@ -0,0 +1,207 @@
/**
* Phase 6 — IMAP bounce poller.
*
* Polls the configured IMAP inbox for delivery-status notifications, runs
* each through `parseBounce()`, and matches the original recipient against
* a recent `document_sends` row. When matched, updates the send row's
* bounce_* columns and fires an `email_bounced` notification to the rep
* who originated the send (hard/soft only — out-of-office is logged but
* not surfaced as an actionable alert).
*
* The job runs globally (no per-port context). IMAP creds are read from
* environment variables (`IMAP_HOST` / `IMAP_PORT` / `IMAP_USER` /
* `IMAP_PASS`) — when any is missing the poll is a no-op so the worker
* boots happily in dev. Run cadence is set in `src/lib/queue/scheduler.ts`
* (every 15 minutes).
*
* State (last-run timestamp) is persisted to `system_settings` under
* `bounce_poller_state` with `port_id = NULL`, so concurrent worker
* instances see the same checkpoint. On first run the lookback is 24 h.
*/
import { and, desc, eq, gte, isNull } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentSends } from '@/lib/db/schema/brochures';
import { systemSettings } from '@/lib/db/schema/system';
import { logger } from '@/lib/logger';
import { parseBounce } from '@/lib/email/bounce-parser';
import { createNotification } from '@/lib/services/notifications.service';
const STATE_KEY = 'bounce_poller_state';
const FIRST_RUN_LOOKBACK_HOURS = 24;
/** How far back to look for the originating document_sends row. Any send
* whose bounce arrives after this window won't be matched — the SMTP
* protocol guarantees NDRs typically arrive within minutes / hours, so
* 7 days is generous. */
const SEND_MATCH_WINDOW_DAYS = 7;
interface PollerState {
lastRunAt: string;
}
async function loadPollerState(): Promise<PollerState | null> {
const row = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, STATE_KEY), isNull(systemSettings.portId)),
});
if (!row) return null;
const value = row.value as PollerState | null;
return value && typeof value === 'object' && 'lastRunAt' in value ? value : null;
}
async function savePollerState(state: PollerState): Promise<void> {
await db
.insert(systemSettings)
.values({
key: STATE_KEY,
value: state as unknown as Record<string, unknown>,
portId: null,
updatedBy: 'system',
})
.onConflictDoUpdate({
target: [systemSettings.key, systemSettings.portId],
set: {
value: state as unknown as Record<string, unknown>,
updatedBy: 'system',
updatedAt: new Date(),
},
});
}
export async function processImapBouncePoll(): Promise<void> {
const host = process.env.IMAP_HOST;
const portStr = process.env.IMAP_PORT;
const user = process.env.IMAP_USER;
const pass = process.env.IMAP_PASS;
if (!host || !portStr || !user || !pass) {
logger.debug('IMAP bounce poll skipped — IMAP_* env not configured');
return;
}
const port = Number.parseInt(portStr, 10);
if (!Number.isFinite(port)) {
logger.warn({ portStr }, 'IMAP bounce poll skipped — IMAP_PORT not numeric');
return;
}
const state = await loadPollerState();
const since = state?.lastRunAt
? new Date(state.lastRunAt)
: new Date(Date.now() - FIRST_RUN_LOOKBACK_HOURS * 60 * 60 * 1000);
// Capture run start BEFORE network calls so the next poll's `since`
// covers anything that arrived while we were processing.
const runStartedAt = new Date();
const imapflowModule = await import('imapflow');
const ImapFlow = imapflowModule.ImapFlow;
const client = new ImapFlow({
host,
port,
secure: port === 993,
auth: { user, pass },
logger: false,
// Mirror email-threads.service.ts: bound any single network step so a
// hung server can't stall the maintenance worker.
socketTimeout: 60_000,
greetingTimeout: 30_000,
connectionTimeout: 30_000,
});
let scanned = 0;
let matched = 0;
let skippedNoMatch = 0;
let skippedNonBounce = 0;
try {
await client.connect();
await client.mailboxOpen('INBOX');
const searchResult = await client.search({ since });
const uids: number[] = searchResult === false ? [] : searchResult;
if (uids.length === 0) {
logger.debug({ since: since.toISOString() }, 'IMAP bounce poll: nothing new');
} else {
for await (const message of client.fetch(uids, { source: true })) {
scanned++;
try {
if (!message.source) continue;
const parsed = await parseBounce(message.source);
if (!parsed.originalRecipient || parsed.bounceClass === 'unknown') {
skippedNonBounce++;
continue;
}
const lookback = new Date(Date.now() - SEND_MATCH_WINDOW_DAYS * 86_400_000);
// Most-recent matching send to this recipient; the recipient
// may have been sent multiple files in the same window — the
// bounce always refers to the latest.
const candidates = await db
.select()
.from(documentSends)
.where(
and(
eq(documentSends.recipientEmail, parsed.originalRecipient),
gte(documentSends.sentAt, lookback),
),
)
.orderBy(desc(documentSends.sentAt))
.limit(1);
const target = candidates[0];
if (!target) {
skippedNoMatch++;
continue;
}
// Idempotency: a NDR can re-deliver to our mailbox if SMTP
// retries; only update + notify once per send row.
if (target.bounceDetectedAt) continue;
await db
.update(documentSends)
.set({
bounceStatus: parsed.bounceClass,
bounceReason: parsed.reason,
bounceDetectedAt: new Date(),
})
.where(eq(documentSends.id, target.id));
matched++;
// Skip OOO — informational, not actionable. Hard/soft notify
// the original sender so they can re-send or escalate.
if (
target.sentByUserId &&
(parsed.bounceClass === 'hard' || parsed.bounceClass === 'soft')
) {
await createNotification({
portId: target.portId,
userId: target.sentByUserId,
type: 'email_bounced',
title: 'Email bounced',
description: `Your email to ${parsed.originalRecipient} bounced — ${parsed.reason}`,
link: target.interestId ? `/interests/${target.interestId}` : undefined,
entityType: 'document_send',
entityId: target.id,
dedupeKey: `bounce:${target.id}`,
});
}
} catch (err) {
logger.warn({ err, uid: message.uid }, 'IMAP bounce: failed to process message');
}
}
}
await savePollerState({ lastRunAt: runStartedAt.toISOString() });
logger.info(
{ scanned, matched, skippedNoMatch, skippedNonBounce, sinceISO: since.toISOString() },
'IMAP bounce poll complete',
);
} finally {
try {
await client.logout();
} catch {
// Logout failures are non-fatal — the connection will be torn down
// by the timeout settings above.
}
}
}