@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,548 @@
|
|
|
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 type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
8
|
+
import { attachmentKey, putAttachment, rawMessageKey, sha256Hex } from "../attachment/store";
|
|
9
|
+
import { SupportAuditActions } from "../audit/actions";
|
|
10
|
+
import type { SupportConfig } from "../config/config";
|
|
11
|
+
import { SupportAttachment } from "../data/attachment";
|
|
12
|
+
import { SupportMessage } from "../data/message";
|
|
13
|
+
import {
|
|
14
|
+
SUPPORT_ATTACHMENTS_TABLE,
|
|
15
|
+
SUPPORT_MESSAGES_TABLE,
|
|
16
|
+
SUPPORT_THREADS_TABLE,
|
|
17
|
+
type SupportDatabase,
|
|
18
|
+
} from "../data/tables";
|
|
19
|
+
import { SupportThread, UNCLASSIFIED } from "../data/thread";
|
|
20
|
+
import { MAX_TEXT_BODY, type ParsedInboundMessage, parseInbound } from "../mime/parse";
|
|
21
|
+
import { htmlToText, sanitizeHtml } from "../mime/sanitize";
|
|
22
|
+
import { parentCandidates } from "../mime/threading";
|
|
23
|
+
import { truncateToBytes } from "../mime/truncate";
|
|
24
|
+
import { indexMessage } from "../store/search";
|
|
25
|
+
import { senderAuthenticity } from "./authenticity";
|
|
26
|
+
import { checkRates, checkSize } from "./guard";
|
|
27
|
+
import { resolveInbox } from "./recipient";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Ingest — everything the `email()` handler does, with every dependency injected.
|
|
31
|
+
*
|
|
32
|
+
* The handler in `handler.ts` is a thin shell that reads bindings off `env` and calls this. That
|
|
33
|
+
* split is what makes the interesting half testable: threading, idempotency, the guard's ordering,
|
|
34
|
+
* and the attachment bounds are all exercised here against a real D1 and injected fakes, with no
|
|
35
|
+
* Worker entry involved.
|
|
36
|
+
*
|
|
37
|
+
* ## The order of operations is the design
|
|
38
|
+
*
|
|
39
|
+
* Refusals are ordered by what they cost, and persistence comes before anything that can be slow or
|
|
40
|
+
* unavailable:
|
|
41
|
+
*
|
|
42
|
+
* 1. **Size**, from the declared length, before parsing.
|
|
43
|
+
* 2. **Parse**, then **is this ours** — decided on the SMTP envelope recipient, not on a header.
|
|
44
|
+
* 3. **Rate**, which needs a database read and so goes after the free checks.
|
|
45
|
+
* 4. **Idempotency**, because Email Routing can deliver twice and the second must be a no-op.
|
|
46
|
+
* 5. **Store**, which must not fail.
|
|
47
|
+
* 6. **Dispatch classification**, which is allowed to fail — a model that is slow or briefly down
|
|
48
|
+
* must never take the persistence of the message with it, which is the whole reason
|
|
49
|
+
* classification is a Workflow.
|
|
50
|
+
*
|
|
51
|
+
* A message that is not ours returns `{ handled: false }` and emits nothing. Every capability's
|
|
52
|
+
* handler sees every message, so mail for the bounce handler passing through here is the normal
|
|
53
|
+
* case, not an event.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** What ingest did. */
|
|
57
|
+
export type IngestOutcome =
|
|
58
|
+
/** Not addressed to a configured inbox — another capability's mail, or nobody's. */
|
|
59
|
+
| { handled: false; reason: "not_addressed" }
|
|
60
|
+
/** Refused by the guard before anything was written. */
|
|
61
|
+
| { handled: false; reason: "rejected"; rejection: string }
|
|
62
|
+
/** Already stored under this `Message-ID`. A redelivery, and a no-op. */
|
|
63
|
+
| { handled: true; duplicate: true; threadId: string; messageId: string }
|
|
64
|
+
/** Stored. */
|
|
65
|
+
| {
|
|
66
|
+
handled: true;
|
|
67
|
+
duplicate: false;
|
|
68
|
+
threadId: string;
|
|
69
|
+
messageId: string;
|
|
70
|
+
/** Whether the message opened a new thread rather than continuing one. */
|
|
71
|
+
newThread: boolean;
|
|
72
|
+
/** How many attachments were stored, after the config bounds were applied. */
|
|
73
|
+
attachments: number;
|
|
74
|
+
/** Whether a classification was dispatched. False for auto-submitted mail and when AI is off. */
|
|
75
|
+
classifying: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Everything ingest needs, all injectable. */
|
|
79
|
+
export interface IngestDeps {
|
|
80
|
+
/** The support tables. */
|
|
81
|
+
db: SupportDatabase;
|
|
82
|
+
/** The resolved config. */
|
|
83
|
+
config: SupportConfig;
|
|
84
|
+
/** The R2 bucket raw messages and attachments are written to. Absent means neither is kept. */
|
|
85
|
+
bucket?: R2Bucket;
|
|
86
|
+
/** Whether the FTS5 index is composed. False means search runs as a `LIKE` scan and nothing is indexed. */
|
|
87
|
+
fts: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Start the classification Workflow for a stored message. Allowed to fail and allowed to decline —
|
|
90
|
+
* an unprovisioned project has no binding, and a thread that stays `uncategorized` is a legitimate
|
|
91
|
+
* state. The inbound path deliberately ignores the outcome; the reclassify route does not, because
|
|
92
|
+
* it is answering a human.
|
|
93
|
+
*/
|
|
94
|
+
dispatchClassify: (messageId: string) => Promise<boolean>;
|
|
95
|
+
/** Resolve a sender address to a user id, or null. Allowed to fail; never fatal. */
|
|
96
|
+
linkSender: (address: string) => Promise<string | null>;
|
|
97
|
+
/** The audit seam. */
|
|
98
|
+
emit: AuditEmit;
|
|
99
|
+
/** The request/invocation logger. */
|
|
100
|
+
log: Logger;
|
|
101
|
+
/** Generate a row id. */
|
|
102
|
+
newId: () => string;
|
|
103
|
+
/** Now. */
|
|
104
|
+
now: () => Date;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The message as it arrived, plus the envelope facts only the runtime knows. */
|
|
108
|
+
export interface IngestInput {
|
|
109
|
+
/** The raw MIME bytes. */
|
|
110
|
+
raw: ArrayBuffer;
|
|
111
|
+
/** The SMTP envelope recipient — `ForwardableEmailMessage.to`. The authority on which inbox this is. */
|
|
112
|
+
envelopeTo?: string;
|
|
113
|
+
/** The SMTP envelope sender, for the log only. The `From` header is what a thread keys on. */
|
|
114
|
+
envelopeFrom?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Record a refusal. Never throws — a guard decision must not become a 500. */
|
|
118
|
+
async function auditRejection(
|
|
119
|
+
deps: IngestDeps,
|
|
120
|
+
reason: string,
|
|
121
|
+
detail: string,
|
|
122
|
+
fromAddress: string | undefined,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
try {
|
|
125
|
+
await deps.emit({
|
|
126
|
+
action: SupportAuditActions.inboundRejected,
|
|
127
|
+
outcome: "denied",
|
|
128
|
+
severity: "warning",
|
|
129
|
+
actorType: "anonymous",
|
|
130
|
+
// Null, deliberately. An inbound sender is an unauthenticated claim in a header, and writing
|
|
131
|
+
// an unverified identity into the trail as the actor is how a forged `From` gets to name
|
|
132
|
+
// somebody. The address goes in metadata, where it reads as evidence rather than as identity.
|
|
133
|
+
actorId: null,
|
|
134
|
+
resourceType: "support_inbox",
|
|
135
|
+
// The reason only. `detail` names the exact bound and the observed count, and the audit trail is
|
|
136
|
+
// queryable and long-lived — the specifics belong in the log, which is bounded and already
|
|
137
|
+
// carries the whole picture. Same split the control-plane seam makes.
|
|
138
|
+
metadata: { reason, fromAddress: fromAddress ?? null },
|
|
139
|
+
});
|
|
140
|
+
deps.log.warn("support inbound message refused", { reason, detail, fromAddress: fromAddress ?? null });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
deps.log.warn("support inbound rejection audit dropped", { reason, detail, error });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Find the thread a message continues, by walking its threading chain most-precise-first.
|
|
148
|
+
*
|
|
149
|
+
* The lookup goes through `pithy_support_messages.mime_message_id`, which is uniquely indexed — so
|
|
150
|
+
* this is an index seek per candidate and the chain is bounded at 50 entries. It stops at the first
|
|
151
|
+
* hit, which is why `parentCandidates` puts `In-Reply-To` ahead of a reversed `References`.
|
|
152
|
+
*/
|
|
153
|
+
async function findParentThread(
|
|
154
|
+
db: SupportDatabase,
|
|
155
|
+
parsed: ParsedInboundMessage,
|
|
156
|
+
inbox: string,
|
|
157
|
+
): Promise<string | undefined> {
|
|
158
|
+
const candidates = parentCandidates(parsed.inReplyTo, parsed.references);
|
|
159
|
+
if (candidates.length === 0) return undefined;
|
|
160
|
+
|
|
161
|
+
// Scoped to the inbox this message arrived on. A Worker can serve several — that is what
|
|
162
|
+
// `thread.inboxAddress` exists for — and an unscoped match lets a message delivered to `security@`
|
|
163
|
+
// graft itself onto a `support@` thread, which then keeps the *other* inbox's address and vanishes
|
|
164
|
+
// from the filter its own recipient would use to find it.
|
|
165
|
+
const parent = await db
|
|
166
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
167
|
+
.innerJoin(SUPPORT_THREADS_TABLE, `${SUPPORT_THREADS_TABLE}.id`, `${SUPPORT_MESSAGES_TABLE}.threadId`)
|
|
168
|
+
.select([`${SUPPORT_MESSAGES_TABLE}.threadId`, `${SUPPORT_MESSAGES_TABLE}.mimeMessageId`])
|
|
169
|
+
.where(`${SUPPORT_MESSAGES_TABLE}.mimeMessageId`, "in", candidates)
|
|
170
|
+
.where(`${SUPPORT_THREADS_TABLE}.inboxAddress`, "=", inbox)
|
|
171
|
+
.execute();
|
|
172
|
+
if (parent.length === 0) return undefined;
|
|
173
|
+
|
|
174
|
+
// Preserve the candidate ordering: the database returned rows in whatever order it liked, and the
|
|
175
|
+
// whole point of the ordering is that the *nearest* ancestor wins.
|
|
176
|
+
const byId = new Map(parent.map((row) => [row.mimeMessageId, row.threadId]));
|
|
177
|
+
for (const candidate of candidates) {
|
|
178
|
+
const threadId = byId.get(candidate);
|
|
179
|
+
if (threadId !== undefined) return threadId;
|
|
180
|
+
}
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* How many columns one `pithy_support_attachments` row binds. Derived from the schema rather than
|
|
186
|
+
* written as a literal, so adding a column re-chunks instead of silently re-breaking the cap.
|
|
187
|
+
*/
|
|
188
|
+
const SUPPORT_ATTACHMENT_COLUMNS = Object.keys(SupportAttachment.shape).length;
|
|
189
|
+
|
|
190
|
+
/** Store the attachments this config allows, and return the rows written. */
|
|
191
|
+
async function storeAttachments(
|
|
192
|
+
deps: IngestDeps,
|
|
193
|
+
parsed: ParsedInboundMessage,
|
|
194
|
+
threadId: string,
|
|
195
|
+
messageId: string,
|
|
196
|
+
now: Date,
|
|
197
|
+
): Promise<number> {
|
|
198
|
+
const { attachments: bounds } = deps.config;
|
|
199
|
+
const bucket = deps.bucket;
|
|
200
|
+
if (!bounds.enabled || !bucket || parsed.attachments.length === 0) return 0;
|
|
201
|
+
|
|
202
|
+
// Bound the count before the loop: how many parts a message has is a number the sender chose.
|
|
203
|
+
const accepted = parsed.attachments.slice(0, bounds.maxCount);
|
|
204
|
+
if (parsed.attachments.length > accepted.length) {
|
|
205
|
+
deps.log.warn("support attachments truncated", {
|
|
206
|
+
threadId,
|
|
207
|
+
declared: parsed.attachments.length,
|
|
208
|
+
stored: accepted.length,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const rows: SupportAttachment[] = [];
|
|
213
|
+
for (const attachment of accepted) {
|
|
214
|
+
if (attachment.bytes.byteLength > bounds.maxBytes) {
|
|
215
|
+
// Skipped, not fatal: the message it arrived on is the thing worth keeping, and an operator
|
|
216
|
+
// who can see the metadata is better off than one whose mail silently vanished.
|
|
217
|
+
deps.log.warn("support attachment skipped, over the size bound", {
|
|
218
|
+
threadId,
|
|
219
|
+
bytes: attachment.bytes.byteLength,
|
|
220
|
+
maxBytes: bounds.maxBytes,
|
|
221
|
+
});
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const id = deps.newId();
|
|
225
|
+
const key = attachmentKey(threadId, id);
|
|
226
|
+
await putAttachment(bucket, key, attachment.bytes);
|
|
227
|
+
rows.push({
|
|
228
|
+
id,
|
|
229
|
+
messageId,
|
|
230
|
+
threadId,
|
|
231
|
+
filename: attachment.filename,
|
|
232
|
+
contentType: attachment.contentType,
|
|
233
|
+
size: attachment.bytes.byteLength,
|
|
234
|
+
sha256: await sha256Hex(attachment.bytes),
|
|
235
|
+
storageKey: key,
|
|
236
|
+
contentId: attachment.contentId ?? null,
|
|
237
|
+
inline: attachment.inline,
|
|
238
|
+
createdAt: now,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Chunked, because D1 binds one parameter per column per row and rejects a statement over 100 of
|
|
243
|
+
// them. An attachment row is 11 columns, so the capability's own default `maxCount: 10` is 110
|
|
244
|
+
// parameters — the default configuration failed, storing zero rows while the bytes were already in
|
|
245
|
+
// R2, and the guard around this call turned that into a warn line nobody would see.
|
|
246
|
+
const encoded = rows.map((row) => SupportAttachment.encode(row));
|
|
247
|
+
for (const chunk of chunkRowsByBoundParameters(encoded, SUPPORT_ATTACHMENT_COLUMNS)) {
|
|
248
|
+
await deps.db.insertInto(SUPPORT_ATTACHMENTS_TABLE).values(chunk).execute();
|
|
249
|
+
}
|
|
250
|
+
return rows.length;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Ingest one inbound message. */
|
|
254
|
+
export async function ingestInbound(deps: IngestDeps, input: IngestInput): Promise<IngestOutcome> {
|
|
255
|
+
const now = deps.now();
|
|
256
|
+
const rawBytes = input.raw.byteLength;
|
|
257
|
+
|
|
258
|
+
// 1. Size, before parsing — the only check that is free.
|
|
259
|
+
const size = checkSize(deps.config.guard, rawBytes);
|
|
260
|
+
if (!size.accepted) {
|
|
261
|
+
await auditRejection(deps, size.reason, size.detail, undefined);
|
|
262
|
+
return { handled: false, reason: "rejected", rejection: size.reason };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 2. Parse, then decide whether this message is ours at all.
|
|
266
|
+
const parsed = await parseInbound(input.raw, { expectedAuthservId: deps.config.guard.authservId });
|
|
267
|
+
const inbox = resolveInbox({
|
|
268
|
+
inboundAddresses: deps.config.inboundAddresses,
|
|
269
|
+
envelopeTo: input.envelopeTo,
|
|
270
|
+
headerRecipients: parsed.headerRecipients,
|
|
271
|
+
});
|
|
272
|
+
if (inbox === undefined) return { handled: false, reason: "not_addressed" };
|
|
273
|
+
|
|
274
|
+
// 3. Rate bounds, which cost a read.
|
|
275
|
+
const rates = await checkRates(deps.db, deps.config.guard, {
|
|
276
|
+
rawBytes,
|
|
277
|
+
fromAddress: parsed.fromAddress,
|
|
278
|
+
now,
|
|
279
|
+
});
|
|
280
|
+
if (!rates.accepted) {
|
|
281
|
+
await auditRejection(deps, rates.reason, rates.detail, parsed.fromAddress);
|
|
282
|
+
return { handled: false, reason: "rejected", rejection: rates.reason };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 4. Idempotency. Email Routing redelivering is normal; a duplicate in somebody's inbox is not.
|
|
286
|
+
// The unique index is the real guarantee — this read only makes the common case quiet rather than
|
|
287
|
+
// a caught constraint violation.
|
|
288
|
+
if (parsed.messageId !== undefined) {
|
|
289
|
+
// Scoped to this inbox, matching the threading lookup. A Worker may serve several, and a customer
|
|
290
|
+
// who addresses one message to both `support@` and `security@` causes two deliveries of the same
|
|
291
|
+
// `Message-ID` — a global key would silently drop the second, so the message would appear in one
|
|
292
|
+
// inbox and never in the other.
|
|
293
|
+
const existing = await deps.db
|
|
294
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
295
|
+
.select(["id", "threadId"])
|
|
296
|
+
.where("mimeMessageId", "=", parsed.messageId)
|
|
297
|
+
.where("toAddress", "=", inbox)
|
|
298
|
+
.executeTakeFirst();
|
|
299
|
+
if (existing) {
|
|
300
|
+
deps.log.info("support message already stored, ignoring redelivery", { messageId: existing.id });
|
|
301
|
+
return { handled: true, duplicate: true, threadId: existing.threadId, messageId: existing.id };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 5. Derive the stored bodies. Sanitization happens exactly here, once, on the way in — and the
|
|
306
|
+
// raw form is kept, so a sanitizer improvement can be re-run over what actually arrived.
|
|
307
|
+
const html = parsed.html !== undefined ? await sanitizeHtml(parsed.html) : undefined;
|
|
308
|
+
// Bounded on both paths. `parseInbound` already caps the text part, but the HTML-only fallback is
|
|
309
|
+
// derived here and was not — and an HTML-only message just under `guard.maxRawBytes` renders to a
|
|
310
|
+
// multi-megabyte body that exceeds D1's row limit. The insert would then throw *after* the thread
|
|
311
|
+
// row was written, and the handler swallows it, so the customer's mail is lost with a log line.
|
|
312
|
+
const derived =
|
|
313
|
+
parsed.text.trim().length > 0 ? parsed.text : parsed.html !== undefined ? await htmlToText(parsed.html) : "";
|
|
314
|
+
const text = truncateToBytes(derived, MAX_TEXT_BODY);
|
|
315
|
+
|
|
316
|
+
// Is the `From:` header something we may believe? The answer travels with the thread rather than
|
|
317
|
+
// gating whether it is stored: an unverified sender is still matched to an account, because that is
|
|
318
|
+
// the useful part and every mail client does it — but the thread records that the match was made on
|
|
319
|
+
// an unproven header, and the read path withholds the billing history an operator would act on.
|
|
320
|
+
const authenticity = senderAuthenticity({
|
|
321
|
+
authResults: parsed.authResults,
|
|
322
|
+
fromAddress: parsed.fromAddress,
|
|
323
|
+
envelopeFrom: input.envelopeFrom,
|
|
324
|
+
trusted: deps.config.guard.trustAuthenticationResults,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Logged once per message, at info, because it is the one fact that decides whether in-Worker
|
|
328
|
+
// sender authentication is even possible on this deployment — and it cannot be established from
|
|
329
|
+
// documentation. An empty list means Cloudflare hands this Worker nothing to verify against, and
|
|
330
|
+
// the honest-match design is the end of the road rather than a stepping stone. Issue #47.
|
|
331
|
+
deps.log.info("support inbound authentication headers observed", {
|
|
332
|
+
seen: parsed.authHeadersSeen,
|
|
333
|
+
trusted: deps.config.guard.trustAuthenticationResults,
|
|
334
|
+
authenticated: authenticity.authenticated,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const parentThreadId = await findParentThread(deps.db, parsed, inbox);
|
|
338
|
+
const threadId = parentThreadId ?? deps.newId();
|
|
339
|
+
const newThread = parentThreadId === undefined;
|
|
340
|
+
const messageId = deps.newId();
|
|
341
|
+
const subject = parsed.subject.length > 0 ? parsed.subject : "(no subject)";
|
|
342
|
+
|
|
343
|
+
// 6. The raw MIME, immutable, before the row that points at it — so a stored row never names an
|
|
344
|
+
// object that is not there.
|
|
345
|
+
let rawKey: string | null = null;
|
|
346
|
+
if (deps.bucket && deps.config.attachments.retainRaw) {
|
|
347
|
+
rawKey = rawMessageKey(threadId, messageId);
|
|
348
|
+
await deps.bucket.put(rawKey, input.raw, {
|
|
349
|
+
httpMetadata: { contentType: "application/octet-stream", contentDisposition: "attachment" },
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 7. The message first, and the ordering is deliberate. Its unique `mimeMessageId` index is the
|
|
354
|
+
// last line of defense against a redelivery that raced past the read above — and when it fires,
|
|
355
|
+
// this insert throws. Writing the thread first would leave that failure behind a thread row with a
|
|
356
|
+
// message count and no messages, showing in the inbox as an empty conversation nobody can act on.
|
|
357
|
+
// With the message first, a lost race writes nothing at all.
|
|
358
|
+
const message: SupportMessage = {
|
|
359
|
+
id: messageId,
|
|
360
|
+
threadId,
|
|
361
|
+
direction: "inbound",
|
|
362
|
+
channel: "email",
|
|
363
|
+
submittedByUserId: null,
|
|
364
|
+
context: null,
|
|
365
|
+
mimeMessageId: parsed.messageId ?? null,
|
|
366
|
+
mimeInReplyTo: parsed.inReplyTo ?? null,
|
|
367
|
+
mimeReferences: parsed.references.length > 0 ? parsed.references : null,
|
|
368
|
+
fromAddress: parsed.fromAddress,
|
|
369
|
+
fromName: parsed.fromName ?? null,
|
|
370
|
+
toAddress: inbox,
|
|
371
|
+
subject,
|
|
372
|
+
textBody: text,
|
|
373
|
+
htmlBody: html ?? null,
|
|
374
|
+
emailJobId: null,
|
|
375
|
+
rawKey,
|
|
376
|
+
rawBytes,
|
|
377
|
+
receivedAt: now,
|
|
378
|
+
createdAt: now,
|
|
379
|
+
};
|
|
380
|
+
try {
|
|
381
|
+
await deps.db.insertInto(SUPPORT_MESSAGES_TABLE).values(SupportMessage.encode(message)).execute();
|
|
382
|
+
} catch (error) {
|
|
383
|
+
// A concurrent redelivery is the expected cause, and it means the other invocation stored this
|
|
384
|
+
// message already — so this is the same no-op the idempotency read above would have produced,
|
|
385
|
+
// not a failure worth propagating.
|
|
386
|
+
const existing = parsed.messageId
|
|
387
|
+
? await deps.db
|
|
388
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
389
|
+
.select(["id", "threadId"])
|
|
390
|
+
.where("mimeMessageId", "=", parsed.messageId)
|
|
391
|
+
.where("toAddress", "=", inbox)
|
|
392
|
+
.executeTakeFirst()
|
|
393
|
+
: undefined;
|
|
394
|
+
if (existing) {
|
|
395
|
+
deps.log.info("support message stored concurrently, ignoring redelivery", { messageId: existing.id });
|
|
396
|
+
return { handled: true, duplicate: true, threadId: existing.threadId, messageId: existing.id };
|
|
397
|
+
}
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 8. The thread. A new one starts unclassified; an existing one has its counters moved forward.
|
|
402
|
+
//
|
|
403
|
+
// Wrapped, and the message row is removed if this fails. Message-before-thread is the right order —
|
|
404
|
+
// it means a lost idempotency race writes nothing — but it leaves one failure the inverse does not:
|
|
405
|
+
// a committed message with no thread. Because the idempotency read keys on that message, every
|
|
406
|
+
// subsequent redelivery would then report `duplicate` and the conversation would be unrecoverable,
|
|
407
|
+
// invisible to `listThreads` and `readThread` alike. Undoing the message restores the state where a
|
|
408
|
+
// redelivery simply works.
|
|
409
|
+
try {
|
|
410
|
+
if (newThread) {
|
|
411
|
+
// The user link is best-effort by contract: a sender who has no account is the normal case, and
|
|
412
|
+
// an auth package that is not installed must not stop mail from being stored.
|
|
413
|
+
//
|
|
414
|
+
// Attempted regardless of authenticity, because the match is the useful part and every mail
|
|
415
|
+
// client makes it. What authenticity changes is what we *claim*: the verdict is stored on the
|
|
416
|
+
// thread, and the read path withholds the billing history an operator would act on when it is
|
|
417
|
+
// false. A labeled guess is honest; a guess dressed as a verified fact is account takeover.
|
|
418
|
+
let userId: string | null = null;
|
|
419
|
+
try {
|
|
420
|
+
userId = await deps.linkSender(parsed.fromAddress);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
deps.log.warn("support sender link failed", { error });
|
|
423
|
+
}
|
|
424
|
+
if (userId && !authenticity.authenticated) {
|
|
425
|
+
deps.log.info("support sender matched on an unverified From address", { method: authenticity.method });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const thread: SupportThread = {
|
|
429
|
+
id: threadId,
|
|
430
|
+
channel: "email",
|
|
431
|
+
inboxAddress: inbox,
|
|
432
|
+
subject,
|
|
433
|
+
fromAddress: parsed.fromAddress,
|
|
434
|
+
fromName: parsed.fromName ?? null,
|
|
435
|
+
senderAuthenticated: authenticity.authenticated,
|
|
436
|
+
userId,
|
|
437
|
+
// Matched from an address in a header, which is the weaker of the two provenances and the
|
|
438
|
+
// reason the column exists. Null when nothing matched — an absent link has no source.
|
|
439
|
+
accountLinkSource: userId ? "email_address" : null,
|
|
440
|
+
...UNCLASSIFIED,
|
|
441
|
+
archived: false,
|
|
442
|
+
archivedAt: null,
|
|
443
|
+
archivedBy: null,
|
|
444
|
+
messageCount: 1,
|
|
445
|
+
firstMessageAt: now,
|
|
446
|
+
lastMessageAt: now,
|
|
447
|
+
createdAt: now,
|
|
448
|
+
updatedAt: now,
|
|
449
|
+
};
|
|
450
|
+
await deps.db.insertInto(SUPPORT_THREADS_TABLE).values(SupportThread.encode(thread)).execute();
|
|
451
|
+
} else {
|
|
452
|
+
// Re-attempt the link when the thread has none. `data/thread.ts` states the intent plainly — the
|
|
453
|
+
// link is derived, so "a customer who signs up later is linked by the next message rather than
|
|
454
|
+
// by a repair" — and doing it only on the new-thread branch froze `userId` at whatever was true
|
|
455
|
+
// when the conversation opened, which for anyone who wrote in *before* signing up is null
|
|
456
|
+
// forever.
|
|
457
|
+
let linkedNow: string | null = null;
|
|
458
|
+
if (authenticity.authenticated) {
|
|
459
|
+
const current = await deps.db
|
|
460
|
+
.selectFrom(SUPPORT_THREADS_TABLE)
|
|
461
|
+
.select(["userId"])
|
|
462
|
+
.where("id", "=", threadId)
|
|
463
|
+
.executeTakeFirst();
|
|
464
|
+
if (current?.userId == null) {
|
|
465
|
+
try {
|
|
466
|
+
linkedNow = await deps.linkSender(parsed.fromAddress);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
deps.log.warn("support sender re-link failed", { error });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
await deps.db
|
|
474
|
+
.updateTable(SUPPORT_THREADS_TABLE)
|
|
475
|
+
.set((eb) => ({
|
|
476
|
+
...(linkedNow
|
|
477
|
+
? { userId: linkedNow, senderAuthenticated: 1 as const, accountLinkSource: "email_address" as const }
|
|
478
|
+
: {}),
|
|
479
|
+
messageCount: eb("messageCount", "+", 1),
|
|
480
|
+
lastMessageAt: now.getTime(),
|
|
481
|
+
updatedAt: now.getTime(),
|
|
482
|
+
// A reply to a resolved thread reopens it. Anything else means a customer wrote back and
|
|
483
|
+
// nobody saw it, which is the one failure a support inbox cannot have.
|
|
484
|
+
archived: 0,
|
|
485
|
+
archivedAt: null,
|
|
486
|
+
archivedBy: null,
|
|
487
|
+
}))
|
|
488
|
+
.where("id", "=", threadId)
|
|
489
|
+
.execute();
|
|
490
|
+
}
|
|
491
|
+
} catch (error) {
|
|
492
|
+
await deps.db
|
|
493
|
+
.deleteFrom(SUPPORT_MESSAGES_TABLE)
|
|
494
|
+
.where("id", "=", messageId)
|
|
495
|
+
.execute()
|
|
496
|
+
.catch(() => {
|
|
497
|
+
// Nothing further to do: the message row survives and the next redelivery reports a duplicate
|
|
498
|
+
// it cannot act on. Logged so the orphan is at least findable.
|
|
499
|
+
deps.log.error("support message orphaned — thread write failed and the message could not be removed", {
|
|
500
|
+
messageId,
|
|
501
|
+
threadId,
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// The full-text index, when it is composed. Best-effort by contract: a failed index write must
|
|
508
|
+
// never lose a customer's message, so it is logged and the ingest continues. The row is stored,
|
|
509
|
+
// readable, and repairable by `reindexThread` — it is only missing from one search box.
|
|
510
|
+
if (deps.fts) {
|
|
511
|
+
try {
|
|
512
|
+
await indexMessage(deps.db, { threadId, messageId, subject, body: text });
|
|
513
|
+
} catch (error) {
|
|
514
|
+
deps.log.warn("support message not indexed", { messageId, error });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Guarded for the same reason the index write and the dispatch are, and the consequence here is
|
|
519
|
+
// the worst of the three. This runs *after* the message and thread rows are committed but *before*
|
|
520
|
+
// the classification dispatch — so a transient `bucket.put` failure throws out of ingest, the
|
|
521
|
+
// handler swallows it, and the message is durable but was never classified. An Email Routing
|
|
522
|
+
// redelivery then finds the row and returns `duplicate`, so it never retries: the thread stays
|
|
523
|
+
// `uncategorized` forever, fixable only by a human noticing and reclassifying by hand.
|
|
524
|
+
//
|
|
525
|
+
// Attachments are already best-effort by policy — an oversize one is skipped rather than fatal —
|
|
526
|
+
// so a bucket that is briefly unavailable should cost the same: the attachments, not the message.
|
|
527
|
+
let attachments = 0;
|
|
528
|
+
try {
|
|
529
|
+
attachments = await storeAttachments(deps, parsed, threadId, messageId, now);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
deps.log.warn("support attachments not stored", { messageId, error });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 9. Classification, last and allowed to fail. An out-of-office is not worth an inference, and an
|
|
535
|
+
// adopter who turned classification off should pay nothing for it.
|
|
536
|
+
const classifying = deps.config.ai.enabled && !parsed.autoSubmitted;
|
|
537
|
+
if (classifying) {
|
|
538
|
+
try {
|
|
539
|
+
await deps.dispatchClassify(messageId);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
// The message is already durable. A failed dispatch means a thread stays `uncategorized`,
|
|
542
|
+
// which is a legitimate state and a reclassify away from being fixed.
|
|
543
|
+
deps.log.warn("support classification dispatch failed", { messageId, error });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return { handled: true, duplicate: false, threadId, messageId, newThread, attachments, classifying };
|
|
548
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { parseAddress } from "@pithy-sh/core/src/address/address";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which inbound messages belong to this capability.
|
|
8
|
+
*
|
|
9
|
+
* A Worker has exactly one `email()` entry, and `createEntrypoint` fans every message to **every**
|
|
10
|
+
* capability that declares a handler. So a Worker running both `@pithy-sh/email` and this one sees
|
|
11
|
+
* each bounce twice and each support request twice, and the discrimination is each handler's own
|
|
12
|
+
* job. Email's handler filters by content — it acts only on DSNs and ARF reports. This one filters
|
|
13
|
+
* by **address**, because a support request looks like ordinary mail and there is nothing in its
|
|
14
|
+
* body to key on.
|
|
15
|
+
*
|
|
16
|
+
* ## The envelope recipient is the authority; the headers are not
|
|
17
|
+
*
|
|
18
|
+
* `ForwardableEmailMessage.to` is the SMTP envelope recipient — the address Cloudflare's own routing
|
|
19
|
+
* rule matched to deliver here. It is the only recipient that was proved rather than asserted.
|
|
20
|
+
*
|
|
21
|
+
* The `To:` and `Cc:` headers are neither. Anyone can put `To: support@yourdomain.com` on a message
|
|
22
|
+
* addressed elsewhere, and BCC means a legitimate message frequently does not name its real
|
|
23
|
+
* recipient at all. Trusting them would let a stranger inject threads into an inbox they were never
|
|
24
|
+
* routed to — so they are used only as a fallback for the one case where the envelope is unusable,
|
|
25
|
+
* and never to override it.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Whether an address is one of the configured inbox addresses. Both sides are already normalized. */
|
|
29
|
+
function claims(addresses: readonly string[], candidate: string | undefined): boolean {
|
|
30
|
+
return candidate !== undefined && addresses.includes(candidate);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve which configured inbox a message landed on, or `undefined` when none of them did.
|
|
35
|
+
*
|
|
36
|
+
* The returned address is stored on the thread, so a Worker serving `support@` and `security@` can
|
|
37
|
+
* keep the two apart in one table — and the value is the *configured* one rather than the envelope
|
|
38
|
+
* string, so a thread's inbox is always something that appears in `pithy.config.ts`.
|
|
39
|
+
*/
|
|
40
|
+
export function resolveInbox(options: {
|
|
41
|
+
/** The configured addresses, already normalized and lowercased. */
|
|
42
|
+
inboundAddresses: readonly string[];
|
|
43
|
+
/** The SMTP envelope recipient — `ForwardableEmailMessage.to`. The authority. */
|
|
44
|
+
envelopeTo: string | undefined;
|
|
45
|
+
/** `To`/`Cc`/`Delivered-To` from the parsed headers. Corroborating only. */
|
|
46
|
+
headerRecipients: readonly string[];
|
|
47
|
+
}): string | undefined {
|
|
48
|
+
const { inboundAddresses } = options;
|
|
49
|
+
if (inboundAddresses.length === 0) return undefined;
|
|
50
|
+
|
|
51
|
+
const envelope = parseAddress(options.envelopeTo);
|
|
52
|
+
if (envelope !== undefined) {
|
|
53
|
+
// The envelope was usable, so it decides — including deciding *against*. Falling through to the
|
|
54
|
+
// headers here would be the whole vulnerability: a message routed to `hello@` could then claim
|
|
55
|
+
// `support@` in a header it wrote itself.
|
|
56
|
+
return claims(inboundAddresses, envelope) ? envelope : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// No usable envelope recipient. Rare, and always a sign of something hand-rolled upstream, but the
|
|
60
|
+
// message did reach a Worker that only receives what a routing rule sent it — so the headers are
|
|
61
|
+
// the best evidence left rather than an unvetted claim.
|
|
62
|
+
for (const candidate of options.headerRecipients) {
|
|
63
|
+
const normalized = parseAddress(candidate);
|
|
64
|
+
if (claims(inboundAddresses, normalized)) return normalized;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The package entrypoint — the surface `pithy add support` wires into `pithy.config.ts`. Deliberately
|
|
6
|
+
* narrow: the capability factory, its config and options types, the two federation helpers an adopter
|
|
7
|
+
* calls, the control-plane scopes, and the table schemas. Every other module is imported by deep path
|
|
8
|
+
* (`@pithy-sh/support/src/...`); this is the documented contract, not a barrel over the package.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { type SupportAuditAction, SupportAuditActions } from "./audit/actions";
|
|
12
|
+
export {
|
|
13
|
+
isSupportCapability,
|
|
14
|
+
SUPPORT_MIGRATION_ORDER,
|
|
15
|
+
type SupportCapability,
|
|
16
|
+
type SupportOptions,
|
|
17
|
+
support,
|
|
18
|
+
} from "./capability";
|
|
19
|
+
export {
|
|
20
|
+
DEFAULT_CLASSIFY_MODEL,
|
|
21
|
+
SupportAiConfig,
|
|
22
|
+
SupportAttachmentsConfig,
|
|
23
|
+
SupportConfig,
|
|
24
|
+
type SupportConfigInput,
|
|
25
|
+
SupportGuardConfig,
|
|
26
|
+
SupportReplyConfig,
|
|
27
|
+
SupportSearchConfig,
|
|
28
|
+
} from "./config/config";
|
|
29
|
+
export { SupportAttachment } from "./data/attachment";
|
|
30
|
+
export {
|
|
31
|
+
DEFAULT_SUPPORT_CATEGORIES,
|
|
32
|
+
defineSupportCategories,
|
|
33
|
+
type SupportCategories,
|
|
34
|
+
} from "./data/categories";
|
|
35
|
+
export { SupportClassification } from "./data/classification";
|
|
36
|
+
export { SupportMessageDirection, SupportPriority, SupportSentiment, UNCATEGORIZED } from "./data/enums";
|
|
37
|
+
export { SupportThreadFlag } from "./data/flag";
|
|
38
|
+
export { SupportMessage } from "./data/message";
|
|
39
|
+
export {
|
|
40
|
+
SUPPORT_ATTACHMENTS_TABLE,
|
|
41
|
+
SUPPORT_CLASSIFICATIONS_TABLE,
|
|
42
|
+
SUPPORT_FLAGS_TABLE,
|
|
43
|
+
SUPPORT_MESSAGES_TABLE,
|
|
44
|
+
SUPPORT_SEARCH_TABLE,
|
|
45
|
+
SUPPORT_THREADS_TABLE,
|
|
46
|
+
type SupportDatabase,
|
|
47
|
+
supportDatabase,
|
|
48
|
+
} from "./data/tables";
|
|
49
|
+
export { SupportThread } from "./data/thread";
|
|
50
|
+
export {
|
|
51
|
+
SUPPORT_CONTROL_PLANE_SCOPES,
|
|
52
|
+
SUPPORT_THREADS_ARCHIVE_SCOPE,
|
|
53
|
+
SUPPORT_THREADS_FLAG_SCOPE,
|
|
54
|
+
SUPPORT_THREADS_READ_SCOPE,
|
|
55
|
+
SUPPORT_THREADS_RECLASSIFY_SCOPE,
|
|
56
|
+
SUPPORT_THREADS_REPLY_SCOPE,
|
|
57
|
+
} from "./http/scopes";
|
|
58
|
+
export {
|
|
59
|
+
DEFAULT_SUPPORT_REPLIES,
|
|
60
|
+
defineSupportReplies,
|
|
61
|
+
SupportReplySnippet,
|
|
62
|
+
type SupportReplySnippets,
|
|
63
|
+
} from "./reply/snippets";
|