@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,202 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { SUPPORT_BILLING_SCOPE } from "../data/billingScope.js";
4
+ import { parseAddress } from "@pithy-sh/core/src/address/address";
5
+ //#region src/link/sender.ts
6
+ /** How many purchases a thread view carries. Enough to see the pattern, bounded so a whale is not a slow page. */
7
+ const MAX_LINKED_PURCHASES = 25;
8
+ /**
9
+ * Resolve a sender address to a user id.
10
+ *
11
+ * **Exact match on the normalized address, and deliberately no case-insensitive fallback.** The
12
+ * `email` column is indexed and unique, so this is one index seek; a `lower(email) = ?` fallback
13
+ * would be a full table scan, and it would run for *every unknown sender* — which on a public
14
+ * address means every piece of spam scanning the entire user table. Better Auth normalizes on its
15
+ * own signup path, so the exact match is the case that actually occurs; an address stored with
16
+ * capitals by some other route simply does not link, which costs a line of context rather than a
17
+ * customer's request.
18
+ */
19
+ async function resolveSenderUserId(d1, address) {
20
+ const normalized = parseAddress(address);
21
+ if (!normalized) return null;
22
+ try {
23
+ const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
24
+ return (await authDatabase(d1).selectFrom("pithyAuthUsers").select(["id"]).where("email", "=", normalized).executeTakeFirst())?.id ?? null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+ /**
30
+ * The address an operator's reply to an app thread will be sent to, or null when the account carries
31
+ * nothing deliverable.
32
+ *
33
+ * Extracted rather than inlined because it is the one decision in {@link resolveSubmitterAccount} with
34
+ * a wrong answer available, and inlining it puts that decision behind a guarded dynamic import where
35
+ * no test can reach it.
36
+ *
37
+ * **`parseAddress` only, with no `normalizeAddress` fallback.** The fallback merely trims and
38
+ * lowercases, so it would hand back an address this capability's own parser had just refused — and
39
+ * this value becomes the thread's `fromAddress`, which `sendReply` enqueues an answer to. Returning
40
+ * null makes an unparseable account the same hard fault as a missing one, which is what the caller
41
+ * already does with it: refusing the submission beats accepting a report whose only reply address
42
+ * cannot be delivered to.
43
+ */
44
+ function submitterAddress(email) {
45
+ return email && parseAddress(email) || null;
46
+ }
47
+ /**
48
+ * The account behind an authenticated submitter — resolved by id, never by an address.
49
+ *
50
+ * The inverse of {@link resolveSenderUserId}, and it exists because the in-app channel starts from the
51
+ * opposite end: a session names a user id, and what the thread needs is the address a reply will go
52
+ * back to. Deriving that from anything the client sent would hand a signed-in caller the ability to
53
+ * point a support conversation — and every operator reply on it — at somebody else's mailbox.
54
+ *
55
+ * Returns null when `@pithy-sh/auth` is absent or the account is gone. A submission whose account
56
+ * cannot be read is refused by the caller rather than stored with a guessed address: an app thread with
57
+ * no working reply address is a report nobody can answer.
58
+ */
59
+ async function resolveSubmitterAccount(d1, userId) {
60
+ try {
61
+ const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
62
+ const { User } = await import("@pithy-sh/auth/src/data/betterAuth");
63
+ const row = await authDatabase(d1).selectFrom("pithyAuthUsers").selectAll().where("id", "=", userId).executeTakeFirst();
64
+ if (!row) return null;
65
+ const user = User.parse(row);
66
+ const email = submitterAddress(user.email);
67
+ if (!email) return null;
68
+ return {
69
+ email,
70
+ name: user.name,
71
+ emailVerified: user.emailVerified
72
+ };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ /** Read the account's own fields, when auth is composed. */
78
+ async function resolveAccount(d1, address) {
79
+ try {
80
+ const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
81
+ const { User } = await import("@pithy-sh/auth/src/data/betterAuth");
82
+ const row = await authDatabase(d1).selectFrom("pithyAuthUsers").selectAll().where("email", "=", address).executeTakeFirst();
83
+ if (!row) return null;
84
+ const user = User.parse(row);
85
+ return {
86
+ userId: user.id,
87
+ name: user.name,
88
+ emailVerified: user.emailVerified
89
+ };
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+ /**
95
+ * Read what the account bought, when payments is composed — **`user`-subject rows only**.
96
+ *
97
+ * Both halves of the pair, deliberately. Nothing in the kit keeps an organization id from equalling some
98
+ * user's, so a filter on the id alone would eventually hand one holder's purchases to the other — which
99
+ * is the whole reason payments made the holder a pair. See {@link SUPPORT_BILLING_SCOPE} for why the
100
+ * type half is pinned to `user` rather than resolved.
101
+ */
102
+ async function resolvePurchases(d1, userId) {
103
+ try {
104
+ const { PAYMENTS_PURCHASES_TABLE, paymentsDatabase } = await import("@pithy-sh/payments/src/data/tables");
105
+ const { PaymentsPurchase } = await import("@pithy-sh/payments/src/data/purchase");
106
+ return (await paymentsDatabase(d1).selectFrom(PAYMENTS_PURCHASES_TABLE).selectAll().where("subjectType", "=", SUPPORT_BILLING_SCOPE).where("subjectId", "=", userId).orderBy("purchasedAt", "desc").limit(25).execute()).map((row) => {
107
+ const purchase = PaymentsPurchase.parse(row);
108
+ return {
109
+ id: purchase.id,
110
+ rail: purchase.rail,
111
+ productId: purchase.productId,
112
+ status: purchase.status,
113
+ environment: purchase.environment,
114
+ purchasedAt: purchase.purchasedAt,
115
+ expiresAt: purchase.expiresAt ?? null,
116
+ revokedAt: purchase.revokedAt ?? null
117
+ };
118
+ });
119
+ } catch {
120
+ return [];
121
+ }
122
+ }
123
+ /** Read what the account is entitled to, when payments is composed — **`user`-subject rows only**. */
124
+ async function resolveLinkedEntitlements(d1, userId, now) {
125
+ try {
126
+ const { paymentsDatabase } = await import("@pithy-sh/payments/src/data/tables");
127
+ const { resolveEntitlements } = await import("@pithy-sh/payments/src/projection/resolve");
128
+ return await resolveEntitlements(paymentsDatabase(d1), {
129
+ subjectType: SUPPORT_BILLING_SCOPE,
130
+ subjectId: userId
131
+ }, now);
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+ /**
137
+ * Everything the app already knows about a sender — read at thread-read time rather than stored.
138
+ *
139
+ * Derived, so it is always current: a customer who buys Pro an hour after writing in shows as
140
+ * entitled the next time the thread is opened, with nothing to backfill and nothing to invalidate.
141
+ * That is the same rule the classification follows, applied to the half of the model that lives in
142
+ * somebody else's tables.
143
+ */
144
+ async function resolveSenderContext(d1, address, now, options) {
145
+ const empty = {
146
+ authenticated: options.authenticated,
147
+ userId: null,
148
+ purchases: [],
149
+ entitlements: []
150
+ };
151
+ const normalized = parseAddress(address);
152
+ if (!normalized) return empty;
153
+ const account = await resolveAccount(d1, normalized);
154
+ if (!account) return empty;
155
+ if (!options.authenticated) return {
156
+ authenticated: false,
157
+ userId: account.userId,
158
+ name: account.name,
159
+ purchases: [],
160
+ entitlements: []
161
+ };
162
+ return provenContext(d1, account, now);
163
+ }
164
+ /**
165
+ * The customer context behind a **session-proven** link, resolved from the user id itself.
166
+ *
167
+ * An app thread already holds the id its session proved, so re-deriving it from the thread's address
168
+ * would be both a step backwards and a correctness bug: `resolveSenderUserId` matches the `email`
169
+ * column exactly, and an account stored with capitals by some other route would silently resolve to
170
+ * nobody — turning the one link that *is* certain into the one the console shows as unknown.
171
+ *
172
+ * Everything below the link degrades exactly as it does on the mail path: purchases and entitlements
173
+ * are guarded dynamic imports and come back empty when `@pithy-sh/payments` is absent.
174
+ */
175
+ async function resolveSubmitterContext(d1, userId, now) {
176
+ const account = await resolveSubmitterAccount(d1, userId);
177
+ if (!account) return {
178
+ authenticated: true,
179
+ userId,
180
+ purchases: [],
181
+ entitlements: []
182
+ };
183
+ return provenContext(d1, {
184
+ userId,
185
+ name: account.name,
186
+ emailVerified: account.emailVerified
187
+ }, now);
188
+ }
189
+ /** The proven half, shared by both entry points: the account, plus what it bought and what it holds. */
190
+ async function provenContext(d1, account, now) {
191
+ const [purchases, entitlements] = await Promise.all([resolvePurchases(d1, account.userId), resolveLinkedEntitlements(d1, account.userId, now)]);
192
+ return {
193
+ authenticated: true,
194
+ userId: account.userId,
195
+ name: account.name,
196
+ emailVerified: account.emailVerified,
197
+ purchases,
198
+ entitlements
199
+ };
200
+ }
201
+ //#endregion
202
+ export { MAX_LINKED_PURCHASES, resolveSenderContext, resolveSenderUserId, resolveSubmitterAccount, resolveSubmitterContext, submitterAddress };
@@ -0,0 +1,28 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Migration } from "kysely/migration";
4
+ /**
5
+ * The support inbox: threads, their messages, attachment metadata, the append-only classification
6
+ * history, and per-viewer flags.
7
+ *
8
+ * **The indexes are the point of this migration, not an afterthought.** The dashboard's entire
9
+ * interaction is "inbox, newest first, filtered by category and priority and archived" — a query
10
+ * workload, and one that is miserable to retrofit once a table has rows in production. So the two
11
+ * composite indexes the issue names are created here, in the first migration, alongside the tables.
12
+ *
13
+ * camelCase identifiers throughout; `CamelCasePlugin` snake-cases them in the DDL. `down` is the
14
+ * tested inverse — indexes first, then tables, children before parents.
15
+ *
16
+ * **No CHECK constraints, deliberately.** CLAUDE.md's rule is that one Zod schema per table is the
17
+ * entire table definition, and a CHECK mirroring a `z.enum` or a `SQLiteBoolean` is a second, partial
18
+ * copy of that definition — one that can drift from the schema and that SQLite cannot alter, so
19
+ * adding a priority level or a sentiment would mean a table rebuild rather than a one-line edit to
20
+ * the enum. Every value here reaches SQLite through `Schema.encode`, so the schema already refuses
21
+ * what a CHECK would have. The three bounds that were doing real work — both `confidence` ranges and
22
+ * the attachment `size` floor — moved into the schemas, which is where they belonged.
23
+ *
24
+ * A CHECK still earns its place for an invariant Zod genuinely cannot express: `@pithy-sh/ledger`
25
+ * constrains `held <= balance` across columns, which no per-field schema can state.
26
+ */
27
+ export declare const support_0001_threads: Migration;
28
+ //# sourceMappingURL=0001_threads.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"0001_threads.d.ts","sourceRoot":"","sources":["../../src/migrations/0001_threads.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,oBAAoB,EAAE,SA0QlC,CAAC"}
@@ -0,0 +1,105 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/migrations/0001_threads.ts
4
+ /**
5
+ * The support inbox: threads, their messages, attachment metadata, the append-only classification
6
+ * history, and per-viewer flags.
7
+ *
8
+ * **The indexes are the point of this migration, not an afterthought.** The dashboard's entire
9
+ * interaction is "inbox, newest first, filtered by category and priority and archived" — a query
10
+ * workload, and one that is miserable to retrofit once a table has rows in production. So the two
11
+ * composite indexes the issue names are created here, in the first migration, alongside the tables.
12
+ *
13
+ * camelCase identifiers throughout; `CamelCasePlugin` snake-cases them in the DDL. `down` is the
14
+ * tested inverse — indexes first, then tables, children before parents.
15
+ *
16
+ * **No CHECK constraints, deliberately.** CLAUDE.md's rule is that one Zod schema per table is the
17
+ * entire table definition, and a CHECK mirroring a `z.enum` or a `SQLiteBoolean` is a second, partial
18
+ * copy of that definition — one that can drift from the schema and that SQLite cannot alter, so
19
+ * adding a priority level or a sentiment would mean a table rebuild rather than a one-line edit to
20
+ * the enum. Every value here reaches SQLite through `Schema.encode`, so the schema already refuses
21
+ * what a CHECK would have. The three bounds that were doing real work — both `confidence` ranges and
22
+ * the attachment `size` floor — moved into the schemas, which is where they belonged.
23
+ *
24
+ * A CHECK still earns its place for an invariant Zod genuinely cannot express: `@pithy-sh/ledger`
25
+ * constrains `held <= balance` across columns, which no per-field schema can state.
26
+ */
27
+ const support_0001_threads = {
28
+ up: async (db) => {
29
+ await db.schema.createTable("pithySupportThreads").addColumn("id", "text", (c) => c.primaryKey()).addColumn("channel", "text", (c) => c.notNull().defaultTo("email")).addColumn("inboxAddress", "text").addColumn("subject", "text", (c) => c.notNull()).addColumn("fromAddress", "text", (c) => c.notNull()).addColumn("fromName", "text").addColumn("senderAuthenticated", "integer", (c) => c.notNull().defaultTo(0)).addColumn("userId", "text").addColumn("accountLinkSource", "text").addColumn("declaredCategory", "text").addColumn("category", "text", (c) => c.notNull().defaultTo("uncategorized")).addColumn("priority", "text", (c) => c.notNull().defaultTo("normal")).addColumn("sentiment", "text", (c) => c.notNull().defaultTo("neutral")).addColumn("confidence", "real").addColumn("model", "text").addColumn("classifiedAt", "integer").addColumn("archived", "integer", (c) => c.notNull().defaultTo(0)).addColumn("archivedAt", "integer").addColumn("archivedBy", "text").addColumn("messageCount", "integer", (c) => c.notNull().defaultTo(0)).addColumn("firstMessageAt", "integer", (c) => c.notNull()).addColumn("lastMessageAt", "integer", (c) => c.notNull()).addColumn("createdAt", "integer", (c) => c.notNull()).addColumn("updatedAt", "integer", (c) => c.notNull()).execute();
30
+ await db.schema.createIndex("pithySupportThreadsArchivedIdx").on("pithySupportThreads").columns([
31
+ "archived",
32
+ "lastMessageAt",
33
+ "id"
34
+ ]).execute();
35
+ await db.schema.createIndex("pithySupportThreadsCategoryIdx").on("pithySupportThreads").columns([
36
+ "category",
37
+ "lastMessageAt",
38
+ "id"
39
+ ]).execute();
40
+ await db.schema.createIndex("pithySupportThreadsDeclaredCategoryIdx").on("pithySupportThreads").columns([
41
+ "declaredCategory",
42
+ "lastMessageAt",
43
+ "id"
44
+ ]).execute();
45
+ await db.schema.createIndex("pithySupportThreadsFromIdx").on("pithySupportThreads").columns(["fromAddress", "lastMessageAt"]).execute();
46
+ await db.schema.createIndex("pithySupportThreadsUserIdx").on("pithySupportThreads").column("userId").execute();
47
+ await db.schema.createIndex("pithySupportThreadsChannelIdx").on("pithySupportThreads").columns([
48
+ "channel",
49
+ "lastMessageAt",
50
+ "id"
51
+ ]).execute();
52
+ await db.schema.createIndex("pithySupportThreadsUserChannelIdx").on("pithySupportThreads").columns([
53
+ "userId",
54
+ "channel",
55
+ "lastMessageAt",
56
+ "id"
57
+ ]).execute();
58
+ await db.schema.createTable("pithySupportMessages").addColumn("id", "text", (c) => c.primaryKey()).addColumn("threadId", "text", (c) => c.notNull()).addColumn("direction", "text", (c) => c.notNull()).addColumn("channel", "text", (c) => c.notNull().defaultTo("email")).addColumn("submittedByUserId", "text").addColumn("context", "text").addColumn("mimeMessageId", "text").addColumn("mimeInReplyTo", "text").addColumn("mimeReferences", "text").addColumn("fromAddress", "text").addColumn("fromName", "text").addColumn("toAddress", "text").addColumn("subject", "text", (c) => c.notNull()).addColumn("textBody", "text", (c) => c.notNull()).addColumn("htmlBody", "text").addColumn("emailJobId", "text").addColumn("rawKey", "text").addColumn("rawBytes", "integer").addColumn("receivedAt", "integer", (c) => c.notNull()).addColumn("createdAt", "integer", (c) => c.notNull()).execute();
59
+ await db.schema.createIndex("pithySupportMessagesMimeIdIdx").on("pithySupportMessages").columns(["mimeMessageId", "toAddress"]).unique().execute();
60
+ await db.schema.createIndex("pithySupportMessagesThreadIdx").on("pithySupportMessages").columns(["threadId", "receivedAt"]).execute();
61
+ await db.schema.createIndex("pithySupportMessagesInReplyToIdx").on("pithySupportMessages").column("mimeInReplyTo").execute();
62
+ await db.schema.createIndex("pithySupportMessagesFromIdx").on("pithySupportMessages").columns(["fromAddress", "receivedAt"]).execute();
63
+ await db.schema.createIndex("pithySupportMessagesSubmitterIdx").on("pithySupportMessages").columns(["submittedByUserId", "receivedAt"]).execute();
64
+ await db.schema.createIndex("pithySupportMessagesChannelIdx").on("pithySupportMessages").columns([
65
+ "channel",
66
+ "direction",
67
+ "receivedAt"
68
+ ]).execute();
69
+ await db.schema.createTable("pithySupportAttachments").addColumn("id", "text", (c) => c.primaryKey()).addColumn("messageId", "text", (c) => c.notNull()).addColumn("threadId", "text", (c) => c.notNull()).addColumn("filename", "text", (c) => c.notNull()).addColumn("contentType", "text", (c) => c.notNull()).addColumn("size", "integer", (c) => c.notNull()).addColumn("sha256", "text", (c) => c.notNull()).addColumn("storageKey", "text", (c) => c.notNull()).addColumn("contentId", "text").addColumn("inline", "integer", (c) => c.notNull().defaultTo(0)).addColumn("createdAt", "integer", (c) => c.notNull()).execute();
70
+ await db.schema.createIndex("pithySupportAttachmentsMessageIdx").on("pithySupportAttachments").column("messageId").execute();
71
+ await db.schema.createIndex("pithySupportAttachmentsThreadIdx").on("pithySupportAttachments").column("threadId").execute();
72
+ await db.schema.createTable("pithySupportClassifications").addColumn("id", "text", (c) => c.primaryKey()).addColumn("threadId", "text", (c) => c.notNull()).addColumn("messageId", "text", (c) => c.notNull()).addColumn("category", "text", (c) => c.notNull()).addColumn("priority", "text", (c) => c.notNull()).addColumn("sentiment", "text", (c) => c.notNull()).addColumn("confidence", "real", (c) => c.notNull()).addColumn("model", "text", (c) => c.notNull()).addColumn("createdAt", "integer", (c) => c.notNull()).execute();
73
+ await db.schema.createIndex("pithySupportClassificationsThreadIdx").on("pithySupportClassifications").columns(["threadId", "createdAt"]).execute();
74
+ await db.schema.createIndex("pithySupportClassificationsModelIdx").on("pithySupportClassifications").columns(["model", "createdAt"]).execute();
75
+ await db.schema.createTable("pithySupportThreadFlags").addColumn("id", "text", (c) => c.primaryKey()).addColumn("threadId", "text", (c) => c.notNull()).addColumn("viewer", "text", (c) => c.notNull()).addColumn("read", "integer", (c) => c.notNull().defaultTo(0)).addColumn("snoozedUntil", "integer").addColumn("createdAt", "integer", (c) => c.notNull()).addColumn("updatedAt", "integer", (c) => c.notNull()).execute();
76
+ await db.schema.createIndex("pithySupportThreadFlagsViewerIdx").on("pithySupportThreadFlags").columns(["threadId", "viewer"]).unique().execute();
77
+ },
78
+ down: async (db) => {
79
+ await db.schema.dropIndex("pithySupportThreadFlagsViewerIdx").execute();
80
+ await db.schema.dropTable("pithySupportThreadFlags").execute();
81
+ await db.schema.dropIndex("pithySupportClassificationsModelIdx").execute();
82
+ await db.schema.dropIndex("pithySupportClassificationsThreadIdx").execute();
83
+ await db.schema.dropTable("pithySupportClassifications").execute();
84
+ await db.schema.dropIndex("pithySupportAttachmentsThreadIdx").execute();
85
+ await db.schema.dropIndex("pithySupportAttachmentsMessageIdx").execute();
86
+ await db.schema.dropTable("pithySupportAttachments").execute();
87
+ await db.schema.dropIndex("pithySupportMessagesChannelIdx").execute();
88
+ await db.schema.dropIndex("pithySupportMessagesSubmitterIdx").execute();
89
+ await db.schema.dropIndex("pithySupportMessagesFromIdx").execute();
90
+ await db.schema.dropIndex("pithySupportMessagesInReplyToIdx").execute();
91
+ await db.schema.dropIndex("pithySupportMessagesThreadIdx").execute();
92
+ await db.schema.dropIndex("pithySupportMessagesMimeIdIdx").execute();
93
+ await db.schema.dropTable("pithySupportMessages").execute();
94
+ await db.schema.dropIndex("pithySupportThreadsUserChannelIdx").execute();
95
+ await db.schema.dropIndex("pithySupportThreadsChannelIdx").execute();
96
+ await db.schema.dropIndex("pithySupportThreadsUserIdx").execute();
97
+ await db.schema.dropIndex("pithySupportThreadsFromIdx").execute();
98
+ await db.schema.dropIndex("pithySupportThreadsDeclaredCategoryIdx").execute();
99
+ await db.schema.dropIndex("pithySupportThreadsCategoryIdx").execute();
100
+ await db.schema.dropIndex("pithySupportThreadsArchivedIdx").execute();
101
+ await db.schema.dropTable("pithySupportThreads").execute();
102
+ }
103
+ };
104
+ //#endregion
105
+ export { support_0001_threads };
@@ -0,0 +1,24 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * What a MIME header contributes that an address rule does not.
5
+ *
6
+ * Addresses themselves are `@pithy-sh/core/src/address/address` — `parseAddress` reads one out of a
7
+ * header and `normalizeAddress` puts it in the form every comparison in the kit is against. This
8
+ * capability used to own that rule; it does not any more, because `auth`, `email` and `testers`
9
+ * compare addresses too, and four rules is four chances to disagree about whether two strings are the
10
+ * same person. That disagreement presents as one customer with two threads, not as anything about
11
+ * addresses.
12
+ *
13
+ * What is left here is the display name, which is this capability alone: nobody else stores one, and it
14
+ * is the half of a `From` header that is free text an attacker wrote.
15
+ */
16
+ /**
17
+ * A display name, made safe to store.
18
+ *
19
+ * Trimmed, bounded, and stripped of control characters — including the bidirectional overrides that
20
+ * let `moc.elpmaxe@ada` render as something else entirely. The result is still untrusted text that a
21
+ * renderer must escape; this only guarantees it is text.
22
+ */
23
+ export declare function normalizeDisplayName(value: string | null | undefined): string | undefined;
24
+ //# sourceMappingURL=address.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"address.d.ts","sourceRoot":"","sources":["../../src/mime/address.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;GAYG;AAEH;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAYzF"}
@@ -0,0 +1,30 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/mime/address.ts
4
+ /**
5
+ * What a MIME header contributes that an address rule does not.
6
+ *
7
+ * Addresses themselves are `@pithy-sh/core/src/address/address` — `parseAddress` reads one out of a
8
+ * header and `normalizeAddress` puts it in the form every comparison in the kit is against. This
9
+ * capability used to own that rule; it does not any more, because `auth`, `email` and `testers`
10
+ * compare addresses too, and four rules is four chances to disagree about whether two strings are the
11
+ * same person. That disagreement presents as one customer with two threads, not as anything about
12
+ * addresses.
13
+ *
14
+ * What is left here is the display name, which is this capability alone: nobody else stores one, and it
15
+ * is the half of a `From` header that is free text an attacker wrote.
16
+ */
17
+ /**
18
+ * A display name, made safe to store.
19
+ *
20
+ * Trimmed, bounded, and stripped of control characters — including the bidirectional overrides that
21
+ * let `moc.elpmaxe@ada` render as something else entirely. The result is still untrusted text that a
22
+ * renderer must escape; this only guarantees it is text.
23
+ */
24
+ function normalizeDisplayName(value) {
25
+ if (typeof value !== "string") return void 0;
26
+ const cleaned = value.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, "").trim().slice(0, 200);
27
+ return cleaned.length > 0 ? cleaned : void 0;
28
+ }
29
+ //#endregion
30
+ export { normalizeDisplayName };
@@ -0,0 +1,98 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Inbound MIME, parsed into the shape the rest of the capability works in.
5
+ *
6
+ * `@pithy-sh/email` already depends on `postal-mime` and already parses inbound mail — but only its
7
+ * headers, to classify a bounce. Everything below the headers is untouched there, so multipart
8
+ * bodies, attachments, and the threading chain are parsed here for the first time.
9
+ *
10
+ * **This module does not sanitize.** `html` comes back exactly as it arrived, and the caller runs it
11
+ * through `sanitizeHtml` before anything stores or renders it. Keeping the two apart is what lets
12
+ * this file be unit-tested under node against real messages — `HTMLRewriter` is a Workers global —
13
+ * and it keeps the parse honest: a sanitizer that ran here would quietly make the raw bytes and the
14
+ * parsed form disagree about what arrived.
15
+ */
16
+ /** One attachment, decoded, with its declared metadata carried as-declared. */
17
+ export interface ParsedAttachment {
18
+ /** The filename the sender declared, already stripped of path separators and control characters. */
19
+ filename: string;
20
+ /** The MIME type the sender declared. Recorded, never honored by a serve path. */
21
+ contentType: string;
22
+ /** The decoded bytes. */
23
+ bytes: Uint8Array;
24
+ /** The `Content-ID`, when the part carried one — what an inline image refers to by `cid:`. */
25
+ contentId?: string;
26
+ /** Whether the part declared `Content-Disposition: inline`. */
27
+ inline: boolean;
28
+ }
29
+ /** One inbound message, parsed. */
30
+ export interface ParsedInboundMessage {
31
+ /** The `Message-ID`, without angle brackets. Absent when the sender omitted one. */
32
+ messageId?: string;
33
+ /** The `In-Reply-To` id, without angle brackets — the direct parent. */
34
+ inReplyTo?: string;
35
+ /** The `References` chain, oldest first. */
36
+ references: string[];
37
+ /** The sender's address, normalized. */
38
+ fromAddress: string;
39
+ /** The sender's display name, if any — untrusted text, already stripped of control characters. */
40
+ fromName?: string;
41
+ /** Every address named in `To`/`Cc`/`Delivered-To`, normalized. Corroborating evidence, never authority. */
42
+ headerRecipients: string[];
43
+ /** The subject, bounded. Empty when the message had none. */
44
+ subject: string;
45
+ /** The plain-text body. Empty when the message carried only HTML — the caller derives text from it. */
46
+ text: string;
47
+ /** The HTML body **exactly as it arrived**, unsanitised. Absent when the message carried none. */
48
+ html?: string;
49
+ /** The decoded attachments, in the order the parts appeared. */
50
+ attachments: ParsedAttachment[];
51
+ /**
52
+ * The `Authentication-Results` verdicts the receiving MTA stamped, lowercased, keyed by method
53
+ * (`dmarc`, `spf`, `dkim`). Empty when the header was absent — which is itself a signal.
54
+ *
55
+ * Read from the **topmost** such header only. A sender may include one of their own, and it will be
56
+ * below the MTA's; reading any but the first is a forged verdict.
57
+ */
58
+ authResults: Record<string, string>;
59
+ /**
60
+ * Which sender-authentication headers were actually present in the received bytes.
61
+ *
62
+ * **A diagnostic, not a signal** — nothing branches on it, and it is deliberately not stored. It
63
+ * exists to settle a question this package cannot answer by reading documentation: whether
64
+ * Cloudflare Email Routing delivers a Worker the headers a sender-authenticity check would need.
65
+ * `Authentication-Results`, `Received`, and `DKIM-Signature` are all reported missing from
66
+ * `message.headers` (cloudflare/workerd#6740), but `message.raw` — which is what this parses — is
67
+ * documented as the raw MIME and is a different surface. Inferring the answer from a GitHub issue
68
+ * is guessing; logging what arrived means the first real delivery answers it.
69
+ *
70
+ * See issue #47, which is where that live verification lives.
71
+ */
72
+ authHeadersSeen: string[];
73
+ /**
74
+ * Whether this looks machine-generated: an out-of-office, a mailing-list post, or a bulk send.
75
+ *
76
+ * Not a reason to drop the message — a vacation responder appended to a real thread is context an
77
+ * operator wants — but a good reason not to pay a model to classify it. Read by the ingest path to
78
+ * skip the classification dispatch, which is the only cost per message that is not fixed.
79
+ */
80
+ autoSubmitted: boolean;
81
+ }
82
+ /** The longest text body stored, **in bytes**. A support request needing more than this has a file attached. */
83
+ export declare const MAX_TEXT_BODY: number;
84
+ /**
85
+ * Make a declared filename safe to store and display.
86
+ *
87
+ * Path separators go first — a name is never part of a storage key here, but a dashboard that offers
88
+ * a download should not be handed `../../etc/passwd` to put in a `Content-Disposition`. Control
89
+ * characters and bidirectional overrides go with them: a filename carrying a right-to-left override
90
+ * renders as something other than what it is, which is the oldest attachment trick there is.
91
+ */
92
+ export declare function safeFilename(value: string | null | undefined): string;
93
+ export declare function parseAuthResults(value: string | undefined, expectedAuthservId?: string): Record<string, string>;
94
+ /** Parse a raw MIME message. Throws `support/unparseable_message` when there is no usable sender. */
95
+ export declare function parseInbound(raw: string | ArrayBuffer | Uint8Array, options?: {
96
+ expectedAuthservId?: string;
97
+ }): Promise<ParsedInboundMessage>;
98
+ //# sourceMappingURL=parse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/mime/parse.ts"],"names":[],"mappings":"AAUA;;;;;;;;;;;;GAYG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,gBAAgB;IAC/B,oGAAoG;IACpG,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,WAAW,EAAE,MAAM,CAAC;IACpB,yBAAyB;IACzB,KAAK,EAAE,UAAU,CAAC;IAClB,8FAA8F;IAC9F,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,mCAAmC;AACnC,MAAM,WAAW,oBAAoB;IACnC,oFAAoF;IACpF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,wCAAwC;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,kGAAkG;IAClG,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4GAA4G;IAC5G,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,6DAA6D;IAC7D,OAAO,EAAE,MAAM,CAAC;IAChB,uGAAuG;IACvG,IAAI,EAAE,MAAM,CAAC;IACb,kGAAkG;IAClG,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,WAAW,EAAE,gBAAgB,EAAE,CAAC;IAChC;;;;;;OAMG;IACH,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC;;;;;;;;;;;;OAYG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B;;;;;;OAMG;IACH,aAAa,EAAE,OAAO,CAAC;CACxB;AAiBD,gHAAgH;AAChH,eAAO,MAAM,aAAa,QAAa,CAAC;AAkBxC;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAWrE;AAuBD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,kBAAkB,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA8C/G;AAmBD,qGAAqG;AACrG,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,GAAG,WAAW,GAAG,UAAU,EACtC,OAAO,GAAE;IAAE,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAAO,GAC5C,OAAO,CAAC,oBAAoB,CAAC,CA6D/B"}
@@ -0,0 +1,148 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { SupportUnparseableMessageError } from "../error/errors.js";
4
+ import { normalizeMessageId, parseReferences } from "./threading.js";
5
+ import { normalizeDisplayName } from "./address.js";
6
+ import { truncateToBytes } from "./truncate.js";
7
+ import { parseAddress } from "@pithy-sh/core/src/address/address";
8
+ import PostalMime from "postal-mime";
9
+ //#region src/mime/parse.ts
10
+ /**
11
+ * A verdict at the start of its clause. Anchored: an unanchored scan matched `method=result` inside a
12
+ * *property tag*, and `smtp.mailfrom=` carries an address the sender chose.
13
+ */
14
+ const VERDICT = /^\s*(dmarc|spf|dkim|iprev)\s*=\s*([a-z]+)/i;
15
+ /**
16
+ * The headers whose presence decides whether in-Worker sender authentication is possible at all.
17
+ * Reported, never trusted — see {@link ParsedInboundMessage.authHeadersSeen}.
18
+ */
19
+ const AUTH_HEADERS = [
20
+ "authentication-results",
21
+ "dkim-signature",
22
+ "arc-authentication-results",
23
+ "received"
24
+ ];
25
+ /** The longest subject stored. Long enough for any real one; short enough not to be a storage lever. */
26
+ const MAX_SUBJECT = 500;
27
+ /** The longest text body stored, **in bytes**. A support request needing more than this has a file attached. */
28
+ const MAX_TEXT_BODY = 262144;
29
+ /** Pull the first usable address out of whatever `postal-mime` produced for a header. */
30
+ function addressesOf(value) {
31
+ const list = value === void 0 ? [] : Array.isArray(value) ? value : [value];
32
+ const found = [];
33
+ for (const entry of list) {
34
+ const members = entry.group !== void 0 ? entry.group : entry.address !== void 0 ? [entry] : [];
35
+ for (const member of members) {
36
+ const normalized = parseAddress(member.address);
37
+ if (normalized) found.push(normalized);
38
+ }
39
+ }
40
+ return found;
41
+ }
42
+ /**
43
+ * Make a declared filename safe to store and display.
44
+ *
45
+ * Path separators go first — a name is never part of a storage key here, but a dashboard that offers
46
+ * a download should not be handed `../../etc/passwd` to put in a `Content-Disposition`. Control
47
+ * characters and bidirectional overrides go with them: a filename carrying a right-to-left override
48
+ * renders as something other than what it is, which is the oldest attachment trick there is.
49
+ */
50
+ function safeFilename(value) {
51
+ const cleaned = (value ?? "").replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, "").replace(/[/\\]/g, "_").trim().slice(0, 200);
52
+ return cleaned.length > 0 ? cleaned : "attachment";
53
+ }
54
+ /**
55
+ * Parse `Authentication-Results` into `method → verdict`.
56
+ *
57
+ * Cloudflare stamps this before a message reaches a Worker, so it is the one trustworthy statement
58
+ * about the sender available here — everything else in the message is the sender's own claim. The
59
+ * format is `mta.example.com; spf=pass smtp.mailfrom=a@b; dkim=pass header.d=b; dmarc=pass`, and
60
+ * only the method/verdict pairs are read; the property tags after each are informational.
61
+ */
62
+ /**
63
+ * The authserv-id out of an `Authentication-Results` first clause: the host name, with the optional
64
+ * version token and any CFWS comments removed, lowercased.
65
+ */
66
+ function authservIdOf(clause) {
67
+ return (clause ?? "").replace(/\([^)]*\)/g, " ").trim().replace(/\s+\d+$/, "").trim().toLowerCase();
68
+ }
69
+ function parseAuthResults(value, expectedAuthservId) {
70
+ if (typeof value !== "string") return {};
71
+ const clauses = value.replace(/"(?:[^"\\]|\\.)*"/g, "\"\"").split(";");
72
+ const leadingIsVerdict = VERDICT.test(clauses[0] ?? "");
73
+ if (expectedAuthservId !== void 0 && !leadingIsVerdict) {
74
+ if (authservIdOf(clauses[0]) !== authservIdOf(expectedAuthservId)) return {};
75
+ }
76
+ const results = {};
77
+ for (const clause of leadingIsVerdict ? clauses : clauses.slice(1)) {
78
+ const match = VERDICT.exec(clause);
79
+ const method = match?.[1]?.toLowerCase();
80
+ const verdict = match?.[2]?.toLowerCase();
81
+ if (method && verdict && !(method in results)) results[method] = verdict;
82
+ }
83
+ return results;
84
+ }
85
+ /** Whether a header set marks the message as machine-generated. */
86
+ function isAutoSubmitted(headers) {
87
+ if ((headers.get("auto-submitted")?.toLowerCase() ?? "").startsWith("auto-")) return true;
88
+ const precedence = headers.get("precedence")?.toLowerCase() ?? "";
89
+ if ([
90
+ "bulk",
91
+ "list",
92
+ "junk",
93
+ "auto_reply"
94
+ ].includes(precedence)) return true;
95
+ return headers.has("list-id") || headers.has("list-unsubscribe") || headers.has("x-autoreply");
96
+ }
97
+ /** Decode `postal-mime`'s attachment content into bytes, whatever form it handed back. */
98
+ function attachmentBytes(attachment) {
99
+ const content = attachment.content;
100
+ if (typeof content === "string") return new TextEncoder().encode(content);
101
+ if (content instanceof Uint8Array) return content;
102
+ return new Uint8Array(content);
103
+ }
104
+ /** Parse a raw MIME message. Throws `support/unparseable_message` when there is no usable sender. */
105
+ async function parseInbound(raw, options = {}) {
106
+ let email;
107
+ try {
108
+ email = await PostalMime.parse(raw);
109
+ } catch (cause) {
110
+ throw new SupportUnparseableMessageError({ detail: "postal-mime could not parse the message" }, { cause });
111
+ }
112
+ const headers = /* @__PURE__ */ new Map();
113
+ for (const header of email.headers) {
114
+ const key = header.key.toLowerCase();
115
+ if (!headers.has(key)) headers.set(key, header.value);
116
+ }
117
+ const fromAddress = addressesOf(email.from)[0];
118
+ if (!fromAddress) throw new SupportUnparseableMessageError({ detail: "the message carried no parseable From address" });
119
+ const from = Array.isArray(email.from) ? email.from[0] : email.from;
120
+ const inReplyTo = normalizeMessageId(email.inReplyTo);
121
+ return {
122
+ messageId: normalizeMessageId(email.messageId),
123
+ inReplyTo,
124
+ references: parseReferences(email.references),
125
+ fromAddress,
126
+ fromName: normalizeDisplayName(from?.name),
127
+ headerRecipients: [.../* @__PURE__ */ new Set([
128
+ ...addressesOf(email.to),
129
+ ...addressesOf(email.cc),
130
+ ...parseAddress(email.deliveredTo) ? [parseAddress(email.deliveredTo)] : []
131
+ ])],
132
+ subject: (email.subject ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_SUBJECT),
133
+ text: truncateToBytes(email.text ?? "", MAX_TEXT_BODY),
134
+ html: email.html,
135
+ attachments: email.attachments.map((attachment) => ({
136
+ filename: safeFilename(attachment.filename),
137
+ contentType: attachment.mimeType || "application/octet-stream",
138
+ bytes: attachmentBytes(attachment),
139
+ contentId: attachment.contentId?.replace(/^<|>$/g, "") || void 0,
140
+ inline: attachment.disposition === "inline"
141
+ })),
142
+ authResults: parseAuthResults(headers.get("authentication-results"), options.expectedAuthservId),
143
+ authHeadersSeen: AUTH_HEADERS.filter((name) => headers.has(name)),
144
+ autoSubmitted: isAutoSubmitted(headers)
145
+ };
146
+ }
147
+ //#endregion
148
+ export { MAX_TEXT_BODY, parseAuthResults, parseInbound, safeFilename };