@softure-ai/mailing 0.1.11 → 0.1.13

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 (92) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +182 -9
  3. package/dist/cli/run.d.ts +1 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +179 -25
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +5 -3
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/next/index.d.ts +3 -1
  13. package/dist/next/index.d.ts.map +1 -1
  14. package/dist/next/index.js +3 -1
  15. package/dist/next/index.js.map +1 -1
  16. package/dist/next/pages.d.ts +25 -0
  17. package/dist/next/pages.d.ts.map +1 -1
  18. package/dist/next/pages.js +33 -10
  19. package/dist/next/pages.js.map +1 -1
  20. package/dist/next/preview-mail.d.ts +5 -0
  21. package/dist/next/preview-mail.d.ts.map +1 -0
  22. package/dist/next/preview-mail.js +8 -0
  23. package/dist/next/preview-mail.js.map +1 -0
  24. package/dist/next/run-deliveries.d.ts +4 -0
  25. package/dist/next/run-deliveries.d.ts.map +1 -0
  26. package/dist/next/run-deliveries.js +7 -0
  27. package/dist/next/run-deliveries.js.map +1 -0
  28. package/dist/options.d.ts +3 -1
  29. package/dist/options.d.ts.map +1 -1
  30. package/dist/options.js +16 -1
  31. package/dist/options.js.map +1 -1
  32. package/dist/server/campaign-file.d.ts +7 -2
  33. package/dist/server/campaign-file.d.ts.map +1 -1
  34. package/dist/server/campaign-file.js +35 -15
  35. package/dist/server/campaign-file.js.map +1 -1
  36. package/dist/server/campaigns.d.ts +13 -8
  37. package/dist/server/campaigns.d.ts.map +1 -1
  38. package/dist/server/campaigns.js +15 -14
  39. package/dist/server/campaigns.js.map +1 -1
  40. package/dist/server/deliveries.d.ts +32 -0
  41. package/dist/server/deliveries.d.ts.map +1 -1
  42. package/dist/server/deliveries.js +36 -0
  43. package/dist/server/deliveries.js.map +1 -1
  44. package/dist/server/dns.d.ts +15 -1
  45. package/dist/server/dns.d.ts.map +1 -1
  46. package/dist/server/dns.js +45 -4
  47. package/dist/server/dns.js.map +1 -1
  48. package/dist/server/index.d.ts +7 -5
  49. package/dist/server/index.d.ts.map +1 -1
  50. package/dist/server/index.js +6 -4
  51. package/dist/server/index.js.map +1 -1
  52. package/dist/server/operator-output.d.ts +5 -0
  53. package/dist/server/operator-output.d.ts.map +1 -0
  54. package/dist/server/operator-output.js +19 -0
  55. package/dist/server/operator-output.js.map +1 -0
  56. package/dist/server/options.d.ts +2 -0
  57. package/dist/server/options.d.ts.map +1 -1
  58. package/dist/server/options.js +5 -0
  59. package/dist/server/options.js.map +1 -1
  60. package/dist/server/run-deliveries.d.ts +62 -0
  61. package/dist/server/run-deliveries.d.ts.map +1 -0
  62. package/dist/server/run-deliveries.js +133 -0
  63. package/dist/server/run-deliveries.js.map +1 -0
  64. package/dist/server/send-mail.d.ts +21 -2
  65. package/dist/server/send-mail.d.ts.map +1 -1
  66. package/dist/server/send-mail.js +50 -30
  67. package/dist/server/send-mail.js.map +1 -1
  68. package/dist/server/suppressions.d.ts +5 -0
  69. package/dist/server/suppressions.d.ts.map +1 -1
  70. package/dist/server/suppressions.js +27 -1
  71. package/dist/server/suppressions.js.map +1 -1
  72. package/migrations/0005_add_sql_surface.sql +44 -0
  73. package/module.json +1 -1
  74. package/package.json +1 -1
  75. package/src/cli/run.ts +184 -27
  76. package/src/contract.ts +9 -3
  77. package/src/index.ts +1 -1
  78. package/src/next/index.ts +3 -1
  79. package/src/next/pages.tsx +69 -14
  80. package/src/next/preview-mail.ts +9 -0
  81. package/src/next/run-deliveries.ts +10 -0
  82. package/src/options.ts +18 -1
  83. package/src/server/campaign-file.ts +36 -2
  84. package/src/server/campaigns.ts +25 -21
  85. package/src/server/deliveries.ts +53 -0
  86. package/src/server/dns.ts +60 -4
  87. package/src/server/index.ts +9 -3
  88. package/src/server/operator-output.ts +20 -0
  89. package/src/server/options.ts +6 -0
  90. package/src/server/run-deliveries.ts +193 -0
  91. package/src/server/send-mail.ts +63 -37
  92. package/src/server/suppressions.ts +28 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,48 @@ 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.13
