@pithy-sh/support 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/package.json +68 -0
  4. package/pithy.manifest.json +40 -0
  5. package/src/ai/classify.ts +239 -0
  6. package/src/attachment/store.ts +78 -0
  7. package/src/audit/actions.ts +71 -0
  8. package/src/capability.ts +293 -0
  9. package/src/client/projection.ts +60 -0
  10. package/src/cloudflare-test.d.ts +15 -0
  11. package/src/config/config.ts +400 -0
  12. package/src/data/attachment.ts +65 -0
  13. package/src/data/billingScope.ts +32 -0
  14. package/src/data/categories.ts +117 -0
  15. package/src/data/classification.ts +50 -0
  16. package/src/data/enums.ts +90 -0
  17. package/src/data/flag.ts +37 -0
  18. package/src/data/message.ts +224 -0
  19. package/src/data/tables.ts +58 -0
  20. package/src/data/thread.ts +138 -0
  21. package/src/error/errors.ts +133 -0
  22. package/src/http/guards.ts +59 -0
  23. package/src/http/handlers.ts +418 -0
  24. package/src/http/resolve.ts +109 -0
  25. package/src/http/responses.ts +506 -0
  26. package/src/http/routes.ts +272 -0
  27. package/src/http/schemas.ts +251 -0
  28. package/src/http/scopes.ts +117 -0
  29. package/src/http/views.ts +169 -0
  30. package/src/inbound/authenticity.ts +114 -0
  31. package/src/inbound/guard.ts +127 -0
  32. package/src/inbound/handler.ts +102 -0
  33. package/src/inbound/ingest.ts +548 -0
  34. package/src/inbound/recipient.ts +67 -0
  35. package/src/index.ts +63 -0
  36. package/src/link/sender.ts +334 -0
  37. package/src/migrations/0001_threads.ts +296 -0
  38. package/src/mime/address.ts +37 -0
  39. package/src/mime/parse.ts +299 -0
  40. package/src/mime/sanitize.ts +253 -0
  41. package/src/mime/threading.ts +127 -0
  42. package/src/mime/truncate.ts +55 -0
  43. package/src/provision/provisionSupport.ts +179 -0
  44. package/src/provision/resolveSupportConfig.ts +67 -0
  45. package/src/reply/send.ts +322 -0
  46. package/src/reply/snippets.ts +167 -0
  47. package/src/secret/registry.ts +24 -0
  48. package/src/seeds/example.ts +385 -0
  49. package/src/store/paging.ts +22 -0
  50. package/src/store/search.ts +197 -0
  51. package/src/store/searchIndex.ts +71 -0
  52. package/src/store/threads.ts +452 -0
  53. package/src/submission/encoding.ts +66 -0
  54. package/src/submission/guard.ts +120 -0
  55. package/src/submission/submit.ts +539 -0
  56. package/src/version.generated.ts +16 -0
  57. package/src/workflows/classify.ts +164 -0
  58. package/src/workflows/retryPolicy.ts +48 -0
  59. package/src/workflows/specs.ts +61 -0
  60. package/src/workflows/worker.ts +82 -0
  61. package/src/workflows/wrangler.jsonc +46 -0
