@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,90 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * The closed axes of the support model — the ones that are genuinely fixed, as opposed to the
8
+ * taxonomy in `categories.ts`, which is federated because every app has its own vocabulary.
9
+ *
10
+ * Priority and sentiment are not federated on purpose. A category answers *what is this*, and that
11
+ * question has as many answers as there are products. Priority answers *how fast*, and sentiment
12
+ * answers *who is about to churn* — both are questions about the sender, not about the product, so a
13
+ * fourth priority level or a fifth sentiment would describe the same reality with more words.
14
+ */
15
+
16
+ /** How fast a thread needs a human. */
17
+ export const SupportPriority = z
18
+ .enum(["urgent", "normal", "low"])
19
+ .describe("How fast this thread needs a human: `urgent` today, `normal` this week, `low` when there is time.");
20
+ export type SupportPriority = z.infer<typeof SupportPriority>;
21
+
22
+ /** How the sender sounds. The churn signal, not a politeness score. */
23
+ export const SupportSentiment = z
24
+ .enum(["angry", "frustrated", "neutral", "positive"])
25
+ .describe(
26
+ "How the sender sounds — the churn signal. `angry` and `frustrated` are the ones worth sorting an inbox by.",
27
+ );
28
+ export type SupportSentiment = z.infer<typeof SupportSentiment>;
29
+
30
+ /** Which way a message traveled. */
31
+ export const SupportMessageDirection = z
32
+ .enum(["inbound", "outbound"])
33
+ .describe("`inbound` arrived from the customer; `outbound` is a reply this Worker sent on the send path.");
34
+ export type SupportMessageDirection = z.infer<typeof SupportMessageDirection>;
35
+
36
+ /**
37
+ * How a message reached this inbox.
38
+ *
39
+ * Closed for the same reason priority is: a channel is a *transport this capability implements*, not a
40
+ * vocabulary an adopter brings — there is no third answer a project could need that the code would not
41
+ * also have to be taught to speak. It is the axis the two halves of the capability differ on, and the
42
+ * differences are real rather than cosmetic: `email` arrives at a public address from an unauthenticated
43
+ * `From:` header, and `app` arrives on a request whose session was already proved.
44
+ */
45
+ export const SupportChannel = z
46
+ .enum(["email", "app"])
47
+ .describe(
48
+ "How this arrived: `email` at a configured inbound address, or `app` from a signed-in user of the adopter's own app. The axis the console filters on, and the one the account link's provenance follows from.",
49
+ );
50
+ export type SupportChannel = z.infer<typeof SupportChannel>;
51
+
52
+ /**
53
+ * How a thread came to name an account — the provenance of `userId`, and the distinction
54
+ * `inbound/authenticity.ts` spends two hundred lines earning.
55
+ *
56
+ * A `From:` header is an unauthenticated claim, so an email thread's link is a *match on an address
57
+ * anybody could have written*. An in-app submission has no `From:` to spoof: `requireAuth()` proved
58
+ * the session before the handler ran, so the link is the identity rather than a guess about it.
59
+ *
60
+ * **A console must not render the two the same way.** They differ in exactly the situation that
61
+ * matters — deciding whether to act on somebody's billing history — and a single boolean cannot say
62
+ * which one it is looking at. `senderAuthenticated` answers *may we believe this*, and this answers
63
+ * *how did we come to believe it*; the second is what an operator needs when the first is false and
64
+ * there is still a name on the thread.
65
+ */
66
+ export const SupportAccountLinkSource = z
67
+ .enum(["session", "email_address"])
68
+ .describe(
69
+ "How this thread's `userId` was established: `session` means an authenticated request proved it, `email_address` means it was matched from the address in a `From:` header. Never equivalent — one is the identity, the other is a lookup on a claim.",
70
+ );
71
+ export type SupportAccountLinkSource = z.infer<typeof SupportAccountLinkSource>;
72
+
73
+ /**
74
+ * The category every taxonomy carries, whatever else it declares, and the value a classification
75
+ * falls back to.
76
+ *
77
+ * It exists so "the model was unsure" is a first-class answer rather than a guess. A text model will
78
+ * always produce *a* label, and a plausible-sounding invented one silently poisons every filter
79
+ * downstream — so an out-of-taxonomy answer lands here instead.
80
+ */
81
+ export const UNCATEGORIZED = "uncategorized";
82
+
83
+ /**
84
+ * The one other category key the code names directly, because a behavior hangs off it: a thread the
85
+ * classifier calls spam is archived on sight when `guard.archiveSpam` is on.
86
+ *
87
+ * Naming it here rather than inline keeps the string in one place — an adopter may reword the
88
+ * *description* the model reads, but the key is the join between the taxonomy and that behavior.
89
+ */
90
+ export const SPAM = "spam";
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * One viewer's private view of one thread, in `pithy_support_thread_flags`.
9
+ *
10
+ * The other deliberate exception to "no coordination state", and the milder of the two. `archived` is
11
+ * shared because done means done; these are **per viewer** because nobody coordinates around them —
12
+ * whether Ada has read a thread says nothing about whether Grace has, and two people marking the same
13
+ * thread read is not a conflict. Keeping them in their own table rather than as columns on the thread
14
+ * is what makes that structural instead of a convention.
15
+ *
16
+ * `viewer` is the control-plane subject — the management client's identity, not a Pithy user. There
17
+ * is no user table behind it and there does not need to be: the value is opaque, and a connection
18
+ * that goes away simply leaves rows nothing reads.
19
+ */
20
+ export const SupportThreadFlag = z
21
+ .object({
22
+ id: z.string().describe("UUID primary key."),
23
+ threadId: z.string().describe("The `pithy_support_threads.id` these flags apply to."),
24
+ viewer: z
25
+ .string()
26
+ .describe(
27
+ "The control-plane subject whose view this is. Unique together with `threadId`, so a viewer has exactly one row per thread and marking read twice is an upsert rather than a second row.",
28
+ ),
29
+ read: SQLiteBoolean.describe("Whether this viewer has read the thread."),
30
+ snoozedUntil: SQLiteDate.nullish().describe(
31
+ "Hide the thread from this viewer's inbox until this moment. Null means not snoozed. A time rather than a boolean, so a snooze expires on its own and nothing has to sweep.",
32
+ ),
33
+ createdAt: SQLiteDate.describe("When the flag row was created."),
34
+ updatedAt: SQLiteDate.describe("When the flag row was last written."),
35
+ })
36
+ .describe("One viewer's read/snooze state for one thread — private, uncoordinated, and safe to lose.");
37
+ export type SupportThreadFlag = z.output<typeof SupportThreadFlag>;
@@ -0,0 +1,224 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { SupportChannel, SupportMessageDirection } from "./enums";
7
+
8
+ /**
9
+ * What the app knows about the moment a report was written, and the user did not have to type.
10
+ *
11
+ * A bug report without the screen, the build, and the environment is a round trip — the first reply is
12
+ * always "which version, and where were you?". The app already holds all three, so asking a human to
13
+ * retype them is the kind of friction this channel exists to remove.
14
+ *
15
+ * **It is a closed object, and unknown keys are refused rather than stripped.** This is a capability,
16
+ * not a telemetry pipe: the risk of an open bag is that an adopter passes their whole client state
17
+ * through it and quietly lands a customer's data in a support inbox that a console renders and an
18
+ * operator reads. A closed set means a field that is not here cannot be posted by accident, and a
19
+ * refusal says so at the boundary rather than silently dropping what somebody believed they sent.
20
+ * Every field is bounded, because every one of them is a string a client chose.
21
+ */
22
+ export const SupportSubmissionContext = z
23
+ .object({
24
+ screen: z
25
+ .string()
26
+ .min(1)
27
+ .max(120)
28
+ .optional()
29
+ .describe(
30
+ "Where in the app they were — a route, a screen name, whatever the app calls it. The single most useful field, because it turns 'the button does nothing' into a place to look.",
31
+ ),
32
+ appVersion: z
33
+ .string()
34
+ .min(1)
35
+ .max(64)
36
+ .optional()
37
+ .describe(
38
+ "The build the report came from. Answers 'is this already fixed' without a round trip, which is the whole reason a bug report asks for it.",
39
+ ),
40
+ platform: z
41
+ .string()
42
+ .min(1)
43
+ .max(64)
44
+ .optional()
45
+ .describe(
46
+ "The client the app is running as — `ios`, `android`, `web`, or whatever the adopter's own build calls itself. Free text rather than an enum: the set of clients is the adopter's, not this capability's.",
47
+ ),
48
+ environment: z
49
+ .string()
50
+ .min(1)
51
+ .max(32)
52
+ .optional()
53
+ .describe(
54
+ "Which environment the client was pointed at. A report from staging read as production is a bug hunted in the wrong database, and the client is the only thing that knows.",
55
+ ),
56
+ // **A bounded string, and the tag is validated where it is used rather than here.**
57
+ //
58
+ // This is the *public submission body*. A `Locale` on it rejects the whole bug report with
59
+ // `validation/invalid_input` when the tag is not well-formed — and `en_US` is exactly what Android,
60
+ // iOS and Java `Locale.toString()` produce, which is to say the likeliest thing a mobile client
61
+ // sends. Losing a report because its diagnostic metadata had an underscore in it is a far worse
62
+ // trade than losing the metadata, and a report is the one thing this route exists to not lose.
63
+ //
64
+ // The boundary that matters is the one into `pithy_email_jobs.locale`, whose column really is
65
+ // `Locale`-constrained: `enqueueEmail` normalizes an unusable tag to `null` there, so a malformed
66
+ // value stored here degrades the language of one reply and breaks nothing. `.catch(undefined)`
67
+ // would express that here, but `asRead` cannot derive a reader's contract from a `ZodCatch`, and
68
+ // the admin view is projected from this schema.
69
+ locale: z
70
+ .string()
71
+ .min(1)
72
+ .max(32)
73
+ .optional()
74
+ .describe(
75
+ "The locale the app was rendering in. Carried because a whole class of reports — a date a day out, a currency in the wrong place, text that overflows — is only reproducible in the reporter's locale. Not validated as a tag here: see `enqueueEmail`, which normalizes it before it reaches a column that is.",
76
+ ),
77
+ })
78
+ .strict()
79
+ .describe(
80
+ "The bounded context an app supplies with a submission, so a bug report arrives with the facts its first reply would otherwise have to ask for.",
81
+ );
82
+ export type SupportSubmissionContext = z.output<typeof SupportSubmissionContext>;
83
+
84
+ /** The `References` header, split into ids. A JSON column so the chain stays a list rather than a string to re-split. */
85
+ export const SupportReferenceIds = z
86
+ .array(z.string().describe("One RFC 5322 message id from the `References` chain, without its angle brackets."))
87
+ .describe("The `References` chain, oldest first — the thread's ancestry as the sender's mail client recorded it.");
88
+ export type SupportReferenceIds = z.output<typeof SupportReferenceIds>;
89
+
90
+ /**
91
+ * One message in `pithy_support_messages` — inbound mail as it arrived, or a reply as it went out.
92
+ *
93
+ * **The raw MIME is kept immutable in R2 and never rewritten.** `textBody`/`htmlBody` are a derived,
94
+ * sanitized rendering of it, so a parser bug, a sanitizer change, or a reclassification pass can all
95
+ * be re-run from the bytes that actually arrived. Storing only the parsed form would make the parse a
96
+ * one-way decision taken at the worst possible moment — inside an `email()` handler with a CPU budget.
97
+ *
98
+ * `mimeMessageId` carries a **unique index**, and that is the idempotency anchor: Email Routing can
99
+ * deliver the same message twice, and the second delivery must be a no-op rather than a duplicate.
100
+ * SQLite permits repeated `NULL`s in a unique index, so a message that arrived without a `Message-ID`
101
+ * (rare, and always a sign of something hand-rolled) still stores instead of colliding.
102
+ */
103
+ export const SupportMessage = z
104
+ .object({
105
+ id: z.string().describe("UUID primary key for this message row — Pithy's id, not the sender's."),
106
+ threadId: z.string().describe("The `pithy_support_threads.id` this message belongs to."),
107
+ direction: SupportMessageDirection.describe("Which way this message traveled."),
108
+ channel: SupportChannel.describe(
109
+ "How this message traveled, which on an outbound row is **how the answer was delivered**: `email` means it was handed to `@pithy-sh/email`'s durable send path, `app` means it was stored for the submitter to read in the app and no mail was sent. Per message rather than only per thread, because the two genuinely differ — one `app` thread can hold an answer that was mailed and an answer that was not, and those are different promises about when somebody will read them.",
110
+ ),
111
+ submittedByUserId: z
112
+ .string()
113
+ .nullish()
114
+ .describe(
115
+ "The authenticated `pithy_auth_users.id` that posted this message from inside the app. Null on every mail-path message and on every outbound reply. Taken from the request's session and **never** from anything the client sent — it is the identity, so it is also what the per-account submission bound counts.",
116
+ ),
117
+ context: sqliteJson(SupportSubmissionContext)
118
+ .nullish()
119
+ .describe(
120
+ "The bounded context the app supplied — screen, build, platform, environment, locale. Null on every mail-path message, and on an app submission that declared none.",
121
+ ),
122
+ mimeMessageId: z
123
+ .string()
124
+ .nullish()
125
+ .describe(
126
+ "The RFC 5322 `Message-ID`, without angle brackets. Uniquely indexed, so a redelivered message is dropped rather than duplicated. Null when the sender omitted one.",
127
+ ),
128
+ mimeInReplyTo: z
129
+ .string()
130
+ .nullish()
131
+ .describe(
132
+ "The `In-Reply-To` id, without angle brackets — the direct parent. The first key threading tries, because it names exactly one message.",
133
+ ),
134
+ mimeReferences: sqliteJson(SupportReferenceIds)
135
+ .nullish()
136
+ .describe(
137
+ "The `References` chain as a JSON column. Threading falls back to it when `In-Reply-To` names nothing we hold, which is what keeps a conversation together across a client that rewrites the subject.",
138
+ ),
139
+ fromAddress: z
140
+ .string()
141
+ .nullish()
142
+ .describe(
143
+ "The address this message was sent from, lowercased. **Null exactly on an answer delivered in the app**, which left no envelope — there is no address a reply that was never mailed came from, and putting the deployment's own address there would claim a send that did not happen. Never null on anything that traveled by mail, and never present on anything that did not. Checked on the object, both ways, so it is a rule rather than a note.",
144
+ ),
145
+ fromName: z.string().nullish().describe("The sender's display name. Untrusted text; render it escaped."),
146
+ toAddress: z
147
+ .string()
148
+ .nullish()
149
+ .describe(
150
+ "The address this message was addressed to, lowercased. **Null on an app submission to a project with no inbound address configured** — there was no envelope, and inventing one would put a string nothing can deliver to into the column the idempotency index keys on. Never null on a mail-path message.",
151
+ ),
152
+ subject: z.string().describe("This message's own subject line, which may differ from the thread's."),
153
+ textBody: z
154
+ .string()
155
+ .describe(
156
+ "The plain-text body, or a text rendering of an HTML-only message. Always present, because the classifier reads this and a model should never be handed markup.",
157
+ ),
158
+ htmlBody: z
159
+ .string()
160
+ .nullish()
161
+ .describe(
162
+ "The HTML body **after sanitization** — scripts, event handlers, styles, frames, and remote-loading attributes removed. Null when the message carried no HTML. The raw original is still in R2 if the sanitizer ever needs re-running.",
163
+ ),
164
+ emailJobId: z
165
+ .string()
166
+ .nullish()
167
+ .describe(
168
+ "The `pithy_email_jobs.id` this message was enqueued as. **Present exactly when `direction` is `outbound` and `channel` is `email`**, and null everywhere else — it is the join to the job, never the way to ask whether an answer went out. That question is `channel`'s, because a null here would otherwise mean both 'this arrived' and 'this was delivered in the app', and a client would have to consult `direction` to tell them apart. It is the *job* id rather than the sent `Message-ID` deliberately: Cloudflare assigns the real id at send time and never tells the enqueuer, so a column claiming to hold it would be null exactly when somebody needed it.",
169
+ ),
170
+ rawKey: z
171
+ .string()
172
+ .nullish()
173
+ .describe(
174
+ "The R2 object key holding the immutable raw MIME. Null only when the bucket binding was absent at ingest, which is a degraded mode rather than a normal one.",
175
+ ),
176
+ rawBytes: z.number().int().nullish().describe("The raw message's size in bytes, as received."),
177
+ receivedAt: SQLiteDate.describe(
178
+ "When this Worker received the message — our clock, never the sender's `Date` header, which is attacker-controlled and would let anyone place themselves at the top of an inbox.",
179
+ ),
180
+ createdAt: SQLiteDate.describe("When the message row was written."),
181
+ })
182
+ .describe("One message in `pithy_support_messages` — the derived rendering of mail whose raw form lives in R2.")
183
+ // **The invariant, stated once, where every producer already passes.** `emailJobId` is the join to
184
+ // `pithy_email_jobs` and nothing else: it is present exactly when this message traveled by mail on
185
+ // the way out. Written at a call site instead, this rule would hold until the second writer — and
186
+ // there are already five (ingest, submission, the reply path, the seeds, the tests). Enforced here
187
+ // it also runs on the way *back* out of D1, so a row that reached the table by any other route
188
+ // cannot be handed to a projection that would render it as sent.
189
+ .check((ctx) => {
190
+ const row = ctx.value;
191
+ const mailed = row.direction === "outbound" && row.channel === "email";
192
+ const hasJob = row.emailJobId !== null && row.emailJobId !== undefined;
193
+ if (hasJob === mailed) return;
194
+ ctx.issues.push({
195
+ code: "custom",
196
+ input: ctx.value,
197
+ path: ["emailJobId"],
198
+ message: mailed
199
+ ? "An outbound `email` message must carry the `emailJobId` it was enqueued as. The row is written after the enqueue is accepted, so one without a job id is a reply nothing was ever asked to send."
200
+ : "Only an outbound `email` message carries an `emailJobId`. An answer delivered in the app was never enqueued, and an inbound message was never sent — a job id on either makes the column mean something other than the job it names.",
201
+ });
202
+ })
203
+ // **The second invariant, enforced the way the first one is.** `fromAddress` was loosened to nullable
204
+ // for one row — the answer `sendReply` stores in the app, which has no envelope — and the rule that
205
+ // bounds that was written as prose on the field. Prose holds until the second writer, which is the
206
+ // argument the check above is made of; there is no reason it carries less weight here. A null on a
207
+ // message that traveled by mail is a thread nobody can answer, because `sendReply` addresses the
208
+ // reply to it. An address on one that did not travel claims a send that never happened, to every
209
+ // projection that renders the column.
210
+ .check((ctx) => {
211
+ const row = ctx.value;
212
+ const inApp = row.direction === "outbound" && row.channel === "app";
213
+ const hasAddress = row.fromAddress !== null && row.fromAddress !== undefined;
214
+ if (hasAddress === !inApp) return;
215
+ ctx.issues.push({
216
+ code: "custom",
217
+ input: ctx.value,
218
+ path: ["fromAddress"],
219
+ message: inApp
220
+ ? "An answer delivered in the app has no `fromAddress`. It left no envelope, so an address there names a send that did not happen."
221
+ : "Everything but an answer delivered in the app carries the `fromAddress` it came from or went out as. Without one there is no address to reply to.",
222
+ });
223
+ });
224
+ export type SupportMessage = z.output<typeof SupportMessage>;
@@ -0,0 +1,58 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
6
+ import type { Kysely } from "kysely";
7
+ import type { z } from "zod";
8
+ import { SupportAttachment } from "./attachment";
9
+ import { SupportClassification } from "./classification";
10
+ import { SupportThreadFlag } from "./flag";
11
+ import { SupportMessage } from "./message";
12
+ import { SupportThread } from "./thread";
13
+
14
+ /**
15
+ * The support tables. Keys are camelCase; `CamelCasePlugin` snake-cases them to the `pithy_support_*`
16
+ * SQL, so query code in this package never types the prefix and never types an underscore.
17
+ */
18
+
19
+ /** Conversations. `pithy_support_threads`. */
20
+ export const SUPPORT_THREADS_TABLE = "pithySupportThreads";
21
+ /** Messages, inbound and outbound. `pithy_support_messages`. */
22
+ export const SUPPORT_MESSAGES_TABLE = "pithySupportMessages";
23
+ /** Attachment metadata. `pithy_support_attachments`. */
24
+ export const SUPPORT_ATTACHMENTS_TABLE = "pithySupportAttachments";
25
+ /** Append-only classification history. `pithy_support_classifications`. */
26
+ export const SUPPORT_CLASSIFICATIONS_TABLE = "pithySupportClassifications";
27
+ /** Per-viewer read/snooze state. `pithy_support_thread_flags`. */
28
+ export const SUPPORT_FLAGS_TABLE = "pithySupportThreadFlags";
29
+
30
+ /**
31
+ * The FTS5 full-text index. Not a Kysely table and deliberately not in {@link supportTables}: it is a
32
+ * virtual table with no Zod schema and no rows of its own worth typing, reached through raw `sql`
33
+ * from `store/search.ts` alone.
34
+ *
35
+ * Named in snake_case because nothing translates it — `CamelCasePlugin` rewrites identifiers Kysely
36
+ * builds, and every statement touching this one is written by hand.
37
+ */
38
+ export const SUPPORT_SEARCH_TABLE = "pithy_support_search";
39
+
40
+ /** The table map the capability contributes to the app database. */
41
+ export const supportTables = {
42
+ [SUPPORT_THREADS_TABLE]: SupportThread,
43
+ [SUPPORT_MESSAGES_TABLE]: SupportMessage,
44
+ [SUPPORT_ATTACHMENTS_TABLE]: SupportAttachment,
45
+ [SUPPORT_CLASSIFICATIONS_TABLE]: SupportClassification,
46
+ [SUPPORT_FLAGS_TABLE]: SupportThreadFlag,
47
+ } satisfies Record<string, z.ZodObject>;
48
+
49
+ /** The support tables, as a type. */
50
+ export type SupportTables = typeof supportTables;
51
+
52
+ /** The typed Kysely view over the support tables. Carries `CamelCasePlugin`. */
53
+ export type SupportDatabase = Kysely<DatabaseSchema<SupportTables>>;
54
+
55
+ /** Build the support database from a `DB` binding. */
56
+ export function supportDatabase(d1: D1Database): SupportDatabase {
57
+ return createDatabase(d1, supportTables) as unknown as SupportDatabase;
58
+ }
@@ -0,0 +1,138 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { SupportAccountLinkSource, SupportChannel, SupportPriority, SupportSentiment, UNCATEGORIZED } from "./enums";
7
+
8
+ /**
9
+ * One conversation in `pithy_support_threads` — the row the inbox is a list of.
10
+ *
11
+ * **Everything on it is derived from immutable inbound mail.** The classification is recomputed, never
12
+ * repaired; the user link is a lookup, not a decision; the counters are facts about the messages
13
+ * below. There is no assignee and no status, because a status field is a state machine and a state
14
+ * machine attracts SLAs, escalation, and reporting — which is a ticketing product and explicitly not
15
+ * this. The two exceptions are deliberate and named: `archived` (one shared boolean meaning done) and
16
+ * the per-viewer flags, which live in their own table because nobody coordinates around them.
17
+ *
18
+ * The classification axes are **denormalized onto the thread** rather than joined from
19
+ * `pithy_support_classifications`. The whole dashboard interaction is "newest first, filtered by
20
+ * category and priority and archived", and that is a composite-index workload — a join to find the
21
+ * latest classification per thread would make the one query this table exists to serve the expensive
22
+ * one. The classification table keeps the history; this keeps the answer.
23
+ */
24
+ export const SupportThread = z
25
+ .object({
26
+ id: z
27
+ .string()
28
+ .describe(
29
+ "UUID primary key. Text, not autoincrement, because a thread id reaches a dashboard and an enumerable inbox is an enumerable customer list.",
30
+ ),
31
+ channel: SupportChannel.describe(
32
+ "How this conversation started — `email` at an inbound address, or `app` from a signed-in user. Never rewritten by a later message: the channel a thread opened on is what its account link's provenance follows from, and a thread that changed channel would change what its link means.",
33
+ ),
34
+ inboxAddress: z
35
+ .string()
36
+ .nullish()
37
+ .describe(
38
+ "The support address this thread arrived on, lowercased. Stored per thread so one Worker can serve several inboxes (support@, security@) and the dashboard can filter by which — and it is the address a reply comes back to. **Null on an `app` thread with no inbound address configured**, which is a supported deployment: a project can collect in-app feedback with no mail set up at all. Never null on an `email` thread, which by definition arrived at one.",
39
+ ),
40
+ subject: z
41
+ .string()
42
+ .describe(
43
+ "The subject of the message that opened the thread. Later replies never rewrite it — the thread keeps its name.",
44
+ ),
45
+ fromAddress: z
46
+ .string()
47
+ .describe(
48
+ "The sender's address, lowercased. The join key for the user link, the sender history, and the volume guard — so it is normalized on the way in, once. On an `app` thread it is the authenticated account's own email, read from the account rather than from anything the client sent, which is what lets a reply leave on the existing mail path with nothing new to configure.",
49
+ ),
50
+ fromName: z
51
+ .string()
52
+ .nullish()
53
+ .describe(
54
+ "The sender's display name from the `From` header, when they had one. Untrusted text; render it escaped.",
55
+ ),
56
+ senderAuthenticated: SQLiteBoolean.describe(
57
+ "Whether the sender was proved to be who they claim. On an `email` thread that means the `From:` header was proved — DMARC passed, or SPF passed on an aligned domain — and **false does not mean forged**, it means unproven, which is the common case for a domain publishing no DMARC policy. On an `app` thread it is always true, and it is true for a stronger reason: there was no header to prove, because `requireAuth()` proved the session before the request reached a handler. It gates the customer link: an unproven sender is never decorated with somebody's real purchase history, because that is what turns a spoofed message into support-driven account takeover.",
58
+ ),
59
+ userId: z
60
+ .string()
61
+ .nullish()
62
+ .describe(
63
+ "The `pithy_auth_users.id` this sender resolves to, or null when the address belongs to nobody with an account — or when the sender was not authenticated, because an unproven `From:` must not resolve to a real customer. Derived on the mail path, so a customer who signs up later is linked by the next message rather than by a repair; on the app path it is the session's own user id and there is nothing to derive.",
64
+ ),
65
+ accountLinkSource: SupportAccountLinkSource.nullish().describe(
66
+ "How `userId` was established, or null when there is no link. **The column that keeps a session-proven link from reading like a header-inferred one** — `senderAuthenticated` says whether to believe it, this says what was believed. A console rendering the two identically is the failure this exists to prevent, because the same operator action follows from very different evidence.",
67
+ ),
68
+ declaredCategory: z
69
+ .string()
70
+ .nullish()
71
+ .describe(
72
+ "**What the submitter said this is about** — a claim, made once, by the person writing. Null when nobody said: every `email` thread, and every `app` thread whose client offered no chooser. Written when the thread opens and **never afterwards**: not by a later message, and not by the classifier, which owns `category` and does not know this column exists. That separation is the whole point. A classification is recomputed on every retry, every manual reclassify, and every post-upgrade backfill — so a single column holding both facts loses whichever was written first, and the one an operator most needs is the *disagreement* between them: `declaredCategory: billing` beside `category: bug_report` says the customer thinks they were overcharged and the model thinks the app is broken, and no one column can say that. Validated against the effective taxonomy before it is stored, so this is never a client-writable vocabulary.",
73
+ ),
74
+ category: z
75
+ .string()
76
+ .describe(
77
+ "**What the classifier made of it** — the current answer, from this project's effective taxonomy, recomputed and never repaired. A plain string rather than an enum because the taxonomy is federated — the valid set is not known until an adopter composes the capability, so it is validated at the classification boundary instead of by the column. Distinct from `declaredCategory` above, which nothing here ever reads or writes.",
78
+ ),
79
+ priority: SupportPriority.describe("The current priority — how fast this thread needs a human."),
80
+ sentiment: SupportSentiment.describe("The current sentiment — the churn signal."),
81
+ confidence: z
82
+ .number()
83
+ .min(0)
84
+ .max(1)
85
+ .nullish()
86
+ .describe(
87
+ "The model's self-reported confidence in the current classification, 0..1. Null until a classification lands. Kept so a low-confidence inbox can be reviewed separately from a confident one.",
88
+ ),
89
+ model: z
90
+ .string()
91
+ .nullish()
92
+ .describe(
93
+ "The Workers AI model id that produced the current classification. Recorded so a reclassification pass after a model upgrade can tell which rows came from which model — the whole reason reclassification is possible at all.",
94
+ ),
95
+ classifiedAt: SQLiteDate.nullish().describe(
96
+ "When the current classification was written; null until the Workflow has run.",
97
+ ),
98
+ archived: SQLiteBoolean.describe(
99
+ "Done. One shared boolean, not per viewer, because if one person resolves a thread another should not still see it open. The mildest possible coordination state: no transitions to get wrong, nothing can be stuck, and unarchiving undoes a mistake instantly.",
100
+ ),
101
+ archivedAt: SQLiteDate.nullish().describe("When the thread was last archived; null while it is open."),
102
+ archivedBy: z
103
+ .string()
104
+ .nullish()
105
+ .describe(
106
+ "The control-plane subject that archived it. Recorded for the dashboard's convenience only — the answer to 'who marked this done' comes from the audit trail, which is why this is not an ownership column.",
107
+ ),
108
+ messageCount: z.number().int().describe("How many messages the thread holds, inbound and outbound together."),
109
+ firstMessageAt: SQLiteDate.describe("When the thread opened — the receive time of its first message."),
110
+ lastMessageAt: SQLiteDate.describe(
111
+ "When the thread last moved. The inbox sorts on this, and it is the `receivedAt` half of every composite index and of the pagination cursor.",
112
+ ),
113
+ createdAt: SQLiteDate.describe("When the thread row was created."),
114
+ updatedAt: SQLiteDate.describe("When the thread row was last written."),
115
+ })
116
+ .describe("One support conversation in `pithy_support_threads` — derived entirely from the mail below it.");
117
+ export type SupportThread = z.output<typeof SupportThread>;
118
+
119
+ /**
120
+ * What a brand-new thread's classification columns hold before the Workflow has run.
121
+ *
122
+ * **`declaredCategory` is deliberately not in here**, and the omission is load-bearing rather than an
123
+ * oversight. This object is the set of columns the classifier owns — `runClassification` overwrites
124
+ * every one of them on each run — so a submitter's claim listed among them would be erased by the
125
+ * first classification and by every retry after it. It is set beside this spread, from the submission,
126
+ * and nothing on the classification path names it.
127
+ */
128
+ export const UNCLASSIFIED = {
129
+ category: UNCATEGORIZED,
130
+ priority: "normal",
131
+ sentiment: "neutral",
132
+ confidence: null,
133
+ model: null,
134
+ classifiedAt: null,
135
+ } as const satisfies Pick<
136
+ SupportThread,
137
+ "category" | "priority" | "sentiment" | "confidence" | "model" | "classifiedAt"
138
+ >;