8
+
9
+ - `runDeliveries` and `sendCampaign` stop at the first `retry-later` (the provider is down, timed out or rate
10
+ limited), as they stop at a refused account: `halted: { reason: "mailing.unavailable", httpStatus }`,
11
+ `remaining: null`. Before, every following recipient got the same answer and spent an attempt, so an outage longer
12
+ than `maxAttempts` runs closed them all as `rejected`. New option `stopOnRetryLater` (default `true`); pass `false`
13
+ for the old behaviour. `halted.reason` (and `CampaignHalt`) is now the new exported `RunHaltReason`
14
+ (`HaltingErrorCode | "mailing.unavailable"`): a map keyed by `HaltingErrorCode` over it needs the new entry.
15
+ `softure-mail campaign` explains the new reason.
16
+
17
+ ## 0.1.12
18
+
19
+ - New `runDeliveries(ctx, { recipients, build }, { limit, pauseMs, dryRun, onDelivery })` (`/server`, and
20
+ `runDeliveries(input, options)` in `/next`): one `deliverOnce` per recipient, `build` returning the delivery or
21
+ `null` to skip. Stops at a refused account or a spent quota (`halted`, `remaining: null`), counts `limit` like
22
+ `sendCampaign`, and returns `DeliveryRunSummary` (`dryRun`, `recipients`, `skipped`, `sent`, `rejected` by code,
23
+ `done`, `inFlight`, `retryLater`, `uncertain`, `halted`, `remaining`). A dry run writes and sends nothing.
24
+ - Migration `0005`: a SQL surface for the app's own queries, `mailing.recipient_key(text)` (equal to
25
+ `getRecipientKey`), `mailing.is_suppressed(text)`, `mailing.was_delivered(scope, text)`, and the views
26
+ `mailing.delivery_outcomes` and `mailing.suppressed_recipients`. Run `softure migrate`. Dropping
27
+ `mailing.deliveries` or `mailing.suppressions` now needs `CASCADE`.
28
+ - New `previewMail(config, mail, { env })` (`/server`, `previewMail(mail)` in `/next`): exactly the
29
+ `ProviderMessage` `sendMail` would hand the provider, footer and RFC 8058 headers included, without sending,
30
+ logging or reading the database. New `maskAddress` and `redactUnsubscribeSignatures` for operator output.
31
+ - `mailing({ testAddress })` and `softure-mail test [<content-file>] [--kind <kind>] [--preview]`: sends a fixed
32
+ test mail, or a campaign's content outside the ledger, to that address only.
33
+ - `softure-mail campaign`: the dry run prints the mail as the test address gets it (addresses masked, link
34
+ signatures redacted); `--content-file <file>` names the content file for `softure-deploy run`, which the README
35
+ documents. `getCampaignProblems(content, config)` also refuses a body with a pasted unsubscribe link or footer, and
36
+ `sendCampaign`, `planCampaign` and the command refuse such content.
37
+ - `mailing({ kindAliases: { news: "newsletter" } })` and `resolveMailKind(config, kind)`: campaign files and
38
+ `softure-mail test --kind` may name a kind by its alias.
39
+ - `checkSenderDns(domain, { inbound: "cloudflare" })` and `softure-mail dns --inbound cloudflare`: Cloudflare Email
40
+ Routing's MX and one SPF record including `_spf.mx.cloudflare.net` on the reply-to domain (else the checked
41
+ domain). New finding `missing-include`, new report key `inbound` (`[]` when not asked), new `CLOUDFLARE_INBOUND`.
42
+ Code that builds a `SenderDnsReport` by hand must add `inbound`.
43
+ - `legacyUnsubscribe.verify(values, ctx, env)` receives the environment `unsubscribe` runs with.
44
+ - New `createUnsubscribePage({ classNames, unstyled, Layout })` in `/next`: slots `root`, `card`, `form`, `submit`,
45
+ or the app's own layout around the copy and the form. `UnsubscribePage` is unchanged.
46
+ - New `findSuppressedAddresses(ctx, addresses)` (`/server`): which of many addresses unsubscribed, each
47
+ returned as given, in batched queries; for reports such as the waitlist's suppressed split (#324).
48
+
7
49
  ## 0.1.11
