@pithy-sh/support 0.1.2 → 0.1.4

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 (170) hide show
  1. package/dist/ai/classify.d.ts +92 -0
  2. package/dist/ai/classify.d.ts.map +1 -0
  3. package/dist/ai/classify.js +153 -0
  4. package/dist/attachment/store.d.ts +55 -0
  5. package/dist/attachment/store.d.ts.map +1 -0
  6. package/dist/attachment/store.js +69 -0
  7. package/dist/audit/actions.d.ts +70 -0
  8. package/dist/audit/actions.d.ts.map +1 -0
  9. package/dist/audit/actions.js +70 -0
  10. package/dist/capability.d.ts +95 -0
  11. package/dist/capability.d.ts.map +1 -0
  12. package/dist/capability.js +189 -0
  13. package/dist/client/projection.d.ts +58 -0
  14. package/dist/client/projection.d.ts.map +1 -0
  15. package/dist/client/projection.js +2 -0
  16. package/dist/config/config.d.ts +217 -0
  17. package/dist/config/config.d.ts.map +1 -0
  18. package/dist/config/config.js +171 -0
  19. package/dist/data/attachment.d.ts +32 -0
  20. package/dist/data/attachment.d.ts.map +1 -0
  21. package/dist/data/attachment.js +34 -0
  22. package/dist/data/billingScope.d.ts +32 -0
  23. package/dist/data/billingScope.d.ts.map +1 -0
  24. package/dist/data/billingScope.js +34 -0
  25. package/dist/data/categories.d.ts +56 -0
  26. package/dist/data/categories.d.ts.map +1 -0
  27. package/dist/data/categories.js +99 -0
  28. package/dist/data/classification.d.ts +36 -0
  29. package/dist/data/classification.d.ts.map +1 -0
  30. package/dist/data/classification.js +30 -0
  31. package/dist/data/enums.d.ts +84 -0
  32. package/dist/data/enums.d.ts.map +1 -0
  33. package/dist/data/enums.js +72 -0
  34. package/dist/data/flag.d.ts +27 -0
  35. package/dist/data/flag.d.ts.map +1 -0
  36. package/dist/data/flag.js +29 -0
  37. package/dist/data/message.d.ts +83 -0
  38. package/dist/data/message.d.ts.map +1 -0
  39. package/dist/data/message.js +86 -0
  40. package/dist/data/tables.d.ts +162 -0
  41. package/dist/data/tables.d.ts.map +1 -0
  42. package/dist/data/tables.js +46 -0
  43. package/dist/data/thread.d.ts +79 -0
  44. package/dist/data/thread.d.ts.map +1 -0
  45. package/dist/data/thread.js +67 -0
  46. package/dist/error/errors.d.ts +66 -0
  47. package/dist/error/errors.d.ts.map +1 -0
  48. package/dist/error/errors.js +84 -0
  49. package/dist/http/guards.d.ts +46 -0
  50. package/dist/http/guards.d.ts.map +1 -0
  51. package/dist/http/guards.js +55 -0
  52. package/dist/http/handlers.d.ts +91 -0
  53. package/dist/http/handlers.d.ts.map +1 -0
  54. package/dist/http/handlers.js +250 -0
  55. package/dist/http/resolve.d.ts +46 -0
  56. package/dist/http/resolve.d.ts.map +1 -0
  57. package/dist/http/resolve.js +74 -0
  58. package/dist/http/responses.d.ts +831 -0
  59. package/dist/http/responses.d.ts.map +1 -0
  60. package/dist/http/responses.js +305 -0
  61. package/dist/http/routes.d.ts +111 -0
  62. package/dist/http/routes.d.ts.map +1 -0
  63. package/dist/http/routes.js +64 -0
  64. package/dist/http/schemas.d.ts +123 -0
  65. package/dist/http/schemas.d.ts.map +1 -0
  66. package/dist/http/schemas.js +91 -0
  67. package/dist/http/scopes.d.ts +58 -0
  68. package/dist/http/scopes.d.ts.map +1 -0
  69. package/dist/http/scopes.js +109 -0
  70. package/dist/http/views.d.ts +38 -0
  71. package/dist/http/views.d.ts.map +1 -0
  72. package/dist/http/views.js +146 -0
  73. package/dist/inbound/authenticity.d.ts +83 -0
  74. package/dist/inbound/authenticity.d.ts.map +1 -0
  75. package/dist/inbound/authenticity.js +63 -0
  76. package/dist/inbound/guard.d.ts +76 -0
  77. package/dist/inbound/guard.d.ts.map +1 -0
  78. package/dist/inbound/guard.js +80 -0
  79. package/dist/inbound/handler.d.ts +30 -0
  80. package/dist/inbound/handler.d.ts.map +1 -0
  81. package/dist/inbound/handler.js +84 -0
  82. package/dist/inbound/ingest.d.ts +106 -0
  83. package/dist/inbound/ingest.d.ts.map +1 -0
  84. package/dist/inbound/ingest.js +328 -0
  85. package/dist/inbound/recipient.d.ts +18 -0
  86. package/dist/inbound/recipient.d.ts.map +1 -0
  87. package/dist/inbound/recipient.js +48 -0
  88. package/dist/index.d.ts +22 -0
  89. package/dist/index.d.ts.map +1 -0
  90. package/dist/index.js +16 -0
  91. package/dist/link/sender.d.ts +162 -0
  92. package/dist/link/sender.d.ts.map +1 -0
  93. package/dist/link/sender.js +202 -0
  94. package/dist/migrations/0001_threads.d.ts +28 -0
  95. package/dist/migrations/0001_threads.d.ts.map +1 -0
  96. package/dist/migrations/0001_threads.js +105 -0
  97. package/dist/mime/address.d.ts +24 -0
  98. package/dist/mime/address.d.ts.map +1 -0
  99. package/dist/mime/address.js +30 -0
  100. package/dist/mime/parse.d.ts +98 -0
  101. package/dist/mime/parse.d.ts.map +1 -0
  102. package/dist/mime/parse.js +148 -0
  103. package/dist/mime/sanitize.d.ts +27 -0
  104. package/dist/mime/sanitize.d.ts.map +1 -0
  105. package/dist/mime/sanitize.js +214 -0
  106. package/dist/mime/threading.d.ts +81 -0
  107. package/dist/mime/threading.d.ts.map +1 -0
  108. package/dist/mime/threading.js +113 -0
  109. package/dist/mime/truncate.d.ts +19 -0
  110. package/dist/mime/truncate.d.ts.map +1 -0
  111. package/dist/mime/truncate.js +49 -0
  112. package/dist/provision/provisionSupport.d.ts +160 -0
  113. package/dist/provision/provisionSupport.d.ts.map +1 -0
  114. package/dist/provision/provisionSupport.js +112 -0
  115. package/dist/provision/resolveSupportConfig.d.ts +47 -0
  116. package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
  117. package/dist/provision/resolveSupportConfig.js +24 -0
  118. package/dist/reply/send.d.ts +125 -0
  119. package/dist/reply/send.d.ts.map +1 -0
  120. package/dist/reply/send.js +155 -0
  121. package/dist/reply/snippets.d.ts +81 -0
  122. package/dist/reply/snippets.d.ts.map +1 -0
  123. package/dist/reply/snippets.js +137 -0
  124. package/dist/secret/registry.d.ts +20 -0
  125. package/dist/secret/registry.d.ts.map +1 -0
  126. package/dist/secret/registry.js +23 -0
  127. package/dist/seeds/example.d.ts +5 -0
  128. package/dist/seeds/example.d.ts.map +1 -0
  129. package/dist/seeds/example.js +352 -0
  130. package/dist/store/paging.d.ts +20 -0
  131. package/dist/store/paging.d.ts.map +1 -0
  132. package/dist/store/paging.js +22 -0
  133. package/dist/store/search.d.ts +69 -0
  134. package/dist/store/search.d.ts.map +1 -0
  135. package/dist/store/search.js +162 -0
  136. package/dist/store/searchIndex.d.ts +55 -0
  137. package/dist/store/searchIndex.d.ts.map +1 -0
  138. package/dist/store/searchIndex.js +68 -0
  139. package/dist/store/threads.d.ts +149 -0
  140. package/dist/store/threads.d.ts.map +1 -0
  141. package/dist/store/threads.js +199 -0
  142. package/dist/submission/encoding.d.ts +23 -0
  143. package/dist/submission/encoding.d.ts.map +1 -0
  144. package/dist/submission/encoding.js +57 -0
  145. package/dist/submission/guard.d.ts +73 -0
  146. package/dist/submission/guard.d.ts.map +1 -0
  147. package/dist/submission/guard.js +48 -0
  148. package/dist/submission/submit.d.ts +154 -0
  149. package/dist/submission/submit.d.ts.map +1 -0
  150. package/dist/submission/submit.js +321 -0
  151. package/dist/version.generated.d.ts +7 -0
  152. package/dist/version.generated.d.ts.map +1 -0
  153. package/dist/version.generated.js +9 -0
  154. package/dist/workflows/classify.d.ts +65 -0
  155. package/dist/workflows/classify.d.ts.map +1 -0
  156. package/dist/workflows/classify.js +70 -0
  157. package/dist/workflows/retryPolicy.d.ts +41 -0
  158. package/dist/workflows/retryPolicy.d.ts.map +1 -0
  159. package/dist/workflows/retryPolicy.js +45 -0
  160. package/dist/workflows/specs.d.ts +45 -0
  161. package/dist/workflows/specs.d.ts.map +1 -0
  162. package/dist/workflows/specs.js +48 -0
  163. package/dist/workflows/worker.d.ts +50 -0
  164. package/dist/workflows/worker.d.ts.map +1 -0
  165. package/dist/workflows/worker.js +39 -0
  166. package/package.json +24 -15
  167. package/src/http/handlers.ts +1 -1
  168. package/src/submission/encoding.ts +1 -1
  169. package/src/submission/submit.ts +1 -1
  170. package/src/version.generated.ts +1 -1
