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:
207
src/jobs/processors/imap-bounce-poller.ts
Normal file
207
src/jobs/processors/imap-bounce-poller.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user