@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,322 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
|
|
5
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
6
|
+
import { SupportAuditActions } from "../audit/actions";
|
|
7
|
+
import type { SupportConfig } from "../config/config";
|
|
8
|
+
import { SupportMessage } from "../data/message";
|
|
9
|
+
import { SUPPORT_MESSAGES_TABLE, SUPPORT_THREADS_TABLE, type SupportDatabase } from "../data/tables";
|
|
10
|
+
import { SupportNotFoundError, SupportReplyFailedError } from "../error/errors";
|
|
11
|
+
import { buildReferencesHeader, replySubject } from "../mime/threading";
|
|
12
|
+
import { indexMessage } from "../store/search";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sending a reply — through `@pithy-sh/email`'s durable send path, never directly.
|
|
16
|
+
*
|
|
17
|
+
* ## Why the reply is composed here and not in the dashboard
|
|
18
|
+
*
|
|
19
|
+
* Three things have to be true of an answer, and a dashboard can guarantee none of them. It has to
|
|
20
|
+
* leave from the adopter's domain carrying the adopter's DKIM, so it is not filtered as a forgery.
|
|
21
|
+
* It has to be durable and retryable, which is what the email capability's job row and Workflow buy.
|
|
22
|
+
* And it has to set `In-Reply-To` and `References` correctly — which only the Worker can do, because
|
|
23
|
+
* only the Worker holds the thread's chain. Implemented dashboard-side, threading breaks in the
|
|
24
|
+
* customer's mail client and every conversation fragments into one message per answer.
|
|
25
|
+
*
|
|
26
|
+
* So the dashboard POSTs a body. That is the entire contract, and it is why the reply route is small.
|
|
27
|
+
*
|
|
28
|
+
* ## The sent Message-ID is not ours to know
|
|
29
|
+
*
|
|
30
|
+
* Cloudflare assigns the real `Message-ID` at send time and does not tell the enqueuer, so the
|
|
31
|
+
* outbound row stores `emailJobId` and leaves `mimeMessageId` null. Threading back still works, and
|
|
32
|
+
* this is the part worth being clear about: when the customer replies, their `References` carries the
|
|
33
|
+
* whole ancestry — including the *inbound* ids this capability stored itself — so `parentCandidates`
|
|
34
|
+
* finds the thread through one of those even though it has never seen the id of its own reply.
|
|
35
|
+
*
|
|
36
|
+
* ## Storing the answer and sending it are two steps, and one of them is optional
|
|
37
|
+
*
|
|
38
|
+
* Everything above describes mail. An `app` thread has a second destination that already exists:
|
|
39
|
+
* `readOwnThread` hands the submitter every message on their own conversation, outbound ones
|
|
40
|
+
* included, so an answer stored and never sent is an answer they will read next time they open it.
|
|
41
|
+
*
|
|
42
|
+
* That is the whole shape of in-app delivery — the outbound row, the thread counters and the audit
|
|
43
|
+
* event are written exactly as they are for mail, and only the `enqueue` is skipped. It is taken in
|
|
44
|
+
* two situations, and they are different in kind:
|
|
45
|
+
*
|
|
46
|
+
* - **The adopter chose it.** `reply.deliverInApp` on a project whose mail works perfectly well.
|
|
47
|
+
* Email Routing takes over a zone's MX, so a project already running mail on that domain cannot
|
|
48
|
+
* receive support replies without disturbing everything else on it — and a fallback conditioned on
|
|
49
|
+
* mail being *impossible* is unreachable by exactly the adopter who most wants this.
|
|
50
|
+
* - **There is nothing to send with.** No address a reply could come back to, or no email capability
|
|
51
|
+
* composed at all. Storing the answer beats refusing it, because the person can still read it.
|
|
52
|
+
*
|
|
53
|
+
* An `email` thread never takes it. Its sender has no read-back — there is no session, only an
|
|
54
|
+
* address — so an answer stored there is one nobody would ever see, and a missing reply address on a
|
|
55
|
+
* mail thread stays the misconfiguration it always was.
|
|
56
|
+
*
|
|
57
|
+
* **The two are never rendered the same.** "Sent by email" and "waiting in the app" are different
|
|
58
|
+
* promises about when somebody will read the answer, and `ReplyResult` is a union rather than an
|
|
59
|
+
* object with an optional `jobId` so that a console has to say which one it is showing.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/** What a reply needs, all injectable. */
|
|
63
|
+
export interface ReplyDeps {
|
|
64
|
+
/** The support tables. */
|
|
65
|
+
db: SupportDatabase;
|
|
66
|
+
/** The resolved config. */
|
|
67
|
+
config: SupportConfig;
|
|
68
|
+
/**
|
|
69
|
+
* The email capability's `enqueue`, already bound to the request env. Support never assembles the
|
|
70
|
+
* send infrastructure — it hands over a recipient, a template id, and a payload.
|
|
71
|
+
*
|
|
72
|
+
* **Optional, because a project can compose support without composing email.** Absent, an `app`
|
|
73
|
+
* thread is answered in the app rather than refused, and only a mail thread has nothing left to
|
|
74
|
+
* try. The refusal is raised here rather than by the caller so that the one place that knows
|
|
75
|
+
* whether mail is needed is the one that asks for it.
|
|
76
|
+
*/
|
|
77
|
+
enqueue?: (input: {
|
|
78
|
+
to: string;
|
|
79
|
+
template: string;
|
|
80
|
+
payload: unknown;
|
|
81
|
+
replyTo?: string;
|
|
82
|
+
/** The language to write the shell in — the reporter's, taken off their submission context. */
|
|
83
|
+
locale?: string;
|
|
84
|
+
inReplyTo?: string;
|
|
85
|
+
references?: string;
|
|
86
|
+
}) => Promise<{ jobId: string }>;
|
|
87
|
+
/** Whether the full-text index is in use. */
|
|
88
|
+
fts: boolean;
|
|
89
|
+
/** The audit seam. */
|
|
90
|
+
emit: AuditEmit;
|
|
91
|
+
/** The request logger. */
|
|
92
|
+
log: Logger;
|
|
93
|
+
/** Generate a row id. */
|
|
94
|
+
newId: () => string;
|
|
95
|
+
/** Now. */
|
|
96
|
+
now: () => Date;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What the operator is sending. */
|
|
100
|
+
export interface ReplyInput {
|
|
101
|
+
/** The thread being answered. */
|
|
102
|
+
threadId: string;
|
|
103
|
+
/** The reply text, as a human wrote and edited it. */
|
|
104
|
+
body: string;
|
|
105
|
+
/** Who is answering, signed at the bottom. Optional. */
|
|
106
|
+
agentName?: string;
|
|
107
|
+
/** The verified control-plane subject sending it — the audit actor. */
|
|
108
|
+
viewer: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What the reply produced, discriminated on the channel it was delivered over.
|
|
113
|
+
*
|
|
114
|
+
* A union rather than one object with an optional `jobId`, because the two outcomes are different
|
|
115
|
+
* promises about when the customer reads the answer — and an optional field is exactly what lets a
|
|
116
|
+
* console render "sent" over both of them.
|
|
117
|
+
*/
|
|
118
|
+
export type ReplyResult =
|
|
119
|
+
| {
|
|
120
|
+
/** Handed to the email capability's durable send path. */
|
|
121
|
+
channel: "email";
|
|
122
|
+
/** The outbound `pithy_support_messages.id`. */
|
|
123
|
+
messageId: string;
|
|
124
|
+
/** The `pithy_email_jobs.id` the send was enqueued as. */
|
|
125
|
+
jobId: string;
|
|
126
|
+
}
|
|
127
|
+
| {
|
|
128
|
+
/** Stored for the submitter to read in the app. No mail was sent, and there is no job. */
|
|
129
|
+
channel: "app";
|
|
130
|
+
/** The outbound `pithy_support_messages.id`. */
|
|
131
|
+
messageId: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Send a reply on a thread. */
|
|
135
|
+
export async function sendReply(deps: ReplyDeps, input: ReplyInput): Promise<ReplyResult> {
|
|
136
|
+
if (!deps.config.reply.enabled) {
|
|
137
|
+
throw new SupportReplyFailedError({
|
|
138
|
+
message: "Replying is disabled on this deployment.",
|
|
139
|
+
action: "Set `reply.enabled` on the support capability in pithy.config.ts, then redeploy.",
|
|
140
|
+
detail: "support config has reply.enabled = false",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const thread = await deps.db
|
|
145
|
+
.selectFrom(SUPPORT_THREADS_TABLE)
|
|
146
|
+
.select(["id", "channel", "subject", "fromAddress", "fromName", "inboxAddress", "messageCount"])
|
|
147
|
+
.where("id", "=", input.threadId)
|
|
148
|
+
.executeTakeFirst();
|
|
149
|
+
if (!thread) throw new SupportNotFoundError({ detail: `no support thread ${input.threadId}` });
|
|
150
|
+
|
|
151
|
+
// The parent is the newest *inbound* message: the customer's last word is what an answer answers,
|
|
152
|
+
// and threading against our own previous reply would chain the conversation to a message whose
|
|
153
|
+
// real id we never learned.
|
|
154
|
+
const parent = await deps.db
|
|
155
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
156
|
+
.select(["mimeMessageId", "mimeReferences", "context"])
|
|
157
|
+
.where("threadId", "=", input.threadId)
|
|
158
|
+
.where("direction", "=", "inbound")
|
|
159
|
+
.orderBy("receivedAt", "desc")
|
|
160
|
+
// Tiebroken on id: two messages can share a millisecond (a redelivery burst, or a fixed clock in
|
|
161
|
+
// a test), and without it "the latest inbound message" is whichever the database felt like —
|
|
162
|
+
// so a reclassify or a reply could thread against the wrong parent.
|
|
163
|
+
.orderBy("id", "desc")
|
|
164
|
+
.limit(1)
|
|
165
|
+
.executeTakeFirst();
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The language to answer in: the one the reporter's app was rendering in when they wrote.
|
|
169
|
+
*
|
|
170
|
+
* **The customer's, never the agent's.** A reply is composed inside an operator's request, and that
|
|
171
|
+
* request's negotiated locale is the operator's — so reading `c.var.t` here would answer a Spanish
|
|
172
|
+
* customer in whatever language the support console happens to be open in. The submission context is
|
|
173
|
+
* the only thing on this thread that says something about the *reader*, and it is already collected
|
|
174
|
+
* because a locale-shaped bug is only reproducible in the reporter's locale.
|
|
175
|
+
*
|
|
176
|
+
* It moves the shell and not the letter. `supportReply`'s words are the adopter's — a human wrote
|
|
177
|
+
* them, and a catalog cannot translate a sentence it has never seen — so what follows this tag is the
|
|
178
|
+
* document's `lang` and `dir` and the footer. That is the honest half, and it is the half that decides
|
|
179
|
+
* whether an Arabic reply lays out right-to-left.
|
|
180
|
+
*
|
|
181
|
+
* Absent on every mail-path message, which is most of them, and absent renders as it always has.
|
|
182
|
+
*/
|
|
183
|
+
const parentContext = SupportMessage.shape.context.safeParse(parent?.context);
|
|
184
|
+
const replyLocale = parentContext.success ? (parentContext.data?.locale ?? undefined) : undefined;
|
|
185
|
+
|
|
186
|
+
const parentReferences = SupportMessage.shape.mimeReferences.safeParse(parent?.mimeReferences);
|
|
187
|
+
const chain = parentReferences.success && Array.isArray(parentReferences.data) ? parentReferences.data : [];
|
|
188
|
+
const references = buildReferencesHeader(chain, parent?.mimeMessageId ?? undefined);
|
|
189
|
+
const subject = replySubject(thread.subject);
|
|
190
|
+
const now = deps.now();
|
|
191
|
+
|
|
192
|
+
// The customer's answer has to come back to an address this inbox actually claims, or the
|
|
193
|
+
// conversation ends at the reply. Defaulting to the inbox the thread arrived on is right for every
|
|
194
|
+
// deployment that has not deliberately configured otherwise.
|
|
195
|
+
//
|
|
196
|
+
// **Both can be absent, and only on an `app` thread**: a project collecting in-app feedback with no
|
|
197
|
+
// mail configured has no address a thread arrived at and none to answer from.
|
|
198
|
+
const replyTo = deps.config.reply.replyToAddress ?? thread.inboxAddress;
|
|
199
|
+
const enqueue = deps.enqueue;
|
|
200
|
+
|
|
201
|
+
// The one decision in this function. An `app` thread has a second destination — the submitter's own
|
|
202
|
+
// read-back — so it takes in-app delivery whenever the adopter asked for it, and whenever there is
|
|
203
|
+
// nothing to send with. An `email` thread has only the one, so a missing address there is still a
|
|
204
|
+
// misconfiguration to fail on rather than an answer to file where its reader cannot reach it.
|
|
205
|
+
const deliverable = enqueue !== undefined && replyTo !== null && replyTo !== undefined;
|
|
206
|
+
const inApp = thread.channel === "app" && (deps.config.reply.deliverInApp || !deliverable);
|
|
207
|
+
|
|
208
|
+
// One branch, so that refusing and sending are the same decision. Split into a guard block and a
|
|
209
|
+
// send block they could drift apart, and the drift would be a reply silently stored on a thread
|
|
210
|
+
// whose reader has no way to see it.
|
|
211
|
+
let jobId: string | null = null;
|
|
212
|
+
if (!inApp) {
|
|
213
|
+
if (!enqueue) {
|
|
214
|
+
throw new SupportReplyFailedError({
|
|
215
|
+
message: "This deployment cannot send mail.",
|
|
216
|
+
action: "Add the email capability (`pithy add email`) and provision it, then retry.",
|
|
217
|
+
detail: `thread ${input.threadId} needs a mailed reply and no email capability is composed`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (!replyTo) {
|
|
221
|
+
// Reachable only on a mail thread now. Refused rather than sent from whatever the email
|
|
222
|
+
// capability defaults to — a reply the customer cannot answer is worse than a refusal an
|
|
223
|
+
// operator can read, because it looks like the conversation continued.
|
|
224
|
+
throw new SupportReplyFailedError({
|
|
225
|
+
message: "This deployment has no address a reply can come back to.",
|
|
226
|
+
action: "Set `inboundAddresses` or `reply.replyToAddress` on the support capability, then retry.",
|
|
227
|
+
detail: `thread ${input.threadId} has no inboxAddress and no reply.replyToAddress is configured`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const enqueued = await enqueue({
|
|
232
|
+
to: thread.fromAddress,
|
|
233
|
+
template: "supportReply",
|
|
234
|
+
payload: { subject, body: input.body, ...(input.agentName ? { agentName: input.agentName } : {}) },
|
|
235
|
+
replyTo,
|
|
236
|
+
locale: replyLocale,
|
|
237
|
+
inReplyTo: parent?.mimeMessageId ? `<${parent.mimeMessageId}>` : undefined,
|
|
238
|
+
references: references.length > 0 ? references : undefined,
|
|
239
|
+
});
|
|
240
|
+
jobId = enqueued.jobId;
|
|
241
|
+
} catch (cause) {
|
|
242
|
+
throw new SupportReplyFailedError({ detail: `enqueue failed for thread ${input.threadId}` }, { cause });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The outbound row goes in after the enqueue succeeded, so the thread never shows a reply that was
|
|
247
|
+
// never accepted for sending.
|
|
248
|
+
const messageId = deps.newId();
|
|
249
|
+
const message: SupportMessage = {
|
|
250
|
+
id: messageId,
|
|
251
|
+
threadId: input.threadId,
|
|
252
|
+
direction: "outbound",
|
|
253
|
+
// How the answer actually traveled, which is the whole reason this column is per message rather
|
|
254
|
+
// than only per thread: one `app` thread can hold a reply that was mailed and a reply that was
|
|
255
|
+
// stored, and those are different promises about when the person reads them.
|
|
256
|
+
channel: inApp ? "app" : "email",
|
|
257
|
+
submittedByUserId: null,
|
|
258
|
+
context: null,
|
|
259
|
+
mimeMessageId: null,
|
|
260
|
+
mimeInReplyTo: parent?.mimeMessageId ?? null,
|
|
261
|
+
mimeReferences: chain.length > 0 ? chain : null,
|
|
262
|
+
// No envelope on an in-app answer. `replyTo` may even be set — a project that chose in-app
|
|
263
|
+
// delivery can have perfectly good mail — but writing it here would claim a send that did not
|
|
264
|
+
// happen.
|
|
265
|
+
fromAddress: inApp ? null : replyTo,
|
|
266
|
+
fromName: null,
|
|
267
|
+
toAddress: inApp ? null : thread.fromAddress,
|
|
268
|
+
subject,
|
|
269
|
+
textBody: input.body,
|
|
270
|
+
htmlBody: null,
|
|
271
|
+
emailJobId: jobId,
|
|
272
|
+
rawKey: null,
|
|
273
|
+
rawBytes: null,
|
|
274
|
+
receivedAt: now,
|
|
275
|
+
createdAt: now,
|
|
276
|
+
};
|
|
277
|
+
await deps.db.insertInto(SUPPORT_MESSAGES_TABLE).values(SupportMessage.encode(message)).execute();
|
|
278
|
+
|
|
279
|
+
if (deps.fts) {
|
|
280
|
+
try {
|
|
281
|
+
await indexMessage(deps.db, { threadId: input.threadId, messageId, subject, body: input.body });
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// A search miss, never a lost reply. `reindexThread` is the repair.
|
|
284
|
+
deps.log.warn("support reply not indexed", { messageId, error });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
await deps.db
|
|
289
|
+
.updateTable(SUPPORT_THREADS_TABLE)
|
|
290
|
+
.set((eb) => ({
|
|
291
|
+
messageCount: eb("messageCount", "+", 1),
|
|
292
|
+
lastMessageAt: now.getTime(),
|
|
293
|
+
updatedAt: now.getTime(),
|
|
294
|
+
}))
|
|
295
|
+
.where("id", "=", input.threadId)
|
|
296
|
+
.execute();
|
|
297
|
+
|
|
298
|
+
// Audited because it leaves under the adopter's domain and their DKIM: to the recipient it is
|
|
299
|
+
// indistinguishable from the founder writing it, so who actually sent it is a security-relevant
|
|
300
|
+
// fact with no other record. The body is never in the metadata — the trail is long-lived and
|
|
301
|
+
// queryable, and a support reply is somebody's private correspondence.
|
|
302
|
+
//
|
|
303
|
+
// The same event on both paths, carrying the channel it went out on. A job id it does not have is
|
|
304
|
+
// the one thing an in-app answer cannot record, and `channel` is what makes its absence readable
|
|
305
|
+
// rather than a gap.
|
|
306
|
+
await deps.emit({
|
|
307
|
+
action: SupportAuditActions.replySent,
|
|
308
|
+
outcome: "success",
|
|
309
|
+
actorType: "control-plane",
|
|
310
|
+
actorId: input.viewer,
|
|
311
|
+
resourceType: "support_thread",
|
|
312
|
+
resourceId: input.threadId,
|
|
313
|
+
metadata: {
|
|
314
|
+
channel: message.channel,
|
|
315
|
+
messageId,
|
|
316
|
+
threaded: Boolean(parent?.mimeMessageId),
|
|
317
|
+
...(jobId === null ? {} : { jobId }),
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
return jobId === null ? { channel: "app", messageId } : { channel: "email", messageId, jobId };
|
|
322
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { SupportInvalidCategoryError } from "../error/errors";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Canned replies — starting points a human picks in the dashboard, edits, and sends.
|
|
9
|
+
*
|
|
10
|
+
* ## What these are, and what they are not
|
|
11
|
+
*
|
|
12
|
+
* A snippet is **body text**, not an email template. The rendering shell — the adopter's theme, the
|
|
13
|
+
* HTML and plain-text pair, the threading headers — is the single `supportReply` template in
|
|
14
|
+
* `@pithy-sh/email`. Adding a second email template per canned reply would put the adopter's
|
|
15
|
+
* wording in a precompiled Handlebars file that only a release can change, which is exactly backwards
|
|
16
|
+
* for text somebody wants to reword on a Tuesday.
|
|
17
|
+
*
|
|
18
|
+
* So the Worker serves a catalog and **never renders it**. The dashboard shows the list, the operator
|
|
19
|
+
* picks one, edits it, and posts the finished body to the reply route — which is why the placeholder
|
|
20
|
+
* tokens below are substituted client-side and this package owns no template engine. A support reply
|
|
21
|
+
* is a letter a person sent; the machine's only job is to hand them a better blank page.
|
|
22
|
+
*
|
|
23
|
+
* ## Federated, like the taxonomy
|
|
24
|
+
*
|
|
25
|
+
* Pithy ships a small set keyed to the default categories, and an adopter adds their own with
|
|
26
|
+
* {@link defineSupportReplies} — the same shape `defineSupportCategories` uses, for the same reason.
|
|
27
|
+
* A snippet tagged with a category is offered first on a thread the classifier put in that category,
|
|
28
|
+
* which is the one place the classification does work for a human rather than for a filter.
|
|
29
|
+
*
|
|
30
|
+
* The shipped wording is deliberately plain and slightly incomplete: every one of them has a blank
|
|
31
|
+
* an operator has to fill. A canned reply that could be sent without reading it is how a support
|
|
32
|
+
* inbox starts insulting people.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The shape a snippet key must take: lowercase `snake_case`, so it is a stable id in a dashboard. */
|
|
36
|
+
const SNIPPET_KEY = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/;
|
|
37
|
+
|
|
38
|
+
/** How long a snippet body may be. Long enough for a real answer; bounded because it ships in a manifest. */
|
|
39
|
+
const MAX_BODY = 2000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The tokens a dashboard substitutes before sending. Documented here because they are a contract
|
|
43
|
+
* between this catalog and whatever renders it, and deliberately few: a placeholder language is a
|
|
44
|
+
* template engine, and a template engine over untrusted context is a rendering vulnerability.
|
|
45
|
+
*/
|
|
46
|
+
export const SNIPPET_TOKENS = {
|
|
47
|
+
/** The sender's display name, or their address when the app knows no name. */
|
|
48
|
+
name: "{{name}}",
|
|
49
|
+
/** The thread's subject. */
|
|
50
|
+
subject: "{{subject}}",
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/** One canned reply. */
|
|
54
|
+
export const SupportReplySnippet = z
|
|
55
|
+
.object({
|
|
56
|
+
label: z
|
|
57
|
+
.string()
|
|
58
|
+
.min(1)
|
|
59
|
+
.max(80)
|
|
60
|
+
.describe("What the dashboard shows in the picker, e.g. `Refund issued`. A verb phrase, not a category name."),
|
|
61
|
+
category: z
|
|
62
|
+
.string()
|
|
63
|
+
.optional()
|
|
64
|
+
.describe(
|
|
65
|
+
"The category this snippet is offered first for. Optional — a snippet with no category is always offered, which is right for the general-purpose ones.",
|
|
66
|
+
),
|
|
67
|
+
body: z
|
|
68
|
+
.string()
|
|
69
|
+
.min(1)
|
|
70
|
+
.max(MAX_BODY)
|
|
71
|
+
.describe(
|
|
72
|
+
"The starting text, with `{{name}}` and `{{subject}}` substituted by the dashboard before sending. Written to be edited: every shipped one leaves a blank a human has to fill.",
|
|
73
|
+
),
|
|
74
|
+
})
|
|
75
|
+
.describe("One canned reply — a starting point a human picks, edits, and sends.");
|
|
76
|
+
export type SupportReplySnippet = z.infer<typeof SupportReplySnippet>;
|
|
77
|
+
|
|
78
|
+
/** A snippet catalog: key → the snippet. */
|
|
79
|
+
export type SupportReplySnippets = Record<string, SupportReplySnippet>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The set Pithy ships, keyed to the default categories.
|
|
83
|
+
*
|
|
84
|
+
* Six, not sixteen. A picker long enough to need scrolling is one nobody reads, and the ones below
|
|
85
|
+
* are the replies a solo developer actually writes over and over.
|
|
86
|
+
*/
|
|
87
|
+
export const DEFAULT_SUPPORT_REPLIES = {
|
|
88
|
+
refund_issued: {
|
|
89
|
+
label: "Refund issued",
|
|
90
|
+
category: "billing",
|
|
91
|
+
body: "Hi {{name}},\n\nI've refunded this — you should see it back on your original payment method within ___ working days.\n\nSorry for the trouble.\n",
|
|
92
|
+
},
|
|
93
|
+
billing_explained: {
|
|
94
|
+
label: "Explain a charge",
|
|
95
|
+
category: "billing",
|
|
96
|
+
body: "Hi {{name}},\n\nThat charge is ___. It covers ___.\n\nIf that isn't what you expected, tell me and I'll sort it out.\n",
|
|
97
|
+
},
|
|
98
|
+
sign_in_help: {
|
|
99
|
+
label: "Sign-in help",
|
|
100
|
+
category: "account_access",
|
|
101
|
+
body: "Hi {{name}},\n\nI've had a look — ___.\n\nTry signing in again and let me know if it still doesn't work. If the email isn't arriving, check your spam folder, and tell me which address you're using so I can confirm it matches the account.\n",
|
|
102
|
+
},
|
|
103
|
+
bug_acknowledged: {
|
|
104
|
+
label: "Bug acknowledged",
|
|
105
|
+
category: "bug_report",
|
|
106
|
+
body: "Hi {{name}},\n\nThanks for reporting this — I've reproduced it and it's a real bug. ___.\n\nI'll follow up here once it's fixed.\n",
|
|
107
|
+
},
|
|
108
|
+
feature_noted: {
|
|
109
|
+
label: "Feature request noted",
|
|
110
|
+
category: "feature_request",
|
|
111
|
+
body: "Hi {{name}},\n\nThanks — that's a good idea and I've written it down. I can't promise a date, but ___.\n\nIf you can tell me a bit about how you'd use it, that genuinely helps me prioritize.\n",
|
|
112
|
+
},
|
|
113
|
+
need_more_detail: {
|
|
114
|
+
label: "Ask for more detail",
|
|
115
|
+
body: "Hi {{name}},\n\nHappy to help with this. Could you tell me ___?\n\nA screenshot or the exact wording of any error would speed this up a lot.\n",
|
|
116
|
+
},
|
|
117
|
+
} as const satisfies SupportReplySnippets;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Declare an adopter's own canned replies, validated at author time.
|
|
121
|
+
*
|
|
122
|
+
* The same shape and the same reasoning as `defineSupportCategories`: a malformed key is a picker
|
|
123
|
+
* entry that silently never matches, and it should fail where the constant is written.
|
|
124
|
+
*/
|
|
125
|
+
export function defineSupportReplies<const T extends SupportReplySnippets>(snippets: T): T {
|
|
126
|
+
for (const [key, snippet] of Object.entries(snippets)) {
|
|
127
|
+
if (!SNIPPET_KEY.test(key)) {
|
|
128
|
+
throw new SupportInvalidCategoryError({
|
|
129
|
+
message: `Invalid support reply key: ${key}`,
|
|
130
|
+
action: "Use a lowercase snake_case key, e.g. `refund_issued`.",
|
|
131
|
+
detail: `key ${JSON.stringify(key)} does not match ${SNIPPET_KEY}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const parsed = SupportReplySnippet.safeParse(snippet);
|
|
135
|
+
if (!parsed.success) {
|
|
136
|
+
throw new SupportInvalidCategoryError({
|
|
137
|
+
message: `Invalid support reply "${key}".`,
|
|
138
|
+
action: "Give it a label and a body, and keep the body under 2000 characters.",
|
|
139
|
+
detail: parsed.error.message,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return snippets;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The effective catalog: the shipped set, plus the adopter's, theirs winning on a key collision. */
|
|
147
|
+
export function resolveReplies(extra: SupportReplySnippets = {}): SupportReplySnippets {
|
|
148
|
+
return { ...DEFAULT_SUPPORT_REPLIES, ...defineSupportReplies(extra) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The catalog as a dashboard consumes it: the snippets for this thread's category first, then the
|
|
153
|
+
* uncategorized general-purpose ones, then everything else.
|
|
154
|
+
*
|
|
155
|
+
* Ordering rather than filtering, deliberately. A classifier that put a refund request in
|
|
156
|
+
* `bug_report` would otherwise hide the refund snippets from the one person who can tell it was
|
|
157
|
+
* wrong — the model orders the list, it never restricts it.
|
|
158
|
+
*/
|
|
159
|
+
export function repliesForCategory(
|
|
160
|
+
snippets: SupportReplySnippets,
|
|
161
|
+
category: string,
|
|
162
|
+
): Array<SupportReplySnippet & { key: string }> {
|
|
163
|
+
const entries = Object.entries(snippets).map(([key, snippet]) => ({ key, ...snippet }));
|
|
164
|
+
const rank = (snippet: SupportReplySnippet): number =>
|
|
165
|
+
snippet.category === category ? 0 : snippet.category === undefined ? 1 : 2;
|
|
166
|
+
return entries.sort((a, b) => rank(a) - rank(b) || a.key.localeCompare(b.key));
|
|
167
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { r2CredentialsRegistry } from "@pithy-sh/storage/src/secret/registry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The secrets support reads — exactly one, and it is not support's own shape.
|
|
8
|
+
*
|
|
9
|
+
* Attachment bytes are written through the R2 binding and served as short-lived signed URLs, and
|
|
10
|
+
* signing needs an S3 key pair. Rather than declare another credential shape, support declares the
|
|
11
|
+
* *same* one `@pithy-sh/storage` owns, under its own name, through storage's factory. That is what
|
|
12
|
+
* lets support point the `ObjectStore` seam at `SUPPORT_BUCKET` while inheriting none of storage's
|
|
13
|
+
* tables, routes, or key policy — the same arrangement `@pithy-sh/media` already has.
|
|
14
|
+
*
|
|
15
|
+
* One factory rather than two hand-written declarations is also what keeps the name safe as a join
|
|
16
|
+
* key: `aggregateSecretRegistries` allows a name declared twice only when every axis agrees, and two
|
|
17
|
+
* hand-written copies drift where one factory cannot.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The name support's R2 credential bundle is stored and resolved under, per environment. */
|
|
21
|
+
export const SUPPORT_R2_SECRET = "support-r2-credentials";
|
|
22
|
+
|
|
23
|
+
/** The support capability's secret-registry slice — aggregated into the shared accessor at startup. */
|
|
24
|
+
export const supportSecretsRegistry = r2CredentialsRegistry(SUPPORT_R2_SECRET);
|