@pithy-sh/support 0.1.2 → 0.1.4
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/dist/ai/classify.d.ts +92 -0
- package/dist/ai/classify.d.ts.map +1 -0
- package/dist/ai/classify.js +153 -0
- package/dist/attachment/store.d.ts +55 -0
- package/dist/attachment/store.d.ts.map +1 -0
- package/dist/attachment/store.js +69 -0
- package/dist/audit/actions.d.ts +70 -0
- package/dist/audit/actions.d.ts.map +1 -0
- package/dist/audit/actions.js +70 -0
- package/dist/capability.d.ts +95 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +189 -0
- package/dist/client/projection.d.ts +58 -0
- package/dist/client/projection.d.ts.map +1 -0
- package/dist/client/projection.js +2 -0
- package/dist/config/config.d.ts +217 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +171 -0
- package/dist/data/attachment.d.ts +32 -0
- package/dist/data/attachment.d.ts.map +1 -0
- package/dist/data/attachment.js +34 -0
- package/dist/data/billingScope.d.ts +32 -0
- package/dist/data/billingScope.d.ts.map +1 -0
- package/dist/data/billingScope.js +34 -0
- package/dist/data/categories.d.ts +56 -0
- package/dist/data/categories.d.ts.map +1 -0
- package/dist/data/categories.js +99 -0
- package/dist/data/classification.d.ts +36 -0
- package/dist/data/classification.d.ts.map +1 -0
- package/dist/data/classification.js +30 -0
- package/dist/data/enums.d.ts +84 -0
- package/dist/data/enums.d.ts.map +1 -0
- package/dist/data/enums.js +72 -0
- package/dist/data/flag.d.ts +27 -0
- package/dist/data/flag.d.ts.map +1 -0
- package/dist/data/flag.js +29 -0
- package/dist/data/message.d.ts +83 -0
- package/dist/data/message.d.ts.map +1 -0
- package/dist/data/message.js +86 -0
- package/dist/data/tables.d.ts +162 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tables.js +46 -0
- package/dist/data/thread.d.ts +79 -0
- package/dist/data/thread.d.ts.map +1 -0
- package/dist/data/thread.js +67 -0
- package/dist/error/errors.d.ts +66 -0
- package/dist/error/errors.d.ts.map +1 -0
- package/dist/error/errors.js +84 -0
- package/dist/http/guards.d.ts +46 -0
- package/dist/http/guards.d.ts.map +1 -0
- package/dist/http/guards.js +55 -0
- package/dist/http/handlers.d.ts +91 -0
- package/dist/http/handlers.d.ts.map +1 -0
- package/dist/http/handlers.js +250 -0
- package/dist/http/resolve.d.ts +46 -0
- package/dist/http/resolve.d.ts.map +1 -0
- package/dist/http/resolve.js +74 -0
- package/dist/http/responses.d.ts +831 -0
- package/dist/http/responses.d.ts.map +1 -0
- package/dist/http/responses.js +305 -0
- package/dist/http/routes.d.ts +111 -0
- package/dist/http/routes.d.ts.map +1 -0
- package/dist/http/routes.js +64 -0
- package/dist/http/schemas.d.ts +123 -0
- package/dist/http/schemas.d.ts.map +1 -0
- package/dist/http/schemas.js +91 -0
- package/dist/http/scopes.d.ts +58 -0
- package/dist/http/scopes.d.ts.map +1 -0
- package/dist/http/scopes.js +109 -0
- package/dist/http/views.d.ts +38 -0
- package/dist/http/views.d.ts.map +1 -0
- package/dist/http/views.js +146 -0
- package/dist/inbound/authenticity.d.ts +83 -0
- package/dist/inbound/authenticity.d.ts.map +1 -0
- package/dist/inbound/authenticity.js +63 -0
- package/dist/inbound/guard.d.ts +76 -0
- package/dist/inbound/guard.d.ts.map +1 -0
- package/dist/inbound/guard.js +80 -0
- package/dist/inbound/handler.d.ts +30 -0
- package/dist/inbound/handler.d.ts.map +1 -0
- package/dist/inbound/handler.js +84 -0
- package/dist/inbound/ingest.d.ts +106 -0
- package/dist/inbound/ingest.d.ts.map +1 -0
- package/dist/inbound/ingest.js +328 -0
- package/dist/inbound/recipient.d.ts +18 -0
- package/dist/inbound/recipient.d.ts.map +1 -0
- package/dist/inbound/recipient.js +48 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/link/sender.d.ts +162 -0
- package/dist/link/sender.d.ts.map +1 -0
- package/dist/link/sender.js +202 -0
- package/dist/migrations/0001_threads.d.ts +28 -0
- package/dist/migrations/0001_threads.d.ts.map +1 -0
- package/dist/migrations/0001_threads.js +105 -0
- package/dist/mime/address.d.ts +24 -0
- package/dist/mime/address.d.ts.map +1 -0
- package/dist/mime/address.js +30 -0
- package/dist/mime/parse.d.ts +98 -0
- package/dist/mime/parse.d.ts.map +1 -0
- package/dist/mime/parse.js +148 -0
- package/dist/mime/sanitize.d.ts +27 -0
- package/dist/mime/sanitize.d.ts.map +1 -0
- package/dist/mime/sanitize.js +214 -0
- package/dist/mime/threading.d.ts +81 -0
- package/dist/mime/threading.d.ts.map +1 -0
- package/dist/mime/threading.js +113 -0
- package/dist/mime/truncate.d.ts +19 -0
- package/dist/mime/truncate.d.ts.map +1 -0
- package/dist/mime/truncate.js +49 -0
- package/dist/provision/provisionSupport.d.ts +160 -0
- package/dist/provision/provisionSupport.d.ts.map +1 -0
- package/dist/provision/provisionSupport.js +112 -0
- package/dist/provision/resolveSupportConfig.d.ts +47 -0
- package/dist/provision/resolveSupportConfig.d.ts.map +1 -0
- package/dist/provision/resolveSupportConfig.js +24 -0
- package/dist/reply/send.d.ts +125 -0
- package/dist/reply/send.d.ts.map +1 -0
- package/dist/reply/send.js +155 -0
- package/dist/reply/snippets.d.ts +81 -0
- package/dist/reply/snippets.d.ts.map +1 -0
- package/dist/reply/snippets.js +137 -0
- package/dist/secret/registry.d.ts +20 -0
- package/dist/secret/registry.d.ts.map +1 -0
- package/dist/secret/registry.js +23 -0
- package/dist/seeds/example.d.ts +5 -0
- package/dist/seeds/example.d.ts.map +1 -0
- package/dist/seeds/example.js +352 -0
- package/dist/store/paging.d.ts +20 -0
- package/dist/store/paging.d.ts.map +1 -0
- package/dist/store/paging.js +22 -0
- package/dist/store/search.d.ts +69 -0
- package/dist/store/search.d.ts.map +1 -0
- package/dist/store/search.js +162 -0
- package/dist/store/searchIndex.d.ts +55 -0
- package/dist/store/searchIndex.d.ts.map +1 -0
- package/dist/store/searchIndex.js +68 -0
- package/dist/store/threads.d.ts +149 -0
- package/dist/store/threads.d.ts.map +1 -0
- package/dist/store/threads.js +199 -0
- package/dist/submission/encoding.d.ts +23 -0
- package/dist/submission/encoding.d.ts.map +1 -0
- package/dist/submission/encoding.js +57 -0
- package/dist/submission/guard.d.ts +73 -0
- package/dist/submission/guard.d.ts.map +1 -0
- package/dist/submission/guard.js +48 -0
- package/dist/submission/submit.d.ts +154 -0
- package/dist/submission/submit.d.ts.map +1 -0
- package/dist/submission/submit.js +321 -0
- package/dist/version.generated.d.ts +7 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +9 -0
- package/dist/workflows/classify.d.ts +65 -0
- package/dist/workflows/classify.d.ts.map +1 -0
- package/dist/workflows/classify.js +70 -0
- package/dist/workflows/retryPolicy.d.ts +41 -0
- package/dist/workflows/retryPolicy.d.ts.map +1 -0
- package/dist/workflows/retryPolicy.js +45 -0
- package/dist/workflows/specs.d.ts +45 -0
- package/dist/workflows/specs.d.ts.map +1 -0
- package/dist/workflows/specs.js +48 -0
- package/dist/workflows/worker.d.ts +50 -0
- package/dist/workflows/worker.d.ts.map +1 -0
- package/dist/workflows/worker.js +39 -0
- package/package.json +24 -15
- package/src/http/handlers.ts +1 -1
- package/src/submission/encoding.ts +1 -1
- package/src/submission/submit.ts +1 -1
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { SupportGuardConfig } from "../config/config";
|
|
4
|
+
import { type SupportDatabase } from "../data/tables";
|
|
5
|
+
/**
|
|
6
|
+
* The inbound guard — the bound between "a public support address" and "a public write endpoint into
|
|
7
|
+
* the adopter's D1", which is what one is without this file.
|
|
8
|
+
*
|
|
9
|
+
* Three checks, deliberately ordered by what they cost:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Size**, from the message's declared length, before a byte is parsed. Parsing is the expensive
|
|
12
|
+
* step and the size is known first, so refusing here is the difference between a flood costing
|
|
13
|
+
* CPU and costing nothing.
|
|
14
|
+
* 2. **Per sender**, which catches the overwhelmingly common case: one misconfigured auto-responder
|
|
15
|
+
* in a loop with the inbox. It is not an attack and it will still fill a database.
|
|
16
|
+
* 3. **Globally**, which is the only one that helps under a distributed flood, where every
|
|
17
|
+
* individual address stays comfortably under the per-sender bound.
|
|
18
|
+
*
|
|
19
|
+
* ## Counted from the messages table, not from a counter table
|
|
20
|
+
*
|
|
21
|
+
* A dedicated counter row would be one write per message and a second thing to reconcile — and it
|
|
22
|
+
* would be **wrong**, because a counter incremented before the insert overcounts a failed store and
|
|
23
|
+
* one incremented after undercounts a flood. The messages table already holds the exact fact, and
|
|
24
|
+
* `(from_address, received_at)` is already indexed for the sender history the dashboard shows. So
|
|
25
|
+
* the guard is a covered index scan over data that must exist anyway, and it cannot drift from what
|
|
26
|
+
* it is counting.
|
|
27
|
+
*
|
|
28
|
+
* The window is a sliding hour rather than a fixed bucket, because a fixed bucket lets twice the
|
|
29
|
+
* limit through across a boundary — which is exactly when a retry storm arrives.
|
|
30
|
+
*
|
|
31
|
+
* ## Mail only, and the in-app channel has its own bound
|
|
32
|
+
*
|
|
33
|
+
* Both counts filter on `channel = "email"`, so an in-app submission is invisible here and a piece of
|
|
34
|
+
* mail is invisible to `submission/guard.ts`. **Neither surface may starve the other.** Counting them
|
|
35
|
+
* together would mean a burst of in-app feedback during an outage locks a paying customer's email out
|
|
36
|
+
* of the inbox, and a mail flood stops the app's own users reporting the very outage that caused it —
|
|
37
|
+
* each of which is the failure arriving at the worst possible moment. They are also bounded on
|
|
38
|
+
* different keys for a real reason: a mail sender is an address anybody can write, while a submitter is
|
|
39
|
+
* an account the adopter issued and can revoke.
|
|
40
|
+
*/
|
|
41
|
+
/** One hour, in milliseconds — the window every rate bound is measured over. */
|
|
42
|
+
export declare const GUARD_WINDOW_MS: number;
|
|
43
|
+
/** Why a message was refused. The value lands in the audit event's `metadata.reason`. */
|
|
44
|
+
export type SupportRejectionReason = "too_large" | "sender_rate" | "global_rate";
|
|
45
|
+
/** The guard's answer: accept, or refuse with a reason. */
|
|
46
|
+
export type GuardVerdict = {
|
|
47
|
+
accepted: true;
|
|
48
|
+
} | {
|
|
49
|
+
accepted: false;
|
|
50
|
+
reason: SupportRejectionReason;
|
|
51
|
+
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
|
+
* Check the size bound alone — the half that needs no database and can therefore run before the
|
|
64
|
+
* message is parsed. Split out because the ordering is the point: this is the only check available
|
|
65
|
+
* while the decision is still free.
|
|
66
|
+
*/
|
|
67
|
+
export declare function checkSize(config: SupportGuardConfig, rawBytes: number): GuardVerdict;
|
|
68
|
+
/**
|
|
69
|
+
* Check the two rate bounds against what is already stored.
|
|
70
|
+
*
|
|
71
|
+
* Both counts run against the sliding window ending now. Inbound only: a burst of *replies* is the
|
|
72
|
+
* adopter's own doing and must never lock them out of their own inbox, which is a real failure mode
|
|
73
|
+
* for a guard that counts every row in the table.
|
|
74
|
+
*/
|
|
75
|
+
export declare function checkRates(db: SupportDatabase, config: SupportGuardConfig, input: GuardInput): Promise<GuardVerdict>;
|
|
76
|
+
//# sourceMappingURL=guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../../src/inbound/guard.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAA0B,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,gFAAgF;AAChF,eAAO,MAAM,eAAe,QAAiB,CAAC;AAE9C,yFAAyF;AACzF,MAAM,MAAM,sBAAsB,GAAG,WAAW,GAAG,aAAa,GAAG,aAAa,CAAC;AAEjF,2DAA2D;AAC3D,MAAM,MAAM,YAAY,GAAG;IAAE,QAAQ,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,QAAQ,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,sBAAsB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpH,sCAAsC;AACtC,MAAM,WAAW,UAAU;IACzB,kDAAkD;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,uCAAuC;IACvC,WAAW,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,GAAG,EAAE,IAAI,CAAC;CACX;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,GAAG,YAAY,CASpF;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,eAAe,EACnB,MAAM,EAAE,kBAAkB,EAC1B,KAAK,EAAE,UAAU,GAChB,OAAO,CAAC,YAAY,CAAC,CAqCvB"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { SUPPORT_MESSAGES_TABLE } from "../data/tables.js";
|
|
4
|
+
//#region src/inbound/guard.ts
|
|
5
|
+
/**
|
|
6
|
+
* The inbound guard — the bound between "a public support address" and "a public write endpoint into
|
|
7
|
+
* the adopter's D1", which is what one is without this file.
|
|
8
|
+
*
|
|
9
|
+
* Three checks, deliberately ordered by what they cost:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Size**, from the message's declared length, before a byte is parsed. Parsing is the expensive
|
|
12
|
+
* step and the size is known first, so refusing here is the difference between a flood costing
|
|
13
|
+
* CPU and costing nothing.
|
|
14
|
+
* 2. **Per sender**, which catches the overwhelmingly common case: one misconfigured auto-responder
|
|
15
|
+
* in a loop with the inbox. It is not an attack and it will still fill a database.
|
|
16
|
+
* 3. **Globally**, which is the only one that helps under a distributed flood, where every
|
|
17
|
+
* individual address stays comfortably under the per-sender bound.
|
|
18
|
+
*
|
|
19
|
+
* ## Counted from the messages table, not from a counter table
|
|
20
|
+
*
|
|
21
|
+
* A dedicated counter row would be one write per message and a second thing to reconcile — and it
|
|
22
|
+
* would be **wrong**, because a counter incremented before the insert overcounts a failed store and
|
|
23
|
+
* one incremented after undercounts a flood. The messages table already holds the exact fact, and
|
|
24
|
+
* `(from_address, received_at)` is already indexed for the sender history the dashboard shows. So
|
|
25
|
+
* the guard is a covered index scan over data that must exist anyway, and it cannot drift from what
|
|
26
|
+
* it is counting.
|
|
27
|
+
*
|
|
28
|
+
* The window is a sliding hour rather than a fixed bucket, because a fixed bucket lets twice the
|
|
29
|
+
* limit through across a boundary — which is exactly when a retry storm arrives.
|
|
30
|
+
*
|
|
31
|
+
* ## Mail only, and the in-app channel has its own bound
|
|
32
|
+
*
|
|
33
|
+
* Both counts filter on `channel = "email"`, so an in-app submission is invisible here and a piece of
|
|
34
|
+
* mail is invisible to `submission/guard.ts`. **Neither surface may starve the other.** Counting them
|
|
35
|
+
* together would mean a burst of in-app feedback during an outage locks a paying customer's email out
|
|
36
|
+
* of the inbox, and a mail flood stops the app's own users reporting the very outage that caused it —
|
|
37
|
+
* each of which is the failure arriving at the worst possible moment. They are also bounded on
|
|
38
|
+
* different keys for a real reason: a mail sender is an address anybody can write, while a submitter is
|
|
39
|
+
* an account the adopter issued and can revoke.
|
|
40
|
+
*/
|
|
41
|
+
/** One hour, in milliseconds — the window every rate bound is measured over. */
|
|
42
|
+
const GUARD_WINDOW_MS = 36e5;
|
|
43
|
+
/**
|
|
44
|
+
* Check the size bound alone — the half that needs no database and can therefore run before the
|
|
45
|
+
* message is parsed. Split out because the ordering is the point: this is the only check available
|
|
46
|
+
* while the decision is still free.
|
|
47
|
+
*/
|
|
48
|
+
function checkSize(config, rawBytes) {
|
|
49
|
+
if (rawBytes > config.maxRawBytes) return {
|
|
50
|
+
accepted: false,
|
|
51
|
+
reason: "too_large",
|
|
52
|
+
detail: `raw message is ${rawBytes} bytes, over the ${config.maxRawBytes} byte bound`
|
|
53
|
+
};
|
|
54
|
+
return { accepted: true };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Check the two rate bounds against what is already stored.
|
|
58
|
+
*
|
|
59
|
+
* Both counts run against the sliding window ending now. Inbound only: a burst of *replies* is the
|
|
60
|
+
* adopter's own doing and must never lock them out of their own inbox, which is a real failure mode
|
|
61
|
+
* for a guard that counts every row in the table.
|
|
62
|
+
*/
|
|
63
|
+
async function checkRates(db, config, input) {
|
|
64
|
+
const since = /* @__PURE__ */ new Date(input.now.getTime() - GUARD_WINDOW_MS);
|
|
65
|
+
const fromSender = await db.selectFrom(SUPPORT_MESSAGES_TABLE).select((eb) => eb.fn.countAll().as("count")).where("direction", "=", "inbound").where("channel", "=", "email").where("fromAddress", "=", input.fromAddress).where("receivedAt", ">=", since.getTime()).executeTakeFirst();
|
|
66
|
+
if ((fromSender?.count ?? 0) >= config.maxPerSenderPerHour) return {
|
|
67
|
+
accepted: false,
|
|
68
|
+
reason: "sender_rate",
|
|
69
|
+
detail: `sender has landed ${fromSender?.count} messages in the last hour, at or over the ${config.maxPerSenderPerHour} bound`
|
|
70
|
+
};
|
|
71
|
+
const total = await db.selectFrom(SUPPORT_MESSAGES_TABLE).select((eb) => eb.fn.countAll().as("count")).where("direction", "=", "inbound").where("channel", "=", "email").where("receivedAt", ">=", since.getTime()).executeTakeFirst();
|
|
72
|
+
if ((total?.count ?? 0) >= config.maxPerHour) return {
|
|
73
|
+
accepted: false,
|
|
74
|
+
reason: "global_rate",
|
|
75
|
+
detail: `inbox has accepted ${total?.count} messages in the last hour, at or over the ${config.maxPerHour} bound`
|
|
76
|
+
};
|
|
77
|
+
return { accepted: true };
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
export { GUARD_WINDOW_MS, checkRates, checkSize };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { CapabilityEmailHandler } from "@pithy-sh/core/src/capability/capability";
|
|
4
|
+
import type { SupportWiring } from "../capability";
|
|
5
|
+
/**
|
|
6
|
+
* The `email()` seam — a thin shell over `ingestInbound`.
|
|
7
|
+
*
|
|
8
|
+
* Everything interesting lives in `ingest.ts`, with every dependency injected; this file exists only
|
|
9
|
+
* to read bindings off the untyped Worker env and to make the two decisions that belong at the
|
|
10
|
+
* boundary rather than inside the orchestration.
|
|
11
|
+
*
|
|
12
|
+
* ## It never throws, and that is not laziness
|
|
13
|
+
*
|
|
14
|
+
* A Worker has one `email()` entry, and `createEntrypoint` fans every message to **every**
|
|
15
|
+
* capability that declares a handler — sequentially, in registration order. An exception thrown here
|
|
16
|
+
* would propagate out of that loop and stop the handlers after it from running, so a malformed
|
|
17
|
+
* message addressed to support could take out `@pithy-sh/email`'s bounce processing, which is how
|
|
18
|
+
* suppression stops working and an adopter's sending reputation quietly degrades. One capability's
|
|
19
|
+
* bad day must not be another's.
|
|
20
|
+
*
|
|
21
|
+
* So the handler catches, logs, and returns. The message is left untouched, which the runtime treats
|
|
22
|
+
* as a drop — correct, because a message this Worker could not store is one it cannot act on either.
|
|
23
|
+
*
|
|
24
|
+
* ## There is no request logger here
|
|
25
|
+
*
|
|
26
|
+
* `c.var.log` belongs to a `fetch`. An inbound message has no request, so the handler builds its own
|
|
27
|
+
* logger from the env rather than pretending otherwise.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createSupportEmailHandler(wiring: SupportWiring): CapabilityEmailHandler;
|
|
30
|
+
//# sourceMappingURL=handler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/inbound/handler.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0CAA0C,CAAC;AAEvF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAMnD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,aAAa,GAAG,sBAAsB,CAkEvF"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { supportDatabase } from "../data/tables.js";
|
|
4
|
+
import { makeClassifyDispatcher, resolveDb } from "../http/resolve.js";
|
|
5
|
+
import { resolveSenderUserId } from "../link/sender.js";
|
|
6
|
+
import { ingestInbound } from "./ingest.js";
|
|
7
|
+
import { createWorkerLogger } from "@pithy-sh/core/src/logger/worker";
|
|
8
|
+
//#region src/inbound/handler.ts
|
|
9
|
+
/**
|
|
10
|
+
* The `email()` seam — a thin shell over `ingestInbound`.
|
|
11
|
+
*
|
|
12
|
+
* Everything interesting lives in `ingest.ts`, with every dependency injected; this file exists only
|
|
13
|
+
* to read bindings off the untyped Worker env and to make the two decisions that belong at the
|
|
14
|
+
* boundary rather than inside the orchestration.
|
|
15
|
+
*
|
|
16
|
+
* ## It never throws, and that is not laziness
|
|
17
|
+
*
|
|
18
|
+
* A Worker has one `email()` entry, and `createEntrypoint` fans every message to **every**
|
|
19
|
+
* capability that declares a handler — sequentially, in registration order. An exception thrown here
|
|
20
|
+
* would propagate out of that loop and stop the handlers after it from running, so a malformed
|
|
21
|
+
* message addressed to support could take out `@pithy-sh/email`'s bounce processing, which is how
|
|
22
|
+
* suppression stops working and an adopter's sending reputation quietly degrades. One capability's
|
|
23
|
+
* bad day must not be another's.
|
|
24
|
+
*
|
|
25
|
+
* So the handler catches, logs, and returns. The message is left untouched, which the runtime treats
|
|
26
|
+
* as a drop — correct, because a message this Worker could not store is one it cannot act on either.
|
|
27
|
+
*
|
|
28
|
+
* ## There is no request logger here
|
|
29
|
+
*
|
|
30
|
+
* `c.var.log` belongs to a `fetch`. An inbound message has no request, so the handler builds its own
|
|
31
|
+
* logger from the env rather than pretending otherwise.
|
|
32
|
+
*/
|
|
33
|
+
function createSupportEmailHandler(wiring) {
|
|
34
|
+
return async (message, env) => {
|
|
35
|
+
const bindings = env;
|
|
36
|
+
const log = createWorkerLogger({
|
|
37
|
+
name: "support",
|
|
38
|
+
fields: { env: typeof env.ENVIRONMENT === "string" ? env.ENVIRONMENT : "unknown" }
|
|
39
|
+
});
|
|
40
|
+
if (wiring.config.inboundAddresses.length === 0) {
|
|
41
|
+
log.warn("support has no inboundAddresses configured — every message is ignored", { action: "Set `inboundAddresses` on the support capability in pithy.config.ts." });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const d1 = resolveDb(env);
|
|
46
|
+
const declared = message.rawSize;
|
|
47
|
+
if (typeof declared === "number" && declared > wiring.config.guard.maxRawBytes) {
|
|
48
|
+
log.warn("support inbound message refused before parsing", {
|
|
49
|
+
reason: "too_large",
|
|
50
|
+
rawSize: declared,
|
|
51
|
+
maxRawBytes: wiring.config.guard.maxRawBytes
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const raw = await new Response(message.raw).arrayBuffer();
|
|
56
|
+
const outcome = await ingestInbound({
|
|
57
|
+
db: supportDatabase(d1),
|
|
58
|
+
config: wiring.config,
|
|
59
|
+
bucket: bindings.SUPPORT_BUCKET,
|
|
60
|
+
fts: wiring.config.search.fts,
|
|
61
|
+
dispatchClassify: makeClassifyDispatcher(env, log),
|
|
62
|
+
linkSender: (address) => resolveSenderUserId(d1, address),
|
|
63
|
+
emit: wiring.emit,
|
|
64
|
+
log,
|
|
65
|
+
newId: () => crypto.randomUUID(),
|
|
66
|
+
now: () => /* @__PURE__ */ new Date()
|
|
67
|
+
}, {
|
|
68
|
+
raw,
|
|
69
|
+
envelopeTo: message.to,
|
|
70
|
+
envelopeFrom: message.from
|
|
71
|
+
});
|
|
72
|
+
if (outcome.handled && !outcome.duplicate) log.info("support message stored", {
|
|
73
|
+
threadId: outcome.threadId,
|
|
74
|
+
messageId: outcome.messageId,
|
|
75
|
+
newThread: outcome.newThread,
|
|
76
|
+
attachments: outcome.attachments
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
log.error("support inbound handling failed", { error });
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { createSupportEmailHandler };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { R2Bucket } from "@cloudflare/workers-types";
|
|
4
|
+
import type { AuditEmit } from "@pithy-sh/core/src/audit/recorder";
|
|
5
|
+
import type { Logger } from "@pithy-sh/core/src/logger/logger";
|
|
6
|
+
import type { SupportConfig } from "../config/config";
|
|
7
|
+
import { type SupportDatabase } from "../data/tables";
|
|
8
|
+
/**
|
|
9
|
+
* Ingest — everything the `email()` handler does, with every dependency injected.
|
|
10
|
+
*
|
|
11
|
+
* The handler in `handler.ts` is a thin shell that reads bindings off `env` and calls this. That
|
|
12
|
+
* split is what makes the interesting half testable: threading, idempotency, the guard's ordering,
|
|
13
|
+
* and the attachment bounds are all exercised here against a real D1 and injected fakes, with no
|
|
14
|
+
* Worker entry involved.
|
|
15
|
+
*
|
|
16
|
+
* ## The order of operations is the design
|
|
17
|
+
*
|
|
18
|
+
* Refusals are ordered by what they cost, and persistence comes before anything that can be slow or
|
|
19
|
+
* unavailable:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Size**, from the declared length, before parsing.
|
|
22
|
+
* 2. **Parse**, then **is this ours** — decided on the SMTP envelope recipient, not on a header.
|
|
23
|
+
* 3. **Rate**, which needs a database read and so goes after the free checks.
|
|
24
|
+
* 4. **Idempotency**, because Email Routing can deliver twice and the second must be a no-op.
|
|
25
|
+
* 5. **Store**, which must not fail.
|
|
26
|
+
* 6. **Dispatch classification**, which is allowed to fail — a model that is slow or briefly down
|
|
27
|
+
* must never take the persistence of the message with it, which is the whole reason
|
|
28
|
+
* classification is a Workflow.
|
|
29
|
+
*
|
|
30
|
+
* A message that is not ours returns `{ handled: false }` and emits nothing. Every capability's
|
|
31
|
+
* handler sees every message, so mail for the bounce handler passing through here is the normal
|
|
32
|
+
* case, not an event.
|
|
33
|
+
*/
|
|
34
|
+
/** What ingest did. */
|
|
35
|
+
export type IngestOutcome =
|
|
36
|
+
/** Not addressed to a configured inbox — another capability's mail, or nobody's. */
|
|
37
|
+
{
|
|
38
|
+
handled: false;
|
|
39
|
+
reason: "not_addressed";
|
|
40
|
+
}
|
|
41
|
+
/** Refused by the guard before anything was written. */
|
|
42
|
+
| {
|
|
43
|
+
handled: false;
|
|
44
|
+
reason: "rejected";
|
|
45
|
+
rejection: string;
|
|
46
|
+
}
|
|
47
|
+
/** Already stored under this `Message-ID`. A redelivery, and a no-op. */
|
|
48
|
+
| {
|
|
49
|
+
handled: true;
|
|
50
|
+
duplicate: true;
|
|
51
|
+
threadId: string;
|
|
52
|
+
messageId: string;
|
|
53
|
+
}
|
|
54
|
+
/** Stored. */
|
|
55
|
+
| {
|
|
56
|
+
handled: true;
|
|
57
|
+
duplicate: false;
|
|
58
|
+
threadId: string;
|
|
59
|
+
messageId: string;
|
|
60
|
+
/** Whether the message opened a new thread rather than continuing one. */
|
|
61
|
+
newThread: boolean;
|
|
62
|
+
/** How many attachments were stored, after the config bounds were applied. */
|
|
63
|
+
attachments: number;
|
|
64
|
+
/** Whether a classification was dispatched. False for auto-submitted mail and when AI is off. */
|
|
65
|
+
classifying: boolean;
|
|
66
|
+
};
|
|
67
|
+
/** Everything ingest needs, all injectable. */
|
|
68
|
+
export interface IngestDeps {
|
|
69
|
+
/** The support tables. */
|
|
70
|
+
db: SupportDatabase;
|
|
71
|
+
/** The resolved config. */
|
|
72
|
+
config: SupportConfig;
|
|
73
|
+
/** The R2 bucket raw messages and attachments are written to. Absent means neither is kept. */
|
|
74
|
+
bucket?: R2Bucket;
|
|
75
|
+
/** Whether the FTS5 index is composed. False means search runs as a `LIKE` scan and nothing is indexed. */
|
|
76
|
+
fts: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Start the classification Workflow for a stored message. Allowed to fail and allowed to decline —
|
|
79
|
+
* an unprovisioned project has no binding, and a thread that stays `uncategorized` is a legitimate
|
|
80
|
+
* state. The inbound path deliberately ignores the outcome; the reclassify route does not, because
|
|
81
|
+
* it is answering a human.
|
|
82
|
+
*/
|
|
83
|
+
dispatchClassify: (messageId: string) => Promise<boolean>;
|
|
84
|
+
/** Resolve a sender address to a user id, or null. Allowed to fail; never fatal. */
|
|
85
|
+
linkSender: (address: string) => Promise<string | null>;
|
|
86
|
+
/** The audit seam. */
|
|
87
|
+
emit: AuditEmit;
|
|
88
|
+
/** The request/invocation logger. */
|
|
89
|
+
log: Logger;
|
|
90
|
+
/** Generate a row id. */
|
|
91
|
+
newId: () => string;
|
|
92
|
+
/** Now. */
|
|
93
|
+
now: () => Date;
|
|
94
|
+
}
|
|
95
|
+
/** The message as it arrived, plus the envelope facts only the runtime knows. */
|
|
96
|
+
export interface IngestInput {
|
|
97
|
+
/** The raw MIME bytes. */
|
|
98
|
+
raw: ArrayBuffer;
|
|
99
|
+
/** The SMTP envelope recipient — `ForwardableEmailMessage.to`. The authority on which inbox this is. */
|
|
100
|
+
envelopeTo?: string;
|
|
101
|
+
/** The SMTP envelope sender, for the log only. The `From` header is what a thread keys on. */
|
|
102
|
+
envelopeFrom?: string;
|
|
103
|
+
}
|
|
104
|
+
/** Ingest one inbound message. */
|
|
105
|
+
export declare function ingestInbound(deps: IngestDeps, input: IngestInput): Promise<IngestOutcome>;
|
|
106
|
+
//# sourceMappingURL=ingest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/inbound/ingest.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mCAAmC,CAAC;AAEnE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kCAAkC,CAAC;AAG/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAWxB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,uBAAuB;AACvB,MAAM,MAAM,aAAa;AACvB,oFAAoF;AAClF;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE;AAC7C,wDAAwD;GACtD;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,UAAU,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE;AAC3D,yEAAyE;GACvE;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE;AACzE,cAAc;GACZ;IACE,OAAO,EAAE,IAAI,CAAC;IACd,SAAS,EAAE,KAAK,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,SAAS,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,WAAW,EAAE,MAAM,CAAC;IACpB,iGAAiG;IACjG,WAAW,EAAE,OAAO,CAAC;CACtB,CAAC;AAEN,+CAA+C;AAC/C,MAAM,WAAW,UAAU;IACzB,0BAA0B;IAC1B,EAAE,EAAE,eAAe,CAAC;IACpB,2BAA2B;IAC3B,MAAM,EAAE,aAAa,CAAC;IACtB,+FAA+F;IAC/F,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,2GAA2G;IAC3G,GAAG,EAAE,OAAO,CAAC;IACb;;;;;OAKG;IACH,gBAAgB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IAC1D,oFAAoF;IACpF,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACxD,sBAAsB;IACtB,IAAI,EAAE,SAAS,CAAC;IAChB,qCAAqC;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,yBAAyB;IACzB,KAAK,EAAE,MAAM,MAAM,CAAC;IACpB,WAAW;IACX,GAAG,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,iFAAiF;AACjF,MAAM,WAAW,WAAW;IAC1B,0BAA0B;IAC1B,GAAG,EAAE,WAAW,CAAC;IACjB,wGAAwG;IACxG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8FAA8F;IAC9F,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA0ID,kCAAkC;AAClC,wBAAsB,aAAa,CAAC,IAAI,EAAE,UAAU,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,aAAa,CAAC,CAsShG"}
|