8
50
 
9
51
  - `sendCampaign(ctx, input, { limit })` hands at most `limit` mails to the provider in one run (sent, rejected by it,
package/README.md CHANGED
@@ -31,7 +31,8 @@ can be imported into it, and `softure-mail` sends campaigns from a content file.
31
31
  when the recipient unsubscribed. An unsubscribe covers every list kind.
32
32
  - The unsubscribe page (`/unsubscribe`) and the one-click route (`/api/mailing/unsubscribe`) in `/next`,
33
33
  each mounted with one line (section 4).
34
- - `@softure-ai/mailing/server`: `isSuppressed(ctx, address)`, `suppressRecipient(ctx, address)` (for
34
+ - `@softure-ai/mailing/server`: `isSuppressed(ctx, address)`, `findSuppressedAddresses(ctx, addresses)`
35
+ (which of many addresses unsubscribed, for a report), `suppressRecipient(ctx, address)` (for
35
36
  scripts, bounce or complaint handlers), `unsubscribe(ctx, link, source)` (a link from
36
37
  `readUnsubscribeLink(params, config)`, or a bare token),
37
38
  `liftSuppression(ctx, address)` (a new explicit consent lifts the person's own opt-out),
@@ -41,11 +42,18 @@ can be imported into it, and `softure-mail` sends campaigns from a content file.
41
42
  adopted the module working (section 10).
42
43
  - **Sending once.** `deliverOnce(ctx, { scope, mail })` (`/server`, and `deliverOnce({ scope, mail })`
43
44
  in `/next`) sends a mail at most once per scope and recipient through the delivery ledger;
45
+ `runDeliveries` does it for a whole list of recipients, with a limit, a pause and a dry run;
44
46
  `importDeliveries` (and `softure-mail import`) seeds it with the app's earlier history.
47
+ - **Preview and test send.** `previewMail(config, mail)` returns exactly what `sendMail` hands the
48
+ provider; `softure-mail test` sends a mail to the configured `testAddress` and nowhere else.
49
+ - **A SQL surface** for the app's own queries: `mailing.recipient_key(address)`,
50
+ `mailing.is_suppressed(address)`, `mailing.was_delivered(scope, address)` and the views
51
+ `mailing.delivery_outcomes` and `mailing.suppressed_recipients` (section 5).
45
52
  - **Campaigns.** `sendCampaign`, `planCampaign` and the content file parser in `/server`; the
46
53
  `softure-mail campaign` command sends a campaign from a content file.
47
54
  - **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.
55
+ required minimum, and check the reply-to domain's MX, the provider's return-path hosts and an inbound
56
+ service (Cloudflare Email Routing).
49
57
  - The `MailProvider` contract and `resend()`, the first adapter.
50
58
  - `@softure-ai/mailing/testing`: `fakeMailProvider()` and `readMailOutbox(file)`.
51
59
 
@@ -145,6 +153,49 @@ const outcome = await deliverOnce({
145
153
  claim cannot overwrite the one that took over.
146
154
  - A malformed scope or kind throws (a bug); a database failure propagates.
147
155
 
156
+ ### Many recipients at once: `runDeliveries`
157
+
158
+ Lifecycle mail (a trial that ends, a renewal) goes to every recipient a query finds, each at most
159
+ once. `runDeliveries` loops over the recipients, builds each one's delivery and sends it through
160
+ `deliverOnce`:
161
+
162
+ ```ts
163
+ import { runDeliveries } from "@softure-ai/mailing/next"; // or /server with ctx first
164
+
165
+ const summary = await runDeliveries(
166
+ {
167
+ recipients: await listTrialsEndingThisWeek(),
168
+ build: (trial) => (trial.email === null ? null : {
169
+ scope: `billing.trial-ending:${trial.subscriptionId}`,
170
+ mail: { to: trial.email, subject: messages.trialEnding.subject, text: renderText(trial), kind: "account-notices" },
171
+ }),
172
+ },
173
+ { limit: 80, pauseMs: 500 },
174
+ );
175
+ // { dryRun, recipients, skipped, sent, rejected: { "mailing.suppressed": 1, … }, done, inFlight, retryLater,
176
+ // uncertain, halted, remaining }
177
+ ```
178
+
179
+ - `recipients` is an iterable or async iterable of anything; `build` returns the delivery (or a
180
+ promise of it), or `null` to skip the recipient (`skipped`, nothing stored).
181
+ - **It stops at the account.** A refused key or a spent quota (`halted`) ends the run at once:
182
+ `halted: { reason, httpStatus }`, `remaining: null`; that recipient and the rest go out on the
183
+ next run. Rejections of one mail (`suppressed`, `rejected`, `invalid_input`) are counted and the
184
+ run goes on.
185
+ - **It stops at an outage.** The first mail the provider cannot take now (`retry-later`: 5xx, a
186
+ timeout, a 429 rate limit) ends the run the same way, as `halted: { reason: "mailing.unavailable",
187
+ httpStatus }`: every following mail would get the same answer and spend one of its `maxAttempts`.
188
+ `stopOnRetryLater: false` keeps the run going instead.
189
+ - **`limit`** counts the mails handed to the provider, like `sendCampaign`'s; once reached, the rest
190
+ of the list is only counted: `remaining` is how many the next run would send. **`pauseMs`**
191
+ pauses after each of those mails.
192
+ - **`dryRun: true`** reads the ledger and the suppression list and checks each mail, and writes and
193
+ sends nothing: `sent` is then what would reach the provider.
194
+ - List mail needs `MAILING_UNSUBSCRIBE_SECRET`: without it the run throws at the first list mail,
195
+ before any claim, instead of using up every recipient's attempts.
196
+ - `onDelivery(outcome)` sees every outcome (never the address); `deliverOnce`'s options
197
+ (`maxAttempts`, `retakeUncertain`, the claim windows) pass through.
198
+
148
199
  ### Importing an existing history
149
200
 
150
201
  An app that already kept its own once-only records (which campaign went to which signup, which
@@ -204,10 +255,25 @@ we shipped something.
204
255
  ```
205
256
 
206
257
  ```bash
207
- softure-mail campaign launch.md --recipients recipients.txt --dry-run # counts, sends nothing
258
+ softure-mail campaign launch.md --recipients recipients.txt --dry-run # counts and previews, sends nothing
208
259
  softure-mail campaign launch.md --recipients recipients.txt # sends
209
260
  ```
210
261
 
262
+ - **The dry run previews the mail** as the provider would get it: sender, reply-to, subject, the
263
+ RFC 8058 headers, the text and HTML bodies with the footer. It is rendered for `testAddress`
264
+ (else `recipient@example.com`), with addresses masked (`o*******@example.com`) and the link
265
+ signatures replaced by `<signature>`, so the output is safe to paste. Without
266
+ `MAILING_UNSUBSCRIBE_SECRET` it says so instead.
267
+ - **No pasted footers.** Content that carries an unsubscribe link (the app's unsubscribe or one-click
268
+ path, or a signed `r=…&t=…` pair) or the footer's copy in any locale is refused before anything
269
+ runs: the module adds a footer signed for each recipient, and a link pasted from a sent mail would
270
+ let every recipient unsubscribe the person it was signed for. `getCampaignProblems(content, config)`
271
+ runs the same check (links on the app's own origin; a link to another site is left alone), and
272
+ `sendCampaign` and `planCampaign` refuse such content.
273
+ - **Kind aliases.** `mailing({ kindAliases: { news: "newsletter" } })` lets a content file (and
274
+ `softure-mail test --kind`) say `kind: news`; the campaign is stored and sent as `newsletter`.
275
+ - **Masked output.** No command prints a full address or a working unsubscribe link.
276
+
211
277
  - The recipients file holds one address per line (`#` comments and blank lines skipped).
212
278
  - **Recipients from the database.** Without `--recipients` the command asks
213
279
  `mailing({ listCampaignRecipients })`, which lists the addresses from the app's own data
@@ -228,6 +294,28 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
228
294
  ```
229
295
 
230
296
  With the content on standard input, its `html:` file is looked up in the working directory.
297
+ - **Through `softure-deploy run`.** An app deployed with `@softure-ai/deploy` runs the command in the
298
+ live container without a shell on the server. `softure-deploy run` passes only `--key=value`
299
+ words, so the content file goes as `--content-file=<file>` (read on the operator's machine and sent
300
+ on standard input) and the recipients come from `listCampaignRecipients`. One file travels per
301
+ run, so such a campaign has no `html:` file. One runner per command in `scripts/ops/`, which the
302
+ app's Dockerfile bundles into `ops/<name>.mjs`:
303
+
304
+ ```ts
305
+ // scripts/ops/mail-campaign.ts
306
+ import { runMailCli } from "@softure-ai/mailing/cli";
307
+ import config from "../../softure.config";
308
+
309
+ process.exitCode = await runMailCli({ config, argv: ["campaign", ...process.argv.slice(2)] });
310
+ ```
311
+
312
+ ```bash
313
+ softure-deploy run --host=app-prod mail-campaign --content-file=launch.md --dry-run
314
+ softure-deploy run --host=app-prod mail-campaign --content-file=launch.md --limit=80
315
+ softure-deploy run --host=app-prod mail-test --content-file=launch.md # scripts/ops/mail-test.ts: argv ["test", ...]
316
+ ```
317
+
318
+ The exit status is the command's: 0 done, 1 failed or incomplete, 2 usage.
231
319
  - The command loads `softure.config.*` like `softure migrate` (or `--config <file>`), opens the
232
320
  config's `database.handle` when set, otherwise its own connection on `database.url`, and needs `MAILING_UNSUBSCRIBE_SECRET` (campaigns are list
233
321
  mail). Run `softure migrate` first.
@@ -246,9 +334,11 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
246
334
  }),
247
335
  ```
248
336
 
249
- - **A refused key or a spent quota stops the run** at the first such answer: the command prints why
250
- (with the HTTP status) and exits 1; that recipient and every one after it are left for the next
251
- run. `sendCampaign` returns the same as `halted: { reason, httpStatus }`.
337
+ - **A refused key, a spent quota or an outage stops the run** at the first such answer (an outage is
338
+ `retry-later`: 5xx, a timeout, a 429 rate limit): the command prints why (with the HTTP status) and
339
+ exits 1; that recipient and every one after it are left for the next run. `sendCampaign` returns the
340
+ same as `halted: { reason, httpStatus }` (`reason` is `mailing.unavailable` for an outage);
341
+ `stopOnRetryLater: false` keeps it going past an outage.
252
342
  - **Instalments with `--limit <n>`.** On a plan whose daily quota is shared with other mail (Resend's
253
343
  free plan gives 100 a day), `--limit 80` hands at most 80 mails to the provider in this run and
254
344
  leaves room for the day's transactional mail. Only mails that reach the provider count (sent,
@@ -266,6 +356,31 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
266
356
  `runMailCli({ config, argv })` from `@softure-ai/mailing/cli`, or `sendCampaign(ctx, { campaign,
267
357
  recipients })` from `/server`, where `recipients` may be an async iterable.
268
358
 
359
+ ### Test send: `softure-mail test`
360
+
361
+ ```bash
362
+ softure-mail test # a short fixed mail, transactional
363
+ softure-mail test --kind newsletter # the same as list mail: footer and unsubscribe headers
364
+ softure-mail test launch.md # a campaign's content, as the campaign would send it
365
+ softure-mail test launch.md --preview # print it, send nothing
366
+ ```
367
+
368
+ It sends to `mailing({ testAddress })` and to no one else; without that option it refuses. A
369
+ campaign file is sent outside the ledger (nothing is registered or recorded), so it can be sent
370
+ again after every edit. `--kind` (an alias of `kindAliases` too) overrides the file's kind; list
371
+ mail opens the database for the suppression check, so an unsubscribed test address answers
372
+ `suppressed`. The output masks the address and names the provider's failure with its HTTP status.
373
+ `--preview` prints what `previewMail` returns, like the campaign's dry run. The preview reads the
374
+ secret from `runMailCli({ env })`, a real send from `process.env`, as every send does.
375
+
376
+ `previewMail(config, mail, { env, idempotencyKey })` (`/server`, and `previewMail(mail)` in `/next`) is
377
+ the function behind both: `ok(message)` with exactly the `ProviderMessage` `sendMail` would hand the
378
+ provider, `{ error: "mailing.invalid_input", fields }`, or `{ error: "mailing.unavailable", cause:
379
+ "no_unsubscribe_secret" }` for list mail without the secret. It sends, logs and reads nothing: a
380
+ suppressed recipient's mail renders too. Its links work for `mail.to`; show them to that person
381
+ only, or pass the text through `redactUnsubscribeSignatures`. `maskAddress(address)` masks an
382
+ address the same way the commands do.
383
+
269
384
  ### Sender DNS check: `softure-mail dns`
270
385
 
271
386
  ```bash
@@ -292,6 +407,11 @@ It reports each check as `pass`, `warn` or `fail`, exiting 1 on any `fail`:
292
407
  be a CNAME (to `targetDomain` or under it, when set) or have MX records. `resendReturnPath(domain)` (the flag) gives
293
408
  Resend's `send.<domain>` and `rsend.<domain>` with target `rmta.net`; a domain Resend set up with MX records on
294
409
  `send` and no `rsend` checks `--return-path send.<domain>` alone.
410
+ - **INBOUND** (`inbound: "cloudflare"`, `--inbound cloudflare`): Cloudflare Email Routing receives the reply-to
411
+ domain's mail (else the checked domain's). Every MX record must be under `mx.cloudflare.net`
412
+ (`unexpected-target` otherwise, `null-mx`, `missing`), and the domain must publish exactly one SPF record
413
+ (`multiple`) that includes `_spf.mx.cloudflare.net` (`missing-include`). The report's `inbound` holds the MX and
414
+ the SPF check, `[]` when not asked.
295
415
 
296
416
  It cannot see whether the provider signs with the published key. The function takes `resolveTxt`, `resolveMx` and
297
417
  `resolveCname` for tests; by default it asks the system resolver.
@@ -332,6 +452,8 @@ mailing({
332
452
  | `maxAttempts` | `number \| null` | `5` | Claims before `mailing.unavailable` closes a delivery as rejected, 1 to 100; `null` never closes it (section 1, Retries). |
333
453
  | `staleClaimMs` | `number` | `900000` (15 min) | How long a delivery claim may stay open before another sender takes it over. 1 minute to 23 hours. |
334
454
  | `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. |
455
+ | `testAddress` | `string` | none | The only address `softure-mail test` sends to (section 1, Test send). |
456
+ | `kindAliases` | `Record<string, string>` | none | Alias to kind for campaign files and `softure-mail test --kind`, e.g. `{ news: "newsletter" }`. Kebab-case on both sides; an alias never names a kind another alias points at. |
335
457
  | `routes` | `{ unsubscribe?, oneClick? }` | `/unsubscribe`, `/api/mailing/unsubscribe` | Where you mount the page and the route; links are built on `appOrigin` plus these paths. |
336
458
 
337
459
  `resend({ apiKey?, endpoint?, fetch? })`: without `apiKey` it reads `RESEND_API_KEY` on every send,
@@ -372,6 +494,28 @@ export { UnsubscribePage as default } from "@softure-ai/mailing/next";
372
494
  export { getUnsubscribeRoute as GET, postUnsubscribeRoute as POST } from "@softure-ai/mailing/next";
373
495
  ```
374
496
 
497
+ **The app's look.** `createUnsubscribePage({ classNames, unstyled, Layout })` gives the same page
498
+ with the app's classes on its slots (`root`, the `main` wrapper; `card`; `form`; `submit`), or with
499
+ its own layout around the copy and the form (the `root` and `card` classes then do not apply; the
500
+ `referrer` policy tag is kept):
501
+
502
+ ```tsx
503
+ // app/unsubscribe/page.tsx
504
+ import { createUnsubscribePage, type UnsubscribeLayoutProps } from "@softure-ai/mailing/next";
505
+
506
+ function AppShell({ title, lead, children }: UnsubscribeLayoutProps) {
507
+ return (
508
+ <SiteFrame>
509
+ <h1>{title}</h1>
510
+ <p>{lead}</p>
511
+ {children}
512
+ </SiteFrame>
513
+ );
514
+ }
515
+
516
+ export default createUnsubscribePage({ Layout: AppShell, classNames: { submit: "w-full" } });
517
+ ```
518
+
375
519
  Both must stay public: keep them out of the auth guard's `protect` list. The route has no per-IP
376
520
  rate limit on purpose: mail providers post from a few shared addresses that act for millions of
377
521
  people, so a bucket would refuse real unsubscribes; the signature is checked before any database
@@ -392,6 +536,31 @@ Schema `mailing`, applied by `softure migrate`:
392
536
  | | `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). |
393
537
  | `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). |
394
538
  | `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. |
539
+ | `0005_add_sql_surface.sql` | functions and views | the SQL surface below. |
540
+
541
+ **SQL for the app's own queries.** Reports and recipient selections use these, never the tables,
542
+ so a later change to the tables does not silently break them:
543
+
544
+ | Name | What it gives |
545
+ | --- | --- |
546
+ | `mailing.recipient_key(address text) → text` | `getRecipientKey` in SQL: base64url SHA-256 of the trimmed, lowercased address. IMMUTABLE, so it can sit in an index on the app's table. |
547
+ | `mailing.is_suppressed(address text) → boolean` | whether the address unsubscribed from list mail |
548
+ | `mailing.was_delivered(scope text, address text) → boolean` | whether a mail of that scope was sent to the address (sent by the module or imported) |
549
+ | view `mailing.delivery_outcomes` | `scope`, `recipient_key`, `kind`, `campaign_id`, `status` (`pending`, `claimed`, `sent`, `rejected`), `attempts`, `claimed_at`, `finished_at`, `imported_at` |
550
+ | view `mailing.suppressed_recipients` | `recipient_key`, `source` (`one-click`, `page`, `operator`), `created_at` |
551
+
552
+ ```sql
553
+ -- trials ending this week that were not told yet and did not unsubscribe
554
+ select s.id, u.email
555
+ from subscriptions s join users u on u.id = s.user_id
556
+ where s.trial_ends_at < now() + interval '7 days'
557
+ and not mailing.was_delivered('billing.trial-ending:' || s.id, u.email)
558
+ and not mailing.is_suppressed(u.email);
559
+ ```
560
+
561
+ `lower()` follows the database's ctype: under a UTF-8 locale it lowercases like JavaScript, under the
562
+ `C` locale only ASCII letters, so an address with non-ASCII capitals then keys differently in SQL.
563
+ The functions and views are granted like the tables (the app role reads them).
395
564
 
396
565
  The health check (`checkMailingTables`) runs `select 1 from mailing.<table> limit 0` for the three
397
566
  tables.
@@ -416,7 +585,8 @@ None.
416
585
  ## 8. Appearance
417
586
 
418
587
  The unsubscribe page is a `Card` from `@softure-ai/ui` with a plain form (no client JavaScript),
419
- styled by the app's tokens.
588
+ styled by the app's tokens. `createUnsubscribePage({ classNames, unstyled, Layout })` adds the app's
589
+ classes or its own layout (section 4).
420
590
 
421
591
  ## 9. Copy
422
592
 
@@ -452,7 +622,10 @@ also names ones it may carry, for an app that sent more than one form on the sam
452
622
  names in all, at least one required). A link without `r` that carries every required one
453
623
  (non-empty, at most 512 characters) is a legacy link, and its values also hold the optional ones it
454
624
  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,
455
- and the action and the one-click POST call `verify(values, ctx)`. It returns the recipient's
625
+ and the action and the one-click POST call `verify(values, ctx, env)`. `env` is the environment the
626
+ unsubscribe runs with (`process.env` in the page and the route; `unsubscribe(ctx, link, source, env)`
627
+ passes its own), so `verify` reads the old signing key from it and a test hands it one without
628
+ stubbing `process.env`. It returns the recipient's
456
629
  **address** when the link is genuine, else `null`; the module records the opt-out under that
457
630
  address's key and runs `onUnsubscribed`, exactly as for its own links. `verify` must check the
458
631
  link's signature itself (in constant time): whatever address it returns is unsubscribed. A throw is
@@ -463,7 +636,7 @@ page and route at the old paths too:
463
636
  mailing({
464
637
  from: "…",
465
638
  provider: resend(),
466
- legacyUnsubscribe: { params: ["u", "t"], verify: ({ u, t }, ctx) => verifyOldLink(ctx.db, u, t) },
639
+ legacyUnsubscribe: { params: ["u", "t"], verify: ({ u, t }, ctx, env) => verifyOldLink(ctx.db, env.OLD_UNSUBSCRIBE_KEY, u, t) },
467
640
  }),
468
641
 
469
642
  // app/old-unsubscribe/page.tsx
package/dist/cli/run.d.ts CHANGED
@@ -7,7 +7,7 @@ export interface CliOutput {
7
7
  readonly error: (line: string) => void;
8
8
  }
9
9
  export interface RunMailCliOptions {
10
- /** The app's config. Needed by `campaign` and `import`, and by `dns` without `--domain`. */
10
+ /** The app's config. Needed by `campaign`, `test` 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[];
@@ -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;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,QAgD8C,CAAC;AAwCtE,yGAAyG;AACzG,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAmB5E"}
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;AAKhG,OAAO,EAAkI,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAuB,MAAM,kBAAkB,CAAC;AAK3O,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,oGAAoG;IACpG,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,QAgEwD,CAAC;AAmDhF,yGAAyG;AACzG,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAqB5E"}