diff --git a/services/api/src/services/admin/inbound-bounce.ts b/services/api/src/services/admin/inbound-bounce.ts index 0841258c..68b4c6fb 100644 --- a/services/api/src/services/admin/inbound-bounce.ts +++ b/services/api/src/services/admin/inbound-bounce.ts @@ -41,7 +41,9 @@ export function detectBounce(mail: ParsedMail): BounceDetection { const body = (mail.text ?? mail.html ?? "").slice(0, DSN_SCAN_PREFIX_BYTES) const failedRecipient = extractEmail(failedHeader) ?? - extractEmail(matchLine(body, /^final-recipient:\s*(?:rfc822;)?\s*(.+)$/im)) ?? + // Two adjacent \s* around an optional token split a whitespace run every possible way, so a + // run of newlines after the label backtracks quadratically; nesting the second \s* keeps it linear. + extractEmail(matchLine(body, /^final-recipient:\s*(?:rfc822;\s*)?(.+)$/im)) ?? extractEmail(matchLine(body, /^to:\s*(.+)$/im)) const originalMessageId = matchBracketId(matchLine(body, /^original-message-id:\s*(.+)$/im)) ?? @@ -167,7 +169,10 @@ export async function geoidForContact(sql: Sql, email: string): Promise]+>/) + // Every match ends at a ">", so text after the last one cannot change the result; cutting it off + // stops a long run of "<" with no ">" from rescanning itself at every position. + const lastClose = value.lastIndexOf(">") + const m = lastClose === -1 ? null : value.slice(0, lastClose + 1).match(/<[^>]+>/) return m ? m[0] : value.trim().length > 0 ? value.trim() : null } diff --git a/services/api/src/services/host/broadcast-repository.drizzle.ts b/services/api/src/services/host/broadcast-repository.drizzle.ts index 79030a1c..06b698b5 100644 --- a/services/api/src/services/host/broadcast-repository.drizzle.ts +++ b/services/api/src/services/host/broadcast-repository.drizzle.ts @@ -570,7 +570,9 @@ export function makeDrizzleBroadcastRepository(sql: Sql): BroadcastRepository { const reasons = outcomes.map((o) => o.suppressionReason ?? null) const failures = outcomes.map((o) => o.failureKind ?? null) const providerIds = outcomes.map((o) => o.providerMessageId ?? null) - const sentAts = outcomes.map((o) => o.sentAt ?? null) + // postgres.js types an array parameter by its first element, so a Date-led array binds as a + // scalar timestamptz and the ::timestamptz[] cast fails (42846); ISO strings bind untyped. + const sentAts = outcomes.map((o) => o.sentAt?.toISOString() ?? null) await sql` UPDATE broadcast_deliveries d SET status = t.status, diff --git a/services/api/test/integration/host-broadcast-outcomes-pg.test.ts b/services/api/test/integration/host-broadcast-outcomes-pg.test.ts new file mode 100644 index 00000000..868e7b8b --- /dev/null +++ b/services/api/test/integration/host-broadcast-outcomes-pg.test.ts @@ -0,0 +1,135 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { withPg, type PgHarness } from "../helpers/pg.js" +import { seedCleanup } from "../helpers/cleanups.js" +import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" +import type { BroadcastRepository } from "../../src/services/host/broadcast-repository.js" + +const pg = await withPg() + +describe.skipIf(!pg)("broadcast delivery outcomes (integration)", () => { + let h: PgHarness + let repo: BroadcastRepository + let cleanupId: string + + beforeAll(async () => { + h = pg as PgHarness + repo = makeDrizzleBroadcastRepository(h.sql) + const organizerId = await newUser("Host") + cleanupId = await seedCleanup(h.sql, { + organizerUserId: organizerId, + title: "Park sweep", + lng: -118.25, + lat: 34.05, + scheduledAt: new Date(Date.now() + 7 * 86_400_000), + }) + }) + + afterAll(async () => { + await h.teardown() + }) + + async function newUser(name: string): Promise { + const [row] = await h.sql<{ id: string }[]>` + INSERT INTO users (display_name) VALUES (${name}) RETURNING id` + return row!.id + } + + async function sendingBroadcast(): Promise { + const [row] = await h.sql<{ id: string }[]>` + INSERT INTO broadcasts (cleanup_id, kind, status, subject, body_md) + VALUES (${cleanupId}, 'host_broadcast', 'sending', 'Update', 'Bring gloves') + RETURNING id` + return row!.id + } + + it("records a chunk whose first outcome was sent, with its sent time", async () => { + const broadcastId = await sendingBroadcast() + const first = await newUser("First") + const second = await newUser("Second") + await repo.insertDeliveries([ + { + broadcastId, + chunkNo: 0, + recipientKind: "member", + userId: first, + guestId: null, + channel: "inapp", + }, + { + broadcastId, + chunkNo: 0, + recipientKind: "member", + userId: second, + guestId: null, + channel: "inapp", + }, + ]) + const claims = await repo.claimChunk({ + broadcastId, + chunkNo: 0, + staleBefore: new Date(Date.now() - 600_000), + maxAttempts: 3, + limit: 10, + }) + expect(claims).toHaveLength(2) + const sentClaim = claims.find((c) => c.userId === first)! + const suppressedClaim = claims.find((c) => c.userId === second)! + const sentAt = new Date("2026-09-23T10:00:00.123Z") + + await repo.applyDeliveryOutcomes([ + { id: sentClaim.id, status: "sent", sentAt }, + { id: suppressedClaim.id, status: "suppressed", suppressionReason: "prefs_off" }, + ]) + + const rows = await h.sql< + { id: string; status: string; sent_at: Date | null; suppression_reason: string | null }[] + >` + SELECT id, status, sent_at, suppression_reason FROM broadcast_deliveries + WHERE broadcast_id = ${broadcastId}` + const byId = new Map(rows.map((r) => [r.id, r])) + expect(byId.get(sentClaim.id)).toMatchObject({ status: "sent", suppression_reason: null }) + expect(byId.get(sentClaim.id)!.sent_at?.toISOString()).toBe(sentAt.toISOString()) + expect(byId.get(suppressedClaim.id)).toMatchObject({ + status: "suppressed", + suppression_reason: "prefs_off", + sent_at: null, + }) + const record = await repo.refreshCounts(broadcastId) + expect(record?.sentCount).toBe(1) + expect(record?.suppressedCount).toBe(1) + }) + + it("records a chunk where every outcome was sent", async () => { + const broadcastId = await sendingBroadcast() + const users = [await newUser("A"), await newUser("B"), await newUser("C")] + await repo.insertDeliveries( + users.map((userId) => ({ + broadcastId, + chunkNo: 0, + recipientKind: "member" as const, + userId, + guestId: null, + channel: "email" as const, + })), + ) + const claims = await repo.claimChunk({ + broadcastId, + chunkNo: 0, + staleBefore: new Date(Date.now() - 600_000), + maxAttempts: 3, + limit: 10, + }) + const sentAt = new Date() + await repo.applyDeliveryOutcomes( + claims.map((c) => ({ + id: c.id, + status: "sent" as const, + sentAt, + providerMessageId: `m-${c.id}`, + })), + ) + const counts = await repo.deliveryCounts(broadcastId) + expect(counts.sent).toBe(3) + expect(counts.pending).toBe(0) + }) +}) diff --git a/services/api/test/unit/host-broadcast-outcomes-binding.test.ts b/services/api/test/unit/host-broadcast-outcomes-binding.test.ts new file mode 100644 index 00000000..16ff2e99 --- /dev/null +++ b/services/api/test/unit/host-broadcast-outcomes-binding.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest" +import { makeFakeSql } from "../helpers/fake-sql.js" +import type { Sql } from "../../src/db/client.js" +import { makeDrizzleBroadcastRepository } from "../../src/services/host/broadcast-repository.drizzle.js" + +const SENT = "22222222-2222-2222-2222-222222222222" +const SUPPRESSED = "33333333-3333-3333-3333-333333333333" + +describe("applyDeliveryOutcomes binds sent times Postgres can cast to timestamptz[]", () => { + it("binds ISO strings, never Date objects, when the first outcome was sent", async () => { + const fake = makeFakeSql() + const repo = makeDrizzleBroadcastRepository(fake.sql as unknown as Sql) + const sentAt = new Date("2026-09-23T10:00:00.123Z") + + await repo.applyDeliveryOutcomes([ + { id: SENT, status: "sent", sentAt }, + { id: SUPPRESSED, status: "suppressed", suppressionReason: "prefs_off" }, + ]) + + const stmt = fake.statements.find((s) => /UPDATE broadcast_deliveries/.test(s.sql)) + expect(stmt, "applyDeliveryOutcomes should emit the UPDATE").toBeDefined() + const arrays = stmt!.values.filter(Array.isArray) + for (const values of arrays) { + expect(values.some((v) => v instanceof Date)).toBe(false) + } + expect(arrays).toContainEqual(["2026-09-23T10:00:00.123Z", null]) + }) +}) diff --git a/services/api/test/unit/inbound-bounce-linear-time.test.ts b/services/api/test/unit/inbound-bounce-linear-time.test.ts new file mode 100644 index 00000000..5d344661 --- /dev/null +++ b/services/api/test/unit/inbound-bounce-linear-time.test.ts @@ -0,0 +1,99 @@ +import type { ParsedMail } from "@civfix/shared/interfaces" +import { describe, expect, it } from "vitest" +import { CfInboundMail } from "../../src/adapters/inbound-mail.cf.js" +import { + detectBounce, + extractEmail, + matchBracketId, +} from "../../src/services/admin/inbound-bounce.js" + +const RUN = 64 * 1024 +const BUDGET_MS = 500 + +function daemonMail(over: Partial): ParsedMail { + return { + from: { address: "MAILER-DAEMON@mail.example.com" }, + to: [], + subject: "Undelivered Mail Returned to Sender", + text: null, + html: null, + messageId: null, + inReplyTo: null, + headers: {}, + ...over, + } +} + +function timed(fn: () => T): { value: T; ms: number } { + const started = performance.now() + const value = fn() + return { value, ms: performance.now() - started } +} + +describe("bounce parsing stays linear on attacker-sized input", () => { + it("scans a long X-Failed-Recipients header with no address in linear time", () => { + const mail = daemonMail({ headers: { "x-failed-recipients": "a".repeat(RUN) } }) + const { value, ms } = timed(() => detectBounce(mail)) + expect(value.isBounce).toBe(true) + expect(value.failedRecipient).toBeNull() + expect(ms).toBeLessThan(BUDGET_MS) + }) + + it("scans a Final-Recipient label followed by a long run of newlines in linear time", () => { + const mail = daemonMail({ text: `Final-Recipient:${"\n".repeat(RUN)}` }) + const { value, ms } = timed(() => detectBounce(mail)) + expect(value.isBounce).toBe(true) + expect(ms).toBeLessThan(BUDGET_MS) + }) + + it("scans a Message-ID line of unclosed brackets in linear time", () => { + const mail = daemonMail({ text: `Message-ID: ${"<".repeat(RUN)}` }) + const { value, ms } = timed(() => detectBounce(mail)) + expect(value.originalMessageId).toMatch(/^<+$/) + expect(ms).toBeLessThan(BUDGET_MS) + }) + + it("stays linear end to end through the real mail parser", async () => { + const raw = new TextEncoder().encode( + [ + "From: Mail Delivery System ", + "To: report-abcd1234efgh@civfix.org", + "Subject: Undelivered Mail Returned to Sender", + `X-Failed-Recipients: ${"a".repeat(RUN)}`, + "Content-Type: text/plain", + "", + `Final-Recipient: rfc822; ${"b".repeat(RUN)}`, + `Original-Message-ID: ${"<".repeat(RUN)}`, + "", + ].join("\r\n"), + ) + const parsed = await new CfInboundMail().parse(raw) + const { value, ms } = timed(() => detectBounce(parsed)) + expect(value.isBounce).toBe(true) + expect(value.failedRecipient).toBeNull() + expect(ms).toBeLessThan(BUDGET_MS) + }) +}) + +describe("the linear rewrites keep the first match", () => { + it("extractEmail returns the leftmost address, whole local part included", () => { + expect(extractEmail("Final: rfc822; ab.c+tag@city.gov (x) d@e.org")).toBe("ab.c+tag@city.gov") + expect(extractEmail("<>")).toBe("publicworks@city.gov") + expect(extractEmail("no address here")).toBeNull() + }) + + it("Final-Recipient keeps the rfc822 prefix optional and trims to the address", () => { + const withPrefix = daemonMail({ text: "Final-Recipient: rfc822; ops@city.gov\n" }) + const without = daemonMail({ text: "Final-Recipient:\tops@city.gov\n" }) + expect(detectBounce(withPrefix).failedRecipient).toBe("ops@city.gov") + expect(detectBounce(without).failedRecipient).toBe("ops@city.gov") + }) + + it("matchBracketId returns the first bracketed id and falls back to the trimmed value", () => { + expect(matchBracketId(" ")).toBe("") + expect(matchBracketId("xy<")).toBe("") + expect(matchBracketId(" bare-id@host ")).toBe("bare-id@host") + expect(matchBracketId("<>")).toBe("<>") + expect(matchBracketId(" ")).toBeNull() + }) +})