@pithy-sh/support 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/package.json +68 -0
  4. package/pithy.manifest.json +40 -0
  5. package/src/ai/classify.ts +239 -0
  6. package/src/attachment/store.ts +78 -0
  7. package/src/audit/actions.ts +71 -0
  8. package/src/capability.ts +293 -0
  9. package/src/client/projection.ts +60 -0
  10. package/src/cloudflare-test.d.ts +15 -0
  11. package/src/config/config.ts +400 -0
  12. package/src/data/attachment.ts +65 -0
  13. package/src/data/billingScope.ts +32 -0
  14. package/src/data/categories.ts +117 -0
  15. package/src/data/classification.ts +50 -0
  16. package/src/data/enums.ts +90 -0
  17. package/src/data/flag.ts +37 -0
  18. package/src/data/message.ts +224 -0
  19. package/src/data/tables.ts +58 -0
  20. package/src/data/thread.ts +138 -0
  21. package/src/error/errors.ts +133 -0
  22. package/src/http/guards.ts +59 -0
  23. package/src/http/handlers.ts +418 -0
  24. package/src/http/resolve.ts +109 -0
  25. package/src/http/responses.ts +506 -0
  26. package/src/http/routes.ts +272 -0
  27. package/src/http/schemas.ts +251 -0
  28. package/src/http/scopes.ts +117 -0
  29. package/src/http/views.ts +169 -0
  30. package/src/inbound/authenticity.ts +114 -0
  31. package/src/inbound/guard.ts +127 -0
  32. package/src/inbound/handler.ts +102 -0
  33. package/src/inbound/ingest.ts +548 -0
  34. package/src/inbound/recipient.ts +67 -0
  35. package/src/index.ts +63 -0
  36. package/src/link/sender.ts +334 -0
  37. package/src/migrations/0001_threads.ts +296 -0
  38. package/src/mime/address.ts +37 -0
  39. package/src/mime/parse.ts +299 -0
  40. package/src/mime/sanitize.ts +253 -0
  41. package/src/mime/threading.ts +127 -0
  42. package/src/mime/truncate.ts +55 -0
  43. package/src/provision/provisionSupport.ts +179 -0
  44. package/src/provision/resolveSupportConfig.ts +67 -0
  45. package/src/reply/send.ts +322 -0
  46. package/src/reply/snippets.ts +167 -0
  47. package/src/secret/registry.ts +24 -0
  48. package/src/seeds/example.ts +385 -0
  49. package/src/store/paging.ts +22 -0
  50. package/src/store/search.ts +197 -0
  51. package/src/store/searchIndex.ts +71 -0
  52. package/src/store/threads.ts +452 -0
  53. package/src/submission/encoding.ts +66 -0
  54. package/src/submission/guard.ts +120 -0
  55. package/src/submission/submit.ts +539 -0
  56. package/src/version.generated.ts +16 -0
  57. package/src/workflows/classify.ts +164 -0
  58. package/src/workflows/retryPolicy.ts +48 -0
  59. package/src/workflows/specs.ts +61 -0
  60. package/src/workflows/worker.ts +82 -0
  61. package/src/workflows/wrangler.jsonc +46 -0
