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