@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +131 -23
  3. package/dist/cli/run.d.ts +6 -2
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +297 -40
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +35 -5
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/index.d.ts +14 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/next/import-deliveries.d.ts +4 -0
  14. package/dist/next/import-deliveries.d.ts.map +1 -0
  15. package/dist/next/import-deliveries.js +9 -0
  16. package/dist/next/import-deliveries.js.map +1 -0
  17. package/dist/next/index.d.ts +1 -0
  18. package/dist/next/index.d.ts.map +1 -1
  19. package/dist/next/index.js +1 -0
  20. package/dist/next/index.js.map +1 -1
  21. package/dist/next/route.d.ts +3 -2
  22. package/dist/next/route.d.ts.map +1 -1
  23. package/dist/next/route.js +5 -4
  24. package/dist/next/route.js.map +1 -1
  25. package/dist/options.d.ts +19 -2
  26. package/dist/options.d.ts.map +1 -1
  27. package/dist/options.js +30 -6
  28. package/dist/options.js.map +1 -1
  29. package/dist/schema.d.ts +17 -0
  30. package/dist/schema.d.ts.map +1 -1
  31. package/dist/schema.js +2 -0
  32. package/dist/schema.js.map +1 -1
  33. package/dist/server/campaigns.d.ts +5 -0
  34. package/dist/server/campaigns.d.ts.map +1 -1
  35. package/dist/server/campaigns.js +10 -0
  36. package/dist/server/campaigns.js.map +1 -1
  37. package/dist/server/deliveries.d.ts +9 -5
  38. package/dist/server/deliveries.d.ts.map +1 -1
  39. package/dist/server/deliveries.js +9 -7
  40. package/dist/server/deliveries.js.map +1 -1
  41. package/dist/server/dns.d.ts +48 -1
  42. package/dist/server/dns.d.ts.map +1 -1
  43. package/dist/server/dns.js +124 -15
  44. package/dist/server/dns.js.map +1 -1
  45. package/dist/server/import-deliveries.d.ts +68 -0
  46. package/dist/server/import-deliveries.d.ts.map +1 -0
  47. package/dist/server/import-deliveries.js +105 -0
  48. package/dist/server/import-deliveries.js.map +1 -0
  49. package/dist/server/index.d.ts +4 -3
  50. package/dist/server/index.d.ts.map +1 -1
  51. package/dist/server/index.js +4 -3
  52. package/dist/server/index.js.map +1 -1
  53. package/dist/server/suppressions.d.ts.map +1 -1
  54. package/dist/server/suppressions.js +9 -6
  55. package/dist/server/suppressions.js.map +1 -1
  56. package/dist/server/unsubscribe-link.d.ts +3 -2
  57. package/dist/server/unsubscribe-link.d.ts.map +1 -1
  58. package/dist/server/unsubscribe-link.js +12 -3
  59. package/dist/server/unsubscribe-link.js.map +1 -1
  60. package/migrations/0004_allow_imported_deliveries.sql +20 -0
  61. package/module.json +1 -1
  62. package/package.json +1 -1
  63. package/src/cli/run.ts +311 -39
  64. package/src/contract.ts +30 -5
  65. package/src/index.ts +4 -1
  66. package/src/next/import-deliveries.ts +9 -0
  67. package/src/next/index.ts +1 -0
  68. package/src/next/route.ts +5 -4
  69. package/src/options.ts +36 -7
  70. package/src/schema.ts +2 -0
  71. package/src/server/campaigns.ts +13 -0
  72. package/src/server/deliveries.ts +15 -8
  73. package/src/server/dns.ts +174 -16
  74. package/src/server/import-deliveries.ts +149 -0
  75. package/src/server/index.ts +21 -0
  76. package/src/server/suppressions.ts +15 -8
  77. 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. Ported from FIRE_TRACKER `src/lib/mail.ts` (a hand-written Resend `fetch`, plain text only),
5
- with the sender, reply-to, provider and timeout taken from configuration, an HTML body, and a fake
6
- provider for tests. List mail gets signed one-click unsubscribe (RFC 8058) and a suppression list,
7
- ported from FIRE_TRACKER `src/lib/unsubscribe-*.ts` and `/wypisz`. A delivery ledger sends a mail
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) or 500 (database
93
- failure, so the client retries); a GET redirects to the page. The whole link is a credential: it is
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`. A halt (`provider_refused`, `quota_exceeded`)
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 reads TXT records and reports each check as `pass`, `warn` or `fail`, exiting 1 on any `fail`:
200
- SPF (one `v=spf1` record on the domain or each `--spf-host`; `+all` warns), DKIM (a non-empty `p=`
201
- at `<selector>._domainkey.<domain>`; selector `resend` unless `--dkim-selector` is given) and DMARC
202
- (`_dmarc.<domain>` or a parent domain's record; `p=none` warns: it only reports). Resend publishes
203
- SPF on `send.<domain>`. It cannot see whether the provider signs with the published key.
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), `ctx.db` is the transaction. A throw rolls the opt-out back: the route answers 500 and the
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 (1 to 8; never `r` or `status`, which the module's links and
351
- page own; `t` may be shared). A link without `r` that carries every one of them (non-empty, at most
352
- 512 characters) is a legacy link: the page shows the same button with the values in hidden fields,
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;
@@ -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;AAC/F,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIhG,OAAO,EAAkD,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnG,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,+EAA+E;IAC/E,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,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,QAAQ,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;CACpB;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,QAqBa,CAAC;AAmBrC,yGAAyG;AACzG,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiB5E"}
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"}