@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,539 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { R2Bucket } from "@cloudflare/workers-types";
|
|
5
|
+
import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
|
|
6
|
+
import { chunkRowsByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
|
|
7
|
+
import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
9
|
+
import { attachmentKey, putAttachment, sha256Hex } from "../attachment/store";
|
|
10
|
+
import { SupportAuditActions } from "../audit/actions";
|
|
11
|
+
import type { SupportConfig } from "../config/config";
|
|
12
|
+
import { SupportAttachment } from "../data/attachment";
|
|
13
|
+
import { categoryEnum, type SupportCategories } from "../data/categories";
|
|
14
|
+
import { SupportMessage, type SupportSubmissionContext } from "../data/message";
|
|
15
|
+
import {
|
|
16
|
+
SUPPORT_ATTACHMENTS_TABLE,
|
|
17
|
+
SUPPORT_MESSAGES_TABLE,
|
|
18
|
+
SUPPORT_THREADS_TABLE,
|
|
19
|
+
type SupportDatabase,
|
|
20
|
+
} from "../data/tables";
|
|
21
|
+
import { SupportThread, UNCLASSIFIED } from "../data/thread";
|
|
22
|
+
import { SupportInvalidCategoryError, SupportNotFoundError, SupportRejectedError } from "../error/errors";
|
|
23
|
+
import { safeFilename } from "../mime/parse";
|
|
24
|
+
import { indexMessage } from "../store/search";
|
|
25
|
+
import { checkAccountRate, checkAttachment } from "./guard";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The in-app submission path — a signed-in user opening a support thread from inside the app, without
|
|
29
|
+
* leaving it for their mail client.
|
|
30
|
+
*
|
|
31
|
+
* This is `inbound/ingest.ts` for the other channel, and it is deliberately the *same* destination:
|
|
32
|
+
* one threads table, one messages table, one classifier, one taxonomy, one console. A second package
|
|
33
|
+
* would have been a second inbox and a support person with two of them, neither holding the whole
|
|
34
|
+
* conversation — and the reply to an in-app report goes out by email regardless, because that is where
|
|
35
|
+
* the person will read it.
|
|
36
|
+
*
|
|
37
|
+
* ## What this path does not have to do
|
|
38
|
+
*
|
|
39
|
+
* `inbound/authenticity.ts` is two hundred lines earning the right to say a thread belongs to a
|
|
40
|
+
* customer, because a `From:` header is an unauthenticated claim and decorating a spoofed thread with a
|
|
41
|
+
* real customer's billing history is the opening move of support-driven account takeover.
|
|
42
|
+
*
|
|
43
|
+
* **There is no `From:` here to spoof.** `requireAuth()` proved the session before the handler ran, so
|
|
44
|
+
* the account arrives already established — and the thread records that with `accountLinkSource:
|
|
45
|
+
* "session"`, so a console can tell a link that *is* the identity from one that was matched against a
|
|
46
|
+
* header. Nothing in the submitted payload names an account, and nothing here reads one from it.
|
|
47
|
+
*
|
|
48
|
+
* ## The order of operations
|
|
49
|
+
*
|
|
50
|
+
* The mail path's rule, applied to a different shape: **refuse on the cheapest true reason first.**
|
|
51
|
+
* The two shape bounds a client controls the cost of — how many files, and how large each encoded one
|
|
52
|
+
* is — are applied at the transport boundary in `http/handlers.ts` *before* anything is decoded,
|
|
53
|
+
* because `atob` materialises its whole result and a size check after it has already been handed the
|
|
54
|
+
* allocation it meant to refuse. What is left arrives here as bytes:
|
|
55
|
+
*
|
|
56
|
+
* 0. **The declared category**, which needs no I/O at all — it is a comparison against a taxonomy this
|
|
57
|
+
* process already holds, so it is genuinely the cheapest true reason there is.
|
|
58
|
+
* 1. **The account**, because everything else depends on the address a reply will come back to, and a
|
|
59
|
+
* report nobody can answer is not worth storing.
|
|
60
|
+
* 2. **The rate bound**, before anything is written.
|
|
61
|
+
* 3. **The attachments**, every one checked before one is written, so a refusal never leaves half a
|
|
62
|
+
* submission's files in the bucket.
|
|
63
|
+
* 4. **Store**, which must not fail.
|
|
64
|
+
* 5. **Dispatch classification**, which is allowed to fail — the same rule the mail path follows, and
|
|
65
|
+
* for the same reason: a model that is briefly down must never take somebody's report with it.
|
|
66
|
+
*
|
|
67
|
+
* The count bound is checked in both places on purpose. The handler's is what keeps the decode
|
|
68
|
+
* bounded; this one is what keeps the bound true for any caller that reaches this function directly,
|
|
69
|
+
* which is every test and every future transport.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/** What a submission produced. */
|
|
73
|
+
export interface SubmitOutcome {
|
|
74
|
+
/** The thread it landed in — new, or the one it continued. */
|
|
75
|
+
threadId: string;
|
|
76
|
+
/** The message row it created. */
|
|
77
|
+
messageId: string;
|
|
78
|
+
/** Whether it opened a thread rather than continuing one. */
|
|
79
|
+
newThread: boolean;
|
|
80
|
+
/** How many attachments were stored. */
|
|
81
|
+
attachments: number;
|
|
82
|
+
/** Whether a classification was dispatched. */
|
|
83
|
+
classifying: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One file a submitter attached, already decoded. */
|
|
87
|
+
export interface SubmittedAttachment {
|
|
88
|
+
/** The filename as the client declared it. Untrusted; recorded, never honored. */
|
|
89
|
+
filename: string;
|
|
90
|
+
/** The MIME type as the client declared it, checked against the configured allowlist. */
|
|
91
|
+
contentType: string;
|
|
92
|
+
/** The bytes themselves. */
|
|
93
|
+
bytes: Uint8Array;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Everything a submission needs, all injectable — nothing below this reads a binding. */
|
|
97
|
+
export interface SubmitDeps {
|
|
98
|
+
/** The support tables. */
|
|
99
|
+
db: SupportDatabase;
|
|
100
|
+
/** The resolved config. */
|
|
101
|
+
config: SupportConfig;
|
|
102
|
+
/**
|
|
103
|
+
* The effective taxonomy — the shipped defaults plus the adopter's, already merged.
|
|
104
|
+
*
|
|
105
|
+
* Taken as a dep rather than re-derived from `config.categories` here, because two resolutions of one
|
|
106
|
+
* taxonomy are two things free to disagree: the classifier parses against the capability's merged
|
|
107
|
+
* set, and a submitted claim validated against a differently-merged one would be accepted or refused
|
|
108
|
+
* on a vocabulary nothing else in the process uses.
|
|
109
|
+
*/
|
|
110
|
+
categories: SupportCategories;
|
|
111
|
+
/** The R2 bucket attachments are written to. Absent means none are stored. */
|
|
112
|
+
bucket?: R2Bucket;
|
|
113
|
+
/** Whether the FTS5 index is composed. */
|
|
114
|
+
fts: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* The submitter's account, by id. Returns null when `@pithy-sh/auth` is absent or the row is gone —
|
|
117
|
+
* both of which contradict the session that got here, so the caller treats it as a fault rather than
|
|
118
|
+
* as a supported state.
|
|
119
|
+
*/
|
|
120
|
+
resolveAccount: (userId: string) => Promise<{ email: string; name?: string } | null>;
|
|
121
|
+
/** Start a classification for a stored message. Allowed to fail and allowed to decline. */
|
|
122
|
+
dispatchClassify: (messageId: string) => Promise<boolean>;
|
|
123
|
+
/** The audit seam. */
|
|
124
|
+
emit: AuditEmit;
|
|
125
|
+
/** The request logger. */
|
|
126
|
+
log: Logger;
|
|
127
|
+
/** Generate a row id. */
|
|
128
|
+
newId: () => string;
|
|
129
|
+
/** Now. */
|
|
130
|
+
now: () => Date;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What the submitter sent, with the identity taken from the session rather than from the payload. */
|
|
134
|
+
export interface SubmitInput {
|
|
135
|
+
/** The authenticated user id. **From `c.var.auth`, never from the body.** */
|
|
136
|
+
userId: string;
|
|
137
|
+
/** What the report is about. Becomes the thread's name, and the subject of every reply on it. */
|
|
138
|
+
subject: string;
|
|
139
|
+
/** The report itself, as the person wrote it. */
|
|
140
|
+
body: string;
|
|
141
|
+
/**
|
|
142
|
+
* What the submitter says this is about, from the app's own chooser. Their claim, not a
|
|
143
|
+
* classification — validated against the effective taxonomy, stored on the thread it opens, and never
|
|
144
|
+
* read by the classifier. Absent when the client offers no chooser, which stays the ordinary case.
|
|
145
|
+
*
|
|
146
|
+
* Accepted only on a submission that **opens** a thread; sending it with `threadId` is refused rather
|
|
147
|
+
* than ignored (see {@link checkDeclaredCategory}).
|
|
148
|
+
*/
|
|
149
|
+
declaredCategory?: string;
|
|
150
|
+
/** Continue this thread instead of opening one. Refused unless it is this account's own app thread. */
|
|
151
|
+
threadId?: string;
|
|
152
|
+
/** The bounded context the app supplied. */
|
|
153
|
+
context?: SupportSubmissionContext;
|
|
154
|
+
/** The files attached, already decoded. */
|
|
155
|
+
attachments: readonly SubmittedAttachment[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Record a refusal. Never throws — a guard decision must not become a 500 on top of a 429. */
|
|
159
|
+
async function auditRejection(deps: SubmitDeps, reason: string, detail: string, userId: string): Promise<void> {
|
|
160
|
+
try {
|
|
161
|
+
await deps.emit({
|
|
162
|
+
action: SupportAuditActions.submissionRejected,
|
|
163
|
+
outcome: "denied",
|
|
164
|
+
severity: "warning",
|
|
165
|
+
// `user`, and the id is populated — the opposite of the mail path, where the actor is anonymous
|
|
166
|
+
// because a `From:` header is a claim. Here the session proved it, so naming the account is
|
|
167
|
+
// recording a fact rather than repeating an assertion, and it is the whole operational value of
|
|
168
|
+
// the event: an adopter acts on the account, and cannot without knowing which.
|
|
169
|
+
actorType: "user",
|
|
170
|
+
actorId: userId,
|
|
171
|
+
resourceType: "support_inbox",
|
|
172
|
+
metadata: { reason, channel: "app" },
|
|
173
|
+
});
|
|
174
|
+
deps.log.warn("support submission refused", { reason, detail, userId });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
deps.log.warn("support submission rejection audit dropped", { reason, detail, error });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the thread a submission continues.
|
|
182
|
+
*
|
|
183
|
+
* **Both conditions, and a 404 rather than a 403 when either fails.** The thread must be this
|
|
184
|
+
* account's and it must be an app thread: a 403 would confirm that the id names a real conversation,
|
|
185
|
+
* which on an inbox of other people's correspondence is the disclosure the check exists to prevent —
|
|
186
|
+
* so an id belonging to somebody else is indistinguishable from one belonging to nobody.
|
|
187
|
+
*
|
|
188
|
+
* An `email` thread is excluded even when it carries this account's `userId`, and that exclusion is
|
|
189
|
+
* the point rather than an oversight: that link was matched from an address in a header nobody proved,
|
|
190
|
+
* so treating it as ownership would let whoever currently holds an address read a conversation that
|
|
191
|
+
* merely *claims* to be theirs.
|
|
192
|
+
*/
|
|
193
|
+
async function resolveOwnThread(db: SupportDatabase, threadId: string, userId: string): Promise<{ id: string }> {
|
|
194
|
+
const thread = await db
|
|
195
|
+
.selectFrom(SUPPORT_THREADS_TABLE)
|
|
196
|
+
.select(["id"])
|
|
197
|
+
.where("id", "=", threadId)
|
|
198
|
+
.where("userId", "=", userId)
|
|
199
|
+
.where("channel", "=", "app")
|
|
200
|
+
.executeTakeFirst();
|
|
201
|
+
if (!thread) throw new SupportNotFoundError({ detail: `no app thread ${threadId} owned by ${userId}` });
|
|
202
|
+
return thread;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check a submitter's declared category, before anything else and before any I/O.
|
|
207
|
+
*
|
|
208
|
+
* **An undeclared key is refused, never stored and never downgraded.** Storing it would make
|
|
209
|
+
* `declared_category` a client-writable vocabulary: one client shipping `Billing` or `billng` and the
|
|
210
|
+
* console's filter silently misses those threads forever, while the inbox grows a long tail of one-off
|
|
211
|
+
* keys nobody declared. Downgrading to `uncategorized` is the worse of the two remaining options,
|
|
212
|
+
* because it makes a broken chooser indistinguishable from a submitter who genuinely chose nothing —
|
|
213
|
+
* the exact collapse this column exists to prevent, arrived at from the other side.
|
|
214
|
+
*
|
|
215
|
+
* It is refused rather than tolerated **because the writer can be fixed.** `classifyMessage` falls back
|
|
216
|
+
* to `uncategorized` on a label the model invented, and that is right there: a model cannot be told it
|
|
217
|
+
* was wrong at the call site. A client can, and a 400 is how it gets told — the chooser was built from
|
|
218
|
+
* a taxonomy the adopter declared, so a value outside it is that client's bug and should be loud.
|
|
219
|
+
*
|
|
220
|
+
* **A category may not ride along with a `threadId`.** A thread already carries what it was filed
|
|
221
|
+
* under, and a second, later claim has no honest meaning: ignoring it is a chooser that does nothing —
|
|
222
|
+
* which is the whole defect this seam exists to close — and honoring it would let a follow-up rewrite
|
|
223
|
+
* the premise a conversation was opened on, the way `subject` deliberately cannot. So it is refused,
|
|
224
|
+
* loudly, and a chooser stays what it is: something offered on the form that opens a request.
|
|
225
|
+
*
|
|
226
|
+
* **Nothing the submitter wrote reaches `message`.** The offending value goes in `detail`, which the
|
|
227
|
+
* HTTP codec strips — this capability's whole input is text somebody else chose, and an error that
|
|
228
|
+
* echoed it back would make the error channel a reflection surface.
|
|
229
|
+
*/
|
|
230
|
+
function checkDeclaredCategory(deps: SubmitDeps, input: SubmitInput): void {
|
|
231
|
+
const declared = input.declaredCategory;
|
|
232
|
+
if (declared === undefined) return;
|
|
233
|
+
|
|
234
|
+
if (input.threadId !== undefined) {
|
|
235
|
+
throw new ValidationError({
|
|
236
|
+
message: "A category is chosen when a request is opened, not on a later message.",
|
|
237
|
+
action: "Send the category with the first message, or leave it off to continue the conversation.",
|
|
238
|
+
detail: `submission declared a category alongside threadId ${input.threadId}`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The same schema `classifyMessage` parses a model's answer against, over the same effective
|
|
243
|
+
// taxonomy — one definition of "a category this project has", checked at both boundaries. `safeParse`
|
|
244
|
+
// rather than `parse`: a refusal here is a 400 this function shapes, never a `ZodError` escaping into
|
|
245
|
+
// somebody else's handler.
|
|
246
|
+
if (!categoryEnum(deps.categories).safeParse(declared).success) {
|
|
247
|
+
throw new SupportInvalidCategoryError({
|
|
248
|
+
message: "That is not a category this app offers.",
|
|
249
|
+
action: "Choose one of the categories the app declares, or send the request without one.",
|
|
250
|
+
detail: `submitted category ${JSON.stringify(declared)} is not in the effective taxonomy (${Object.keys(deps.categories).join(", ")})`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** How many columns one `pithy_support_attachments` row binds — derived, so a new column re-chunks. */
|
|
256
|
+
const SUPPORT_ATTACHMENT_COLUMNS = Object.keys(SupportAttachment.shape).length;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Check every attachment before one byte is written.
|
|
260
|
+
*
|
|
261
|
+
* All-or-nothing, and refused rather than skipped — the opposite of the mail path, and the difference
|
|
262
|
+
* is who is on the other end. An oversize part of an email is dropped because the sender is gone and
|
|
263
|
+
* the message is still worth keeping; a submitter is right there, holding the screenshot that is the
|
|
264
|
+
* most valuable thing the report carries, and silently storing the report without it means the first
|
|
265
|
+
* reply asks for a file they believed they had already sent.
|
|
266
|
+
*/
|
|
267
|
+
function checkAttachments(deps: SubmitDeps, attachments: readonly SubmittedAttachment[]): void {
|
|
268
|
+
const bounds = deps.config.submission.attachments;
|
|
269
|
+
if (attachments.length === 0) return;
|
|
270
|
+
|
|
271
|
+
if (!bounds.enabled) {
|
|
272
|
+
throw new ValidationError({
|
|
273
|
+
message: "This app does not accept attachments on a support request.",
|
|
274
|
+
action: "Send the report without a file, and describe what you would have attached.",
|
|
275
|
+
detail: "submission.attachments.enabled is false",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (attachments.length > bounds.maxCount) {
|
|
279
|
+
throw new ValidationError({
|
|
280
|
+
message: `A support request may carry at most ${bounds.maxCount} attachments.`,
|
|
281
|
+
action: `Send at most ${bounds.maxCount} files.`,
|
|
282
|
+
detail: `submission declared ${attachments.length} attachments, over the ${bounds.maxCount} bound`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
for (const attachment of attachments) {
|
|
286
|
+
const verdict = checkAttachment(deps.config.submission, {
|
|
287
|
+
contentType: attachment.contentType,
|
|
288
|
+
bytes: attachment.bytes.byteLength,
|
|
289
|
+
});
|
|
290
|
+
if (verdict.accepted) continue;
|
|
291
|
+
throw new ValidationError({
|
|
292
|
+
message:
|
|
293
|
+
verdict.reason === "attachment_too_large"
|
|
294
|
+
? `An attachment may be at most ${bounds.maxBytes} bytes.`
|
|
295
|
+
: "That kind of file cannot be attached to a support request.",
|
|
296
|
+
action:
|
|
297
|
+
verdict.reason === "attachment_too_large"
|
|
298
|
+
? "Attach a smaller file."
|
|
299
|
+
: `Attach one of: ${bounds.allowedContentTypes.join(", ")}.`,
|
|
300
|
+
detail: verdict.detail,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Store the checked attachments. Returns how many rows were written. */
|
|
306
|
+
async function storeAttachments(
|
|
307
|
+
deps: SubmitDeps,
|
|
308
|
+
attachments: readonly SubmittedAttachment[],
|
|
309
|
+
threadId: string,
|
|
310
|
+
messageId: string,
|
|
311
|
+
now: Date,
|
|
312
|
+
): Promise<number> {
|
|
313
|
+
const bucket = deps.bucket;
|
|
314
|
+
if (attachments.length === 0) return 0;
|
|
315
|
+
if (!bucket) {
|
|
316
|
+
// Checked here rather than at the top, so a submission with no files still works on a deployment
|
|
317
|
+
// that never provisioned a bucket — which is every project that turned attachments off.
|
|
318
|
+
deps.log.warn("support submission attachments dropped — no bucket bound", { threadId, messageId });
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const rows: SupportAttachment[] = [];
|
|
323
|
+
for (const attachment of attachments) {
|
|
324
|
+
const id = deps.newId();
|
|
325
|
+
const key = attachmentKey(threadId, id);
|
|
326
|
+
await putAttachment(bucket, key, attachment.bytes);
|
|
327
|
+
rows.push({
|
|
328
|
+
id,
|
|
329
|
+
messageId,
|
|
330
|
+
threadId,
|
|
331
|
+
// The same sanitizer the mail path runs, and not optional here: `SupportAttachment.filename`
|
|
332
|
+
// states the column holds a name "after stripping path separators and control characters", and
|
|
333
|
+
// two producers of one column must guarantee the same thing about it. This channel is the more
|
|
334
|
+
// attacker-friendly of the two for it — a MIME filename has to survive header encoding, while
|
|
335
|
+
// this one is a JSON string a signed-in client picks byte for byte — so a bidi override that
|
|
336
|
+
// renders `shot<U+202E>gnp.exe` as `shot.png`, or a name a console might put in a
|
|
337
|
+
// `Content-Disposition`, arrives here intact unless something takes it out.
|
|
338
|
+
filename: safeFilename(attachment.filename),
|
|
339
|
+
contentType: attachment.contentType,
|
|
340
|
+
size: attachment.bytes.byteLength,
|
|
341
|
+
sha256: await sha256Hex(attachment.bytes),
|
|
342
|
+
storageKey: key,
|
|
343
|
+
contentId: null,
|
|
344
|
+
// Nothing arriving this way is inline: there is no HTML body referencing it by `cid:`, which is
|
|
345
|
+
// the only thing `inline` means.
|
|
346
|
+
inline: false,
|
|
347
|
+
createdAt: now,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const encoded = rows.map((row) => SupportAttachment.encode(row));
|
|
352
|
+
for (const chunk of chunkRowsByBoundParameters(encoded, SUPPORT_ATTACHMENT_COLUMNS)) {
|
|
353
|
+
await deps.db.insertInto(SUPPORT_ATTACHMENTS_TABLE).values(chunk).execute();
|
|
354
|
+
}
|
|
355
|
+
return rows.length;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Mint an RFC 5322 message id for a submission.
|
|
360
|
+
*
|
|
361
|
+
* **Without this the conversation forks the first time the customer answers.** A reply to an app thread
|
|
362
|
+
* leaves by email carrying `In-Reply-To`, the customer's client puts that id in its `References`, and
|
|
363
|
+
* `parentCandidates` looks it up in `pithy_support_messages` — so a submission stored with no id of its
|
|
364
|
+
* own is a thread whose every answer opens a new one. The whole reason `mime/threading.ts` refuses to
|
|
365
|
+
* match on subject is that fragmented conversations are the failure mode worth engineering against.
|
|
366
|
+
*
|
|
367
|
+
* The domain comes from the inbox address, because that is the domain the reply will actually leave
|
|
368
|
+
* from; with no address configured there is no reply and therefore nothing to thread.
|
|
369
|
+
*/
|
|
370
|
+
export function submissionMessageId(messageId: string, inboxAddress: string | null | undefined): string | null {
|
|
371
|
+
const domain = inboxAddress?.split("@")[1];
|
|
372
|
+
return domain ? `${messageId}@${domain}` : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Take one in-app submission. */
|
|
376
|
+
export async function submitFeedback(deps: SubmitDeps, input: SubmitInput): Promise<SubmitOutcome> {
|
|
377
|
+
const now = deps.now();
|
|
378
|
+
|
|
379
|
+
// 0. The declared category, first because it costs nothing: no query, no decode, no allocation the
|
|
380
|
+
// caller chose the size of. Checked here rather than only at the transport boundary for the reason
|
|
381
|
+
// the attachment count is — the bound has to hold for every caller that reaches this function
|
|
382
|
+
// directly, which is every test and every future transport.
|
|
383
|
+
checkDeclaredCategory(deps, input);
|
|
384
|
+
|
|
385
|
+
// 1. The account. A session proved this id, so failing to read it is a fault in this deployment
|
|
386
|
+
// rather than a supported state — `@pithy-sh/auth` composed is what made the session exist.
|
|
387
|
+
const account = await deps.resolveAccount(input.userId);
|
|
388
|
+
if (!account) {
|
|
389
|
+
throw new InternalError({
|
|
390
|
+
message: "Your account could not be read.",
|
|
391
|
+
action: "Retry, and contact support by email if it keeps happening.",
|
|
392
|
+
detail: `no pithy_auth_users row for authenticated user ${input.userId} — is @pithy-sh/auth composed on this Worker?`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 2. The rate bound, before anything is decoded or written.
|
|
397
|
+
const rate = await checkAccountRate(deps.db, deps.config.submission, { userId: input.userId, now });
|
|
398
|
+
if (!rate.accepted) {
|
|
399
|
+
await auditRejection(deps, rate.reason, rate.detail, input.userId);
|
|
400
|
+
throw new SupportRejectedError({
|
|
401
|
+
message: "You have sent too many support requests recently.",
|
|
402
|
+
action: "Wait an hour and try again, or reply to the email on an existing request.",
|
|
403
|
+
detail: rate.detail,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// 3. Every attachment, before one is written.
|
|
408
|
+
checkAttachments(deps, input.attachments);
|
|
409
|
+
|
|
410
|
+
// 4. The thread — this account's own, or a new one. The ownership check is the read-back rule
|
|
411
|
+
// applied to a write, and it raises the same 404 for somebody else's thread as for no thread.
|
|
412
|
+
const existing = input.threadId ? await resolveOwnThread(deps.db, input.threadId, input.userId) : undefined;
|
|
413
|
+
const threadId = existing?.id ?? deps.newId();
|
|
414
|
+
const newThread = existing === undefined;
|
|
415
|
+
const messageId = deps.newId();
|
|
416
|
+
|
|
417
|
+
// The address a reply comes back to, and the only meaning `inboxAddress` has on an app thread. Null
|
|
418
|
+
// is supported: a project can collect in-app feedback with no mail configured at all.
|
|
419
|
+
const inbox = deps.config.reply.replyToAddress ?? deps.config.inboundAddresses[0] ?? null;
|
|
420
|
+
|
|
421
|
+
const message: SupportMessage = {
|
|
422
|
+
id: messageId,
|
|
423
|
+
threadId,
|
|
424
|
+
direction: "inbound",
|
|
425
|
+
channel: "app",
|
|
426
|
+
submittedByUserId: input.userId,
|
|
427
|
+
context: input.context ?? null,
|
|
428
|
+
mimeMessageId: submissionMessageId(messageId, inbox),
|
|
429
|
+
mimeInReplyTo: null,
|
|
430
|
+
mimeReferences: null,
|
|
431
|
+
fromAddress: account.email,
|
|
432
|
+
fromName: account.name ?? null,
|
|
433
|
+
toAddress: inbox,
|
|
434
|
+
subject: input.subject,
|
|
435
|
+
textBody: input.body,
|
|
436
|
+
// Nothing a submitter typed is ever stored as HTML. The mail path has an HTML body because mail
|
|
437
|
+
// arrives as one and the raw form is kept; a submission is plain text the client sent, and
|
|
438
|
+
// rendering it as markup in a console would be inventing an injection surface that has no source.
|
|
439
|
+
htmlBody: null,
|
|
440
|
+
emailJobId: null,
|
|
441
|
+
// No raw form to keep: unlike a MIME message, what arrived *is* the parsed shape.
|
|
442
|
+
rawKey: null,
|
|
443
|
+
rawBytes: null,
|
|
444
|
+
receivedAt: now,
|
|
445
|
+
createdAt: now,
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
// 5. Message first, then the thread — the same order `ingest.ts` uses, with the same compensation.
|
|
449
|
+
// A committed message with no thread is unrecoverable and invisible to every read path, so the
|
|
450
|
+
// message is removed if the thread write fails.
|
|
451
|
+
await deps.db.insertInto(SUPPORT_MESSAGES_TABLE).values(SupportMessage.encode(message)).execute();
|
|
452
|
+
try {
|
|
453
|
+
if (newThread) {
|
|
454
|
+
const thread: SupportThread = {
|
|
455
|
+
id: threadId,
|
|
456
|
+
channel: "app",
|
|
457
|
+
inboxAddress: inbox,
|
|
458
|
+
subject: input.subject,
|
|
459
|
+
fromAddress: account.email,
|
|
460
|
+
fromName: account.name ?? null,
|
|
461
|
+
// True, and for a stronger reason than any email thread can reach: there was no header to
|
|
462
|
+
// prove, because the session was proved before the request arrived here.
|
|
463
|
+
senderAuthenticated: true,
|
|
464
|
+
userId: input.userId,
|
|
465
|
+
accountLinkSource: "session",
|
|
466
|
+
// The submitter's claim, set once and never rewritten. **Beside the spread rather than inside
|
|
467
|
+
// it**: `UNCLASSIFIED` is the set of columns the classifier owns and overwrites on every run,
|
|
468
|
+
// and a claim listed among them would be gone the first time a model looked at this thread.
|
|
469
|
+
declaredCategory: input.declaredCategory ?? null,
|
|
470
|
+
...UNCLASSIFIED,
|
|
471
|
+
archived: false,
|
|
472
|
+
archivedAt: null,
|
|
473
|
+
archivedBy: null,
|
|
474
|
+
messageCount: 1,
|
|
475
|
+
firstMessageAt: now,
|
|
476
|
+
lastMessageAt: now,
|
|
477
|
+
createdAt: now,
|
|
478
|
+
updatedAt: now,
|
|
479
|
+
};
|
|
480
|
+
await deps.db.insertInto(SUPPORT_THREADS_TABLE).values(SupportThread.encode(thread)).execute();
|
|
481
|
+
} else {
|
|
482
|
+
await deps.db
|
|
483
|
+
.updateTable(SUPPORT_THREADS_TABLE)
|
|
484
|
+
.set((eb) => ({
|
|
485
|
+
messageCount: eb("messageCount", "+", 1),
|
|
486
|
+
lastMessageAt: now.getTime(),
|
|
487
|
+
updatedAt: now.getTime(),
|
|
488
|
+
// A customer writing again reopens a resolved thread, exactly as a reply by mail does.
|
|
489
|
+
// Somebody wrote back and nobody saw it is the one failure a support inbox cannot have.
|
|
490
|
+
archived: 0,
|
|
491
|
+
archivedAt: null,
|
|
492
|
+
archivedBy: null,
|
|
493
|
+
}))
|
|
494
|
+
.where("id", "=", threadId)
|
|
495
|
+
.execute();
|
|
496
|
+
}
|
|
497
|
+
} catch (error) {
|
|
498
|
+
await deps.db
|
|
499
|
+
.deleteFrom(SUPPORT_MESSAGES_TABLE)
|
|
500
|
+
.where("id", "=", messageId)
|
|
501
|
+
.execute()
|
|
502
|
+
.catch(() => {
|
|
503
|
+
deps.log.error("support submission orphaned — thread write failed and the message could not be removed", {
|
|
504
|
+
messageId,
|
|
505
|
+
threadId,
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (deps.fts) {
|
|
512
|
+
try {
|
|
513
|
+
await indexMessage(deps.db, { threadId, messageId, subject: input.subject, body: input.body });
|
|
514
|
+
} catch (error) {
|
|
515
|
+
deps.log.warn("support submission not indexed", { messageId, error });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Best-effort, like the mail path: a bucket that is briefly unavailable costs the attachments, never
|
|
520
|
+
// the report. The bytes were already checked, so nothing unbounded reaches R2 on this line.
|
|
521
|
+
let attachments = 0;
|
|
522
|
+
try {
|
|
523
|
+
attachments = await storeAttachments(deps, input.attachments, threadId, messageId, now);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
deps.log.warn("support submission attachments not stored", { messageId, error });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 6. Classification, last and allowed to fail — the same classifier, the same federated taxonomy.
|
|
529
|
+
const classifying = deps.config.ai.enabled;
|
|
530
|
+
if (classifying) {
|
|
531
|
+
try {
|
|
532
|
+
await deps.dispatchClassify(messageId);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
deps.log.warn("support submission classification dispatch failed", { messageId, error });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return { threadId, messageId, newThread, attachments, classifying };
|
|
539
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
// GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
|
|
5
|
+
//
|
|
6
|
+
// A Worker cannot read its own package.json, so this is how @pithy-sh/support knows its own version at
|
|
7
|
+
// runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
|
|
8
|
+
// which is what answers "should this project upgrade" and "is this customer exposed to what we just
|
|
9
|
+
// fixed". Those questions are only answerable per module, because a project composes some capabilities
|
|
10
|
+
// and not others.
|
|
11
|
+
|
|
12
|
+
/** This package's npm name — the join key against a release feed. */
|
|
13
|
+
export const PACKAGE_NAME = "@pithy-sh/support";
|
|
14
|
+
|
|
15
|
+
/** This package's version, stamped from its own package.json at generation time. */
|
|
16
|
+
export const PACKAGE_VERSION = "0.1.0";
|