@@ -0,0 +1,334 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { parseAddress } from "@pithy-sh/core/src/address/address";
6
+ import type { Entitlement } from "@pithy-sh/core/src/entitlement/entitlement";
7
+ import { SUPPORT_BILLING_SCOPE } from "../data/billingScope";
8
+
9
+ /**
10
+ * The link from an address in a `From` header to the customer the app already knows about.
11
+ *
12
+ * This is the feature the whole capability exists for. A support thread that arrives already knowing
13
+ * who the sender is, what they bought, on which rail, and whether their last renewal failed is a
14
+ * different object from a message in a mailbox — and every one of those facts is already in the
15
+ * adopter's own D1, one table over, unused.
16
+ *
17
+ * ## Guarded dynamic imports, not dependencies
18
+ *
19
+ * `@pithy-sh/auth` and `@pithy-sh/payments` are **optional** here (principle 4: depend on core seams,
20
+ * never on a sibling's internals), so both are reached the way `@pithy-sh/matchmaking` already
21
+ * reaches auth — a dynamic import inside a `try`, degrading to "no link" when the package is not
22
+ * installed. A support inbox in a project with no accounts and no payments is a perfectly reasonable
23
+ * thing to run, and it must not fail to start, or to store mail, because of what it cannot see.
24
+ *
25
+ * Every function here is **best-effort by contract**: it returns nothing rather than throwing, and
26
+ * the caller stores the message either way. Linkage is context, and context is never worth losing a
27
+ * customer's support request over.
28
+ *
29
+ * ## The billing half links people, and only people
30
+ *
31
+ * `@pithy-sh/payments` keys a purchase on a **subject pair** — a user or an organization, plus an id —
32
+ * and which of the two a project uses is its `billingSubject` config. Support cannot honor that choice.
33
+ * It starts from a `From:` header, resolves it to a *person*, and does its billing lookup at thread-read
34
+ * time, where there is no Hono `Context` to hand the adopter's subject resolver: the seam's whole job is
35
+ * to answer "which organization is *this caller* acting for", and a support thread has no caller.
36
+ *
37
+ * So this file reads `user`-subject rows, and {@link SUPPORT_BILLING_SCOPE} says so on every response.
38
+ * Under organization billing the panel is empty, and it must not be *silently* empty — the lookups below
39
+ * are guarded dynamic imports whose `catch` already returns `[]`, so an empty panel is indistinguishable
40
+ * between "bought nothing", "`@pithy-sh/payments` is not installed" and "billed to an organization".
41
+ * An operator reads the first of those and decides a refund on it. A declared scope is what turns the
42
+ * third case from a silence into a limitation somebody can see.
43
+ *
44
+ * **The scope constant used to be declared here, and is now `data/billingScope.ts` (Jim, 2026-08-21).**
45
+ * The old argument was that the query and the wire must read one value, which is right and unchanged —
46
+ * but `http/responses.ts` honoring it by importing from *this* file pulled the whole server data layer
47
+ * into a browser program, because that is what this file's imports are (#419). One value, in a module
48
+ * neither half owns.
49
+ */
50
+
51
+ /** The account a sender resolves to, and what the app knows about them. */
52
+ export interface SenderContext {
53
+ /**
54
+ * Whether the `From:` header was proved to belong to the sender. **Everything below is empty when
55
+ * this is false**, and a dashboard must render the sender as an unverified claim rather than as a
56
+ * customer — the whole value of this panel is that an operator trusts it, so it must not be
57
+ * populated from an address anybody could have written.
58
+ */
59
+ authenticated: boolean;
60
+ /** The linked `pithy_auth_users.id`, or null when this address belongs to nobody with an account. */
61
+ userId: string | null;
62
+ /** The account's display name, when auth is composed and the sender is known. */
63
+ name?: string;
64
+ /** Whether the account has verified this address. A useful signal beside an unverified claim in a header. */
65
+ emailVerified?: boolean;
66
+ /**
67
+ * Their purchase history, newest first — **individually billed purchases only**. Empty when payments
68
+ * is absent, when they have bought nothing, and when their billing is held by an organization this
69
+ * seam cannot resolve. `SenderContextView.billingScope` is what tells a console those apart.
70
+ */
71
+ purchases: readonly SenderPurchase[];
72
+ /**
73
+ * Their entitlements, lapsed ones included and marked inactive — a paywall wants to say when Pro
74
+ * ended. Same scope as {@link SenderContext.purchases}: `user` subjects, nothing organization-held.
75
+ */
76
+ entitlements: readonly Entitlement[];
77
+ }
78
+
79
+ /** One purchase, flattened to what a support console renders. */
80
+ export interface SenderPurchase {
81
+ /** The purchase row id. */
82
+ id: string;
83
+ /** Which rail it went through — `apple`, `google`, or `stripe`. */
84
+ rail: string;
85
+ /** The Pithy product key. */
86
+ productId: string;
87
+ /** Its lifecycle state — `active`, `refunded`, `expired`, and so on. */
88
+ status: string;
89
+ /** Whether the purchase happened in the store's sandbox rather than production. */
90
+ environment: string;
91
+ /** When it was bought. */
92
+ purchasedAt: Date;
93
+ /** When it runs out; null for something owned forever. */
94
+ expiresAt: Date | null;
95
+ /** When it was refunded or revoked; null while it stands. */
96
+ revokedAt: Date | null;
97
+ }
98
+
99
+ /** How many purchases a thread view carries. Enough to see the pattern, bounded so a whale is not a slow page. */
100
+ export const MAX_LINKED_PURCHASES = 25;
101
+
102
+ /**
103
+ * Resolve a sender address to a user id.
104
+ *
105
+ * **Exact match on the normalized address, and deliberately no case-insensitive fallback.** The
106
+ * `email` column is indexed and unique, so this is one index seek; a `lower(email) = ?` fallback
107
+ * would be a full table scan, and it would run for *every unknown sender* — which on a public
108
+ * address means every piece of spam scanning the entire user table. Better Auth normalizes on its
109
+ * own signup path, so the exact match is the case that actually occurs; an address stored with
110
+ * capitals by some other route simply does not link, which costs a line of context rather than a
111
+ * customer's request.
112
+ */
113
+ export async function resolveSenderUserId(d1: D1Database, address: string): Promise<string | null> {
114
+ const normalized = parseAddress(address);
115
+ if (!normalized) return null;
116
+
117
+ try {
118
+ const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
119
+ const row = await authDatabase(d1)
120
+ .selectFrom("pithyAuthUsers")
121
+ .select(["id"])
122
+ .where("email", "=", normalized)
123
+ .executeTakeFirst();
124
+ return row?.id ?? null;
125
+ } catch {
126
+ // `@pithy-sh/auth` is not installed, or the table does not exist yet. Both mean the same thing
127
+ // here: nobody to link to.
128
+ return null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * The address an operator's reply to an app thread will be sent to, or null when the account carries
134
+ * nothing deliverable.
135
+ *
136
+ * Extracted rather than inlined because it is the one decision in {@link resolveSubmitterAccount} with
137
+ * a wrong answer available, and inlining it puts that decision behind a guarded dynamic import where
138
+ * no test can reach it.
139
+ *
140
+ * **`parseAddress` only, with no `normalizeAddress` fallback.** The fallback merely trims and
141
+ * lowercases, so it would hand back an address this capability's own parser had just refused — and
142
+ * this value becomes the thread's `fromAddress`, which `sendReply` enqueues an answer to. Returning
143
+ * null makes an unparseable account the same hard fault as a missing one, which is what the caller
144
+ * already does with it: refusing the submission beats accepting a report whose only reply address
145
+ * cannot be delivered to.
146
+ */
147
+ export function submitterAddress(email: string | null | undefined): string | null {
148
+ return (email && parseAddress(email)) || null;
149
+ }
150
+
151
+ /**
152
+ * The account behind an authenticated submitter — resolved by id, never by an address.
153
+ *
154
+ * The inverse of {@link resolveSenderUserId}, and it exists because the in-app channel starts from the
155
+ * opposite end: a session names a user id, and what the thread needs is the address a reply will go
156
+ * back to. Deriving that from anything the client sent would hand a signed-in caller the ability to
157
+ * point a support conversation — and every operator reply on it — at somebody else's mailbox.
158
+ *
159
+ * Returns null when `@pithy-sh/auth` is absent or the account is gone. A submission whose account
160
+ * cannot be read is refused by the caller rather than stored with a guessed address: an app thread with
161
+ * no working reply address is a report nobody can answer.
162
+ */
163
+ export async function resolveSubmitterAccount(
164
+ d1: D1Database,
165
+ userId: string,
166
+ ): Promise<{ email: string; name?: string; emailVerified?: boolean } | null> {
167
+ try {
168
+ const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
169
+ const { User } = await import("@pithy-sh/auth/src/data/betterAuth");
170
+ const row = await authDatabase(d1)
171
+ .selectFrom("pithyAuthUsers")
172
+ .selectAll()
173
+ .where("id", "=", userId)
174
+ .executeTakeFirst();
175
+ if (!row) return null;
176
+ const user = User.parse(row);
177
+ const email = submitterAddress(user.email);
178
+ if (!email) return null;
179
+ return { email, name: user.name, emailVerified: user.emailVerified };
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ /** Read the account's own fields, when auth is composed. */
186
+ async function resolveAccount(
187
+ d1: D1Database,
188
+ address: string,
189
+ ): Promise<{ userId: string; name?: string; emailVerified?: boolean } | null> {
190
+ try {
191
+ const { authDatabase } = await import("@pithy-sh/auth/src/data/tables");
192
+ const { User } = await import("@pithy-sh/auth/src/data/betterAuth");
193
+ const row = await authDatabase(d1)
194
+ .selectFrom("pithyAuthUsers")
195
+ .selectAll()
196
+ .where("email", "=", address)
197
+ .executeTakeFirst();
198
+ if (!row) return null;
199
+ const user = User.parse(row);
200
+ return { userId: user.id, name: user.name, emailVerified: user.emailVerified };
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Read what the account bought, when payments is composed — **`user`-subject rows only**.
208
+ *
209
+ * Both halves of the pair, deliberately. Nothing in the kit keeps an organization id from equalling some
210
+ * user's, so a filter on the id alone would eventually hand one holder's purchases to the other — which
211
+ * is the whole reason payments made the holder a pair. See {@link SUPPORT_BILLING_SCOPE} for why the
212
+ * type half is pinned to `user` rather than resolved.
213
+ */
214
+ async function resolvePurchases(d1: D1Database, userId: string): Promise<readonly SenderPurchase[]> {
215
+ try {
216
+ const { PAYMENTS_PURCHASES_TABLE, paymentsDatabase } = await import("@pithy-sh/payments/src/data/tables");
217
+ const { PaymentsPurchase } = await import("@pithy-sh/payments/src/data/purchase");
218
+ const rows = await paymentsDatabase(d1)
219
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
220
+ .selectAll()
221
+ .where("subjectType", "=", SUPPORT_BILLING_SCOPE)
222
+ .where("subjectId", "=", userId)
223
+ .orderBy("purchasedAt", "desc")
224
+ .limit(MAX_LINKED_PURCHASES)
225
+ .execute();
226
+ return rows.map((row) => {
227
+ const purchase = PaymentsPurchase.parse(row);
228
+ return {
229
+ id: purchase.id,
230
+ rail: purchase.rail,
231
+ productId: purchase.productId,
232
+ status: purchase.status,
233
+ environment: purchase.environment,
234
+ purchasedAt: purchase.purchasedAt,
235
+ expiresAt: purchase.expiresAt ?? null,
236
+ revokedAt: purchase.revokedAt ?? null,
237
+ };
238
+ });
239
+ } catch {
240
+ return [];
241
+ }
242
+ }
243
+
244
+ /** Read what the account is entitled to, when payments is composed — **`user`-subject rows only**. */
245
+ async function resolveLinkedEntitlements(d1: D1Database, userId: string, now: Date): Promise<readonly Entitlement[]> {
246
+ try {
247
+ const { paymentsDatabase } = await import("@pithy-sh/payments/src/data/tables");
248
+ const { resolveEntitlements } = await import("@pithy-sh/payments/src/projection/resolve");
249
+ return await resolveEntitlements(
250
+ paymentsDatabase(d1),
251
+ { subjectType: SUPPORT_BILLING_SCOPE, subjectId: userId },
252
+ now,
253
+ );
254
+ } catch {
255
+ return [];
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Everything the app already knows about a sender — read at thread-read time rather than stored.
261
+ *
262
+ * Derived, so it is always current: a customer who buys Pro an hour after writing in shows as
263
+ * entitled the next time the thread is opened, with nothing to backfill and nothing to invalidate.
264
+ * That is the same rule the classification follows, applied to the half of the model that lives in
265
+ * somebody else's tables.
266
+ */
267
+ export async function resolveSenderContext(
268
+ d1: D1Database,
269
+ address: string,
270
+ now: Date,
271
+ options: { authenticated: boolean },
272
+ ): Promise<SenderContext> {
273
+ const empty: SenderContext = { authenticated: options.authenticated, userId: null, purchases: [], entitlements: [] };
274
+
275
+ const normalized = parseAddress(address);
276
+ if (!normalized) return empty;
277
+
278
+ const account = await resolveAccount(d1, normalized);
279
+ if (!account) return empty;
280
+
281
+ // **An unverified match is reported; its billing history is not.**
282
+ //
283
+ // The two halves carry very different risk. A name beside an address is a labeled guess an
284
+ // operator can sanity-check, and withholding it would make the panel useless for the majority of
285
+ // real senders, since most domains publish no verdict this Worker can trust. An itemized purchase
286
+ // history is what somebody decides to issue a refund or reset an account on — presenting a real
287
+ // customer's on a thread that merely *claims* to be them is the whole account-takeover path.
288
+ //
289
+ // `emailVerified` is withheld too: it describes the *account*, but next to an unverified sender it
290
+ // reads as "this sender is verified", which is exactly the confusion this seam exists to remove.
291
+ if (!options.authenticated) {
292
+ return { authenticated: false, userId: account.userId, name: account.name, purchases: [], entitlements: [] };
293
+ }
294
+
295
+ return provenContext(d1, account, now);
296
+ }
297
+
298
+ /**
299
+ * The customer context behind a **session-proven** link, resolved from the user id itself.
300
+ *
301
+ * An app thread already holds the id its session proved, so re-deriving it from the thread's address
302
+ * would be both a step backwards and a correctness bug: `resolveSenderUserId` matches the `email`
303
+ * column exactly, and an account stored with capitals by some other route would silently resolve to
304
+ * nobody — turning the one link that *is* certain into the one the console shows as unknown.
305
+ *
306
+ * Everything below the link degrades exactly as it does on the mail path: purchases and entitlements
307
+ * are guarded dynamic imports and come back empty when `@pithy-sh/payments` is absent.
308
+ */
309
+ export async function resolveSubmitterContext(d1: D1Database, userId: string, now: Date): Promise<SenderContext> {
310
+ const account = await resolveSubmitterAccount(d1, userId);
311
+ if (!account) return { authenticated: true, userId, purchases: [], entitlements: [] };
312
+ return provenContext(d1, { userId, name: account.name, emailVerified: account.emailVerified }, now);
313
+ }
314
+
315
+ /** The proven half, shared by both entry points: the account, plus what it bought and what it holds. */
316
+ async function provenContext(
317
+ d1: D1Database,
318
+ account: { userId: string; name?: string; emailVerified?: boolean },
319
+ now: Date,
320
+ ): Promise<SenderContext> {
321
+ const [purchases, entitlements] = await Promise.all([
322
+ resolvePurchases(d1, account.userId),
323
+ resolveLinkedEntitlements(d1, account.userId, now),
324
+ ]);
325
+
326
+ return {
327
+ authenticated: true,
328
+ userId: account.userId,
329
+ name: account.name,
330
+ emailVerified: account.emailVerified,
331
+ purchases,
332
+ entitlements,
333
+ };
334
+ }
@@ -0,0 +1,296 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * The support inbox: threads, their messages, attachment metadata, the append-only classification
9
+ * history, and per-viewer flags.
10
+ *
11
+ * **The indexes are the point of this migration, not an afterthought.** The dashboard's entire
12
+ * interaction is "inbox, newest first, filtered by category and priority and archived" — a query
13
+ * workload, and one that is miserable to retrofit once a table has rows in production. So the two
14
+ * composite indexes the issue names are created here, in the first migration, alongside the tables.
15
+ *
16
+ * camelCase identifiers throughout; `CamelCasePlugin` snake-cases them in the DDL. `down` is the
17
+ * tested inverse — indexes first, then tables, children before parents.
18
+ *
19
+ * **No CHECK constraints, deliberately.** CLAUDE.md's rule is that one Zod schema per table is the
20
+ * entire table definition, and a CHECK mirroring a `z.enum` or a `SQLiteBoolean` is a second, partial
21
+ * copy of that definition — one that can drift from the schema and that SQLite cannot alter, so
22
+ * adding a priority level or a sentiment would mean a table rebuild rather than a one-line edit to
23
+ * the enum. Every value here reaches SQLite through `Schema.encode`, so the schema already refuses
24
+ * what a CHECK would have. The three bounds that were doing real work — both `confidence` ranges and
25
+ * the attachment `size` floor — moved into the schemas, which is where they belonged.
26
+ *
27
+ * A CHECK still earns its place for an invariant Zod genuinely cannot express: `@pithy-sh/ledger`
28
+ * constrains `held <= balance` across columns, which no per-field schema can state.
29
+ */
30
+ export const support_0001_threads: Migration = {
31
+ up: async (db: Kysely<unknown>): Promise<void> => {
32
+ await db.schema
33
+ .createTable("pithySupportThreads")
34
+ .addColumn("id", "text", (c) => c.primaryKey())
35
+ .addColumn("channel", "text", (c) => c.notNull().defaultTo("email"))
36
+ // Nullable, unlike every other address on this table: an `app` thread in a project with no
37
+ // inbound address configured never arrived anywhere, and collecting in-app feedback with no mail
38
+ // set up at all is a deployment this capability supports.
39
+ .addColumn("inboxAddress", "text")
40
+ .addColumn("subject", "text", (c) => c.notNull())
41
+ .addColumn("fromAddress", "text", (c) => c.notNull())
42
+ .addColumn("fromName", "text")
43
+ .addColumn("senderAuthenticated", "integer", (c) => c.notNull().defaultTo(0))
44
+ .addColumn("userId", "text")
45
+ .addColumn("accountLinkSource", "text")
46
+ // Nullable and with no default, unlike `category` beside it, because "nobody said" is a real and
47
+ // common state — every mail thread, and every app thread whose client offered no chooser — and
48
+ // `uncategorized` here would be indistinguishable from a submitter who chose it deliberately.
49
+ .addColumn("declaredCategory", "text")
50
+ .addColumn("category", "text", (c) => c.notNull().defaultTo("uncategorized"))
51
+ .addColumn("priority", "text", (c) => c.notNull().defaultTo("normal"))
52
+ .addColumn("sentiment", "text", (c) => c.notNull().defaultTo("neutral"))
53
+ .addColumn("confidence", "real")
54
+ .addColumn("model", "text")
55
+ .addColumn("classifiedAt", "integer")
56
+ .addColumn("archived", "integer", (c) => c.notNull().defaultTo(0))
57
+ .addColumn("archivedAt", "integer")
58
+ .addColumn("archivedBy", "text")
59
+ .addColumn("messageCount", "integer", (c) => c.notNull().defaultTo(0))
60
+ .addColumn("firstMessageAt", "integer", (c) => c.notNull())
61
+ .addColumn("lastMessageAt", "integer", (c) => c.notNull())
62
+ .addColumn("createdAt", "integer", (c) => c.notNull())
63
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
64
+ .execute();
65
+
66
+ // The inbox itself: open threads, newest first. `id` rides along as the pagination tiebreak, so
67
+ // the cursor read is covered by the index rather than sorting rows it had to fetch first.
68
+ await db.schema
69
+ .createIndex("pithySupportThreadsArchivedIdx")
70
+ .on("pithySupportThreads")
71
+ .columns(["archived", "lastMessageAt", "id"])
72
+ .execute();
73
+ // The filtered inbox. Category is the filter people actually use, and it is the one the issue
74
+ // names, so it gets its own composite rather than relying on the archived index plus a scan.
75
+ await db.schema
76
+ .createIndex("pithySupportThreadsCategoryIdx")
77
+ .on("pithySupportThreads")
78
+ .columns(["category", "lastMessageAt", "id"])
79
+ .execute();
80
+ // The other filtered inbox: threads by what the *submitter* said they were about. It carries the
81
+ // same `(lastMessageAt, id)` tail as the category composite because it serves the same query in
82
+ // the same order, and it is a second index rather than a second use of the first — an operator
83
+ // triaging a project with `ai.enabled: false` filters on this one exclusively, since nothing ever
84
+ // writes `category` there.
85
+ await db.schema
86
+ .createIndex("pithySupportThreadsDeclaredCategoryIdx")
87
+ .on("pithySupportThreads")
88
+ .columns(["declaredCategory", "lastMessageAt", "id"])
89
+ .execute();
90
+ // The sender's history — what the volume guard counts and what a thread view shows beside the
91
+ // current conversation.
92
+ await db.schema
93
+ .createIndex("pithySupportThreadsFromIdx")
94
+ .on("pithySupportThreads")
95
+ .columns(["fromAddress", "lastMessageAt"])
96
+ .execute();
97
+ // "Everything from this customer", once the sender has been linked to an account.
98
+ await db.schema.createIndex("pithySupportThreadsUserIdx").on("pithySupportThreads").column("userId").execute();
99
+ // The console's channel filter, and the submitter's own list. Both read one channel newest-first,
100
+ // so this carries the same `(lastMessageAt, id)` tail as the archived and category composites —
101
+ // an index on `channel` alone would filter and then sort rows it had to fetch first.
102
+ await db.schema
103
+ .createIndex("pithySupportThreadsChannelIdx")
104
+ .on("pithySupportThreads")
105
+ .columns(["channel", "lastMessageAt", "id"])
106
+ .execute();
107
+ // The read-back: this account's own app threads, newest first. Keyed on the pair rather than on
108
+ // `userId` alone because the read-back is scoped to both — an email thread linked to an account by
109
+ // an unproven header must never be readable by whoever currently holds that address.
110
+ await db.schema
111
+ .createIndex("pithySupportThreadsUserChannelIdx")
112
+ .on("pithySupportThreads")
113
+ .columns(["userId", "channel", "lastMessageAt", "id"])
114
+ .execute();
115
+
116
+ await db.schema
117
+ .createTable("pithySupportMessages")
118
+ .addColumn("id", "text", (c) => c.primaryKey())
119
+ .addColumn("threadId", "text", (c) => c.notNull())
120
+ .addColumn("direction", "text", (c) => c.notNull())
121
+ .addColumn("channel", "text", (c) => c.notNull().defaultTo("email"))
122
+ .addColumn("submittedByUserId", "text")
123
+ .addColumn("context", "text")
124
+ .addColumn("mimeMessageId", "text")
125
+ .addColumn("mimeInReplyTo", "text")
126
+ .addColumn("mimeReferences", "text")
127
+ // Nullable, unlike the thread's: an answer delivered in the app left no envelope, so there is
128
+ // no address it came from. It stays in the volume-guard index below, which counts inbound mail
129
+ // and so never meets a null.
130
+ .addColumn("fromAddress", "text")
131
+ .addColumn("fromName", "text")
132
+ // Nullable for the same reason `pithy_support_threads.inbox_address` is: an app submission has
133
+ // no envelope recipient. It stays in the unique index below — SQLite treats two NULLs as
134
+ // distinct, so app rows never collide there.
135
+ .addColumn("toAddress", "text")
136
+ .addColumn("subject", "text", (c) => c.notNull())
137
+ .addColumn("textBody", "text", (c) => c.notNull())
138
+ .addColumn("htmlBody", "text")
139
+ .addColumn("emailJobId", "text")
140
+ .addColumn("rawKey", "text")
141
+ .addColumn("rawBytes", "integer")
142
+ .addColumn("receivedAt", "integer", (c) => c.notNull())
143
+ .addColumn("createdAt", "integer", (c) => c.notNull())
144
+ .execute();
145
+
146
+ // The idempotency anchor. Email Routing can deliver the same message twice, and the second
147
+ // delivery has to be a no-op rather than a duplicate in somebody's inbox. SQLite permits repeated
148
+ // NULLs in a unique index, so a message with no `Message-ID` still stores.
149
+ //
150
+ // Keyed on `(mimeMessageId, toAddress)` rather than the id alone, matching the ingest lookup. A
151
+ // Worker may serve several inboxes, and a customer who addresses one message to both `support@`
152
+ // and `security@` causes two legitimate deliveries of the same `Message-ID` — a global key would
153
+ // reject the second, so the message would land in one inbox and silently never reach the other.
154
+ await db.schema
155
+ .createIndex("pithySupportMessagesMimeIdIdx")
156
+ .on("pithySupportMessages")
157
+ .columns(["mimeMessageId", "toAddress"])
158
+ .unique()
159
+ .execute();
160
+ // The thread view, in order.
161
+ await db.schema
162
+ .createIndex("pithySupportMessagesThreadIdx")
163
+ .on("pithySupportMessages")
164
+ .columns(["threadId", "receivedAt"])
165
+ .execute();
166
+ // Threading's fallback lookup: find the message an `In-Reply-To` or a `References` entry names.
167
+ await db.schema
168
+ .createIndex("pithySupportMessagesInReplyToIdx")
169
+ .on("pithySupportMessages")
170
+ .column("mimeInReplyTo")
171
+ .execute();
172
+ // What the volume guard counts: messages from one address inside a window.
173
+ await db.schema
174
+ .createIndex("pithySupportMessagesFromIdx")
175
+ .on("pithySupportMessages")
176
+ .columns(["fromAddress", "receivedAt"])
177
+ .execute();
178
+ // What the *submission* guard counts: one account's app submissions inside a window. The app
179
+ // channel's bound is per account rather than per address, because the account is what a session
180
+ // proves and what an adopter can revoke — so it needs its own index rather than the address one.
181
+ await db.schema
182
+ .createIndex("pithySupportMessagesSubmitterIdx")
183
+ .on("pithySupportMessages")
184
+ .columns(["submittedByUserId", "receivedAt"])
185
+ .execute();
186
+ // The mail guard counts only mail. Both rate bounds filter on `channel` so that neither surface
187
+ // can starve the other — heavy in-app feedback must never lock a real customer out of the inbox,
188
+ // and a mail flood must never stop the app's own users reporting the outage.
189
+ await db.schema
190
+ .createIndex("pithySupportMessagesChannelIdx")
191
+ .on("pithySupportMessages")
192
+ .columns(["channel", "direction", "receivedAt"])
193
+ .execute();
194
+
195
+ await db.schema
196
+ .createTable("pithySupportAttachments")
197
+ .addColumn("id", "text", (c) => c.primaryKey())
198
+ .addColumn("messageId", "text", (c) => c.notNull())
199
+ .addColumn("threadId", "text", (c) => c.notNull())
200
+ .addColumn("filename", "text", (c) => c.notNull())
201
+ .addColumn("contentType", "text", (c) => c.notNull())
202
+ .addColumn("size", "integer", (c) => c.notNull())
203
+ .addColumn("sha256", "text", (c) => c.notNull())
204
+ .addColumn("storageKey", "text", (c) => c.notNull())
205
+ .addColumn("contentId", "text")
206
+ .addColumn("inline", "integer", (c) => c.notNull().defaultTo(0))
207
+ .addColumn("createdAt", "integer", (c) => c.notNull())
208
+ .execute();
209
+
210
+ await db.schema
211
+ .createIndex("pithySupportAttachmentsMessageIdx")
212
+ .on("pithySupportAttachments")
213
+ .column("messageId")
214
+ .execute();
215
+ await db.schema
216
+ .createIndex("pithySupportAttachmentsThreadIdx")
217
+ .on("pithySupportAttachments")
218
+ .column("threadId")
219
+ .execute();
220
+
221
+ await db.schema
222
+ .createTable("pithySupportClassifications")
223
+ .addColumn("id", "text", (c) => c.primaryKey())
224
+ .addColumn("threadId", "text", (c) => c.notNull())
225
+ .addColumn("messageId", "text", (c) => c.notNull())
226
+ .addColumn("category", "text", (c) => c.notNull())
227
+ .addColumn("priority", "text", (c) => c.notNull())
228
+ .addColumn("sentiment", "text", (c) => c.notNull())
229
+ .addColumn("confidence", "real", (c) => c.notNull())
230
+ .addColumn("model", "text", (c) => c.notNull())
231
+ .addColumn("createdAt", "integer", (c) => c.notNull())
232
+ .execute();
233
+
234
+ await db.schema
235
+ .createIndex("pithySupportClassificationsThreadIdx")
236
+ .on("pithySupportClassifications")
237
+ .columns(["threadId", "createdAt"])
238
+ .execute();
239
+ // "Which rows came from which model" — the query a reclassification pass after a model upgrade
240
+ // is planned from, and the reason the column exists at all.
241
+ await db.schema
242
+ .createIndex("pithySupportClassificationsModelIdx")
243
+ .on("pithySupportClassifications")
244
+ .columns(["model", "createdAt"])
245
+ .execute();
246
+
247
+ await db.schema
248
+ .createTable("pithySupportThreadFlags")
249
+ .addColumn("id", "text", (c) => c.primaryKey())
250
+ .addColumn("threadId", "text", (c) => c.notNull())
251
+ .addColumn("viewer", "text", (c) => c.notNull())
252
+ .addColumn("read", "integer", (c) => c.notNull().defaultTo(0))
253
+ .addColumn("snoozedUntil", "integer")
254
+ .addColumn("createdAt", "integer", (c) => c.notNull())
255
+ .addColumn("updatedAt", "integer", (c) => c.notNull())
256
+ .execute();
257
+
258
+ // One row per viewer per thread, so marking read twice is an upsert rather than a second row.
259
+ await db.schema
260
+ .createIndex("pithySupportThreadFlagsViewerIdx")
261
+ .on("pithySupportThreadFlags")
262
+ .columns(["threadId", "viewer"])
263
+ .unique()
264
+ .execute();
265
+ },
266
+
267
+ down: async (db: Kysely<unknown>): Promise<void> => {
268
+ await db.schema.dropIndex("pithySupportThreadFlagsViewerIdx").execute();
269
+ await db.schema.dropTable("pithySupportThreadFlags").execute();
270
+
271
+ await db.schema.dropIndex("pithySupportClassificationsModelIdx").execute();
272
+ await db.schema.dropIndex("pithySupportClassificationsThreadIdx").execute();
273
+ await db.schema.dropTable("pithySupportClassifications").execute();
274
+
275
+ await db.schema.dropIndex("pithySupportAttachmentsThreadIdx").execute();
276
+ await db.schema.dropIndex("pithySupportAttachmentsMessageIdx").execute();
277
+ await db.schema.dropTable("pithySupportAttachments").execute();
278
+
279
+ await db.schema.dropIndex("pithySupportMessagesChannelIdx").execute();
280
+ await db.schema.dropIndex("pithySupportMessagesSubmitterIdx").execute();
281
+ await db.schema.dropIndex("pithySupportMessagesFromIdx").execute();
282
+ await db.schema.dropIndex("pithySupportMessagesInReplyToIdx").execute();
283
+ await db.schema.dropIndex("pithySupportMessagesThreadIdx").execute();
284
+ await db.schema.dropIndex("pithySupportMessagesMimeIdIdx").execute();
285
+ await db.schema.dropTable("pithySupportMessages").execute();
286
+
287
+ await db.schema.dropIndex("pithySupportThreadsUserChannelIdx").execute();
288
+ await db.schema.dropIndex("pithySupportThreadsChannelIdx").execute();
289
+ await db.schema.dropIndex("pithySupportThreadsUserIdx").execute();
290
+ await db.schema.dropIndex("pithySupportThreadsFromIdx").execute();
291
+ await db.schema.dropIndex("pithySupportThreadsDeclaredCategoryIdx").execute();
292
+ await db.schema.dropIndex("pithySupportThreadsCategoryIdx").execute();
293
+ await db.schema.dropIndex("pithySupportThreadsArchivedIdx").execute();
294
+ await db.schema.dropTable("pithySupportThreads").execute();
295
+ },
296
+ };
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
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
+ /**
19
+ * A display name, made safe to store.
20
+ *
21
+ * Trimmed, bounded, and stripped of control characters — including the bidirectional overrides that
22
+ * let `moc.elpmaxe@ada` render as something else entirely. The result is still untrusted text that a
23
+ * renderer must escape; this only guarantees it is text.
24
+ */
25
+ export function normalizeDisplayName(value: string | null | undefined): string | undefined {
26
+ if (typeof value !== "string") return undefined;
27
+ const cleaned = value
28
+ // C0/C1 controls, plus the LTR/RTL overrides and embeddings (U+202A–U+202E, U+2066–U+2069).
29
+ // biome-ignore-start lint/suspicious/noControlCharactersInRegex: stripping control
30
+ // characters is the entire purpose — a bidi override renders a filename or a display name as
31
+ // something other than what it is, which is the oldest trick in inbound mail.
32
+ .replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, "")
33
+ // biome-ignore-end lint/suspicious/noControlCharactersInRegex: see above
34
+ .trim()
35
+ .slice(0, 200);
36
+ return cleaned.length > 0 ? cleaned : undefined;
37
+ }