@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,452 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
5
|
+
import { type SqlBool, sql } from "kysely";
|
|
6
|
+
import { SupportAttachment } from "../data/attachment";
|
|
7
|
+
import type { SupportChannel, SupportPriority, SupportSentiment } from "../data/enums";
|
|
8
|
+
import { SupportMessage } from "../data/message";
|
|
9
|
+
import {
|
|
10
|
+
SUPPORT_ATTACHMENTS_TABLE,
|
|
11
|
+
SUPPORT_FLAGS_TABLE,
|
|
12
|
+
SUPPORT_MESSAGES_TABLE,
|
|
13
|
+
SUPPORT_SEARCH_TABLE,
|
|
14
|
+
SUPPORT_THREADS_TABLE,
|
|
15
|
+
type SupportDatabase,
|
|
16
|
+
} from "../data/tables";
|
|
17
|
+
import { SupportThread } from "../data/thread";
|
|
18
|
+
import { SupportNotFoundError } from "../error/errors";
|
|
19
|
+
import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from "./paging";
|
|
20
|
+
import { isSearchable, searchPredicate } from "./search";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The inbox query — the one read this whole data model was shaped around.
|
|
24
|
+
*
|
|
25
|
+
* ## Cursor pagination, never offset
|
|
26
|
+
*
|
|
27
|
+
* `OFFSET` is wrong here for a reason specific to an inbox: rows are inserted at the *front* of the
|
|
28
|
+
* order the list is sorted by, so every message that arrives while somebody is reading shifts every
|
|
29
|
+
* subsequent page down by one. The reader sees a thread twice and misses another, and the misses are
|
|
30
|
+
* silent. A cursor on `(lastMessageAt, id)` describes a position in the data rather than a count of
|
|
31
|
+
* rows skipped, so new mail arriving above it changes nothing about what comes next.
|
|
32
|
+
*
|
|
33
|
+
* `id` is in the cursor because `lastMessageAt` is not unique — two messages landing in the same
|
|
34
|
+
* millisecond is unlikely and a page boundary landing between them is exactly where it would show up
|
|
35
|
+
* as a dropped row.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** An opaque position in the inbox ordering. */
|
|
39
|
+
export interface ThreadCursor {
|
|
40
|
+
/** The `lastMessageAt` of the last thread on the previous page, as ms-epoch. */
|
|
41
|
+
lastMessageAt: number;
|
|
42
|
+
/** That thread's id — the tiebreak that makes the position exact. */
|
|
43
|
+
id: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Encode a cursor for the wire.
|
|
48
|
+
*
|
|
49
|
+
* Base64url over JSON, and **opaque by intent rather than by obscurity**: the point is that a client
|
|
50
|
+
* cannot construct one by hand and therefore cannot come to depend on its shape, so the ordering can
|
|
51
|
+
* change later without breaking every caller. It is not a secret — it holds a timestamp and an id
|
|
52
|
+
* the caller was just given.
|
|
53
|
+
*/
|
|
54
|
+
export function encodeCursor(cursor: ThreadCursor): string {
|
|
55
|
+
return btoa(JSON.stringify(cursor)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Decode a cursor. Returns undefined for anything malformed — a bad cursor is a first page, not a 500. */
|
|
59
|
+
export function decodeCursor(value: string | undefined): ThreadCursor | undefined {
|
|
60
|
+
if (!value) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
const json = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
|
|
63
|
+
const parsed: unknown = JSON.parse(json);
|
|
64
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
65
|
+
const { lastMessageAt, id } = parsed as Partial<ThreadCursor>;
|
|
66
|
+
if (typeof lastMessageAt !== "number" || typeof id !== "string") return undefined;
|
|
67
|
+
return { lastMessageAt, id };
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What a caller may filter the inbox by. */
|
|
74
|
+
export interface ListThreadsQuery {
|
|
75
|
+
/** Open threads, done threads, or both. Defaults to open. */
|
|
76
|
+
archived?: boolean;
|
|
77
|
+
/** One category key from the effective taxonomy — what the classifier decided. */
|
|
78
|
+
category?: string;
|
|
79
|
+
/** One category key from the effective taxonomy — what the submitter said. A different question. */
|
|
80
|
+
declaredCategory?: string;
|
|
81
|
+
/** One priority. */
|
|
82
|
+
priority?: SupportPriority;
|
|
83
|
+
/** One sentiment. */
|
|
84
|
+
sentiment?: SupportSentiment;
|
|
85
|
+
/** One channel — mail, or what signed-in users filed from inside the app. */
|
|
86
|
+
channel?: SupportChannel;
|
|
87
|
+
/** Which inbox address, for a Worker serving more than one. */
|
|
88
|
+
inbox?: string;
|
|
89
|
+
/** Free text over subjects and bodies. */
|
|
90
|
+
q?: string;
|
|
91
|
+
/** Where to resume from. */
|
|
92
|
+
cursor?: string;
|
|
93
|
+
/** How many to return. */
|
|
94
|
+
limit?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A thread as the inbox lists it — the row, plus this viewer's own private state. */
|
|
98
|
+
export interface ListedThread extends SupportThread {
|
|
99
|
+
/** Whether this viewer has read it. False when they have no flag row, which is the common case. */
|
|
100
|
+
read: boolean;
|
|
101
|
+
/** When this viewer's snooze expires, or null. */
|
|
102
|
+
snoozedUntil: Date | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One page of the inbox. */
|
|
106
|
+
export interface ThreadPage {
|
|
107
|
+
/** The threads, newest first. */
|
|
108
|
+
threads: ListedThread[];
|
|
109
|
+
/** The cursor for the next page, or null when this was the last one. */
|
|
110
|
+
nextCursor: string | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* List threads, as one viewer sees them.
|
|
115
|
+
*
|
|
116
|
+
* The viewer is a parameter rather than an afterthought because the per-viewer flags are **read
|
|
117
|
+
* here**: a snooze that was stored and never consulted is a button that does nothing, and `read` that
|
|
118
|
+
* is never projected is a column a dashboard cannot render. Both are left-joined, so a thread with no
|
|
119
|
+
* flag row for this viewer — the overwhelming majority — still appears, unread and unsnoozed.
|
|
120
|
+
*/
|
|
121
|
+
export async function listThreads(
|
|
122
|
+
db: SupportDatabase,
|
|
123
|
+
query: ListThreadsQuery,
|
|
124
|
+
options: { fts: boolean; viewer: string; now: Date; log?: Logger },
|
|
125
|
+
): Promise<ThreadPage> {
|
|
126
|
+
try {
|
|
127
|
+
return await runListThreads(db, query, options);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// **The index being configured on is not the same as the index existing.**
|
|
130
|
+
//
|
|
131
|
+
// `search.fts` is a config flag while the virtual table is a provisioned resource, and the two can disagree
|
|
132
|
+
// in both directions: a project that turned the flag on but has not migrated yet, a database
|
|
133
|
+
// restored from a snapshot taken before it, a Worker deployed ahead of `pithy migrate`. In every
|
|
134
|
+
// one of those the flag says FTS and the schema says no such table.
|
|
135
|
+
//
|
|
136
|
+
// Falling back to the `LIKE` scan makes that a slower search rather than a 500 on the inbox
|
|
137
|
+
// route. Narrow on purpose: only a missing-table error is caught, so a genuine query bug still
|
|
138
|
+
// surfaces instead of silently degrading forever.
|
|
139
|
+
if (options.fts && isMissingSearchTable(error)) {
|
|
140
|
+
options.log?.warn("support FTS index is configured but absent — falling back to a LIKE scan", {
|
|
141
|
+
action: "Run `pithy support provision` to create and backfill it, or set `search.fts: false`.",
|
|
142
|
+
});
|
|
143
|
+
return runListThreads(db, query, { ...options, fts: false });
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether an error is SQLite saying the full-text index does not exist. */
|
|
150
|
+
function isMissingSearchTable(error: unknown): boolean {
|
|
151
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
152
|
+
return message.includes("no such table") && message.includes(SUPPORT_SEARCH_TABLE);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The listing itself, for one chosen search backend. */
|
|
156
|
+
async function runListThreads(
|
|
157
|
+
db: SupportDatabase,
|
|
158
|
+
query: ListThreadsQuery,
|
|
159
|
+
options: { fts: boolean; viewer: string; now: Date },
|
|
160
|
+
): Promise<ThreadPage> {
|
|
161
|
+
const limit = Math.min(Math.max(query.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
|
|
162
|
+
const cursor = decodeCursor(query.cursor);
|
|
163
|
+
|
|
164
|
+
let builder = db
|
|
165
|
+
.selectFrom(SUPPORT_THREADS_TABLE)
|
|
166
|
+
// Joined on the viewer as well as the thread, so one operator's snooze never hides a thread from
|
|
167
|
+
// anybody else — the whole reason these flags are per viewer rather than on the thread.
|
|
168
|
+
.leftJoin(SUPPORT_FLAGS_TABLE, (join) =>
|
|
169
|
+
join
|
|
170
|
+
.onRef(`${SUPPORT_FLAGS_TABLE}.threadId`, "=", `${SUPPORT_THREADS_TABLE}.id`)
|
|
171
|
+
.on(`${SUPPORT_FLAGS_TABLE}.viewer`, "=", options.viewer),
|
|
172
|
+
)
|
|
173
|
+
.selectAll(SUPPORT_THREADS_TABLE)
|
|
174
|
+
.select([`${SUPPORT_FLAGS_TABLE}.read as viewerRead`, `${SUPPORT_FLAGS_TABLE}.snoozedUntil as viewerSnoozedUntil`])
|
|
175
|
+
// A snooze that has not expired hides the thread from this viewer. Expiry is evaluated on read
|
|
176
|
+
// rather than swept, so nothing has to run for a snooze to end.
|
|
177
|
+
.where((eb) =>
|
|
178
|
+
eb.or([
|
|
179
|
+
eb(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`, "is", null),
|
|
180
|
+
eb(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`, "<=", options.now.getTime()),
|
|
181
|
+
]),
|
|
182
|
+
)
|
|
183
|
+
// Archived is a filter with a default rather than an optional one: an inbox that showed resolved
|
|
184
|
+
// threads by default would be an inbox nobody trusts to be a work queue.
|
|
185
|
+
.where(`${SUPPORT_THREADS_TABLE}.archived`, "=", query.archived === true ? 1 : 0);
|
|
186
|
+
|
|
187
|
+
if (query.category !== undefined) builder = builder.where(`${SUPPORT_THREADS_TABLE}.category`, "=", query.category);
|
|
188
|
+
// Its own predicate rather than an `OR` with the one above, because the two answer different
|
|
189
|
+
// questions and an operator asking both is asking for the threads where they agree — which is the
|
|
190
|
+
// one shape a single conflated filter could never express.
|
|
191
|
+
if (query.declaredCategory !== undefined)
|
|
192
|
+
builder = builder.where(`${SUPPORT_THREADS_TABLE}.declaredCategory`, "=", query.declaredCategory);
|
|
193
|
+
if (query.priority !== undefined) builder = builder.where(`${SUPPORT_THREADS_TABLE}.priority`, "=", query.priority);
|
|
194
|
+
if (query.sentiment !== undefined)
|
|
195
|
+
builder = builder.where(`${SUPPORT_THREADS_TABLE}.sentiment`, "=", query.sentiment);
|
|
196
|
+
if (query.channel !== undefined) builder = builder.where(`${SUPPORT_THREADS_TABLE}.channel`, "=", query.channel);
|
|
197
|
+
if (query.inbox !== undefined) builder = builder.where(`${SUPPORT_THREADS_TABLE}.inboxAddress`, "=", query.inbox);
|
|
198
|
+
if (query.q !== undefined) {
|
|
199
|
+
if (isSearchable(query.q, options.fts)) {
|
|
200
|
+
builder = builder.where(searchPredicate(query.q, options));
|
|
201
|
+
} else {
|
|
202
|
+
// A term that tokenizes to nothing — `???`, `@@@` — asked a question with no answer, and the
|
|
203
|
+
// honest answer is none. Dropping the predicate instead would hand back the *entire* unfiltered
|
|
204
|
+
// inbox rendered as search results, which is the one direction a filter must never fail in:
|
|
205
|
+
// silently widening reads as "this is what matched" rather than as an error.
|
|
206
|
+
builder = builder.where(sql<SqlBool>`1 = 0`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (cursor) {
|
|
211
|
+
// Strictly after the cursor position in the descending order: an older timestamp, or the same
|
|
212
|
+
// timestamp with a smaller id. Written as the row-value comparison SQLite can satisfy from the
|
|
213
|
+
// `(archived, last_message_at, id)` index rather than as an OR it would have to scan.
|
|
214
|
+
builder = builder.where((eb) =>
|
|
215
|
+
eb.or([
|
|
216
|
+
eb(`${SUPPORT_THREADS_TABLE}.lastMessageAt`, "<", cursor.lastMessageAt),
|
|
217
|
+
eb.and([
|
|
218
|
+
eb(`${SUPPORT_THREADS_TABLE}.lastMessageAt`, "=", cursor.lastMessageAt),
|
|
219
|
+
eb(`${SUPPORT_THREADS_TABLE}.id`, "<", cursor.id),
|
|
220
|
+
]),
|
|
221
|
+
]),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// One more than asked for, so "is there a next page" is a fact rather than a second count query
|
|
226
|
+
// that could disagree with the page it describes.
|
|
227
|
+
const rows = await builder
|
|
228
|
+
.orderBy(`${SUPPORT_THREADS_TABLE}.lastMessageAt`, "desc")
|
|
229
|
+
.orderBy(`${SUPPORT_THREADS_TABLE}.id`, "desc")
|
|
230
|
+
.limit(limit + 1)
|
|
231
|
+
.execute();
|
|
232
|
+
|
|
233
|
+
const page: ListedThread[] = rows.slice(0, limit).map((row) => ({
|
|
234
|
+
...SupportThread.parse(row),
|
|
235
|
+
// A viewer with no flag row has read nothing and snoozed nothing, which is the common case and
|
|
236
|
+
// the right default — not an absence a caller has to interpret.
|
|
237
|
+
read: row.viewerRead === 1,
|
|
238
|
+
snoozedUntil: row.viewerSnoozedUntil == null ? null : new Date(Number(row.viewerSnoozedUntil)),
|
|
239
|
+
}));
|
|
240
|
+
const last = page[page.length - 1];
|
|
241
|
+
return {
|
|
242
|
+
threads: page,
|
|
243
|
+
nextCursor:
|
|
244
|
+
rows.length > limit && last ? encodeCursor({ lastMessageAt: last.lastMessageAt.getTime(), id: last.id }) : null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A thread with everything a reading pane shows. */
|
|
249
|
+
export interface ThreadDetail {
|
|
250
|
+
/** The thread row. */
|
|
251
|
+
thread: SupportThread;
|
|
252
|
+
/** Its messages, oldest first — a conversation reads downward. */
|
|
253
|
+
messages: SupportMessage[];
|
|
254
|
+
/** Its attachments, across every message. */
|
|
255
|
+
attachments: SupportAttachment[];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Read one thread in full. Throws `support/not_found` when it does not exist. */
|
|
259
|
+
export async function readThread(db: SupportDatabase, threadId: string): Promise<ThreadDetail> {
|
|
260
|
+
const row = await db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where("id", "=", threadId).executeTakeFirst();
|
|
261
|
+
if (!row) throw new SupportNotFoundError({ detail: `no support thread ${threadId}` });
|
|
262
|
+
|
|
263
|
+
const [messages, attachments] = await Promise.all([
|
|
264
|
+
db
|
|
265
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
266
|
+
.selectAll()
|
|
267
|
+
.where("threadId", "=", threadId)
|
|
268
|
+
.orderBy("receivedAt", "asc")
|
|
269
|
+
.orderBy("id", "asc")
|
|
270
|
+
.execute(),
|
|
271
|
+
db.selectFrom(SUPPORT_ATTACHMENTS_TABLE).selectAll().where("threadId", "=", threadId).execute(),
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
thread: SupportThread.parse(row),
|
|
276
|
+
messages: messages.map((message) => SupportMessage.parse(message)),
|
|
277
|
+
attachments: attachments.map((attachment) => SupportAttachment.parse(attachment)),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* List one account's own in-app conversations.
|
|
283
|
+
*
|
|
284
|
+
* **Two conditions on the `where`, and the second is not redundant.** `userId` alone would also return
|
|
285
|
+
* every *email* thread this capability linked to the account — and that link was matched against an
|
|
286
|
+
* address in a header nobody proved, so it names an account the sender merely claimed to be. Serving
|
|
287
|
+
* those to whoever currently holds the address would turn the mail path's known weakness into a read
|
|
288
|
+
* primitive, which is precisely the trade this channel exists to avoid making.
|
|
289
|
+
*
|
|
290
|
+
* Archived threads are included, unlike the operator inbox. An inbox hides done threads because it is a
|
|
291
|
+
* work queue; a person looking at their own requests is looking for the one that was answered.
|
|
292
|
+
*/
|
|
293
|
+
export async function listOwnThreads(
|
|
294
|
+
db: SupportDatabase,
|
|
295
|
+
userId: string,
|
|
296
|
+
query: { cursor?: string; limit?: number },
|
|
297
|
+
): Promise<{ threads: SupportThread[]; nextCursor: string | null }> {
|
|
298
|
+
const limit = Math.min(Math.max(query.limit ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE);
|
|
299
|
+
const cursor = decodeCursor(query.cursor);
|
|
300
|
+
|
|
301
|
+
let builder = db
|
|
302
|
+
.selectFrom(SUPPORT_THREADS_TABLE)
|
|
303
|
+
.selectAll()
|
|
304
|
+
.where("userId", "=", userId)
|
|
305
|
+
.where("channel", "=", "app");
|
|
306
|
+
|
|
307
|
+
if (cursor) {
|
|
308
|
+
builder = builder.where((eb) =>
|
|
309
|
+
eb.or([
|
|
310
|
+
eb("lastMessageAt", "<", cursor.lastMessageAt),
|
|
311
|
+
eb.and([eb("lastMessageAt", "=", cursor.lastMessageAt), eb("id", "<", cursor.id)]),
|
|
312
|
+
]),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const rows = await builder
|
|
317
|
+
.orderBy("lastMessageAt", "desc")
|
|
318
|
+
.orderBy("id", "desc")
|
|
319
|
+
.limit(limit + 1)
|
|
320
|
+
.execute();
|
|
321
|
+
const page = rows.slice(0, limit).map((row) => SupportThread.parse(row));
|
|
322
|
+
const last = page[page.length - 1];
|
|
323
|
+
return {
|
|
324
|
+
threads: page,
|
|
325
|
+
nextCursor:
|
|
326
|
+
rows.length > limit && last ? encodeCursor({ lastMessageAt: last.lastMessageAt.getTime(), id: last.id }) : null,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Read one of an account's own in-app conversations.
|
|
332
|
+
*
|
|
333
|
+
* **A 404 for somebody else's thread, never a 403.** The two are the same answer on purpose: a 403
|
|
334
|
+
* confirms the id names a real conversation, and on an inbox of other people's correspondence that
|
|
335
|
+
* confirmation is itself the disclosure. The scoping is in the `where`, not in a check after the read,
|
|
336
|
+
* so there is no version of this that fetches the row first and forgets to compare.
|
|
337
|
+
*/
|
|
338
|
+
export async function readOwnThread(db: SupportDatabase, threadId: string, userId: string): Promise<ThreadDetail> {
|
|
339
|
+
const row = await db
|
|
340
|
+
.selectFrom(SUPPORT_THREADS_TABLE)
|
|
341
|
+
.selectAll()
|
|
342
|
+
.where("id", "=", threadId)
|
|
343
|
+
.where("userId", "=", userId)
|
|
344
|
+
.where("channel", "=", "app")
|
|
345
|
+
.executeTakeFirst();
|
|
346
|
+
if (!row) throw new SupportNotFoundError({ detail: `no app thread ${threadId} owned by ${userId}` });
|
|
347
|
+
|
|
348
|
+
const [messages, attachments] = await Promise.all([
|
|
349
|
+
db
|
|
350
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
351
|
+
.selectAll()
|
|
352
|
+
.where("threadId", "=", threadId)
|
|
353
|
+
.orderBy("receivedAt", "asc")
|
|
354
|
+
.orderBy("id", "asc")
|
|
355
|
+
.execute(),
|
|
356
|
+
db.selectFrom(SUPPORT_ATTACHMENTS_TABLE).selectAll().where("threadId", "=", threadId).execute(),
|
|
357
|
+
]);
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
thread: SupportThread.parse(row),
|
|
361
|
+
messages: messages.map((message) => SupportMessage.parse(message)),
|
|
362
|
+
attachments: attachments.map((attachment) => SupportAttachment.parse(attachment)),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Mark a thread done, or reopen it.
|
|
368
|
+
*
|
|
369
|
+
* The one shared piece of state in the model, and the write is unconditional rather than a
|
|
370
|
+
* compare-and-set: archiving something already archived is not a conflict, it is the same outcome,
|
|
371
|
+
* and a support console that threw on a double-click would be worse than one that did nothing.
|
|
372
|
+
*/
|
|
373
|
+
export async function setArchived(
|
|
374
|
+
db: SupportDatabase,
|
|
375
|
+
threadId: string,
|
|
376
|
+
archived: boolean,
|
|
377
|
+
viewer: string,
|
|
378
|
+
now: Date,
|
|
379
|
+
): Promise<SupportThread> {
|
|
380
|
+
const existing = await db.selectFrom(SUPPORT_THREADS_TABLE).selectAll().where("id", "=", threadId).executeTakeFirst();
|
|
381
|
+
if (!existing) throw new SupportNotFoundError({ detail: `no support thread ${threadId}` });
|
|
382
|
+
|
|
383
|
+
await db
|
|
384
|
+
.updateTable(SUPPORT_THREADS_TABLE)
|
|
385
|
+
.set({
|
|
386
|
+
archived: archived ? 1 : 0,
|
|
387
|
+
archivedAt: archived ? now.getTime() : null,
|
|
388
|
+
archivedBy: archived ? viewer : null,
|
|
389
|
+
updatedAt: now.getTime(),
|
|
390
|
+
})
|
|
391
|
+
.where("id", "=", threadId)
|
|
392
|
+
.execute();
|
|
393
|
+
|
|
394
|
+
return SupportThread.parse({
|
|
395
|
+
...existing,
|
|
396
|
+
archived: archived ? 1 : 0,
|
|
397
|
+
archivedAt: archived ? now.getTime() : null,
|
|
398
|
+
archivedBy: archived ? viewer : null,
|
|
399
|
+
updatedAt: now.getTime(),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Set one viewer's private flags on a thread. Upserts on `(threadId, viewer)`. */
|
|
404
|
+
export async function setFlags(
|
|
405
|
+
db: SupportDatabase,
|
|
406
|
+
options: {
|
|
407
|
+
threadId: string;
|
|
408
|
+
viewer: string;
|
|
409
|
+
read?: boolean;
|
|
410
|
+
snoozedUntil?: Date | null;
|
|
411
|
+
newId: () => string;
|
|
412
|
+
now: Date;
|
|
413
|
+
},
|
|
414
|
+
): Promise<void> {
|
|
415
|
+
// **No read.** Preserving an absent field inside the `doUpdateSet` rather than from a prior
|
|
416
|
+
// `SELECT` is what makes a partial write safe: a dashboard that sends `{read}` and `{snoozedUntil}`
|
|
417
|
+
// concurrently for one thread would otherwise have both calls read the same pre-state and both
|
|
418
|
+
// write *both* columns from it, so whichever landed second would overwrite the other's column with
|
|
419
|
+
// a stale value. Referring to the column itself means each call touches only what it was given.
|
|
420
|
+
const thread = await db
|
|
421
|
+
.selectFrom(SUPPORT_THREADS_TABLE)
|
|
422
|
+
.select(["id"])
|
|
423
|
+
.where("id", "=", options.threadId)
|
|
424
|
+
.executeTakeFirst();
|
|
425
|
+
if (!thread) throw new SupportNotFoundError({ detail: `no support thread ${options.threadId}` });
|
|
426
|
+
|
|
427
|
+
const read = options.read === undefined ? undefined : options.read ? 1 : 0;
|
|
428
|
+
const snoozed = options.snoozedUntil === undefined ? undefined : (options.snoozedUntil?.getTime() ?? null);
|
|
429
|
+
|
|
430
|
+
await db
|
|
431
|
+
.insertInto(SUPPORT_FLAGS_TABLE)
|
|
432
|
+
.values({
|
|
433
|
+
id: options.newId(),
|
|
434
|
+
threadId: options.threadId,
|
|
435
|
+
viewer: options.viewer,
|
|
436
|
+
// The insert path has no existing row to preserve, so an absent field takes its default.
|
|
437
|
+
read: read ?? 0,
|
|
438
|
+
snoozedUntil: snoozed ?? null,
|
|
439
|
+
createdAt: options.now.getTime(),
|
|
440
|
+
updatedAt: options.now.getTime(),
|
|
441
|
+
})
|
|
442
|
+
.onConflict((oc) =>
|
|
443
|
+
oc.columns(["threadId", "viewer"]).doUpdateSet((eb) => ({
|
|
444
|
+
// `eb.ref` names the *stored* column, so an absent field is left exactly as it was rather
|
|
445
|
+
// than rewritten from a value this call read moments ago.
|
|
446
|
+
read: read === undefined ? eb.ref(`${SUPPORT_FLAGS_TABLE}.read`) : read,
|
|
447
|
+
snoozedUntil: snoozed === undefined ? eb.ref(`${SUPPORT_FLAGS_TABLE}.snoozedUntil`) : snoozed,
|
|
448
|
+
updatedAt: options.now.getTime(),
|
|
449
|
+
})),
|
|
450
|
+
)
|
|
451
|
+
.execute();
|
|
452
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Turning a base64 attachment back into bytes.
|
|
8
|
+
*
|
|
9
|
+
* Base64 rather than `multipart/form-data` for the submission route, and the reason is the contract
|
|
10
|
+
* rather than the encoding: every other route in this capability declares what it accepts as a Zod
|
|
11
|
+
* object over JSON, validated on the route line, and a multipart body is the one shape that cannot be.
|
|
12
|
+
* The cost is a third more bytes on the wire, which is why `attachments.maxBytes` is documented as a
|
|
13
|
+
* bound on the **decoded** size — the number that reaches R2 and the adopter's bill.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** How much larger a base64 string is than the bytes it encodes: four characters per three bytes. */
|
|
17
|
+
const BASE64_EXPANSION = 4 / 3;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The largest base64 string worth decoding, derived from a decoded-byte bound.
|
|
21
|
+
*
|
|
22
|
+
* Checked **before** decoding, not after. `atob` materialises the whole result, so a client that sends
|
|
23
|
+
* fifty megabytes of base64 has already been allocated fifty megabytes of Worker memory by the time a
|
|
24
|
+
* post-hoc size check could refuse it — and the request that does that is the one an attacker sends.
|
|
25
|
+
* A little slack for padding and any whitespace a client's encoder inserted.
|
|
26
|
+
*/
|
|
27
|
+
export function maxEncodedLength(maxBytes: number): number {
|
|
28
|
+
return Math.ceil(maxBytes * BASE64_EXPANSION) + 4;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decode one base64 attachment payload.
|
|
33
|
+
*
|
|
34
|
+
* Accepts the URL-safe alphabet too: a client that reached for `base64url` because its platform's
|
|
35
|
+
* default encoder produces it has not made a mistake worth a 400, and the two differ by two
|
|
36
|
+
* characters. Anything that is not base64 at all is a `validation/invalid_input`, which is what the
|
|
37
|
+
* route's own validator would have raised had the string been checkable there.
|
|
38
|
+
*/
|
|
39
|
+
export function decodeBase64(value: string, options: { maxBytes: number }): Uint8Array {
|
|
40
|
+
if (value.length > maxEncodedLength(options.maxBytes)) {
|
|
41
|
+
throw new ValidationError({
|
|
42
|
+
message: "That attachment is too large.",
|
|
43
|
+
action: `Attach a file under ${options.maxBytes} bytes.`,
|
|
44
|
+
detail: `encoded attachment is ${value.length} characters, over the ${maxEncodedLength(options.maxBytes)} the ${options.maxBytes} byte bound allows`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
49
|
+
let binary: string;
|
|
50
|
+
try {
|
|
51
|
+
binary = atob(normalized);
|
|
52
|
+
} catch (cause) {
|
|
53
|
+
throw new ValidationError(
|
|
54
|
+
{
|
|
55
|
+
message: "That attachment could not be read.",
|
|
56
|
+
action: "Send the file's bytes as a base64 string.",
|
|
57
|
+
detail: "attachment payload is not valid base64",
|
|
58
|
+
},
|
|
59
|
+
{ cause },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bytes = new Uint8Array(binary.length);
|
|
64
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
65
|
+
return bytes;
|
|
66
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { SupportSubmissionConfig } from "../config/config";
|
|
5
|
+
import { SUPPORT_MESSAGES_TABLE, type SupportDatabase } from "../data/tables";
|
|
6
|
+
import { GUARD_WINDOW_MS } from "../inbound/guard";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The in-app submission guard — the bound between "a signed-in user may write in" and "one account is
|
|
10
|
+
* the whole inbox".
|
|
11
|
+
*
|
|
12
|
+
* ## Why this is not the mail guard with a different column
|
|
13
|
+
*
|
|
14
|
+
* `inbound/guard.ts` exists because a public address is a public write endpoint reachable by anybody
|
|
15
|
+
* with an SMTP client, and its bounds are the shape of that problem: a size cap before parsing, a
|
|
16
|
+
* per-address rate for the broken auto-responder, a global rate for the distributed flood where no
|
|
17
|
+
* single address stands out.
|
|
18
|
+
*
|
|
19
|
+
* None of that describes this surface. **A submission is attributable and revocable**: it carries an
|
|
20
|
+
* account the adopter issued, so the flood the mail guard's global bound exists for cannot happen
|
|
21
|
+
* anonymously here — an attacker needs an account per bucket of ten, and every one of them is a row an
|
|
22
|
+
* adopter can disable. What remains is one real failure, and it is the one this file bounds: a single
|
|
23
|
+
* account, hostile or looping, filling the inbox.
|
|
24
|
+
*
|
|
25
|
+
* So there is one check rather than three, and it counts on the **account** rather than the address.
|
|
26
|
+
* Not the same thing: an address is a claim in a header and an account is an identity a session
|
|
27
|
+
* proved, which is the whole distinction this channel exists to exploit.
|
|
28
|
+
*
|
|
29
|
+
* ## Counted from the messages table, and only against its own channel
|
|
30
|
+
*
|
|
31
|
+
* The same reasoning `inbound/guard.ts` states — the rows already exist, a counter row would be a
|
|
32
|
+
* second write that overcounts a failed store and undercounts a flood — over the same sliding hour,
|
|
33
|
+
* and over an index (`submitted_by_user_id`, `received_at`) that the migration creates for this.
|
|
34
|
+
*
|
|
35
|
+
* **`channel = "app"` is on the count deliberately.** Neither surface may starve the other: a customer
|
|
36
|
+
* who emailed twenty times about a billing dispute must still be able to file a bug from inside the
|
|
37
|
+
* app, and somebody filing bug reports must never consume the mail inbox's capacity. Sharing one
|
|
38
|
+
* counter would couple two threat models that have nothing in common.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Why a submission was refused. The value lands in the audit event's `metadata.reason`. */
|
|
42
|
+
export type SupportSubmissionRejectionReason = "account_rate" | "attachment_too_large" | "attachment_type";
|
|
43
|
+
|
|
44
|
+
/** The guard's answer: accept, or refuse with a reason. */
|
|
45
|
+
export type SubmissionVerdict =
|
|
46
|
+
| { accepted: true }
|
|
47
|
+
| { accepted: false; reason: SupportSubmissionRejectionReason; detail: string };
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check how much this account has already filed inside the window.
|
|
51
|
+
*
|
|
52
|
+
* `>=` rather than `>`, matching the mail guard: the bound is how many an account *may land*, so the
|
|
53
|
+
* request that would be the eleventh is refused when ten are already stored.
|
|
54
|
+
*/
|
|
55
|
+
export async function checkAccountRate(
|
|
56
|
+
db: SupportDatabase,
|
|
57
|
+
config: SupportSubmissionConfig,
|
|
58
|
+
input: { userId: string; now: Date },
|
|
59
|
+
): Promise<SubmissionVerdict> {
|
|
60
|
+
const since = new Date(input.now.getTime() - GUARD_WINDOW_MS);
|
|
61
|
+
|
|
62
|
+
const filed = await db
|
|
63
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
64
|
+
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
65
|
+
.where("channel", "=", "app")
|
|
66
|
+
// What the *account* filed. An answer delivered in the app is an `app` row too, and it carries no
|
|
67
|
+
// `submittedByUserId` — but the bound is on what a person sends, so it says so rather than
|
|
68
|
+
// relying on a null in another column to keep the count honest.
|
|
69
|
+
.where("direction", "=", "inbound")
|
|
70
|
+
.where("submittedByUserId", "=", input.userId)
|
|
71
|
+
.where("receivedAt", ">=", since.getTime())
|
|
72
|
+
.executeTakeFirst();
|
|
73
|
+
|
|
74
|
+
if ((filed?.count ?? 0) >= config.maxPerAccountPerHour) {
|
|
75
|
+
return {
|
|
76
|
+
accepted: false,
|
|
77
|
+
reason: "account_rate",
|
|
78
|
+
// The account id, never anything the submitter wrote. `detail` is stripped by the HTTP codec but
|
|
79
|
+
// reaches the log, and this capability's input is text somebody else chose.
|
|
80
|
+
detail: `account ${input.userId} has filed ${filed?.count} submissions in the last hour, at or over the ${config.maxPerAccountPerHour} bound`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { accepted: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check one attachment against the declared bounds.
|
|
88
|
+
*
|
|
89
|
+
* Size is measured on the **decoded** bytes, which is the number that matters and not the one the
|
|
90
|
+
* request carried: a base64 payload is a third larger than what lands in R2, and bounding the encoded
|
|
91
|
+
* form would silently make the effective limit whatever `maxBytes * 0.75` happens to be.
|
|
92
|
+
*
|
|
93
|
+
* The type check is an **allowlist**, so a type nobody thought about is refused rather than accepted —
|
|
94
|
+
* the opposite default from the mail path, where refusing an unexpected type would lose a customer's
|
|
95
|
+
* bug report. It bounds what lands in the adopter's bucket; it is not what stops a stored-XSS, which
|
|
96
|
+
* is `putAttachment` writing every object as `application/octet-stream` whatever was declared.
|
|
97
|
+
*/
|
|
98
|
+
export function checkAttachment(
|
|
99
|
+
config: SupportSubmissionConfig,
|
|
100
|
+
input: { contentType: string; bytes: number },
|
|
101
|
+
): SubmissionVerdict {
|
|
102
|
+
if (input.bytes > config.attachments.maxBytes) {
|
|
103
|
+
return {
|
|
104
|
+
accepted: false,
|
|
105
|
+
reason: "attachment_too_large",
|
|
106
|
+
detail: `attachment is ${input.bytes} decoded bytes, over the ${config.attachments.maxBytes} byte bound`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (!config.attachments.allowedContentTypes.includes(input.contentType)) {
|
|
110
|
+
return {
|
|
111
|
+
accepted: false,
|
|
112
|
+
reason: "attachment_type",
|
|
113
|
+
// The declared type is echoed because it is one of a closed set the adopter configured, not free
|
|
114
|
+
// text — an operator reading this needs to know which type was refused to decide whether to
|
|
115
|
+
// allow it.
|
|
116
|
+
detail: `attachment declares ${input.contentType}, which is not in the configured allowlist`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { accepted: true };
|
|
120
|
+
}
|