@@ -0,0 +1,169 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SupportMessage } from "../data/message";
5
+ import type { SupportThread } from "../data/thread";
6
+ import type { SenderContext } from "../link/sender";
7
+ import type { ListedThread } from "../store/threads";
8
+ import type {
9
+ SenderContextView,
10
+ SupportAttachmentView,
11
+ SupportListedThreadView,
12
+ SupportMessageView,
13
+ SupportMyMessageView,
14
+ SupportMyThreadView,
15
+ SupportThreadView,
16
+ } from "./responses";
17
+
18
+ /**
19
+ * The projections between a support row and the wire.
20
+ *
21
+ * Every return type is `z.output` of the matching object in `responses.ts`, so what this Worker sends
22
+ * and what a management client validates against are one declaration. A field added to one and not
23
+ * the other does not compile.
24
+ *
25
+ * **These convert dates to ISO-8601 strings, and that changes nothing on the wire.** A `Date` handed
26
+ * to `c.json` serializes as an ISO string already — so the bytes were always this, while the type
27
+ * claimed a `Date` no client ever received and no schema could describe. Converting explicitly is
28
+ * what makes the response statable, and it is the whole reason the fields could be typed at all.
29
+ *
30
+ * **A row is never spread.** Each projection names its fields, so a column added to
31
+ * `pithy_support_threads` is disclosed by a decision rather than by default — which is the same rule
32
+ * every other capability's admin surface follows, and the one a `SELECT *` handed to `c.json` breaks
33
+ * silently.
34
+ */
35
+
36
+ /** An ISO string, or null. */
37
+ function iso(date: Date | null | undefined): string | null {
38
+ return date ? date.toISOString() : null;
39
+ }
40
+
41
+ /** Project one thread row. */
42
+ export function threadView(thread: SupportThread): SupportThreadView {
43
+ return {
44
+ id: thread.id,
45
+ channel: thread.channel,
46
+ inboxAddress: thread.inboxAddress ?? null,
47
+ subject: thread.subject,
48
+ fromAddress: thread.fromAddress,
49
+ fromName: thread.fromName ?? null,
50
+ senderAuthenticated: thread.senderAuthenticated,
51
+ userId: thread.userId ?? null,
52
+ accountLinkSource: thread.accountLinkSource ?? null,
53
+ declaredCategory: thread.declaredCategory ?? null,
54
+ category: thread.category,
55
+ priority: thread.priority,
56
+ sentiment: thread.sentiment,
57
+ confidence: thread.confidence ?? null,
58
+ model: thread.model ?? null,
59
+ classifiedAt: iso(thread.classifiedAt),
60
+ archived: thread.archived,
61
+ archivedAt: iso(thread.archivedAt),
62
+ archivedBy: thread.archivedBy ?? null,
63
+ messageCount: thread.messageCount,
64
+ firstMessageAt: thread.firstMessageAt.toISOString(),
65
+ lastMessageAt: thread.lastMessageAt.toISOString(),
66
+ createdAt: thread.createdAt.toISOString(),
67
+ updatedAt: thread.updatedAt.toISOString(),
68
+ };
69
+ }
70
+
71
+ /** Project one thread for the inbox, carrying this viewer's own read and snooze state. */
72
+ export function listedThreadView(thread: ListedThread): SupportListedThreadView {
73
+ return { ...threadView(thread), read: thread.read, snoozedUntil: iso(thread.snoozedUntil) };
74
+ }
75
+
76
+ /**
77
+ * Project one message.
78
+ *
79
+ * The threading internals — `mimeMessageId`, `mimeInReplyTo`, `mimeReferences`, `rawKey`, `rawBytes` —
80
+ * are dropped. They are how a reply is stitched to its parent, and an operator would never act on one.
81
+ * `rawKey` in particular is the R2 object holding the message exactly as it arrived, which is the one
82
+ * copy of this data that has had nothing done to it.
83
+ */
84
+ export function messageView(message: SupportMessage): SupportMessageView {
85
+ return {
86
+ id: message.id,
87
+ direction: message.direction,
88
+ channel: message.channel,
89
+ context: message.context ?? null,
90
+ fromAddress: message.fromAddress ?? null,
91
+ fromName: message.fromName ?? null,
92
+ toAddress: message.toAddress ?? null,
93
+ subject: message.subject,
94
+ // Already sanitized at ingest. The raw original stays in R2 and is never served here.
95
+ htmlBody: message.htmlBody ?? null,
96
+ textBody: message.textBody,
97
+ emailJobId: message.emailJobId ?? null,
98
+ receivedAt: message.receivedAt.toISOString(),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Project one thread for the person who opened it.
104
+ *
105
+ * **Built from scratch rather than from {@link threadView}, and that is the security boundary.** A
106
+ * submitter's view derived by omitting fields from an operator's would disclose the next column
107
+ * somebody adds, silently, on the day they add it — the failure would be a default rather than a
108
+ * decision. Starting from nothing means a field reaches a customer only because somebody wrote it
109
+ * here.
110
+ *
111
+ * So: no classification, no priority, no sentiment, no confidence, no model, no viewer flags, no
112
+ * account link, no `archivedBy`, no addresses. What is left is their conversation.
113
+ */
114
+ export function myThreadView(thread: SupportThread): SupportMyThreadView {
115
+ return {
116
+ id: thread.id,
117
+ subject: thread.subject,
118
+ resolved: thread.archived,
119
+ messageCount: thread.messageCount,
120
+ lastMessageAt: thread.lastMessageAt.toISOString(),
121
+ createdAt: thread.createdAt.toISOString(),
122
+ };
123
+ }
124
+
125
+ /** Project one message for the person in the conversation. Their own attachments ride along. */
126
+ export function myMessageView(message: SupportMessage, attachments: SupportAttachmentView[]): SupportMyMessageView {
127
+ return {
128
+ id: message.id,
129
+ direction: message.direction,
130
+ // `textBody` on both sides. An answer is composed as plain text by `reply/send.ts`, and a
131
+ // submission was plain text when it arrived — so there is no `htmlBody` to hand back, and nothing
132
+ // here needs a sanitizer because nothing here is markup.
133
+ body: message.textBody,
134
+ context: message.context ?? null,
135
+ attachments,
136
+ sentAt: message.receivedAt.toISOString(),
137
+ };
138
+ }
139
+
140
+ /** Project the customer context beside a conversation. */
141
+ export function senderView(sender: SenderContext): SenderContextView {
142
+ return {
143
+ authenticated: sender.authenticated,
144
+ userId: sender.userId,
145
+ // Constant, and stated on every response rather than carried on `SenderContext`: it describes what
146
+ // this seam can reach, not what a lookup found. `link/sender.ts` reads `user`-subject rows only.
147
+ billingScope: "user",
148
+ // Spread rather than set to null: both are absent for an unproven sender, and an explicit null
149
+ // would read as "we looked and there is none" rather than "we did not look".
150
+ ...(sender.name === undefined ? {} : { name: sender.name }),
151
+ ...(sender.emailVerified === undefined ? {} : { emailVerified: sender.emailVerified }),
152
+ purchases: sender.purchases.map((purchase) => ({
153
+ id: purchase.id,
154
+ rail: purchase.rail,
155
+ productId: purchase.productId,
156
+ status: purchase.status,
157
+ environment: purchase.environment,
158
+ purchasedAt: purchase.purchasedAt.toISOString(),
159
+ expiresAt: iso(purchase.expiresAt),
160
+ revokedAt: iso(purchase.revokedAt),
161
+ })),
162
+ entitlements: sender.entitlements.map((entitlement) => ({
163
+ key: entitlement.key,
164
+ active: entitlement.active,
165
+ expiresAt: iso(entitlement.expiresAt),
166
+ source: entitlement.source,
167
+ })),
168
+ };
169
+ }
@@ -0,0 +1,114 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { parseAddress } from "@pithy-sh/core/src/address/address";
5
+ import { getDomain } from "tldts";
6
+
7
+ /**
8
+ * Whether a message's `From:` header is something we are entitled to believe.
9
+ *
10
+ * ## Why this exists
11
+ *
12
+ * `From:` is an unauthenticated claim. Anyone can send mail saying `From: ada@example.com`, and for
13
+ * every domain that publishes DMARC `p=none` — which is most small domains and plenty of large ones
14
+ * — a receiving MTA will deliver it rather than reject it.
15
+ *
16
+ * That claim is the join key for the customer link: it resolves to a `pithy_auth_users` row and
17
+ * pulls that account's name, entitlements, and itemized purchase history into a support console,
18
+ * where a human reads it and acts. Rendering an attacker's thread decorated with a real customer's
19
+ * billing history is the standard opening move of support-driven account takeover, and this
20
+ * capability's own `account_access` and `privacy_request` categories route exactly those messages to
21
+ * an operator already primed to act on them.
22
+ *
23
+ * The recipient side of this package gets the equivalent question right and says so at length: the
24
+ * SMTP envelope is authority, headers are not. This is that same rule applied to the sender.
25
+ *
26
+ * ## What counts as authenticated
27
+ *
28
+ * DMARC is the only verdict that asserts what we actually need — that the **`From:` domain** is
29
+ * aligned with something that passed. `spf=pass` alone says the *envelope* sender's domain passed,
30
+ * which is a different domain and is exactly the gap DMARC exists to close, so it counts only when
31
+ * the envelope sender aligns with the header From. `dkim=pass` alone has the same alignment gap.
32
+ *
33
+ * Absence is not a pass. A message with no `Authentication-Results` at all is unauthenticated, which
34
+ * is the right answer for mail that reached us through something that never checked.
35
+ */
36
+
37
+ /** What is known about a sender's claimed identity. */
38
+ export interface SenderAuthenticity {
39
+ /** Whether the `From:` header may be treated as the real sender. */
40
+ authenticated: boolean;
41
+ /** How it was established, for the log and for a dashboard to explain itself. `none` when it was not. */
42
+ method: "dmarc" | "spf-aligned" | "none";
43
+ }
44
+
45
+ /** The domain half of an address, lowercased. */
46
+ function domainOf(address: string | undefined): string | undefined {
47
+ const normalized = parseAddress(address);
48
+ const at = normalized?.lastIndexOf("@") ?? -1;
49
+ return normalized && at > 0 ? normalized.slice(at + 1) : undefined;
50
+ }
51
+
52
+ /**
53
+ * Whether two domains are aligned in the relaxed sense DMARC uses — the same Organizational Domain.
54
+ *
55
+ * **This is a public-suffix lookup, not label arithmetic, because RFC 7489 §3.2 defines relaxed
56
+ * alignment in terms of the Organizational Domain and that term is defined by the public suffix
57
+ * list.** An earlier version counted labels and required a shared parent of at least two, which is a
58
+ * decent approximation and wrong in exactly the way approximations of security boundaries are wrong:
59
+ * `co.uk` has two labels, so it "aligned" with `bbc.co.uk`. Implementing the actual algorithm removes
60
+ * the whole class rather than the instance somebody happened to notice.
61
+ *
62
+ * `allowPrivateDomains` is on. Without it `a.github.io` and `b.github.io` both reduce to `github.io`
63
+ * and two unrelated people's sites align with each other; the private section of the list is what
64
+ * knows that `github.io` is a boundary. Stricter is the right direction for a check that gates
65
+ * whether a stranger's mail gets decorated with a real customer's purchase history.
66
+ *
67
+ * A domain that has no Organizational Domain at all — a bare public suffix like `co.uk`, an IP
68
+ * literal, `localhost` — returns `null` from `getDomain` and therefore aligns with nothing, which is
69
+ * the correct answer rather than a special case.
70
+ */
71
+ export function domainsAlign(a: string | undefined, b: string | undefined): boolean {
72
+ if (!a || !b) return false;
73
+ const organizational = getDomain(a, { allowPrivateDomains: true });
74
+ return organizational !== null && organizational === getDomain(b, { allowPrivateDomains: true });
75
+ }
76
+
77
+ /**
78
+ * Decide whether the `From:` header is believable.
79
+ *
80
+ * Takes the parsed verdicts and both addresses rather than the whole message, so the decision is a
81
+ * pure function of four values and is testable without a MIME fixture.
82
+ */
83
+ export function senderAuthenticity(options: {
84
+ /** The `Authentication-Results` verdicts, keyed by method. */
85
+ authResults: Record<string, string>;
86
+ /** The address in the `From:` header, normalized. */
87
+ fromAddress: string;
88
+ /** The SMTP envelope sender — what SPF was actually evaluated against. */
89
+ envelopeFrom: string | undefined;
90
+ /**
91
+ * Whether the adopter has attested that the header is worth reading at all.
92
+ *
93
+ * **Default off, and this is the load-bearing gate.** Under Cloudflare Email Routing a Worker is
94
+ * not reliably given the MTA's own `Authentication-Results`, and a header the *sender* wrote is
95
+ * indistinguishable from one an MTA wrote once the MTA's copy is absent — so reading it by default
96
+ * would mean believing whatever the attacker typed. Everything below is a verdict about a value
97
+ * that only means something once somebody has said their pipeline produces it honestly.
98
+ */
99
+ trusted: boolean;
100
+ }): SenderAuthenticity {
101
+ if (!options.trusted) return { authenticated: false, method: "none" };
102
+
103
+ if (options.authResults.dmarc === "pass") return { authenticated: true, method: "dmarc" };
104
+
105
+ // SPF passed, but on the envelope sender's domain. It only tells us anything about the `From:`
106
+ // header when the two align — which is the check DMARC would have done for us.
107
+ if (options.authResults.spf === "pass") {
108
+ const envelope = domainOf(options.envelopeFrom);
109
+ const header = domainOf(options.fromAddress);
110
+ if (domainsAlign(envelope, header)) return { authenticated: true, method: "spf-aligned" };
111
+ }
112
+
113
+ return { authenticated: false, method: "none" };
114
+ }
@@ -0,0 +1,127 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SupportGuardConfig } from "../config/config";
5
+ import { SUPPORT_MESSAGES_TABLE, type SupportDatabase } from "../data/tables";
6
+
7
+ /**
8
+ * The inbound guard — the bound between "a public support address" and "a public write endpoint into
9
+ * the adopter's D1", which is what one is without this file.
10
+ *
11
+ * Three checks, deliberately ordered by what they cost:
12
+ *
13
+ * 1. **Size**, from the message's declared length, before a byte is parsed. Parsing is the expensive
14
+ * step and the size is known first, so refusing here is the difference between a flood costing
15
+ * CPU and costing nothing.
16
+ * 2. **Per sender**, which catches the overwhelmingly common case: one misconfigured auto-responder
17
+ * in a loop with the inbox. It is not an attack and it will still fill a database.
18
+ * 3. **Globally**, which is the only one that helps under a distributed flood, where every
19
+ * individual address stays comfortably under the per-sender bound.
20
+ *
21
+ * ## Counted from the messages table, not from a counter table
22
+ *
23
+ * A dedicated counter row would be one write per message and a second thing to reconcile — and it
24
+ * would be **wrong**, because a counter incremented before the insert overcounts a failed store and
25
+ * one incremented after undercounts a flood. The messages table already holds the exact fact, and
26
+ * `(from_address, received_at)` is already indexed for the sender history the dashboard shows. So
27
+ * the guard is a covered index scan over data that must exist anyway, and it cannot drift from what
28
+ * it is counting.
29
+ *
30
+ * The window is a sliding hour rather than a fixed bucket, because a fixed bucket lets twice the
31
+ * limit through across a boundary — which is exactly when a retry storm arrives.
32
+ *
33
+ * ## Mail only, and the in-app channel has its own bound
34
+ *
35
+ * Both counts filter on `channel = "email"`, so an in-app submission is invisible here and a piece of
36
+ * mail is invisible to `submission/guard.ts`. **Neither surface may starve the other.** Counting them
37
+ * together would mean a burst of in-app feedback during an outage locks a paying customer's email out
38
+ * of the inbox, and a mail flood stops the app's own users reporting the very outage that caused it —
39
+ * each of which is the failure arriving at the worst possible moment. They are also bounded on
40
+ * different keys for a real reason: a mail sender is an address anybody can write, while a submitter is
41
+ * an account the adopter issued and can revoke.
42
+ */
43
+
44
+ /** One hour, in milliseconds — the window every rate bound is measured over. */
45
+ export const GUARD_WINDOW_MS = 60 * 60 * 1000;
46
+
47
+ /** Why a message was refused. The value lands in the audit event's `metadata.reason`. */
48
+ export type SupportRejectionReason = "too_large" | "sender_rate" | "global_rate";
49
+
50
+ /** The guard's answer: accept, or refuse with a reason. */
51
+ export type GuardVerdict = { accepted: true } | { accepted: false; reason: SupportRejectionReason; detail: string };
52
+
53
+ /** What the guard needs to decide. */
54
+ export interface GuardInput {
55
+ /** The raw message size in bytes, as received. */
56
+ rawBytes: number;
57
+ /** The sender's normalized address. */
58
+ fromAddress: string;
59
+ /** Now. Injected so the window is testable without waiting an hour. */
60
+ now: Date;
61
+ }
62
+
63
+ /**
64
+ * Check the size bound alone — the half that needs no database and can therefore run before the
65
+ * message is parsed. Split out because the ordering is the point: this is the only check available
66
+ * while the decision is still free.
67
+ */
68
+ export function checkSize(config: SupportGuardConfig, rawBytes: number): GuardVerdict {
69
+ if (rawBytes > config.maxRawBytes) {
70
+ return {
71
+ accepted: false,
72
+ reason: "too_large",
73
+ detail: `raw message is ${rawBytes} bytes, over the ${config.maxRawBytes} byte bound`,
74
+ };
75
+ }
76
+ return { accepted: true };
77
+ }
78
+
79
+ /**
80
+ * Check the two rate bounds against what is already stored.
81
+ *
82
+ * Both counts run against the sliding window ending now. Inbound only: a burst of *replies* is the
83
+ * adopter's own doing and must never lock them out of their own inbox, which is a real failure mode
84
+ * for a guard that counts every row in the table.
85
+ */
86
+ export async function checkRates(
87
+ db: SupportDatabase,
88
+ config: SupportGuardConfig,
89
+ input: GuardInput,
90
+ ): Promise<GuardVerdict> {
91
+ const since = new Date(input.now.getTime() - GUARD_WINDOW_MS);
92
+
93
+ const fromSender = await db
94
+ .selectFrom(SUPPORT_MESSAGES_TABLE)
95
+ .select((eb) => eb.fn.countAll<number>().as("count"))
96
+ .where("direction", "=", "inbound")
97
+ .where("channel", "=", "email")
98
+ .where("fromAddress", "=", input.fromAddress)
99
+ .where("receivedAt", ">=", since.getTime())
100
+ .executeTakeFirst();
101
+
102
+ if ((fromSender?.count ?? 0) >= config.maxPerSenderPerHour) {
103
+ return {
104
+ accepted: false,
105
+ reason: "sender_rate",
106
+ detail: `sender has landed ${fromSender?.count} messages in the last hour, at or over the ${config.maxPerSenderPerHour} bound`,
107
+ };
108
+ }
109
+
110
+ const total = await db
111
+ .selectFrom(SUPPORT_MESSAGES_TABLE)
112
+ .select((eb) => eb.fn.countAll<number>().as("count"))
113
+ .where("direction", "=", "inbound")
114
+ .where("channel", "=", "email")
115
+ .where("receivedAt", ">=", since.getTime())
116
+ .executeTakeFirst();
117
+
118
+ if ((total?.count ?? 0) >= config.maxPerHour) {
119
+ return {
120
+ accepted: false,
121
+ reason: "global_rate",
122
+ detail: `inbox has accepted ${total?.count} messages in the last hour, at or over the ${config.maxPerHour} bound`,
123
+ };
124
+ }
125
+
126
+ return { accepted: true };
127
+ }
@@ -0,0 +1,102 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { CapabilityEmailHandler } from "@pithy-sh/core/src/capability/capability";
5
+ import { createWorkerLogger } from "@pithy-sh/core/src/logger/worker";
6
+ import type { SupportWiring } from "../capability";
7
+ import { supportDatabase } from "../data/tables";
8
+ import { makeClassifyDispatcher, resolveDb, type SupportEnv } from "../http/resolve";
9
+ import { resolveSenderUserId } from "../link/sender";
10
+ import { ingestInbound } from "./ingest";
11
+
12
+ /**
13
+ * The `email()` seam — a thin shell over `ingestInbound`.
14
+ *
15
+ * Everything interesting lives in `ingest.ts`, with every dependency injected; this file exists only
16
+ * to read bindings off the untyped Worker env and to make the two decisions that belong at the
17
+ * boundary rather than inside the orchestration.
18
+ *
19
+ * ## It never throws, and that is not laziness
20
+ *
21
+ * A Worker has one `email()` entry, and `createEntrypoint` fans every message to **every**
22
+ * capability that declares a handler — sequentially, in registration order. An exception thrown here
23
+ * would propagate out of that loop and stop the handlers after it from running, so a malformed
24
+ * message addressed to support could take out `@pithy-sh/email`'s bounce processing, which is how
25
+ * suppression stops working and an adopter's sending reputation quietly degrades. One capability's
26
+ * bad day must not be another's.
27
+ *
28
+ * So the handler catches, logs, and returns. The message is left untouched, which the runtime treats
29
+ * as a drop — correct, because a message this Worker could not store is one it cannot act on either.
30
+ *
31
+ * ## There is no request logger here
32
+ *
33
+ * `c.var.log` belongs to a `fetch`. An inbound message has no request, so the handler builds its own
34
+ * logger from the env rather than pretending otherwise.
35
+ */
36
+ export function createSupportEmailHandler(wiring: SupportWiring): CapabilityEmailHandler {
37
+ return async (message, env) => {
38
+ const bindings = env as unknown as SupportEnv;
39
+ const log = createWorkerLogger({
40
+ name: "support",
41
+ fields: { env: typeof env.ENVIRONMENT === "string" ? env.ENVIRONMENT : "unknown" },
42
+ });
43
+
44
+ // A capability configured with no addresses claims nothing. Say so once, at warn, rather than
45
+ // parsing every message the Worker receives to reach the same conclusion silently.
46
+ if (wiring.config.inboundAddresses.length === 0) {
47
+ log.warn("support has no inboundAddresses configured — every message is ignored", {
48
+ action: "Set `inboundAddresses` on the support capability in pithy.config.ts.",
49
+ });
50
+ return;
51
+ }
52
+
53
+ try {
54
+ const d1 = resolveDb(env);
55
+
56
+ // Refuse on the *declared* size before buffering. `checkSize` runs again inside ingest — it is
57
+ // the orchestration's own contract and must hold when called directly — but by then the whole
58
+ // message is already in memory, which is exactly the cost the size bound exists to avoid.
59
+ // `rawSize` is the runtime's own count and needs no bytes read to consult.
60
+ const declared = message.rawSize;
61
+ if (typeof declared === "number" && declared > wiring.config.guard.maxRawBytes) {
62
+ log.warn("support inbound message refused before parsing", {
63
+ reason: "too_large",
64
+ rawSize: declared,
65
+ maxRawBytes: wiring.config.guard.maxRawBytes,
66
+ });
67
+ return;
68
+ }
69
+
70
+ const raw = await new Response(message.raw).arrayBuffer();
71
+
72
+ const outcome = await ingestInbound(
73
+ {
74
+ db: supportDatabase(d1),
75
+ config: wiring.config,
76
+ bucket: bindings.SUPPORT_BUCKET,
77
+ fts: wiring.config.search.fts,
78
+ dispatchClassify: makeClassifyDispatcher(env, log),
79
+ linkSender: (address) => resolveSenderUserId(d1, address),
80
+ emit: wiring.emit,
81
+ log,
82
+ newId: () => crypto.randomUUID(),
83
+ now: () => new Date(),
84
+ },
85
+ { raw, envelopeTo: message.to, envelopeFrom: message.from },
86
+ );
87
+
88
+ if (outcome.handled && !outcome.duplicate) {
89
+ log.info("support message stored", {
90
+ threadId: outcome.threadId,
91
+ messageId: outcome.messageId,
92
+ newThread: outcome.newThread,
93
+ attachments: outcome.attachments,
94
+ });
95
+ }
96
+ } catch (error) {
97
+ // Swallowed on purpose — see the note above. The failure is logged with everything an operator
98
+ // needs and nothing the sender wrote.
99
+ log.error("support inbound handling failed", { error });
100
+ }
101
+ };
102
+ }