Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions services/api/src/services/admin/inbound-bounce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) ??
Expand Down Expand Up @@ -167,7 +169,10 @@ export async function geoidForContact(sql: Sql, email: string): Promise<string |

export function extractEmail(value: string | null): string | null {
if (value === null) return null
const m = value.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/)
// A match found from inside a run of local-part characters is also found from the run's start,
// which is further left, so the lookbehind changes no first match; without it every position of a
// long run with no @ rescans the rest of the run (quadratic on an attacker-sized header).
const m = value.match(/(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/)
return m ? m[0] : null
}

Expand All @@ -178,6 +183,9 @@ export function matchLine(body: string, re: RegExp): string | null {

export function matchBracketId(value: string | null): string | null {
if (value === null) return null
const m = value.match(/<[^>]+>/)
// 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
135 changes: 135 additions & 0 deletions services/api/test/integration/host-broadcast-outcomes-pg.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const [row] = await h.sql<{ id: string }[]>`
INSERT INTO users (display_name) VALUES (${name}) RETURNING id`
return row!.id
}

async function sendingBroadcast(): Promise<string> {
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)
})
})
28 changes: 28 additions & 0 deletions services/api/test/unit/host-broadcast-outcomes-binding.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
99 changes: 99 additions & 0 deletions services/api/test/unit/inbound-bounce-linear-time.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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<T>(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 <MAILER-DAEMON@mail.example.com>",
"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("<<publicworks@city.gov>>")).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(" <a@b> <c@d> ")).toBe("<a@b>")
expect(matchBracketId("x<a@b>y<")).toBe("<a@b>")
expect(matchBracketId(" bare-id@host ")).toBe("bare-id@host")
expect(matchBracketId("<>")).toBe("<>")
expect(matchBracketId(" ")).toBeNull()
})
})
Loading