@@ -0,0 +1,76 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { SupportGuardConfig } from "../config/config";
4
+ import { type SupportDatabase } from "../data/tables";
5
+ /**
6
+ * The inbound guard — the bound between "a public support address" and "a public write endpoint into
7
+ * the adopter's D1", which is what one is without this file.
8
+ *
9
+ * Three checks, deliberately ordered by what they cost:
10
+ *
11
+ * 1. **Size**, from the message's declared length, before a byte is parsed. Parsing is the expensive
12
+ * step and the size is known first, so refusing here is the difference between a flood costing
13
+ * CPU and costing nothing.
14
+ * 2. **Per sender**, which catches the overwhelmingly common case: one misconfigured auto-responder
15
+ * in a loop with the inbox. It is not an attack and it will still fill a database.
16
+ * 3. **Globally**, which is the only one that helps under a distributed flood, where every
17
+ * individual address stays comfortably under the per-sender bound.
18
+ *
19
+ * ## Counted from the messages table, not from a counter table
20
+ *
21
+ * A dedicated counter row would be one write per message and a second thing to reconcile — and it
22
+ * would be **wrong**, because a counter incremented before the insert overcounts a failed store and
23
+ * one incremented after undercounts a flood. The messages table already holds the exact fact, and
24
+ * `(from_address, received_at)` is already indexed for the sender history the dashboard shows. So
25
+ * the guard is a covered index scan over data that must exist anyway, and it cannot drift from what
26
+ * it is counting.
27
+ *
28
+ * The window is a sliding hour rather than a fixed bucket, because a fixed bucket lets twice the
29
+ * limit through across a boundary — which is exactly when a retry storm arrives.
30
+ *
31
+ * ## Mail only, and the in-app channel has its own bound
32
+ *
33
+ * Both counts filter on `channel = "email"`, so an in-app submission is invisible here and a piece of
34
+ * mail is invisible to `submission/guard.ts`. **Neither surface may starve the other.** Counting them
35
+ * together would mean a burst of in-app feedback during an outage locks a paying customer's email out
36
+ * of the inbox, and a mail flood stops the app's own users reporting the very outage that caused it —
37
+ * each of which is the failure arriving at the worst possible moment. They are also bounded on
38
+ * different keys for a real reason: a mail sender is an address anybody can write, while a submitter is
39
+ * an account the adopter issued and can revoke.
40
+ */
41
+ /** One hour, in milliseconds — the window every rate bound is measured over. */
42
+ export declare const GUARD_WINDOW_MS: number;
43
+ /** Why a message was refused. The value lands in the audit event's `metadata.reason`. */
44
+ export type SupportRejectionReason = "too_large" | "sender_rate" | "global_rate";
45
+ /** The guard's answer: accept, or refuse with a reason. */
46
+ export type GuardVerdict = {
47
+ accepted: true;
48
+ } | {
49
+ accepted: false;
50
+ reason: SupportRejectionReason;
51
+ 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
+ * Check the size bound alone — the half that needs no database and can therefore run before the
64
+ * message is parsed. Split out because the ordering is the point: this is the only check available
65
+ * while the decision is still free.
66
+ */
67
+ export declare function checkSize(config: SupportGuardConfig, rawBytes: number): GuardVerdict;
68
+ /**
69
+ * Check the two rate bounds against what is already stored.
70
+ *
71
+ * Both counts run against the sliding window ending now. Inbound only: a burst of *replies* is the
72
+ * adopter's own doing and must never lock them out of their own inbox, which is a real failure mode
73
+ * for a guard that counts every row in the table.
74
+ */
75
+ export declare function checkRates(db: SupportDatabase, config: SupportGuardConfig, input: GuardInput): Promise<GuardVerdict>;
76
+ //# sourceMappingURL=guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../../src/inbound/guard.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAA0B,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,gFAAgF;AAChF,eAAO,MAAM,eAAe,QAAiB,CAAC;AAE9C,yFAAyF;AACzF,MAAM,MAAM,sBAAsB,GAAG,WAAW,GAAG,aAAa,GAAG,aAAa,CAAC;AAEjF,2DAA2D;AAC3D,MAAM,MAAM,YAAY,GAAG;IAAE,QAAQ,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,QAAQ,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,sBAAsB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpH,sCAAsC;AACtC,MAAM,WAAW,UAAU;IACzB,kDAAkD;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,uCAAuC;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,GAAG,EAAE,IAAI,CAAC;CACX;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,GAAG,YAAY,CASpF;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,eAAe,EACnB,MAAM,EAAE,kBAAkB,EAC1B,KAAK,EAAE,UAAU,GAChB,OAAO,CAAC,YAAY,CAAC,CAqCvB"}
@@ -0,0 +1,80 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { SUPPORT_MESSAGES_TABLE } from "../data/tables.js";
4
+ //#region src/inbound/guard.ts
5
+ /**
6
+ * The inbound guard — the bound between "a public support address" and "a public write endpoint into
7
+ * the adopter's D1", which is what one is without this file.
8
+ *
9
+ * Three checks, deliberately ordered by what they cost:
10
+ *
11
+ * 1. **Size**, from the message's declared length, before a byte is parsed. Parsing is the expensive
12
+ * step and the size is known first, so refusing here is the difference between a flood costing
13
+ * CPU and costing nothing.
14
+ * 2. **Per sender**, which catches the overwhelmingly common case: one misconfigured auto-responder
15
+ * in a loop with the inbox. It is not an attack and it will still fill a database.
16
+ * 3. **Globally**, which is the only one that helps under a distributed flood, where every
17
+ * individual address stays comfortably under the per-sender bound.
18
+ *
19
+ * ## Counted from the messages table, not from a counter table
20
+ *
21
+ * A dedicated counter row would be one write per message and a second thing to reconcile — and it
22
+ * would be **wrong**, because a counter incremented before the insert overcounts a failed store and
23
+ * one incremented after undercounts a flood. The messages table already holds the exact fact, and
24
+ * `(from_address, received_at)` is already indexed for the sender history the dashboard shows. So
25
+ * the guard is a covered index scan over data that must exist anyway, and it cannot drift from what
26
+ * it is counting.
27
+ *
28
+ * The window is a sliding hour rather than a fixed bucket, because a fixed bucket lets twice the
29
+ * limit through across a boundary — which is exactly when a retry storm arrives.
30
+ *
31
+ * ## Mail only, and the in-app channel has its own bound
32
+ *
33
+ * Both counts filter on `channel = "email"`, so an in-app submission is invisible here and a piece of
34
+ * mail is invisible to `submission/guard.ts`. **Neither surface may starve the other.** Counting them
35
+ * together would mean a burst of in-app feedback during an outage locks a paying customer's email out
36
+ * of the inbox, and a mail flood stops the app's own users reporting the very outage that caused it —
37
+ * each of which is the failure arriving at the worst possible moment. They are also bounded on
38
+ * different keys for a real reason: a mail sender is an address anybody can write, while a submitter is
39
+ * an account the adopter issued and can revoke.
40
+ */
41
+ /** One hour, in milliseconds — the window every rate bound is measured over. */
42
+ const GUARD_WINDOW_MS = 36e5;
43
+ /**
44
+ * Check the size bound alone — the half that needs no database and can therefore run before the
45
+ * message is parsed. Split out because the ordering is the point: this is the only check available
46
+ * while the decision is still free.
47
+ */
48
+ function checkSize(config, rawBytes) {
49
+ if (rawBytes > config.maxRawBytes) return {
50
+ accepted: false,
51
+ reason: "too_large",
52
+ detail: `raw message is ${rawBytes} bytes, over the ${config.maxRawBytes} byte bound`
53
+ };
54
+ return { accepted: true };
55
+ }
56
+ /**
57
+ * Check the two rate bounds against what is already stored.
58
+ *
59
+ * Both counts run against the sliding window ending now. Inbound only: a burst of *replies* is the
60
+ * adopter's own doing and must never lock them out of their own inbox, which is a real failure mode
61
+ * for a guard that counts every row in the table.
62
+ */
63
+ async function checkRates(db, config, input) {
64
+ const since = /* @__PURE__ */ new Date(input.now.getTime() - GUARD_WINDOW_MS);
65
+ const fromSender = await db.selectFrom(SUPPORT_MESSAGES_TABLE).select((eb) => eb.fn.countAll().as("count")).where("direction", "=", "inbound").where("channel", "=", "email").where("fromAddress", "=", input.fromAddress).where("receivedAt", ">=", since.getTime()).executeTakeFirst();
66
+ if ((fromSender?.count ?? 0) >= config.maxPerSenderPerHour) return {
67
+ accepted: false,
68
+ reason: "sender_rate",
69
+ detail: `sender has landed ${fromSender?.count} messages in the last hour, at or over the ${config.maxPerSenderPerHour} bound`
70
+ };
71
+ const total = await db.selectFrom(SUPPORT_MESSAGES_TABLE).select((eb) => eb.fn.countAll().as("count")).where("direction", "=", "inbound").where("channel", "=", "email").where("receivedAt", ">=", since.getTime()).executeTakeFirst();
72
+ if ((total?.count ?? 0) >= config.maxPerHour) return {
73
+ accepted: false,
74
+ reason: "global_rate",
75
+ detail: `inbox has accepted ${total?.count} messages in the last hour, at or over the ${config.maxPerHour} bound`
76
+ };
77
+ return { accepted: true };
78
+ }
79
+ //#endregion
80
+ export { GUARD_WINDOW_MS, checkRates, checkSize };
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { CapabilityEmailHandler } from "@pithy-sh/core/src/capability/capability";
4
+ import type { SupportWiring } from "../capability";
5
+ /**
6
+ * The `email()` seam — a thin shell over `ingestInbound`.
7
+ *
8
+ * Everything interesting lives in `ingest.ts`, with every dependency injected; this file exists only
9
+ * to read bindings off the untyped Worker env and to make the two decisions that belong at the
10
+ * boundary rather than inside the orchestration.
11
+ *
12
+ * ## It never throws, and that is not laziness
13
+ *
14
+ * A Worker has one `email()` entry, and `createEntrypoint` fans every message to **every**
15
+ * capability that declares a handler — sequentially, in registration order. An exception thrown here
16
+ * would propagate out of that loop and stop the handlers after it from running, so a malformed
17
+ * message addressed to support could take out `@pithy-sh/email`'s bounce processing, which is how
18
+ * suppression stops working and an adopter's sending reputation quietly degrades. One capability's
19
+ * bad day must not be another's.
20
+ *
21
+ * So the handler catches, logs, and returns. The message is left untouched, which the runtime treats
22
+ * as a drop — correct, because a message this Worker could not store is one it cannot act on either.
23
+ *
24
+ * ## There is no request logger here
25
+ *
26
+ * `c.var.log` belongs to a `fetch`. An inbound message has no request, so the handler builds its own
27
+ * logger from the env rather than pretending otherwise.
28
+ */
29
+ export declare function createSupportEmailHandler(wiring: SupportWiring): CapabilityEmailHandler;
30
+ //# sourceMappingURL=handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/inbound/handler.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0CAA0C,CAAC;AAEvF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAMnD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,aAAa,GAAG,sBAAsB,CAkEvF"}
@@ -0,0 +1,84 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { supportDatabase } from "../data/tables.js";
4
+ import { makeClassifyDispatcher, resolveDb } from "../http/resolve.js";
5
+ import { resolveSenderUserId } from "../link/sender.js";
6
+ import { ingestInbound } from "./ingest.js";
7
+ import { createWorkerLogger } from "@pithy-sh/core/src/logger/worker";
8
+ //#region src/inbound/handler.ts
9
+ /**
10
+ * The `email()` seam — a thin shell over `ingestInbound`.
11
+ *
12
+ * Everything interesting lives in `ingest.ts`, with every dependency injected; this file exists only
13
+ * to read bindings off the untyped Worker env and to make the two decisions that belong at the
14
+ * boundary rather than inside the orchestration.
15
+ *
16
+ * ## It never throws, and that is not laziness
17
+ *
18
+ * A Worker has one `email()` entry, and `createEntrypoint` fans every message to **every**
19
+ * capability that declares a handler — sequentially, in registration order. An exception thrown here
20
+ * would propagate out of that loop and stop the handlers after it from running, so a malformed
21
+ * message addressed to support could take out `@pithy-sh/email`'s bounce processing, which is how
22
+ * suppression stops working and an adopter's sending reputation quietly degrades. One capability's
23
+ * bad day must not be another's.
24
+ *
25
+ * So the handler catches, logs, and returns. The message is left untouched, which the runtime treats
26
+ * as a drop — correct, because a message this Worker could not store is one it cannot act on either.
27
+ *
28
+ * ## There is no request logger here
29
+ *
30
+ * `c.var.log` belongs to a `fetch`. An inbound message has no request, so the handler builds its own
31
+ * logger from the env rather than pretending otherwise.
32
+ */
33
+ function createSupportEmailHandler(wiring) {
34
+ return async (message, env) => {
35
+ const bindings = env;
36
+ const log = createWorkerLogger({
37
+ name: "support",
38
+ fields: { env: typeof env.ENVIRONMENT === "string" ? env.ENVIRONMENT : "unknown" }
39
+ });
40
+ if (wiring.config.inboundAddresses.length === 0) {
41
+ log.warn("support has no inboundAddresses configured — every message is ignored", { action: "Set `inboundAddresses` on the support capability in pithy.config.ts." });
42
+ return;
43
+ }
44
+ try {
45
+ const d1 = resolveDb(env);
46
+ const declared = message.rawSize;
47
+ if (typeof declared === "number" && declared > wiring.config.guard.maxRawBytes) {
48
+ log.warn("support inbound message refused before parsing", {
49
+ reason: "too_large",
50
+ rawSize: declared,
51
+ maxRawBytes: wiring.config.guard.maxRawBytes
52
+ });
53
+ return;
54
+ }
55
+ const raw = await new Response(message.raw).arrayBuffer();
56
+ const outcome = await ingestInbound({
57
+ db: supportDatabase(d1),
58
+ config: wiring.config,
59
+ bucket: bindings.SUPPORT_BUCKET,
60
+ fts: wiring.config.search.fts,
61
+ dispatchClassify: makeClassifyDispatcher(env, log),
62
+ linkSender: (address) => resolveSenderUserId(d1, address),
63
+ emit: wiring.emit,
64
+ log,
65
+ newId: () => crypto.randomUUID(),
66
+ now: () => /* @__PURE__ */ new Date()
67
+ }, {
68
+ raw,
69
+ envelopeTo: message.to,
70
+ envelopeFrom: message.from
71
+ });
72
+ if (outcome.handled && !outcome.duplicate) log.info("support message stored", {
73
+ threadId: outcome.threadId,
74
+ messageId: outcome.messageId,
75
+ newThread: outcome.newThread,
76
+ attachments: outcome.attachments
77
+ });
78
+ } catch (error) {
79
+ log.error("support inbound handling failed", { error });
80
+ }
81
+ };
82
+ }
83
+ //#endregion
84
+ export { createSupportEmailHandler };
@@ -0,0 +1,106 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { R2Bucket } from "@cloudflare/workers-types";
4
+ import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
5
+ import type { Logger } from "@pithy-sh/core/src/logger/logger";
6
+ import type { SupportConfig } from "../config/config";
7
+ import { type SupportDatabase } from "../data/tables";
8
+ /**
9
+ * Ingest — everything the `email()` handler does, with every dependency injected.
10
+ *
11
+ * The handler in `handler.ts` is a thin shell that reads bindings off `env` and calls this. That
12
+ * split is what makes the interesting half testable: threading, idempotency, the guard's ordering,
13
+ * and the attachment bounds are all exercised here against a real D1 and injected fakes, with no
14
+ * Worker entry involved.
15
+ *
16
+ * ## The order of operations is the design
17
+ *
18
+ * Refusals are ordered by what they cost, and persistence comes before anything that can be slow or
19
+ * unavailable:
20
+ *
21
+ * 1. **Size**, from the declared length, before parsing.
22
+ * 2. **Parse**, then **is this ours** — decided on the SMTP envelope recipient, not on a header.
23
+ * 3. **Rate**, which needs a database read and so goes after the free checks.
24
+ * 4. **Idempotency**, because Email Routing can deliver twice and the second must be a no-op.
25
+ * 5. **Store**, which must not fail.
26
+ * 6. **Dispatch classification**, which is allowed to fail — a model that is slow or briefly down
27
+ * must never take the persistence of the message with it, which is the whole reason
28
+ * classification is a Workflow.
29
+ *
30
+ * A message that is not ours returns `{ handled: false }` and emits nothing. Every capability's
31
+ * handler sees every message, so mail for the bounce handler passing through here is the normal
32
+ * case, not an event.
33
+ */
34
+ /** What ingest did. */
35
+ export type IngestOutcome =
36
+ /** Not addressed to a configured inbox — another capability's mail, or nobody's. */
37
+ {
38
+ handled: false;
39
+ reason: "not_addressed";
40
+ }
41
+ /** Refused by the guard before anything was written. */
42
+ | {
43
+ handled: false;
44
+ reason: "rejected";
45
+ rejection: string;
46
+ }
47
+ /** Already stored under this `Message-ID`. A redelivery, and a no-op. */
48
+ | {
49
+ handled: true;
50
+ duplicate: true;
51
+ threadId: string;
52
+ messageId: string;
53
+ }
54
+ /** Stored. */
55
+ | {
56
+ handled: true;
57
+ duplicate: false;
58
+ threadId: string;
59
+ messageId: string;
60
+ /** Whether the message opened a new thread rather than continuing one. */
61
+ newThread: boolean;
62
+ /** How many attachments were stored, after the config bounds were applied. */
63
+ attachments: number;
64
+ /** Whether a classification was dispatched. False for auto-submitted mail and when AI is off. */
65
+ classifying: boolean;
66
+ };
67
+ /** Everything ingest needs, all injectable. */
68
+ export interface IngestDeps {
69
+ /** The support tables. */
70
+ db: SupportDatabase;
71
+ /** The resolved config. */
72
+ config: SupportConfig;
73
+ /** The R2 bucket raw messages and attachments are written to. Absent means neither is kept. */
74
+ bucket?: R2Bucket;
75
+ /** Whether the FTS5 index is composed. False means search runs as a `LIKE` scan and nothing is indexed. */
76
+ fts: boolean;
77
+ /**
78
+ * Start the classification Workflow for a stored message. Allowed to fail and allowed to decline —
79
+ * an unprovisioned project has no binding, and a thread that stays `uncategorized` is a legitimate
80
+ * state. The inbound path deliberately ignores the outcome; the reclassify route does not, because
81
+ * it is answering a human.
82
+ */
83
+ dispatchClassify: (messageId: string) => Promise<boolean>;
84
+ /** Resolve a sender address to a user id, or null. Allowed to fail; never fatal. */
85
+ linkSender: (address: string) => Promise<string | null>;
86
+ /** The audit seam. */
87
+ emit: AuditEmit;
88
+ /** The request/invocation logger. */
89
+ log: Logger;
90
+ /** Generate a row id. */
91
+ newId: () => string;
92
+ /** Now. */
93
+ now: () => Date;
94
+ }
95
+ /** The message as it arrived, plus the envelope facts only the runtime knows. */
96
+ export interface IngestInput {
97
+ /** The raw MIME bytes. */
98
+ raw: ArrayBuffer;
99
+ /** The SMTP envelope recipient — `ForwardableEmailMessage.to`. The authority on which inbox this is. */
100
+ envelopeTo?: string;
101
+ /** The SMTP envelope sender, for the log only. The `From` header is what a thread keys on. */
102
+ envelopeFrom?: string;
103
+ }
104
+ /** Ingest one inbound message. */
105
+ export declare function ingestInbound(deps: IngestDeps, input: IngestInput): Promise<IngestOutcome>;
106
+ //# sourceMappingURL=ingest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/inbound/ingest.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAEnE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAG/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAWxB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,uBAAuB;AACvB,MAAM,MAAM,aAAa;AACvB,oFAAoF;AAClF;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE;AAC7C,wDAAwD;GACtD;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,UAAU,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE;AAC3D,yEAAyE;GACvE;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE;AACzE,cAAc;GACZ;IACE,OAAO,EAAE,IAAI,CAAC;IACd,SAAS,EAAE,KAAK,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,SAAS,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,WAAW,EAAE,MAAM,CAAC;IACpB,iGAAiG;IACjG,WAAW,EAAE,OAAO,CAAC;CACtB,CAAC;AAEN,+CAA+C;AAC/C,MAAM,WAAW,UAAU;IACzB,0BAA0B;IAC1B,EAAE,EAAE,eAAe,CAAC;IACpB,2BAA2B;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,2GAA2G;IAC3G,GAAG,EAAE,OAAO,CAAC;IACb;;;;;OAKG;IACH,gBAAgB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,oFAAoF;IACpF,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACxD,sBAAsB;IACtB,IAAI,EAAE,SAAS,CAAC;IAChB,qCAAqC;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,KAAK,EAAE,MAAM,MAAM,CAAC;IACpB,WAAW;IACX,GAAG,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,iFAAiF;AACjF,MAAM,WAAW,WAAW;IAC1B,0BAA0B;IAC1B,GAAG,EAAE,WAAW,CAAC;IACjB,wGAAwG;IACxG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8FAA8F;IAC9F,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA0ID,kCAAkC;AAClC,wBAAsB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,CAAC,CAsShG"}