@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.
- package/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +68 -0
- package/pithy.manifest.json +40 -0
- package/src/ai/classify.ts +239 -0
- package/src/attachment/store.ts +78 -0
- package/src/audit/actions.ts +71 -0
- package/src/capability.ts +293 -0
- package/src/client/projection.ts +60 -0
- package/src/cloudflare-test.d.ts +15 -0
- package/src/config/config.ts +400 -0
- package/src/data/attachment.ts +65 -0
- package/src/data/billingScope.ts +32 -0
- package/src/data/categories.ts +117 -0
- package/src/data/classification.ts +50 -0
- package/src/data/enums.ts +90 -0
- package/src/data/flag.ts +37 -0
- package/src/data/message.ts +224 -0
- package/src/data/tables.ts +58 -0
- package/src/data/thread.ts +138 -0
- package/src/error/errors.ts +133 -0
- package/src/http/guards.ts +59 -0
- package/src/http/handlers.ts +418 -0
- package/src/http/resolve.ts +109 -0
- package/src/http/responses.ts +506 -0
- package/src/http/routes.ts +272 -0
- package/src/http/schemas.ts +251 -0
- package/src/http/scopes.ts +117 -0
- package/src/http/views.ts +169 -0
- package/src/inbound/authenticity.ts +114 -0
- package/src/inbound/guard.ts +127 -0
- package/src/inbound/handler.ts +102 -0
- package/src/inbound/ingest.ts +548 -0
- package/src/inbound/recipient.ts +67 -0
- package/src/index.ts +63 -0
- package/src/link/sender.ts +334 -0
- package/src/migrations/0001_threads.ts +296 -0
- package/src/mime/address.ts +37 -0
- package/src/mime/parse.ts +299 -0
- package/src/mime/sanitize.ts +253 -0
- package/src/mime/threading.ts +127 -0
- package/src/mime/truncate.ts +55 -0
- package/src/provision/provisionSupport.ts +179 -0
- package/src/provision/resolveSupportConfig.ts +67 -0
- package/src/reply/send.ts +322 -0
- package/src/reply/snippets.ts +167 -0
- package/src/secret/registry.ts +24 -0
- package/src/seeds/example.ts +385 -0
- package/src/store/paging.ts +22 -0
- package/src/store/search.ts +197 -0
- package/src/store/searchIndex.ts +71 -0
- package/src/store/threads.ts +452 -0
- package/src/submission/encoding.ts +66 -0
- package/src/submission/guard.ts +120 -0
- package/src/submission/submit.ts +539 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/classify.ts +164 -0
- package/src/workflows/retryPolicy.ts +48 -0
- package/src/workflows/specs.ts +61 -0
- package/src/workflows/worker.ts +82 -0
- 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
|
+
}
|