@pithy-sh/support 0.1.2 → 0.1.3

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