@pithy-sh/email 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 +55 -0
- package/pithy.manifest.json +73 -0
- package/src/analytics.ts +39 -0
- package/src/audit/actions.ts +48 -0
- package/src/bounce/classify.ts +103 -0
- package/src/bounce/handler.ts +136 -0
- package/src/capability.ts +385 -0
- package/src/cloudflare-test.d.ts +19 -0
- package/src/crypto/signingKey.ts +44 -0
- package/src/crypto/token.ts +148 -0
- package/src/data/emailEvent.ts +42 -0
- package/src/data/emailJob.ts +138 -0
- package/src/data/emailSuppression.ts +40 -0
- package/src/data/enums.ts +75 -0
- package/src/data/tables.ts +47 -0
- package/src/error/errors.ts +129 -0
- package/src/http/callbacks.ts +200 -0
- package/src/http/guards.ts +154 -0
- package/src/http/responses.ts +192 -0
- package/src/http/routes.ts +467 -0
- package/src/http/schemas.ts +203 -0
- package/src/http/view.ts +139 -0
- package/src/index.ts +73 -0
- package/src/jobs/read.ts +273 -0
- package/src/jobs/retry.ts +214 -0
- package/src/migrations/0001_init.ts +174 -0
- package/src/migrations/0001_suppressions.ts +40 -0
- package/src/provision/devDelivery.ts +47 -0
- package/src/provision/hostCatalogs.ts +107 -0
- package/src/provision/provisionEmail.ts +179 -0
- package/src/provision/resolveEmailConfig.ts +225 -0
- package/src/provision/settingsCheck.ts +212 -0
- package/src/send/batchIdentity.ts +47 -0
- package/src/send/enqueue.ts +391 -0
- package/src/send/errorMapping.ts +73 -0
- package/src/send/events.ts +34 -0
- package/src/send/fromComposition.ts +57 -0
- package/src/send/retryPolicy.ts +42 -0
- package/src/send/runSend.ts +320 -0
- package/src/send/sendAt.ts +77 -0
- package/src/send/sender.ts +44 -0
- package/src/send/senderBinding.ts +56 -0
- package/src/send/suppression.ts +194 -0
- package/src/templates/engine.ts +392 -0
- package/src/templates/messages.es.ts +109 -0
- package/src/templates/messages.ts +315 -0
- package/src/templates/partials.ts +88 -0
- package/src/templates/precompiled.generated.ts +1342 -0
- package/src/templates/registry.ts +550 -0
- package/src/templates/samples.ts +75 -0
- package/src/templates/severity.ts +102 -0
- package/src/templates/theme.ts +212 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/hostApp.ts +54 -0
- package/src/workflows/hostEnv.ts +219 -0
- package/src/workflows/instanceLiveness.ts +39 -0
- package/src/workflows/instances.ts +16 -0
- package/src/workflows/params.ts +35 -0
- package/src/workflows/scheduler.ts +220 -0
- package/src/workflows/sendBatch.ts +154 -0
- package/src/workflows/worker.ts +203 -0
- package/src/workflows/wrangler.jsonc +75 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pithy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @pithy-sh/email
|
|
2
|
+
|
|
3
|
+
D1-backed email for Cloudflare. Every send is a durable job: scheduled, retried, tracked.
|
|
4
|
+
|
|
5
|
+
Themeable templates, click, open and unsubscribe tracking, and bounce-driven suppression. No mail is ever sent inline.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pithy add email
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Documentation: [pithy.sh/docs/capabilities/email](https://pithy.sh/docs/capabilities/email).** Overview, adding it, using it, and the reference: the job model, send modes, templates.
|
|
12
|
+
|
|
13
|
+
_Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT — adopter-side app value. The root `LICENSE` covers it.
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pithy-sh/email",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/pithy-sh/pithy.git",
|
|
8
|
+
"directory": "packages/email"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"pithy.manifest.json",
|
|
13
|
+
"!src/**/*.test.*"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
"./src/*": "./src/*.ts"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"precompile": "bun scripts/precompile.ts",
|
|
24
|
+
"build": "bun run precompile && tsc -p tsconfig.json --noEmit false --outDir dist",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
26
|
+
"test": "bun run precompile && vitest run",
|
|
27
|
+
"test:node": "bun run precompile && vitest run --project=node",
|
|
28
|
+
"test:workers": "bun run precompile && vitest run --project=workers",
|
|
29
|
+
"test:integration": "vitest run --config vitest.integration.config.ts",
|
|
30
|
+
"clean": "rm -rf dist .turbo",
|
|
31
|
+
"reset": "bun run clean && rm -rf node_modules"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@cloudflare/workers-types": "^5.20260729.1",
|
|
35
|
+
"@hono/zod-validator": "^0.9.0",
|
|
36
|
+
"@pithy-sh/core": "workspace:*",
|
|
37
|
+
"@pithy-sh/secrets": "workspace:*",
|
|
38
|
+
"handlebars": "^4.7.9",
|
|
39
|
+
"hono": "^4.13.2",
|
|
40
|
+
"kysely": "^0.29.0",
|
|
41
|
+
"postal-mime": "^3.0.0",
|
|
42
|
+
"zod": "^4.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@cloudflare/vitest-plugin": "^1.0.0",
|
|
46
|
+
"@pithy-sh/cloudflare": "workspace:*",
|
|
47
|
+
"@pithy-sh/tsconfig": "workspace:*",
|
|
48
|
+
"@types/node": "^22.15.0",
|
|
49
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
50
|
+
"kysely-d1": "^0.4.0",
|
|
51
|
+
"typescript": "^7.0.2",
|
|
52
|
+
"vitest": "^4.1.0",
|
|
53
|
+
"wrangler": "^4.115.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "email",
|
|
3
|
+
"package": "@pithy-sh/email",
|
|
4
|
+
"requiredBindings": [
|
|
5
|
+
{ "type": "d1", "name": "DB" },
|
|
6
|
+
{ "type": "d1", "name": "EMAIL_SUPPRESSIONS" },
|
|
7
|
+
{ "type": "workflow", "name": "EMAIL_SENDER", "job": "send", "className": "EmailSendWorkflow" }
|
|
8
|
+
],
|
|
9
|
+
"peerCapabilities": ["secrets"],
|
|
10
|
+
"migrationNamespace": "email",
|
|
11
|
+
"devSecrets": [{ "name": "email-link-signing-key", "devValue": "random" }],
|
|
12
|
+
"secrets": [
|
|
13
|
+
{
|
|
14
|
+
"name": "email-link-signing-key",
|
|
15
|
+
"origin": {
|
|
16
|
+
"kind": "minted",
|
|
17
|
+
"recipe": {
|
|
18
|
+
"kind": "random",
|
|
19
|
+
"bytes": 32,
|
|
20
|
+
"encoding": "base64url"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"rotation": {
|
|
24
|
+
"kind": "local"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"whenToEnable": "Transactional and lifecycle email — magic link, OTP, welcome, security alerts, invites, plus newsletters and campaigns. Every send is a durable job: scheduled, retried, and tracked. Auth needs it for magic-link and OTP delivery.",
|
|
29
|
+
"scaffold": [
|
|
30
|
+
"Onboard the sending domain onto Cloudflare Email Service (SPF/DKIM/DMARC are configured automatically for a Cloudflare-hosted zone). The from address must use that domain. See the package README.",
|
|
31
|
+
"Create the link-signing key: `pithy secrets create email-link-signing-key`.",
|
|
32
|
+
"Run `pithy email provision` — it creates the shared suppression database, migrates it, and deploys the per-environment email worker (send + scheduler Workflows, every-minute cron, send binding).",
|
|
33
|
+
"To capture bounces in your own worker, enable Email Routing on a subdomain (never the apex — that would move your receiving MX) and pass `--routing-zone <id> --inbound-address <addr> --app-worker <name>` to `pithy email provision`; it creates the one routing rule that delivers bounce/complaint mail to the production app worker's inbound email() handler. Cloudflare also auto-suppresses hard bounces account-wide regardless."
|
|
34
|
+
],
|
|
35
|
+
"configOptions": [
|
|
36
|
+
{
|
|
37
|
+
"key": "fromAddress",
|
|
38
|
+
"default": "noreply@example.com",
|
|
39
|
+
"describe": "The address every email is sent from. Must use a domain onboarded onto Cloudflare Email Service."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"key": "fromName",
|
|
43
|
+
"default": "Pithy",
|
|
44
|
+
"describe": "The sender display name recipients see. A recognizable name improves deliverability."
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"key": "baseUrl",
|
|
48
|
+
"default": "https://api.example.com",
|
|
49
|
+
"constant": "publicOrigin",
|
|
50
|
+
"describe": "The public base URL of your app worker. Tracking and unsubscribe links are built against it, so it must be reachable from a recipient's inbox."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"key": "basePath",
|
|
54
|
+
"default": "/email",
|
|
55
|
+
"describe": "Where the management routes mount — the send log (`<basePath>/jobs`) and the suppression list (`<basePath>/suppressions`). Every one is control-plane and default-denied: they answer a connected management client, never an end user. The recipient callbacks (click, open, unsubscribe) are not affected and keep their fixed `/_pithy/email` prefix, because those URLs are already minted into mail nobody can recall."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"key": "theme",
|
|
59
|
+
"default": "saffron",
|
|
60
|
+
"describe": "An off-the-shelf theme to bootstrap from: `saffron` (Pithy default), `midnight` (blue), `forest` (green), or `rose` (warm). Fine-tune it with a `customTheme` object in pithy.config.ts (accent, logo URLs, palette colors); body width is per-template, not a theme setting."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"key": "schedulerEnabled",
|
|
64
|
+
"default": true,
|
|
65
|
+
"describe": "Whether the every-minute scheduler Workflow runs. It finds due scheduled and per-timezone sends and fans them out. Disable only if you send exclusively immediate email."
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"key": "devDelivery",
|
|
69
|
+
"default": "remote",
|
|
70
|
+
"describe": "What the prebuilt email worker does with a message under `pithy dev`. The default, `remote`, SENDS REAL MAIL from your machine: the Worker runs locally and delivers through Cloudflare Email Service, so a magic link you trigger from localhost arrives in the real inbox — same DKIM, same delivery logs as production. It needs a Cloudflare login `wrangler dev` can use and a sending domain already onboarded. Set `simulator` to send nothing: wrangler logs the sender, recipient and subject and writes the rendered bodies to disk, which is what an offline machine and CI want. Local development only — every deployed environment always sends for real."
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
package/src/analytics.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { EmailEventType } from "./data/enums";
|
|
5
|
+
import type { EmailDatabase } from "./data/tables";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-batch email analytics, read from `pithy_email_events` in the app database. A "batch" is a
|
|
9
|
+
* `campaignId` (set on marketing sends and any send that passes one), so a single grouped query yields
|
|
10
|
+
* how many were sent, opened, clicked, bounced, complained, and unsubscribed.
|
|
11
|
+
*
|
|
12
|
+
* Events live in the **per-environment** app DB and are written where the action happens: `sent` by the
|
|
13
|
+
* send Workflow, `open`/`click`/`unsubscribe` by the callbacks (whose links point at the sending
|
|
14
|
+
* environment's worker), and `bounce`/`complaint` by the inbound handler. So a production campaign's
|
|
15
|
+
* full funnel lands in production's app DB; cross-environment bounce attribution is platform-limited
|
|
16
|
+
* (one inbound worker per domain), so feature/staging see their own opens/clicks/unsubscribes but not
|
|
17
|
+
* asynchronously-routed bounces.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Event counts for a campaign, one per `EmailEventType`. Types with no events are `0`. */
|
|
21
|
+
export type CampaignStats = Record<EmailEventType, number>;
|
|
22
|
+
|
|
23
|
+
/** Every event type at zero — derived from the enum so a new `EmailEventType` can't be forgotten here. */
|
|
24
|
+
const ZERO: CampaignStats = Object.fromEntries(EmailEventType.options.map((type) => [type, 0])) as CampaignStats;
|
|
25
|
+
|
|
26
|
+
/** Aggregate the events recorded for one campaign into per-type counts. */
|
|
27
|
+
export async function campaignStats(db: EmailDatabase, campaignId: string): Promise<CampaignStats> {
|
|
28
|
+
const rows = await db
|
|
29
|
+
.selectFrom("pithyEmailEvents")
|
|
30
|
+
.select(["type"])
|
|
31
|
+
.select((eb) => eb.fn.countAll<number>().as("count"))
|
|
32
|
+
.where("campaignId", "=", campaignId)
|
|
33
|
+
.groupBy("type")
|
|
34
|
+
.execute();
|
|
35
|
+
|
|
36
|
+
const stats: CampaignStats = { ...ZERO };
|
|
37
|
+
for (const row of rows) stats[row.type as EmailEventType] = Number(row.count);
|
|
38
|
+
return stats;
|
|
39
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The audit actions this capability emits, through the core `emit()` seam.
|
|
6
|
+
*
|
|
7
|
+
* **Reads are audited here, not only writes.** The job log is a list of who was mailed what and when,
|
|
8
|
+
* and the suppression list is every address in the project that ever bounced, complained, or opted
|
|
9
|
+
* out — reading either is a disclosure of other people's personal data, and a management credential
|
|
10
|
+
* quietly paging through one of them at three in the morning is exactly the event an adopter needs to
|
|
11
|
+
* be able to find afterwards. "Which operator read the suppression list on the twelfth" is a question
|
|
12
|
+
* with an answer, and these are what make it one.
|
|
13
|
+
*
|
|
14
|
+
* Emitted with `c.var.emit`, never by importing `@pithy-sh/audit` — the seam is always present
|
|
15
|
+
* (`noopEmit` when no audit capability is composed), so there is no null check and no hard dependency.
|
|
16
|
+
* Identifiers, counts, and filters in metadata: never a rendered subject, never a template payload.
|
|
17
|
+
*/
|
|
18
|
+
export const EmailAuditActions = {
|
|
19
|
+
/**
|
|
20
|
+
* A page of the job log was read. Recorded with the filter and the page size, not the rows — the
|
|
21
|
+
* point is that somebody read *some* of it, and how much.
|
|
22
|
+
*/
|
|
23
|
+
jobsRead: "email/jobs_read",
|
|
24
|
+
/** One job was opened in full, which is where a recipient's whole address is disclosed. */
|
|
25
|
+
jobRead: "email/job_read",
|
|
26
|
+
/**
|
|
27
|
+
* A failed job was put back in the queue.
|
|
28
|
+
*
|
|
29
|
+
* The one operation here that sends mail to a real person, so it is recorded at `warning` with the
|
|
30
|
+
* job id and the status it came from.
|
|
31
|
+
*/
|
|
32
|
+
jobRetried: "email/job_retried",
|
|
33
|
+
/** A page of the global suppression list was read — addresses, in bulk, across every environment. */
|
|
34
|
+
suppressionsRead: "email/suppressions_read",
|
|
35
|
+
/** An address was blocked by hand. Silent to the recipient, so the trail is the only record. */
|
|
36
|
+
suppressionAdded: "email/suppression_added",
|
|
37
|
+
/**
|
|
38
|
+
* An address was unblocked.
|
|
39
|
+
*
|
|
40
|
+
* The most dangerous line in this file: it re-opens sending to somebody who hard-bounced, reported
|
|
41
|
+
* spam, or asked to be left alone. Recorded at `warning` with the reason the row carried, so the
|
|
42
|
+
* trail says what was undone and not merely that something was.
|
|
43
|
+
*/
|
|
44
|
+
suppressionRemoved: "email/suppression_removed",
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
/** One of the email capability's audit actions. */
|
|
48
|
+
export type EmailAuditAction = (typeof EmailAuditActions)[keyof typeof EmailAuditActions];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { BounceType } from "../data/enums";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Classify an inbound message as a bounce, complaint, auto-reply, or something to ignore. With the
|
|
8
|
+
* modern Email Service, hard bounces are auto-suppressed at the platform and surfaced synchronously on
|
|
9
|
+
* send; this handles delivery-status notifications (DSNs) and feedback-loop complaints (ARF) that are
|
|
10
|
+
* *routed back* to the worker, plus vacation auto-replies (a deliberate no-op).
|
|
11
|
+
*
|
|
12
|
+
* The classifier is pure and works on the already-parsed pieces of the message — its headers and the
|
|
13
|
+
* raw body text — so it is unit-testable without the Workers runtime. Identification is best-effort:
|
|
14
|
+
* the failed recipient comes from the DSN's `Final-Recipient`, and the originating job is matched later
|
|
15
|
+
* by the original `Message-ID` against the stored send `messageId` (no custom VERP return path, which
|
|
16
|
+
* the simple send binding cannot set).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What an inbound message turned out to be. `ignore` and `auto_reply` produce no side effects. */
|
|
20
|
+
export interface InboundClassification {
|
|
21
|
+
/** The classification — `ignore` for anything that isn't a recognized bounce/complaint/auto-reply. */
|
|
22
|
+
type: BounceType | "ignore";
|
|
23
|
+
/** The affected recipient address, when the message identifies one. */
|
|
24
|
+
recipient?: string;
|
|
25
|
+
/** The SMTP/DSN status or diagnostic code, for the row and the event detail. */
|
|
26
|
+
code?: string;
|
|
27
|
+
/** The original message's `Message-ID`, used to match the job that bounced. */
|
|
28
|
+
originalMessageId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read a header-style field (`Name: value`) from a block of text, case-insensitively. */
|
|
32
|
+
function field(text: string, name: string): string | undefined {
|
|
33
|
+
const match = new RegExp(`^${name}:\\s*(.+)$`, "im").exec(text);
|
|
34
|
+
return match?.[1]?.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Extract the address from an RFC822 recipient line like `rfc822;user@example.com` or `<user@x>`. */
|
|
38
|
+
function extractAddress(value: string | undefined): string | undefined {
|
|
39
|
+
if (!value) return undefined;
|
|
40
|
+
const afterSemicolon = value.includes(";") ? value.slice(value.indexOf(";") + 1) : value;
|
|
41
|
+
const angle = /<([^>]+)>/.exec(afterSemicolon);
|
|
42
|
+
const addr = (angle?.[1] ?? afterSemicolon).trim();
|
|
43
|
+
return addr.includes("@") ? addr : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The original message id — the explicit DSN field, else the last `Message-ID` (the embedded original). */
|
|
47
|
+
function originalMessageId(raw: string): string | undefined {
|
|
48
|
+
const explicit = field(raw, "Original-Message-ID");
|
|
49
|
+
if (explicit) return explicit.replace(/^<|>$/g, "");
|
|
50
|
+
const ids = [...raw.matchAll(/^Message-ID:\s*<([^>]+)>/gim)].map((m) => m[1]);
|
|
51
|
+
return ids.length > 0 ? ids[ids.length - 1] : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The inbound message's parsed pieces the classifier needs. */
|
|
55
|
+
export interface InboundMessage {
|
|
56
|
+
/** The top-level `Content-Type` header value (carries `report-type` for DSN/ARF). */
|
|
57
|
+
contentType?: string;
|
|
58
|
+
/** The `Auto-Submitted` header value, if present. */
|
|
59
|
+
autoSubmitted?: string;
|
|
60
|
+
/** Whether any auto-responder header (`X-Autoreply`, `X-Autorespond`) is present. */
|
|
61
|
+
autoReplyHeader?: boolean;
|
|
62
|
+
/** The raw message body text — DSN/ARF fields are line-structured within it. */
|
|
63
|
+
raw: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Classify an inbound message. */
|
|
67
|
+
export function classifyInbound(message: InboundMessage): InboundClassification {
|
|
68
|
+
const contentType = (message.contentType ?? "").toLowerCase();
|
|
69
|
+
const raw = message.raw;
|
|
70
|
+
|
|
71
|
+
// A delivery-status notification (DSN): a hard or soft bounce.
|
|
72
|
+
if (contentType.includes("report-type=delivery-status") || /^Action:\s*failed/im.test(raw)) {
|
|
73
|
+
const status = field(raw, "Status");
|
|
74
|
+
const diagnostic = field(raw, "Diagnostic-Code");
|
|
75
|
+
const recipient = extractAddress(field(raw, "Final-Recipient") ?? field(raw, "Original-Recipient"));
|
|
76
|
+
const soft = status?.startsWith("4") ?? false;
|
|
77
|
+
return {
|
|
78
|
+
type: soft ? "soft" : "hard",
|
|
79
|
+
recipient,
|
|
80
|
+
code: status ?? diagnostic,
|
|
81
|
+
originalMessageId: originalMessageId(raw),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A feedback-loop complaint (ARF): the recipient reported the message as spam.
|
|
86
|
+
if (contentType.includes("report-type=feedback-report") || /^Feedback-Type:/im.test(raw)) {
|
|
87
|
+
const recipient = extractAddress(field(raw, "Original-Rcpt-To") ?? field(raw, "Removal-Recipient"));
|
|
88
|
+
return {
|
|
89
|
+
type: "complaint",
|
|
90
|
+
recipient,
|
|
91
|
+
code: field(raw, "Feedback-Type") ?? "complaint",
|
|
92
|
+
originalMessageId: originalMessageId(raw),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A vacation/auto-responder reply: explicitly a no-op so it never suppresses a real recipient.
|
|
97
|
+
const autoSubmitted = (message.autoSubmitted ?? "").toLowerCase();
|
|
98
|
+
if (message.autoReplyHeader || (autoSubmitted && autoSubmitted !== "no")) {
|
|
99
|
+
return { type: "auto_reply" };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { type: "ignore" };
|
|
103
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { normalizeAddress } from "@pithy-sh/core/src/address/address";
|
|
6
|
+
import type { CapabilityEmailHandler } from "@pithy-sh/core/src/capability/capability";
|
|
7
|
+
import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
8
|
+
import PostalMime from "postal-mime";
|
|
9
|
+
import {
|
|
10
|
+
type EmailDatabase,
|
|
11
|
+
type EmailSuppressionDatabase,
|
|
12
|
+
emailDatabase,
|
|
13
|
+
emailSuppressionDatabase,
|
|
14
|
+
} from "../data/tables";
|
|
15
|
+
import { recordEvent } from "../send/events";
|
|
16
|
+
import { suppress } from "../send/suppression";
|
|
17
|
+
import { classifyInbound, type InboundClassification } from "./classify";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The inbound bounce/complaint handler — the capability's `email()` seam. The Worker entrypoint fans
|
|
21
|
+
* incoming mail to it; it parses the message, classifies it, and applies the result: a hard bounce or
|
|
22
|
+
* complaint suppresses the address and marks the originating job bounced; a soft bounce records the
|
|
23
|
+
* event but does not suppress (Cloudflare auto-retries those); an auto-reply or anything unrecognized is
|
|
24
|
+
* a no-op. The job is matched by the original `Message-ID` against the stored send `messageId`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** What `applyInbound` did, for logging and tests. */
|
|
28
|
+
export interface InboundOutcome {
|
|
29
|
+
/** Whether any side effect was applied (false for auto-reply / ignore). */
|
|
30
|
+
acted: boolean;
|
|
31
|
+
/** The classification type. */
|
|
32
|
+
type: InboundClassification["type"];
|
|
33
|
+
/** The job id matched by original Message-ID, if any. */
|
|
34
|
+
jobId?: string;
|
|
35
|
+
/** Whether the address was added to the suppression list. */
|
|
36
|
+
suppressed: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apply a classification's side effects. Suppression is written to the **shared** suppression database
|
|
41
|
+
* (so it applies in every environment); the job lookup and the bounce/complaint event use the app
|
|
42
|
+
* database. Pure of any MIME parsing — unit-testable. Note the single inbound worker only reaches its
|
|
43
|
+
* own app DB, so a job-row update is best-effort (the originating job may live in another environment),
|
|
44
|
+
* whereas the suppression — the safety-critical part — is always global.
|
|
45
|
+
*/
|
|
46
|
+
export async function applyInbound(
|
|
47
|
+
db: EmailDatabase,
|
|
48
|
+
suppressionDb: EmailSuppressionDatabase,
|
|
49
|
+
c: InboundClassification,
|
|
50
|
+
now: Date,
|
|
51
|
+
environment?: string,
|
|
52
|
+
): Promise<InboundOutcome> {
|
|
53
|
+
if (c.type === "ignore" || c.type === "auto_reply") return { acted: false, type: c.type, suppressed: false };
|
|
54
|
+
|
|
55
|
+
// Match the originating job by the original Message-ID against the stored send messageId.
|
|
56
|
+
const job = c.originalMessageId
|
|
57
|
+
? await db
|
|
58
|
+
.selectFrom("pithyEmailJobs")
|
|
59
|
+
.select(["id", "toAddress", "status", "campaignId"])
|
|
60
|
+
.where("messageId", "=", c.originalMessageId)
|
|
61
|
+
.executeTakeFirst()
|
|
62
|
+
: undefined;
|
|
63
|
+
|
|
64
|
+
const recipient = c.recipient ? normalizeAddress(c.recipient) : job ? normalizeAddress(job.toAddress) : undefined;
|
|
65
|
+
const isSuppressing = c.type === "hard" || c.type === "complaint";
|
|
66
|
+
let suppressed = false;
|
|
67
|
+
|
|
68
|
+
if (recipient && isSuppressing) {
|
|
69
|
+
await suppress(
|
|
70
|
+
suppressionDb,
|
|
71
|
+
{
|
|
72
|
+
email: recipient,
|
|
73
|
+
reason: c.type === "complaint" ? "complaint" : "hard_bounce",
|
|
74
|
+
jobId: job?.id ?? null,
|
|
75
|
+
environment,
|
|
76
|
+
detail: c.code ?? null,
|
|
77
|
+
},
|
|
78
|
+
now,
|
|
79
|
+
);
|
|
80
|
+
suppressed = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (job) {
|
|
84
|
+
await db
|
|
85
|
+
.updateTable("pithyEmailJobs")
|
|
86
|
+
.set({
|
|
87
|
+
bounceCode: c.code ?? null,
|
|
88
|
+
bounceType: c.type,
|
|
89
|
+
status: isSuppressing ? "bounced" : job.status,
|
|
90
|
+
updatedAt: SQLiteDate.encode(now),
|
|
91
|
+
})
|
|
92
|
+
.where("id", "=", job.id)
|
|
93
|
+
.execute();
|
|
94
|
+
// Record the event with the job's campaign so batch analytics (opens/clicks/bounces/unsubscribes
|
|
95
|
+
// per campaign) see bounces alongside the rest.
|
|
96
|
+
await recordEvent(
|
|
97
|
+
db,
|
|
98
|
+
{
|
|
99
|
+
jobId: job.id,
|
|
100
|
+
recipient: recipient ?? normalizeAddress(job.toAddress),
|
|
101
|
+
type: c.type === "complaint" ? "complaint" : "bounce",
|
|
102
|
+
campaignId: job.campaignId ?? null,
|
|
103
|
+
detail: c.code ?? null,
|
|
104
|
+
},
|
|
105
|
+
now,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { acted: suppressed || Boolean(job), type: c.type, jobId: job?.id, suppressed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Build the inbound-email handler for the `Capability.email` seam. Reads `DB` + `EMAIL_SUPPRESSIONS` from env. */
|
|
113
|
+
export function createBounceHandler(): CapabilityEmailHandler {
|
|
114
|
+
return async (message, env) => {
|
|
115
|
+
const raw = await new Response(message.raw).text();
|
|
116
|
+
const parsed = await PostalMime.parse(raw);
|
|
117
|
+
const headers: Record<string, string> = {};
|
|
118
|
+
for (const header of parsed.headers) headers[header.key.toLowerCase()] = header.value;
|
|
119
|
+
|
|
120
|
+
const classification = classifyInbound({
|
|
121
|
+
contentType: headers["content-type"],
|
|
122
|
+
autoSubmitted: headers["auto-submitted"],
|
|
123
|
+
autoReplyHeader: "x-autoreply" in headers || "x-autorespond" in headers,
|
|
124
|
+
raw,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const bindings = env as unknown as { DB: D1Database; EMAIL_SUPPRESSIONS: D1Database; ENVIRONMENT?: string };
|
|
128
|
+
await applyInbound(
|
|
129
|
+
emailDatabase(bindings.DB),
|
|
130
|
+
emailSuppressionDatabase(bindings.EMAIL_SUPPRESSIONS),
|
|
131
|
+
classification,
|
|
132
|
+
new Date(),
|
|
133
|
+
bindings.ENVIRONMENT,
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
}
|