@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,169 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { SupportMessage } from "../data/message";
|
|
5
|
+
import type { SupportThread } from "../data/thread";
|
|
6
|
+
import type { SenderContext } from "../link/sender";
|
|
7
|
+
import type { ListedThread } from "../store/threads";
|
|
8
|
+
import type {
|
|
9
|
+
SenderContextView,
|
|
10
|
+
SupportAttachmentView,
|
|
11
|
+
SupportListedThreadView,
|
|
12
|
+
SupportMessageView,
|
|
13
|
+
SupportMyMessageView,
|
|
14
|
+
SupportMyThreadView,
|
|
15
|
+
SupportThreadView,
|
|
16
|
+
} from "./responses";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The projections between a support row and the wire.
|
|
20
|
+
*
|
|
21
|
+
* Every return type is `z.output` of the matching object in `responses.ts`, so what this Worker sends
|
|
22
|
+
* and what a management client validates against are one declaration. A field added to one and not
|
|
23
|
+
* the other does not compile.
|
|
24
|
+
*
|
|
25
|
+
* **These convert dates to ISO-8601 strings, and that changes nothing on the wire.** A `Date` handed
|
|
26
|
+
* to `c.json` serializes as an ISO string already — so the bytes were always this, while the type
|
|
27
|
+
* claimed a `Date` no client ever received and no schema could describe. Converting explicitly is
|
|
28
|
+
* what makes the response statable, and it is the whole reason the fields could be typed at all.
|
|
29
|
+
*
|
|
30
|
+
* **A row is never spread.** Each projection names its fields, so a column added to
|
|
31
|
+
* `pithy_support_threads` is disclosed by a decision rather than by default — which is the same rule
|
|
32
|
+
* every other capability's admin surface follows, and the one a `SELECT *` handed to `c.json` breaks
|
|
33
|
+
* silently.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** An ISO string, or null. */
|
|
37
|
+
function iso(date: Date | null | undefined): string | null {
|
|
38
|
+
return date ? date.toISOString() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Project one thread row. */
|
|
42
|
+
export function threadView(thread: SupportThread): SupportThreadView {
|
|
43
|
+
return {
|
|
44
|
+
id: thread.id,
|
|
45
|
+
channel: thread.channel,
|
|
46
|
+
inboxAddress: thread.inboxAddress ?? null,
|
|
47
|
+
subject: thread.subject,
|
|
48
|
+
fromAddress: thread.fromAddress,
|
|
49
|
+
fromName: thread.fromName ?? null,
|
|
50
|
+
senderAuthenticated: thread.senderAuthenticated,
|
|
51
|
+
userId: thread.userId ?? null,
|
|
52
|
+
accountLinkSource: thread.accountLinkSource ?? null,
|
|
53
|
+
declaredCategory: thread.declaredCategory ?? null,
|
|
54
|
+
category: thread.category,
|
|
55
|
+
priority: thread.priority,
|
|
56
|
+
sentiment: thread.sentiment,
|
|
57
|
+
confidence: thread.confidence ?? null,
|
|
58
|
+
model: thread.model ?? null,
|
|
59
|
+
classifiedAt: iso(thread.classifiedAt),
|
|
60
|
+
archived: thread.archived,
|
|
61
|
+
archivedAt: iso(thread.archivedAt),
|
|
62
|
+
archivedBy: thread.archivedBy ?? null,
|
|
63
|
+
messageCount: thread.messageCount,
|
|
64
|
+
firstMessageAt: thread.firstMessageAt.toISOString(),
|
|
65
|
+
lastMessageAt: thread.lastMessageAt.toISOString(),
|
|
66
|
+
createdAt: thread.createdAt.toISOString(),
|
|
67
|
+
updatedAt: thread.updatedAt.toISOString(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Project one thread for the inbox, carrying this viewer's own read and snooze state. */
|
|
72
|
+
export function listedThreadView(thread: ListedThread): SupportListedThreadView {
|
|
73
|
+
return { ...threadView(thread), read: thread.read, snoozedUntil: iso(thread.snoozedUntil) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Project one message.
|
|
78
|
+
*
|
|
79
|
+
* The threading internals — `mimeMessageId`, `mimeInReplyTo`, `mimeReferences`, `rawKey`, `rawBytes` —
|
|
80
|
+
* are dropped. They are how a reply is stitched to its parent, and an operator would never act on one.
|
|
81
|
+
* `rawKey` in particular is the R2 object holding the message exactly as it arrived, which is the one
|
|
82
|
+
* copy of this data that has had nothing done to it.
|
|
83
|
+
*/
|
|
84
|
+
export function messageView(message: SupportMessage): SupportMessageView {
|
|
85
|
+
return {
|
|
86
|
+
id: message.id,
|
|
87
|
+
direction: message.direction,
|
|
88
|
+
channel: message.channel,
|
|
89
|
+
context: message.context ?? null,
|
|
90
|
+
fromAddress: message.fromAddress ?? null,
|
|
91
|
+
fromName: message.fromName ?? null,
|
|
92
|
+
toAddress: message.toAddress ?? null,
|
|
93
|
+
subject: message.subject,
|
|
94
|
+
// Already sanitized at ingest. The raw original stays in R2 and is never served here.
|
|
95
|
+
htmlBody: message.htmlBody ?? null,
|
|
96
|
+
textBody: message.textBody,
|
|
97
|
+
emailJobId: message.emailJobId ?? null,
|
|
98
|
+
receivedAt: message.receivedAt.toISOString(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Project one thread for the person who opened it.
|
|
104
|
+
*
|
|
105
|
+
* **Built from scratch rather than from {@link threadView}, and that is the security boundary.** A
|
|
106
|
+
* submitter's view derived by omitting fields from an operator's would disclose the next column
|
|
107
|
+
* somebody adds, silently, on the day they add it — the failure would be a default rather than a
|
|
108
|
+
* decision. Starting from nothing means a field reaches a customer only because somebody wrote it
|
|
109
|
+
* here.
|
|
110
|
+
*
|
|
111
|
+
* So: no classification, no priority, no sentiment, no confidence, no model, no viewer flags, no
|
|
112
|
+
* account link, no `archivedBy`, no addresses. What is left is their conversation.
|
|
113
|
+
*/
|
|
114
|
+
export function myThreadView(thread: SupportThread): SupportMyThreadView {
|
|
115
|
+
return {
|
|
116
|
+
id: thread.id,
|
|
117
|
+
subject: thread.subject,
|
|
118
|
+
resolved: thread.archived,
|
|
119
|
+
messageCount: thread.messageCount,
|
|
120
|
+
lastMessageAt: thread.lastMessageAt.toISOString(),
|
|
121
|
+
createdAt: thread.createdAt.toISOString(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Project one message for the person in the conversation. Their own attachments ride along. */
|
|
126
|
+
export function myMessageView(message: SupportMessage, attachments: SupportAttachmentView[]): SupportMyMessageView {
|
|
127
|
+
return {
|
|
128
|
+
id: message.id,
|
|
129
|
+
direction: message.direction,
|
|
130
|
+
// `textBody` on both sides. An answer is composed as plain text by `reply/send.ts`, and a
|
|
131
|
+
// submission was plain text when it arrived — so there is no `htmlBody` to hand back, and nothing
|
|
132
|
+
// here needs a sanitizer because nothing here is markup.
|
|
133
|
+
body: message.textBody,
|
|
134
|
+
context: message.context ?? null,
|
|
135
|
+
attachments,
|
|
136
|
+
sentAt: message.receivedAt.toISOString(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Project the customer context beside a conversation. */
|
|
141
|
+
export function senderView(sender: SenderContext): SenderContextView {
|
|
142
|
+
return {
|
|
143
|
+
authenticated: sender.authenticated,
|
|
144
|
+
userId: sender.userId,
|
|
145
|
+
// Constant, and stated on every response rather than carried on `SenderContext`: it describes what
|
|
146
|
+
// this seam can reach, not what a lookup found. `link/sender.ts` reads `user`-subject rows only.
|
|
147
|
+
billingScope: "user",
|
|
148
|
+
// Spread rather than set to null: both are absent for an unproven sender, and an explicit null
|
|
149
|
+
// would read as "we looked and there is none" rather than "we did not look".
|
|
150
|
+
...(sender.name === undefined ? {} : { name: sender.name }),
|
|
151
|
+
...(sender.emailVerified === undefined ? {} : { emailVerified: sender.emailVerified }),
|
|
152
|
+
purchases: sender.purchases.map((purchase) => ({
|
|
153
|
+
id: purchase.id,
|
|
154
|
+
rail: purchase.rail,
|
|
155
|
+
productId: purchase.productId,
|
|
156
|
+
status: purchase.status,
|
|
157
|
+
environment: purchase.environment,
|
|
158
|
+
purchasedAt: purchase.purchasedAt.toISOString(),
|
|
159
|
+
expiresAt: iso(purchase.expiresAt),
|
|
160
|
+
revokedAt: iso(purchase.revokedAt),
|
|
161
|
+
})),
|
|
162
|
+
entitlements: sender.entitlements.map((entitlement) => ({
|
|
163
|
+
key: entitlement.key,
|
|
164
|
+
active: entitlement.active,
|
|
165
|
+
expiresAt: iso(entitlement.expiresAt),
|
|
166
|
+
source: entitlement.source,
|
|
167
|
+
})),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { parseAddress } from "@pithy-sh/core/src/address/address";
|
|
5
|
+
import { getDomain } from "tldts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether a message's `From:` header is something we are entitled to believe.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this exists
|
|
11
|
+
*
|
|
12
|
+
* `From:` is an unauthenticated claim. Anyone can send mail saying `From: ada@example.com`, and for
|
|
13
|
+
* every domain that publishes DMARC `p=none` — which is most small domains and plenty of large ones
|
|
14
|
+
* — a receiving MTA will deliver it rather than reject it.
|
|
15
|
+
*
|
|
16
|
+
* That claim is the join key for the customer link: it resolves to a `pithy_auth_users` row and
|
|
17
|
+
* pulls that account's name, entitlements, and itemized purchase history into a support console,
|
|
18
|
+
* where a human reads it and acts. Rendering an attacker's thread decorated with a real customer's
|
|
19
|
+
* billing history is the standard opening move of support-driven account takeover, and this
|
|
20
|
+
* capability's own `account_access` and `privacy_request` categories route exactly those messages to
|
|
21
|
+
* an operator already primed to act on them.
|
|
22
|
+
*
|
|
23
|
+
* The recipient side of this package gets the equivalent question right and says so at length: the
|
|
24
|
+
* SMTP envelope is authority, headers are not. This is that same rule applied to the sender.
|
|
25
|
+
*
|
|
26
|
+
* ## What counts as authenticated
|
|
27
|
+
*
|
|
28
|
+
* DMARC is the only verdict that asserts what we actually need — that the **`From:` domain** is
|
|
29
|
+
* aligned with something that passed. `spf=pass` alone says the *envelope* sender's domain passed,
|
|
30
|
+
* which is a different domain and is exactly the gap DMARC exists to close, so it counts only when
|
|
31
|
+
* the envelope sender aligns with the header From. `dkim=pass` alone has the same alignment gap.
|
|
32
|
+
*
|
|
33
|
+
* Absence is not a pass. A message with no `Authentication-Results` at all is unauthenticated, which
|
|
34
|
+
* is the right answer for mail that reached us through something that never checked.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** What is known about a sender's claimed identity. */
|
|
38
|
+
export interface SenderAuthenticity {
|
|
39
|
+
/** Whether the `From:` header may be treated as the real sender. */
|
|
40
|
+
authenticated: boolean;
|
|
41
|
+
/** How it was established, for the log and for a dashboard to explain itself. `none` when it was not. */
|
|
42
|
+
method: "dmarc" | "spf-aligned" | "none";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The domain half of an address, lowercased. */
|
|
46
|
+
function domainOf(address: string | undefined): string | undefined {
|
|
47
|
+
const normalized = parseAddress(address);
|
|
48
|
+
const at = normalized?.lastIndexOf("@") ?? -1;
|
|
49
|
+
return normalized && at > 0 ? normalized.slice(at + 1) : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether two domains are aligned in the relaxed sense DMARC uses — the same Organizational Domain.
|
|
54
|
+
*
|
|
55
|
+
* **This is a public-suffix lookup, not label arithmetic, because RFC 7489 §3.2 defines relaxed
|
|
56
|
+
* alignment in terms of the Organizational Domain and that term is defined by the public suffix
|
|
57
|
+
* list.** An earlier version counted labels and required a shared parent of at least two, which is a
|
|
58
|
+
* decent approximation and wrong in exactly the way approximations of security boundaries are wrong:
|
|
59
|
+
* `co.uk` has two labels, so it "aligned" with `bbc.co.uk`. Implementing the actual algorithm removes
|
|
60
|
+
* the whole class rather than the instance somebody happened to notice.
|
|
61
|
+
*
|
|
62
|
+
* `allowPrivateDomains` is on. Without it `a.github.io` and `b.github.io` both reduce to `github.io`
|
|
63
|
+
* and two unrelated people's sites align with each other; the private section of the list is what
|
|
64
|
+
* knows that `github.io` is a boundary. Stricter is the right direction for a check that gates
|
|
65
|
+
* whether a stranger's mail gets decorated with a real customer's purchase history.
|
|
66
|
+
*
|
|
67
|
+
* A domain that has no Organizational Domain at all — a bare public suffix like `co.uk`, an IP
|
|
68
|
+
* literal, `localhost` — returns `null` from `getDomain` and therefore aligns with nothing, which is
|
|
69
|
+
* the correct answer rather than a special case.
|
|
70
|
+
*/
|
|
71
|
+
export function domainsAlign(a: string | undefined, b: string | undefined): boolean {
|
|
72
|
+
if (!a || !b) return false;
|
|
73
|
+
const organizational = getDomain(a, { allowPrivateDomains: true });
|
|
74
|
+
return organizational !== null && organizational === getDomain(b, { allowPrivateDomains: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Decide whether the `From:` header is believable.
|
|
79
|
+
*
|
|
80
|
+
* Takes the parsed verdicts and both addresses rather than the whole message, so the decision is a
|
|
81
|
+
* pure function of four values and is testable without a MIME fixture.
|
|
82
|
+
*/
|
|
83
|
+
export function senderAuthenticity(options: {
|
|
84
|
+
/** The `Authentication-Results` verdicts, keyed by method. */
|
|
85
|
+
authResults: Record<string, string>;
|
|
86
|
+
/** The address in the `From:` header, normalized. */
|
|
87
|
+
fromAddress: string;
|
|
88
|
+
/** The SMTP envelope sender — what SPF was actually evaluated against. */
|
|
89
|
+
envelopeFrom: string | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Whether the adopter has attested that the header is worth reading at all.
|
|
92
|
+
*
|
|
93
|
+
* **Default off, and this is the load-bearing gate.** Under Cloudflare Email Routing a Worker is
|
|
94
|
+
* not reliably given the MTA's own `Authentication-Results`, and a header the *sender* wrote is
|
|
95
|
+
* indistinguishable from one an MTA wrote once the MTA's copy is absent — so reading it by default
|
|
96
|
+
* would mean believing whatever the attacker typed. Everything below is a verdict about a value
|
|
97
|
+
* that only means something once somebody has said their pipeline produces it honestly.
|
|
98
|
+
*/
|
|
99
|
+
trusted: boolean;
|
|
100
|
+
}): SenderAuthenticity {
|
|
101
|
+
if (!options.trusted) return { authenticated: false, method: "none" };
|
|
102
|
+
|
|
103
|
+
if (options.authResults.dmarc === "pass") return { authenticated: true, method: "dmarc" };
|
|
104
|
+
|
|
105
|
+
// SPF passed, but on the envelope sender's domain. It only tells us anything about the `From:`
|
|
106
|
+
// header when the two align — which is the check DMARC would have done for us.
|
|
107
|
+
if (options.authResults.spf === "pass") {
|
|
108
|
+
const envelope = domainOf(options.envelopeFrom);
|
|
109
|
+
const header = domainOf(options.fromAddress);
|
|
110
|
+
if (domainsAlign(envelope, header)) return { authenticated: true, method: "spf-aligned" };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { authenticated: false, method: "none" };
|
|
114
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { SupportGuardConfig } from "../config/config";
|
|
5
|
+
import { SUPPORT_MESSAGES_TABLE, type SupportDatabase } from "../data/tables";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The inbound guard — the bound between "a public support address" and "a public write endpoint into
|
|
9
|
+
* the adopter's D1", which is what one is without this file.
|
|
10
|
+
*
|
|
11
|
+
* Three checks, deliberately ordered by what they cost:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Size**, from the message's declared length, before a byte is parsed. Parsing is the expensive
|
|
14
|
+
* step and the size is known first, so refusing here is the difference between a flood costing
|
|
15
|
+
* CPU and costing nothing.
|
|
16
|
+
* 2. **Per sender**, which catches the overwhelmingly common case: one misconfigured auto-responder
|
|
17
|
+
* in a loop with the inbox. It is not an attack and it will still fill a database.
|
|
18
|
+
* 3. **Globally**, which is the only one that helps under a distributed flood, where every
|
|
19
|
+
* individual address stays comfortably under the per-sender bound.
|
|
20
|
+
*
|
|
21
|
+
* ## Counted from the messages table, not from a counter table
|
|
22
|
+
*
|
|
23
|
+
* A dedicated counter row would be one write per message and a second thing to reconcile — and it
|
|
24
|
+
* would be **wrong**, because a counter incremented before the insert overcounts a failed store and
|
|
25
|
+
* one incremented after undercounts a flood. The messages table already holds the exact fact, and
|
|
26
|
+
* `(from_address, received_at)` is already indexed for the sender history the dashboard shows. So
|
|
27
|
+
* the guard is a covered index scan over data that must exist anyway, and it cannot drift from what
|
|
28
|
+
* it is counting.
|
|
29
|
+
*
|
|
30
|
+
* The window is a sliding hour rather than a fixed bucket, because a fixed bucket lets twice the
|
|
31
|
+
* limit through across a boundary — which is exactly when a retry storm arrives.
|
|
32
|
+
*
|
|
33
|
+
* ## Mail only, and the in-app channel has its own bound
|
|
34
|
+
*
|
|
35
|
+
* Both counts filter on `channel = "email"`, so an in-app submission is invisible here and a piece of
|
|
36
|
+
* mail is invisible to `submission/guard.ts`. **Neither surface may starve the other.** Counting them
|
|
37
|
+
* together would mean a burst of in-app feedback during an outage locks a paying customer's email out
|
|
38
|
+
* of the inbox, and a mail flood stops the app's own users reporting the very outage that caused it —
|
|
39
|
+
* each of which is the failure arriving at the worst possible moment. They are also bounded on
|
|
40
|
+
* different keys for a real reason: a mail sender is an address anybody can write, while a submitter is
|
|
41
|
+
* an account the adopter issued and can revoke.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** One hour, in milliseconds — the window every rate bound is measured over. */
|
|
45
|
+
export const GUARD_WINDOW_MS = 60 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
/** Why a message was refused. The value lands in the audit event's `metadata.reason`. */
|
|
48
|
+
export type SupportRejectionReason = "too_large" | "sender_rate" | "global_rate";
|
|
49
|
+
|
|
50
|
+
/** The guard's answer: accept, or refuse with a reason. */
|
|
51
|
+
export type GuardVerdict = { accepted: true } | { accepted: false; reason: SupportRejectionReason; detail: string };
|
|
52
|
+
|
|
53
|
+
/** What the guard needs to decide. */
|
|
54
|
+
export interface GuardInput {
|
|
55
|
+
/** The raw message size in bytes, as received. */
|
|
56
|
+
rawBytes: number;
|
|
57
|
+
/** The sender's normalized address. */
|
|
58
|
+
fromAddress: string;
|
|
59
|
+
/** Now. Injected so the window is testable without waiting an hour. */
|
|
60
|
+
now: Date;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check the size bound alone — the half that needs no database and can therefore run before the
|
|
65
|
+
* message is parsed. Split out because the ordering is the point: this is the only check available
|
|
66
|
+
* while the decision is still free.
|
|
67
|
+
*/
|
|
68
|
+
export function checkSize(config: SupportGuardConfig, rawBytes: number): GuardVerdict {
|
|
69
|
+
if (rawBytes > config.maxRawBytes) {
|
|
70
|
+
return {
|
|
71
|
+
accepted: false,
|
|
72
|
+
reason: "too_large",
|
|
73
|
+
detail: `raw message is ${rawBytes} bytes, over the ${config.maxRawBytes} byte bound`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { accepted: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check the two rate bounds against what is already stored.
|
|
81
|
+
*
|
|
82
|
+
* Both counts run against the sliding window ending now. Inbound only: a burst of *replies* is the
|
|
83
|
+
* adopter's own doing and must never lock them out of their own inbox, which is a real failure mode
|
|
84
|
+
* for a guard that counts every row in the table.
|
|
85
|
+
*/
|
|
86
|
+
export async function checkRates(
|
|
87
|
+
db: SupportDatabase,
|
|
88
|
+
config: SupportGuardConfig,
|
|
89
|
+
input: GuardInput,
|
|
90
|
+
): Promise<GuardVerdict> {
|
|
91
|
+
const since = new Date(input.now.getTime() - GUARD_WINDOW_MS);
|
|
92
|
+
|
|
93
|
+
const fromSender = await db
|
|
94
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
95
|
+
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
96
|
+
.where("direction", "=", "inbound")
|
|
97
|
+
.where("channel", "=", "email")
|
|
98
|
+
.where("fromAddress", "=", input.fromAddress)
|
|
99
|
+
.where("receivedAt", ">=", since.getTime())
|
|
100
|
+
.executeTakeFirst();
|
|
101
|
+
|
|
102
|
+
if ((fromSender?.count ?? 0) >= config.maxPerSenderPerHour) {
|
|
103
|
+
return {
|
|
104
|
+
accepted: false,
|
|
105
|
+
reason: "sender_rate",
|
|
106
|
+
detail: `sender has landed ${fromSender?.count} messages in the last hour, at or over the ${config.maxPerSenderPerHour} bound`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const total = await db
|
|
111
|
+
.selectFrom(SUPPORT_MESSAGES_TABLE)
|
|
112
|
+
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
113
|
+
.where("direction", "=", "inbound")
|
|
114
|
+
.where("channel", "=", "email")
|
|
115
|
+
.where("receivedAt", ">=", since.getTime())
|
|
116
|
+
.executeTakeFirst();
|
|
117
|
+
|
|
118
|
+
if ((total?.count ?? 0) >= config.maxPerHour) {
|
|
119
|
+
return {
|
|
120
|
+
accepted: false,
|
|
121
|
+
reason: "global_rate",
|
|
122
|
+
detail: `inbox has accepted ${total?.count} messages in the last hour, at or over the ${config.maxPerHour} bound`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { accepted: true };
|
|
127
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CapabilityEmailHandler } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { createWorkerLogger } from "@pithy-sh/core/src/logger/worker";
|
|
6
|
+
import type { SupportWiring } from "../capability";
|
|
7
|
+
import { supportDatabase } from "../data/tables";
|
|
8
|
+
import { makeClassifyDispatcher, resolveDb, type SupportEnv } from "../http/resolve";
|
|
9
|
+
import { resolveSenderUserId } from "../link/sender";
|
|
10
|
+
import { ingestInbound } from "./ingest";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `email()` seam — a thin shell over `ingestInbound`.
|
|
14
|
+
*
|
|
15
|
+
* Everything interesting lives in `ingest.ts`, with every dependency injected; this file exists only
|
|
16
|
+
* to read bindings off the untyped Worker env and to make the two decisions that belong at the
|
|
17
|
+
* boundary rather than inside the orchestration.
|
|
18
|
+
*
|
|
19
|
+
* ## It never throws, and that is not laziness
|
|
20
|
+
*
|
|
21
|
+
* A Worker has one `email()` entry, and `createEntrypoint` fans every message to **every**
|
|
22
|
+
* capability that declares a handler — sequentially, in registration order. An exception thrown here
|
|
23
|
+
* would propagate out of that loop and stop the handlers after it from running, so a malformed
|
|
24
|
+
* message addressed to support could take out `@pithy-sh/email`'s bounce processing, which is how
|
|
25
|
+
* suppression stops working and an adopter's sending reputation quietly degrades. One capability's
|
|
26
|
+
* bad day must not be another's.
|
|
27
|
+
*
|
|
28
|
+
* So the handler catches, logs, and returns. The message is left untouched, which the runtime treats
|
|
29
|
+
* as a drop — correct, because a message this Worker could not store is one it cannot act on either.
|
|
30
|
+
*
|
|
31
|
+
* ## There is no request logger here
|
|
32
|
+
*
|
|
33
|
+
* `c.var.log` belongs to a `fetch`. An inbound message has no request, so the handler builds its own
|
|
34
|
+
* logger from the env rather than pretending otherwise.
|
|
35
|
+
*/
|
|
36
|
+
export function createSupportEmailHandler(wiring: SupportWiring): CapabilityEmailHandler {
|
|
37
|
+
return async (message, env) => {
|
|
38
|
+
const bindings = env as unknown as SupportEnv;
|
|
39
|
+
const log = createWorkerLogger({
|
|
40
|
+
name: "support",
|
|
41
|
+
fields: { env: typeof env.ENVIRONMENT === "string" ? env.ENVIRONMENT : "unknown" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// A capability configured with no addresses claims nothing. Say so once, at warn, rather than
|
|
45
|
+
// parsing every message the Worker receives to reach the same conclusion silently.
|
|
46
|
+
if (wiring.config.inboundAddresses.length === 0) {
|
|
47
|
+
log.warn("support has no inboundAddresses configured — every message is ignored", {
|
|
48
|
+
action: "Set `inboundAddresses` on the support capability in pithy.config.ts.",
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const d1 = resolveDb(env);
|
|
55
|
+
|
|
56
|
+
// Refuse on the *declared* size before buffering. `checkSize` runs again inside ingest — it is
|
|
57
|
+
// the orchestration's own contract and must hold when called directly — but by then the whole
|
|
58
|
+
// message is already in memory, which is exactly the cost the size bound exists to avoid.
|
|
59
|
+
// `rawSize` is the runtime's own count and needs no bytes read to consult.
|
|
60
|
+
const declared = message.rawSize;
|
|
61
|
+
if (typeof declared === "number" && declared > wiring.config.guard.maxRawBytes) {
|
|
62
|
+
log.warn("support inbound message refused before parsing", {
|
|
63
|
+
reason: "too_large",
|
|
64
|
+
rawSize: declared,
|
|
65
|
+
maxRawBytes: wiring.config.guard.maxRawBytes,
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const raw = await new Response(message.raw).arrayBuffer();
|
|
71
|
+
|
|
72
|
+
const outcome = await ingestInbound(
|
|
73
|
+
{
|
|
74
|
+
db: supportDatabase(d1),
|
|
75
|
+
config: wiring.config,
|
|
76
|
+
bucket: bindings.SUPPORT_BUCKET,
|
|
77
|
+
fts: wiring.config.search.fts,
|
|
78
|
+
dispatchClassify: makeClassifyDispatcher(env, log),
|
|
79
|
+
linkSender: (address) => resolveSenderUserId(d1, address),
|
|
80
|
+
emit: wiring.emit,
|
|
81
|
+
log,
|
|
82
|
+
newId: () => crypto.randomUUID(),
|
|
83
|
+
now: () => new Date(),
|
|
84
|
+
},
|
|
85
|
+
{ raw, envelopeTo: message.to, envelopeFrom: message.from },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (outcome.handled && !outcome.duplicate) {
|
|
89
|
+
log.info("support message stored", {
|
|
90
|
+
threadId: outcome.threadId,
|
|
91
|
+
messageId: outcome.messageId,
|
|
92
|
+
newThread: outcome.newThread,
|
|
93
|
+
attachments: outcome.attachments,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Swallowed on purpose — see the note above. The failure is logged with everything an operator
|
|
98
|
+
// needs and nothing the sender wrote.
|
|
99
|
+
log.error("support inbound handling failed", { error });
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|