@softure-ai/mailing 0.1.7 → 0.1.9
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/CHANGELOG.md +41 -0
- package/README.md +131 -23
- package/dist/cli/run.d.ts +6 -2
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +297 -40
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +35 -5
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/next/import-deliveries.d.ts +4 -0
- package/dist/next/import-deliveries.d.ts.map +1 -0
- package/dist/next/import-deliveries.js +9 -0
- package/dist/next/import-deliveries.js.map +1 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +1 -0
- package/dist/next/index.js.map +1 -1
- package/dist/next/route.d.ts +3 -2
- package/dist/next/route.d.ts.map +1 -1
- package/dist/next/route.js +5 -4
- package/dist/next/route.js.map +1 -1
- package/dist/options.d.ts +19 -2
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +30 -6
- package/dist/options.js.map +1 -1
- package/dist/schema.d.ts +17 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -0
- package/dist/schema.js.map +1 -1
- package/dist/server/campaigns.d.ts +5 -0
- package/dist/server/campaigns.d.ts.map +1 -1
- package/dist/server/campaigns.js +10 -0
- package/dist/server/campaigns.js.map +1 -1
- package/dist/server/deliveries.d.ts +9 -5
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +9 -7
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/dns.d.ts +48 -1
- package/dist/server/dns.d.ts.map +1 -1
- package/dist/server/dns.js +124 -15
- package/dist/server/dns.js.map +1 -1
- package/dist/server/import-deliveries.d.ts +68 -0
- package/dist/server/import-deliveries.d.ts.map +1 -0
- package/dist/server/import-deliveries.js +105 -0
- package/dist/server/import-deliveries.js.map +1 -0
- package/dist/server/index.d.ts +4 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +4 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/suppressions.d.ts.map +1 -1
- package/dist/server/suppressions.js +9 -6
- package/dist/server/suppressions.js.map +1 -1
- package/dist/server/unsubscribe-link.d.ts +3 -2
- package/dist/server/unsubscribe-link.d.ts.map +1 -1
- package/dist/server/unsubscribe-link.js +12 -3
- package/dist/server/unsubscribe-link.js.map +1 -1
- package/migrations/0004_allow_imported_deliveries.sql +20 -0
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/cli/run.ts +311 -39
- package/src/contract.ts +30 -5
- package/src/index.ts +4 -1
- package/src/next/import-deliveries.ts +9 -0
- package/src/next/index.ts +1 -0
- package/src/next/route.ts +5 -4
- package/src/options.ts +36 -7
- package/src/schema.ts +2 -0
- package/src/server/campaigns.ts +13 -0
- package/src/server/deliveries.ts +15 -8
- package/src/server/dns.ts +174 -16
- package/src/server/import-deliveries.ts +149 -0
- package/src/server/index.ts +21 -0
- package/src/server/suppressions.ts +15 -8
- package/src/server/unsubscribe-link.ts +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,47 @@ Newest first. Each version lists what changed for an app that uses `@softure-ai/
|
|
|
4
4
|
production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
|
|
5
5
|
"Definition of done"). Versions before the first one below are described in their GitHub Releases (`mailing@x.y.z`).
|
|
6
6
|
|
|
7
|
+
## 0.1.9
|
|
8
|
+
|
|
9
|
+
- New `importDeliveries(ctx, rows)` (`/server`, and `importDeliveries(rows)` in `/next`) and `softure-mail import
|
|
10
|
+
<file|->` (JSON Lines): seed `mailing.deliveries` with the deliveries an app made before it adopted the module, so
|
|
11
|
+
the first `deliverOnce` or campaign run skips them. Rows `{ scope, address, status: "sent" | "rejected",
|
|
12
|
+
finishedAt, providerMessageId?, reason?, kind? }`; every row is checked before anything is written, rows already
|
|
13
|
+
in the ledger are left as they are, a re-run imports nothing twice. `checkImportedDeliveries` checks without
|
|
14
|
+
writing (`--dry-run`).
|
|
15
|
+
- Migration `0004`: `mailing.deliveries.imported_at`; an imported `sent` row may have no `provider_message_id`.
|
|
16
|
+
Run `softure migrate`.
|
|
17
|
+
- `maxAttempts` is a module option (default 5) and may be `null`, in the module or per `deliverOnce` call: then
|
|
18
|
+
`mailing.unavailable` always releases the claim and never closes the delivery.
|
|
19
|
+
- `listCampaignRecipients(campaign, ctx)` module option: `softure-mail campaign` without `--recipients` takes the
|
|
20
|
+
recipients from it. New `listConfiguredCampaignRecipients` in `/server`.
|
|
21
|
+
- `softure-mail` reads a file given as `-` from standard input (the campaign's content file or `--recipients`, the
|
|
22
|
+
import's history file), so it runs inside the app's container with `docker compose exec -T`. The README documents
|
|
23
|
+
that topology.
|
|
24
|
+
- `legacyUnsubscribe.params` also takes `{ required, optional }`: a link is legacy when it carries every required
|
|
25
|
+
name, and the optional names it carries go to `verify` too, so two old link forms on one path both keep working.
|
|
26
|
+
The array form still means "all required".
|
|
27
|
+
- `mailing({ oneClickInvalidLinkStatus: 200 })`: the one-click route answers 200 for a link that does not verify, so
|
|
28
|
+
the answer never tells whether a token is live. The default stays 400; a failure still answers 500.
|
|
29
|
+
- `onUnsubscribed` receives `event.link`: `{ scheme: "signed" }` or `{ scheme: "legacy", values }` with the values
|
|
30
|
+
`verify` accepted, so the hook can find the row a legacy link named. New types `LegacyUnsubscribeParams` and
|
|
31
|
+
`VerifiedUnsubscribeLink`.
|
|
32
|
+
|
|
33
|
+
## 0.1.8
|
|
34
|
+
|
|
35
|
+
- `checkSenderDns` takes `expectDmarc: { policy, subdomainPolicy, adkim, aspf }`: a DMARC record weaker than it (a
|
|
36
|
+
lower policy, relaxed or absent alignment where strict is required, `pct` under 100) fails with the new finding
|
|
37
|
+
`weak`. Without the option nothing changes.
|
|
38
|
+
- `replyTo` checks that the reply domain accepts mail: MX records that are not a null MX (finding `null-mx`) and at
|
|
39
|
+
most one SPF record.
|
|
40
|
+
- `returnPath: [{ host, targetDomain? }]` checks return-path hosts: a CNAME (to the target domain, else finding
|
|
41
|
+
`unexpected-target`) or MX records. New `resendReturnPath(domain)` gives Resend's `send.` and `rsend.` hosts.
|
|
42
|
+
- `SenderDnsReport` has two new keys, `replyTo` (`null` when not asked) and `returnPath` (`[]` when not asked); new
|
|
43
|
+
resolver options `resolveMx` and `resolveCname`.
|
|
44
|
+
- `softure-mail dns`: `--dmarc-policy`, `--dmarc-sp`, `--dmarc-adkim`, `--dmarc-aspf`, `--reply-to`,
|
|
45
|
+
`--return-path`, `--resend-return-path`; without `--domain` it also checks the configured `replyTo`. New output
|
|
46
|
+
lines `REPLY` and `PATH`.
|
|
47
|
+
|
|
7
48
|
## 0.1.7
|
|
8
49
|
|
|
9
50
|
- A failed send keeps the provider's HTTP status: `SendMailResult` failures and `deliverOnce` outcomes carry
|
package/README.md
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# @softure-ai/mailing
|
|
2
2
|
|
|
3
3
|
Sends one mail to one recipient through a provider adapter and answers with a typed result, never a
|
|
4
|
-
throw
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
at most once per scope and recipient, and `softure-mail` sends campaigns from a content file where
|
|
9
|
-
FIRE ran deployment-specific scripts.
|
|
4
|
+
throw, with the sender, reply-to, provider and timeout taken from configuration, an HTML body, and a
|
|
5
|
+
fake provider for tests. List mail gets signed one-click unsubscribe (RFC 8058) and a suppression
|
|
6
|
+
list. A delivery ledger sends a mail at most once per scope and recipient, an app's earlier history
|
|
7
|
+
can be imported into it, and `softure-mail` sends campaigns from a content file.
|
|
10
8
|
|
|
11
9
|
## 1. What it provides
|
|
12
10
|
|
|
@@ -42,10 +40,12 @@ FIRE ran deployment-specific scripts.
|
|
|
42
40
|
the withdrawal, and **`legacyUnsubscribe`**, which keeps unsubscribe links the app sent before it
|
|
43
41
|
adopted the module working (section 10).
|
|
44
42
|
- **Sending once.** `deliverOnce(ctx, { scope, mail })` (`/server`, and `deliverOnce({ scope, mail })`
|
|
45
|
-
in `/next`) sends a mail at most once per scope and recipient through the delivery ledger
|
|
43
|
+
in `/next`) sends a mail at most once per scope and recipient through the delivery ledger;
|
|
44
|
+
`importDeliveries` (and `softure-mail import`) seeds it with the app's earlier history.
|
|
46
45
|
- **Campaigns.** `sendCampaign`, `planCampaign` and the content file parser in `/server`; the
|
|
47
46
|
`softure-mail campaign` command sends a campaign from a content file.
|
|
48
|
-
- **Sender DNS.** `checkSenderDns(domain)` and `softure-mail dns` report SPF, DKIM and DMARC
|
|
47
|
+
- **Sender DNS.** `checkSenderDns(domain)` and `softure-mail dns` report SPF, DKIM and DMARC, hold DMARC to a
|
|
48
|
+
required minimum, and check the reply-to domain's MX and the provider's return-path hosts.
|
|
49
49
|
- The `MailProvider` contract and `resend()`, the first adapter.
|
|
50
50
|
- `@softure-ai/mailing/testing`: `fakeMailProvider()` and `readMailOutbox(file)`.
|
|
51
51
|
|
|
@@ -89,8 +89,9 @@ address, and `t`, the base64url HMAC-SHA256 of a fixed prefix plus `r` under
|
|
|
89
89
|
`MAILING_UNSUBSCRIBE_SECRET`. The address never appears in a URL, and the suppression table stores
|
|
90
90
|
only `r`. The page (footer link) shows a button and changes nothing on open: mail scanners open
|
|
91
91
|
links. The one-click route takes a mail client's POST without a session, verifies before touching
|
|
92
|
-
the database and answers 200 (recorded, also again), 400 (link does not verify
|
|
93
|
-
failure, so the client retries); a GET redirects
|
|
92
|
+
the database and answers 200 (recorded, also again), 400 (link does not verify; 200 with
|
|
93
|
+
`oneClickInvalidLinkStatus: 200`) or 500 (database failure, so the client retries); a GET redirects
|
|
94
|
+
to the page. The whole link is a credential: it is
|
|
94
95
|
never logged.
|
|
95
96
|
|
|
96
97
|
### Sending once: the delivery ledger
|
|
@@ -123,7 +124,10 @@ const outcome = await deliverOnce({
|
|
|
123
124
|
Lowercase letters, digits and `._:-`, at most 128 characters. Campaigns use `campaign:<id>`.
|
|
124
125
|
- **Idempotency key.** The ledger sends with `<scope>:<recipient key>`; callers do not pass one.
|
|
125
126
|
- **Retries.** `unavailable` releases the claim (`pending`); the fifth attempt (`maxAttempts`) that
|
|
126
|
-
is still unavailable closes the row as `rejected`.
|
|
127
|
+
is still unavailable closes the row as `rejected`. Five rides out a short provider outage across
|
|
128
|
+
a few runs, while a provider that keeps failing on one mail does not keep it open forever. An app
|
|
129
|
+
that retries on its own schedule sets `mailing({ maxAttempts: null })` (or passes it per call):
|
|
130
|
+
`unavailable` then always releases the claim and never closes the row. A halt (`provider_refused`, `quota_exceeded`)
|
|
127
131
|
never closes a row and never uses up an attempt, so a run with a bad key loses no recipient. The
|
|
128
132
|
provider's HTTP status of the last failed answer is kept in `provider_status`.
|
|
129
133
|
- **Interrupted sends.** A process that dies between the send and the outcome leaves a claim; after
|
|
@@ -137,6 +141,47 @@ const outcome = await deliverOnce({
|
|
|
137
141
|
claim cannot overwrite the one that took over.
|
|
138
142
|
- A malformed scope or kind throws (a bug); a database failure propagates.
|
|
139
143
|
|
|
144
|
+
### Importing an existing history
|
|
145
|
+
|
|
146
|
+
An app that already kept its own once-only records (which campaign went to which signup, which
|
|
147
|
+
notice went to which user for which period) seeds the ledger before its first `deliverOnce` or
|
|
148
|
+
campaign run; otherwise that run mails everyone again. Map each record to the scope the module
|
|
149
|
+
will use for it and import:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { importDeliveries } from "@softure-ai/mailing/server";
|
|
153
|
+
|
|
154
|
+
const result = await importDeliveries(ctx, [
|
|
155
|
+
{ scope: "campaign:2026-09-launch", address: "ada@example.org", status: "sent", finishedAt: "2026-09-20T10:15:00Z", kind: "newsletter" },
|
|
156
|
+
{ scope: "account.trial-ending:user_7", address: "bob@example.org", status: "rejected", finishedAt: row.sentAt, reason: "mailing.rejected" },
|
|
157
|
+
]);
|
|
158
|
+
if (!result.ok) console.error(result.problems); // [{ index, problem }]: nothing was written
|
|
159
|
+
else console.log(result.value); // { rows, imported, alreadyPresent, duplicates }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Or from a file, one JSON object per line with the same fields:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
softure-mail import history.jsonl --dry-run # checks the file, writes nothing
|
|
166
|
+
softure-mail import history.jsonl
|
|
167
|
+
docker compose exec -T app npx softure-mail import - < history.jsonl # inside the app's container
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
- Rows: `scope`, `address` (only its key is stored), `status` (`sent` or `rejected`), `finishedAt`
|
|
171
|
+
(a date or ISO string, not in the future), optional `providerMessageId` (sent rows; history
|
|
172
|
+
rarely kept it, and an imported sent row may go without), `reason` (rejected rows: one of
|
|
173
|
+
`mailing.invalid_input`, `rejected`, `unavailable`, `suppressed`; default `mailing.rejected`) and
|
|
174
|
+
`kind` (default `transactional`).
|
|
175
|
+
- **Every row is checked before anything is written**; problems name the row (the file's line) and
|
|
176
|
+
the field, never the address.
|
|
177
|
+
- **Idempotent.** A row whose scope and recipient the ledger already has (imported earlier, or sent
|
|
178
|
+
by the module) is left as it is and counted as `alreadyPresent`; re-run the same import after a
|
|
179
|
+
failure. Two input rows for the same scope and recipient count once (`duplicates`).
|
|
180
|
+
- Imported rows are closed (`sent` or `rejected`), with `imported_at` set and no `campaign_id`:
|
|
181
|
+
`deliverOnce`, `planCampaign` and `sendCampaign` match a delivery by scope, so they skip it.
|
|
182
|
+
- Unsubscribes the app recorded go to `suppressRecipient`, not here: the suppression list is what
|
|
183
|
+
stops future campaigns.
|
|
184
|
+
|
|
140
185
|
### Campaigns: `softure-mail campaign`
|
|
141
186
|
|
|
142
187
|
A campaign is one list mail to many recipients through the ledger. The content file is frontmatter
|
|
@@ -160,6 +205,25 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
|
|
|
160
205
|
```
|
|
161
206
|
|
|
162
207
|
- The recipients file holds one address per line (`#` comments and blank lines skipped).
|
|
208
|
+
- **Recipients from the database.** Without `--recipients` the command asks
|
|
209
|
+
`mailing({ listCampaignRecipients })`, which lists the addresses from the app's own data
|
|
210
|
+
(duplicates are fine; the filter and the suppression list still apply):
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
listCampaignRecipients: async ({ kind }, ctx) => listOptedInAddresses(ctx.db, kind),
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- **Where it runs.** The command needs the database (the ledger) and the provider key, so it runs
|
|
217
|
+
where both are: inside the app's container. When the database is not reachable from the
|
|
218
|
+
operator's machine, send the content file on standard input (`-`) and let the recipients come
|
|
219
|
+
from `listCampaignRecipients` (or pass `--recipients -` with the content as a file in the image):
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
ssh app-host 'cd /srv/app && docker compose exec -T app npx softure-mail campaign - --dry-run' < launch.md
|
|
223
|
+
ssh app-host 'cd /srv/app && docker compose exec -T app npx softure-mail campaign -' < launch.md
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
With the content on standard input, its `html:` file is looked up in the working directory.
|
|
163
227
|
- The command loads `softure.config.*` like `softure migrate` (or `--config <file>`), opens the
|
|
164
228
|
config's `database.handle` when set, otherwise its own connection on `database.url`, and needs `MAILING_UNSUBSCRIBE_SECRET` (campaigns are list
|
|
165
229
|
mail). Run `softure migrate` first.
|
|
@@ -192,15 +256,32 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
|
|
|
192
256
|
### Sender DNS check: `softure-mail dns`
|
|
193
257
|
|
|
194
258
|
```bash
|
|
195
|
-
softure-mail dns # the domain of `from` in the config
|
|
259
|
+
softure-mail dns # the domain of `from` (and replyTo) in the config
|
|
196
260
|
softure-mail dns --domain mail.example.com --spf-host send.mail.example.com
|
|
261
|
+
softure-mail dns --spf-host send.mail.example.com --resend-return-path \
|
|
262
|
+
--dmarc-policy reject --dmarc-sp reject --dmarc-adkim s --dmarc-aspf s
|
|
197
263
|
```
|
|
198
264
|
|
|
199
|
-
It
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
265
|
+
It reports each check as `pass`, `warn` or `fail`, exiting 1 on any `fail`:
|
|
266
|
+
|
|
267
|
+
- **SPF**: one `v=spf1` record on the domain or each `--spf-host`; `+all` warns. Resend publishes SPF on
|
|
268
|
+
`send.<domain>`.
|
|
269
|
+
- **DKIM**: a non-empty `p=` at `<selector>._domainkey.<domain>`; selector `resend` unless `--dkim-selector` is given.
|
|
270
|
+
- **DMARC**: `_dmarc.<domain>` or a parent domain's record; `p=none` warns: it only reports. With a required minimum
|
|
271
|
+
(`expectDmarc: { policy, subdomainPolicy, adkim, aspf }`, or the `--dmarc-*` flags) a weaker record fails as
|
|
272
|
+
`weak`: `p=quarantine` where `reject` is required, `adkim=r` (or no `adkim`) where `s` is required, `pct` under 100
|
|
273
|
+
when a policy is required. A stricter record passes, so adding `rua=` or tightening a tag never turns it red. For
|
|
274
|
+
an inherited record the domain's policy is `sp`, else `p`.
|
|
275
|
+
- **REPLY** (when `replyTo` is configured and `--domain` is not given, or with `--reply-to`; `replyTo` option of the
|
|
276
|
+
function): the reply domain must have MX records that are not a null MX (`0 .`, "accepts no mail") and at most one
|
|
277
|
+
SPF record. Without it replies bounce while every sender check is green.
|
|
278
|
+
- **PATH** (`returnPath: [{ host, targetDomain? }]`, `--return-path <host>`, `--resend-return-path`): each host must
|
|
279
|
+
be a CNAME (to `targetDomain` or under it, when set) or have MX records. `resendReturnPath(domain)` (the flag) gives
|
|
280
|
+
Resend's `send.<domain>` and `rsend.<domain>` with target `rmta.net`; a domain Resend set up with MX records on
|
|
281
|
+
`send` and no `rsend` checks `--return-path send.<domain>` alone.
|
|
282
|
+
|
|
283
|
+
It cannot see whether the provider signs with the published key. The function takes `resolveTxt`, `resolveMx` and
|
|
284
|
+
`resolveCname` for tests; by default it asks the system resolver.
|
|
204
285
|
|
|
205
286
|
## 2. Installation
|
|
206
287
|
|
|
@@ -232,7 +313,10 @@ mailing({
|
|
|
232
313
|
| `timeoutMs` | `number` | `10000` | 1000 to 60000. |
|
|
233
314
|
| `onUnsubscribed` | `(event, ctx) => Promise<void>` | — | Runs on every verified unsubscribe, in its transaction (section 10). |
|
|
234
315
|
| `legacyUnsubscribe` | `{ params, verify }` | — | Verifies unsubscribe links the app sent before it adopted the module (section 10). |
|
|
316
|
+
| `oneClickInvalidLinkStatus` | `200 \| 400` | `400` | What the one-click route answers for a link that does not verify. `200` gives no oracle on whether a token is live; a failure still answers 500, and the page still shows its message. |
|
|
235
317
|
| `filterCampaignRecipient` | `(recipient, ctx) => Promise<boolean>` | — | Decides per recipient whether a campaign goes to them (section 1, Campaigns). |
|
|
318
|
+
| `listCampaignRecipients` | `(campaign, ctx) => Iterable<string> \| AsyncIterable<string> \| Promise<Iterable<string>>` | — | Lists a campaign's recipients from the app's data; `softure-mail campaign` uses it without `--recipients` (section 1, Campaigns). |
|
|
319
|
+
| `maxAttempts` | `number \| null` | `5` | Claims before `mailing.unavailable` closes a delivery as rejected, 1 to 100; `null` never closes it (section 1, Retries). |
|
|
236
320
|
| `staleClaimMs` | `number` | `900000` (15 min) | How long a delivery claim may stay open before another sender takes it over. 1 minute to 23 hours. |
|
|
237
321
|
| `uncertainClaimMs` | `number` | `82800000` (23 h) | How old a claim may get before it waits for an operator (`uncertain`). More than `staleClaimMs`, at most 30 days; keep it under the provider's idempotency window. |
|
|
238
322
|
| `routes` | `{ unsubscribe?, oneClick? }` | `/unsubscribe`, `/api/mailing/unsubscribe` | Where you mount the page and the route; links are built on `appOrigin` plus these paths. |
|
|
@@ -292,8 +376,9 @@ Schema `mailing`, applied by `softure migrate`:
|
|
|
292
376
|
| --- | --- | --- |
|
|
293
377
|
| `0001_create_suppressions.sql` | `mailing.suppressions` | `recipient_key` (primary key, 43-character base64url), `source` (`one-click`, `page`, `operator`), `created_at`. One row per address, the first opt-out kept. |
|
|
294
378
|
| `0002_create_campaigns_and_deliveries.sql` | `mailing.campaigns` | `id` (kebab-case), `kind` (never `transactional`), `subject`, `content_hash` (sha256 of kind, subject and bodies), `created_at`. |
|
|
295
|
-
| | `mailing.deliveries` | primary key (`scope`, `recipient_key`), `kind`, `campaign_id` (then `scope` is `campaign:<id>`), `status` (`pending`, `claimed`, `sent`, `rejected`), `attempts`, `claimed_at`, `finished_at`, `provider_message_id` (exactly when sent), `reason` (exactly when rejected). |
|
|
379
|
+
| | `mailing.deliveries` | primary key (`scope`, `recipient_key`), `kind`, `campaign_id` (then `scope` is `campaign:<id>`), `status` (`pending`, `claimed`, `sent`, `rejected`), `attempts`, `claimed_at`, `finished_at`, `provider_message_id` (exactly when sent; see `0004`), `reason` (exactly when rejected). |
|
|
296
380
|
| `0003_add_delivery_provider_status.sql` | `mailing.deliveries` | `provider_status` (the HTTP status of the last failed or released answer, 100 to 599, else null); `attempts` may be 0 on a `pending` row (a halt gave its attempt back). |
|
|
381
|
+
| `0004_allow_imported_deliveries.sql` | `mailing.deliveries` | `imported_at` (set by `importDeliveries`, only on `sent`/`rejected` rows); `provider_message_id` only on `sent` rows, and required there unless the row was imported. |
|
|
297
382
|
|
|
298
383
|
The health check (`checkMailingTables`) runs `select 1 from mailing.<table> limit 0` for the three
|
|
299
384
|
tables.
|
|
@@ -333,8 +418,9 @@ The provider is the extension point (section 3).
|
|
|
333
418
|
|
|
334
419
|
**`onUnsubscribed(event, ctx)`** runs on every verified unsubscribe (the page's button or a mail
|
|
335
420
|
client's one-click POST), also a repeated one, in the transaction that stores the opt-out:
|
|
336
|
-
`event` is `{ recipientKey, source }` (the key is privacy's email key of the same address, never the
|
|
337
|
-
address
|
|
421
|
+
`event` is `{ recipientKey, source, link }` (the key is privacy's email key of the same address, never the
|
|
422
|
+
address; `link` is `{ scheme: "signed" }` or `{ scheme: "legacy", values }` with the values `verify`
|
|
423
|
+
accepted, so a hook can find the row a legacy link named), `ctx.db` is the transaction. A throw rolls the opt-out back: the route answers 500 and the
|
|
338
424
|
page offers a retry, so the opt-out and what the hook records never disagree. It does not run for
|
|
339
425
|
`suppressRecipient` (a bounce or a script is not the person's choice). Wire the waitlist's handler,
|
|
340
426
|
which withdraws its consents in privacy's ledger:
|
|
@@ -347,9 +433,12 @@ mailing({ from: "…", provider: resend(), onUnsubscribed: withdrawWaitlistConse
|
|
|
347
433
|
|
|
348
434
|
**`legacyUnsubscribe: { params, verify }`** keeps unsubscribe links working that the app sent
|
|
349
435
|
before it adopted the module, in its own scheme (an HMAC over a sign-up id, say, on its own path).
|
|
350
|
-
`params` are the old link's query names (
|
|
351
|
-
|
|
352
|
-
|
|
436
|
+
`params` are the old link's query names (never `r` or `status`, which the module's links and page
|
|
437
|
+
own; `t` may be shared). An array names parameters the link must all carry; `{ required, optional }`
|
|
438
|
+
also names ones it may carry, for an app that sent more than one form on the same path (at most 8
|
|
439
|
+
names in all, at least one required). A link without `r` that carries every required one
|
|
440
|
+
(non-empty, at most 512 characters) is a legacy link, and its values also hold the optional ones it
|
|
441
|
+
has (an empty one counts as absent, one over 512 characters makes the link invalid): the page shows the same button with the values in hidden fields,
|
|
353
442
|
and the action and the one-click POST call `verify(values, ctx)`. It returns the recipient's
|
|
354
443
|
**address** when the link is genuine, else `null`; the module records the opt-out under that
|
|
355
444
|
address's key and runs `onUnsubscribed`, exactly as for its own links. `verify` must check the
|
|
@@ -368,6 +457,25 @@ mailing({
|
|
|
368
457
|
export { UnsubscribePage as default } from "@softure-ai/mailing/next";
|
|
369
458
|
```
|
|
370
459
|
|
|
460
|
+
Two forms on one path (a signed `?u=<id>&t=<hmac>` in mail and a bare `?t=<token>` shown after
|
|
461
|
+
sign-up), an opt-out recorded on the app's own row, and a one-click route that never tells a live
|
|
462
|
+
token from a dead one:
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
mailing({
|
|
466
|
+
from: "…",
|
|
467
|
+
provider: resend(),
|
|
468
|
+
oneClickInvalidLinkStatus: 200,
|
|
469
|
+
legacyUnsubscribe: {
|
|
470
|
+
params: { required: ["t"], optional: ["u"] },
|
|
471
|
+
verify: ({ u, t }, ctx) => (u === undefined ? findAddressByToken(ctx.db, t) : verifyOldLink(ctx.db, u, t)),
|
|
472
|
+
},
|
|
473
|
+
onUnsubscribed: async (event, ctx) => {
|
|
474
|
+
if (event.link.scheme === "legacy") await markSignupUnsubscribed(ctx.db, event.link.values);
|
|
475
|
+
},
|
|
476
|
+
}),
|
|
477
|
+
```
|
|
478
|
+
|
|
371
479
|
**`liftSuppression(ctx, address)`** is the other direction: a module that has just recorded a new
|
|
372
480
|
explicit consent (the waitlist's sign-up) calls it in that consent's transaction. It deletes the
|
|
373
481
|
row only when its source is `page` or `one-click`; an `operator` row stays. It returns whether it
|
package/dist/cli/run.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { type SoftureConfig } from "@softure-ai/core";
|
|
2
2
|
import { type DatabaseHandle } from "@softure-ai/db";
|
|
3
|
-
import { type ResolveTxt } from "../server/dns.js";
|
|
3
|
+
import { type ResolveCname, type ResolveMx, type ResolveTxt } from "../server/dns.js";
|
|
4
4
|
import { type Env } from "../server/unsubscribe-link.js";
|
|
5
5
|
export interface CliOutput {
|
|
6
6
|
readonly log: (line: string) => void;
|
|
7
7
|
readonly error: (line: string) => void;
|
|
8
8
|
}
|
|
9
9
|
export interface RunMailCliOptions {
|
|
10
|
-
/** The app's config. Needed by `campaign`, and by `dns` without `--domain`. */
|
|
10
|
+
/** The app's config. Needed by `campaign` and `import`, and by `dns` without `--domain`. */
|
|
11
11
|
readonly config?: SoftureConfig;
|
|
12
12
|
/** The arguments after the executable, e.g. `["campaign", "launch.md", "--recipients", "list.txt"]`. */
|
|
13
13
|
readonly argv: readonly string[];
|
|
@@ -17,8 +17,12 @@ export interface RunMailCliOptions {
|
|
|
17
17
|
/** Opens the campaign's database connection. Default: the config's `database.handle`, else `createDatabase(url, { max: 1 })`. */
|
|
18
18
|
readonly openDatabase?: (url: string) => Promise<DatabaseHandle>;
|
|
19
19
|
readonly resolveTxt?: ResolveTxt;
|
|
20
|
+
readonly resolveMx?: ResolveMx;
|
|
21
|
+
readonly resolveCname?: ResolveCname;
|
|
20
22
|
readonly sleep?: (ms: number) => Promise<void>;
|
|
21
23
|
readonly env?: Env;
|
|
24
|
+
/** Reads all of standard input, for a file given as `-`. Default: `process.stdin`. */
|
|
25
|
+
readonly readStdin?: () => Promise<string>;
|
|
22
26
|
}
|
|
23
27
|
export declare const EXIT_OK = 0;
|
|
24
28
|
export declare const EXIT_FAILED = 1;
|
package/dist/cli/run.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAWA,OAAO,EAAe,KAAK,aAAa,EAA8B,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAWA,OAAO,EAAe,KAAK,aAAa,EAA8B,MAAM,kBAAkB,CAAC;AAE/F,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIhG,OAAO,EAAkI,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAuB,MAAM,kBAAkB,CAAC;AAG3O,OAAO,EAAiF,KAAK,GAAG,EAAE,MAAM,+BAA+B,CAAC;AAExI,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,iBAAiB;IAChC,4FAA4F;IAC5F,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC,wGAAwG;IACxG,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,mEAAmE;IACnE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,iIAAiI;IACjI,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IACjE,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC;IACjC,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,QAAQ,CAAC,YAAY,CAAC,EAAE,YAAY,CAAC;IACrC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,QAAQ,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;IACnB,sFAAsF;IACtF,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;CAC5C;AAED,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,UAAU,IAAI,CAAC;AAE5B,8EAA8E;AAC9E,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,eAAO,MAAM,UAAU,QA6C8C,CAAC;AAqCtE,yGAAyG;AACzG,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAmB5E"}
|