@softure-ai/mailing 0.1.10 → 0.1.12

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 +41 -0
  2. package/README.md +187 -10
  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 +202 -29
  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 +15 -2
  37. package/dist/server/campaigns.d.ts.map +1 -1
  38. package/dist/server/campaigns.js +33 -11
  39. package/dist/server/campaigns.js.map +1 -1
  40. package/dist/server/deliveries.d.ts +19 -0
  41. package/dist/server/deliveries.d.ts.map +1 -1
  42. package/dist/server/deliveries.js +29 -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 +6 -4
  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 +55 -0
  61. package/dist/server/run-deliveries.d.ts.map +1 -0
  62. package/dist/server/run-deliveries.js +132 -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 +209 -29
  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 +53 -12
  85. package/src/server/deliveries.ts +33 -0
  86. package/src/server/dns.ts +60 -4
  87. package/src/server/index.ts +7 -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 +184 -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,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.12
8
+
9
+ - New `runDeliveries(ctx, { recipients, build }, { limit, pauseMs, dryRun, onDelivery })` (`/server`, and
10
+ `runDeliveries(input, options)` in `/next`): one `deliverOnce` per recipient, `build` returning the delivery or
11
+ `null` to skip. Stops at a refused account or a spent quota (`halted`, `remaining: null`), counts `limit` like
12
+ `sendCampaign`, and returns `DeliveryRunSummary` (`dryRun`, `recipients`, `skipped`, `sent`, `rejected` by code,
13
+ `done`, `inFlight`, `retryLater`, `uncertain`, `halted`, `remaining`). A dry run writes and sends nothing.
14
+ - Migration `0005`: a SQL surface for the app's own queries, `mailing.recipient_key(text)` (equal to
15
+ `getRecipientKey`), `mailing.is_suppressed(text)`, `mailing.was_delivered(scope, text)`, and the views
16
+ `mailing.delivery_outcomes` and `mailing.suppressed_recipients`. Run `softure migrate`. Dropping
17
+ `mailing.deliveries` or `mailing.suppressions` now needs `CASCADE`.
18
+ - New `previewMail(config, mail, { env })` (`/server`, `previewMail(mail)` in `/next`): exactly the
19
+ `ProviderMessage` `sendMail` would hand the provider, footer and RFC 8058 headers included, without sending,
20
+ logging or reading the database. New `maskAddress` and `redactUnsubscribeSignatures` for operator output.
21
+ - `mailing({ testAddress })` and `softure-mail test [<content-file>] [--kind <kind>] [--preview]`: sends a fixed
22
+ test mail, or a campaign's content outside the ledger, to that address only.
23
+ - `softure-mail campaign`: the dry run prints the mail as the test address gets it (addresses masked, link
24
+ signatures redacted); `--content-file <file>` names the content file for `softure-deploy run`, which the README
25
+ documents. `getCampaignProblems(content, config)` also refuses a body with a pasted unsubscribe link or footer, and
26
+ `sendCampaign`, `planCampaign` and the command refuse such content.
27
+ - `mailing({ kindAliases: { news: "newsletter" } })` and `resolveMailKind(config, kind)`: campaign files and
28
+ `softure-mail test --kind` may name a kind by its alias.
29
+ - `checkSenderDns(domain, { inbound: "cloudflare" })` and `softure-mail dns --inbound cloudflare`: Cloudflare Email
30
+ Routing's MX and one SPF record including `_spf.mx.cloudflare.net` on the reply-to domain (else the checked
31
+ domain). New finding `missing-include`, new report key `inbound` (`[]` when not asked), new `CLOUDFLARE_INBOUND`.
32
+ Code that builds a `SenderDnsReport` by hand must add `inbound`.
33
+ - `legacyUnsubscribe.verify(values, ctx, env)` receives the environment `unsubscribe` runs with.
34
+ - New `createUnsubscribePage({ classNames, unstyled, Layout })` in `/next`: slots `root`, `card`, `form`, `submit`,
35
+ or the app's own layout around the copy and the form. `UnsubscribePage` is unchanged.
36
+ - New `findSuppressedAddresses(ctx, addresses)` (`/server`): which of many addresses unsubscribed, each
37
+ returned as given, in batched queries; for reports such as the waitlist's suppressed split (#324).
38
+
39
+ ## 0.1.11
40
+
41
+ - `sendCampaign(ctx, input, { limit })` hands at most `limit` mails to the provider in one run (sent, rejected by it,
42
+ or not taken now); done, unsubscribed, filtered, in-flight and uncertain recipients do not count. `limit` must be a
43
+ whole number of at least 1 (`RangeError` otherwise). `softure-mail campaign --limit <n>` passes it, exits 0 when
44
+ the limit cut the run, and its dry run prints what one limited run would send.
45
+ - `CampaignSummary.remaining` (new, required): recipients past the cut that the next run would send to; `0` when the
46
+ run reached the end of the list, `null` when it halted. Code that builds a summary by hand must add it.
47
+
7
48
  ## 0.1.10
8
49
 
9
50
  - `OutgoingMail.replyTo`: an optional reply-to address for one mail (exactly one address, checked like `to`, failure
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,45 @@ 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
+ - **`limit`** counts the mails handed to the provider, like `sendCampaign`'s; once reached, the rest
186
+ of the list is only counted: `remaining` is how many the next run would send. **`pauseMs`**
187
+ pauses after each of those mails.
188
+ - **`dryRun: true`** reads the ledger and the suppression list and checks each mail, and writes and
189
+ sends nothing: `sent` is then what would reach the provider.
190
+ - List mail needs `MAILING_UNSUBSCRIBE_SECRET`: without it the run throws at the first list mail,
191
+ before any claim, instead of using up every recipient's attempts.
192
+ - `onDelivery(outcome)` sees every outcome (never the address); `deliverOnce`'s options
193
+ (`maxAttempts`, `retakeUncertain`, the claim windows) pass through.
194
+
148
195
  ### Importing an existing history
149
196
 
150
197
  An app that already kept its own once-only records (which campaign went to which signup, which
@@ -204,10 +251,25 @@ we shipped something.
204
251
  ```
205
252
 
206
253
  ```bash
207
- softure-mail campaign launch.md --recipients recipients.txt --dry-run # counts, sends nothing
254
+ softure-mail campaign launch.md --recipients recipients.txt --dry-run # counts and previews, sends nothing
208
255
  softure-mail campaign launch.md --recipients recipients.txt # sends
209
256
  ```
210
257
 
258
+ - **The dry run previews the mail** as the provider would get it: sender, reply-to, subject, the
259
+ RFC 8058 headers, the text and HTML bodies with the footer. It is rendered for `testAddress`
260
+ (else `recipient@example.com`), with addresses masked (`o*******@example.com`) and the link
261
+ signatures replaced by `<signature>`, so the output is safe to paste. Without
262
+ `MAILING_UNSUBSCRIBE_SECRET` it says so instead.
263
+ - **No pasted footers.** Content that carries an unsubscribe link (the app's unsubscribe or one-click
264
+ path, or a signed `r=…&t=…` pair) or the footer's copy in any locale is refused before anything
265
+ runs: the module adds a footer signed for each recipient, and a link pasted from a sent mail would
266
+ let every recipient unsubscribe the person it was signed for. `getCampaignProblems(content, config)`
267
+ runs the same check (links on the app's own origin; a link to another site is left alone), and
268
+ `sendCampaign` and `planCampaign` refuse such content.
269
+ - **Kind aliases.** `mailing({ kindAliases: { news: "newsletter" } })` lets a content file (and
270
+ `softure-mail test --kind`) say `kind: news`; the campaign is stored and sent as `newsletter`.
271
+ - **Masked output.** No command prints a full address or a working unsubscribe link.
272
+
211
273
  - The recipients file holds one address per line (`#` comments and blank lines skipped).
212
274
  - **Recipients from the database.** Without `--recipients` the command asks
213
275
  `mailing({ listCampaignRecipients })`, which lists the addresses from the app's own data
@@ -228,6 +290,28 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
228
290
  ```
229
291
 
230
292
  With the content on standard input, its `html:` file is looked up in the working directory.
293
+ - **Through `softure-deploy run`.** An app deployed with `@softure-ai/deploy` runs the command in the
294
+ live container without a shell on the server. `softure-deploy run` passes only `--key=value`
295
+ words, so the content file goes as `--content-file=<file>` (read on the operator's machine and sent
296
+ on standard input) and the recipients come from `listCampaignRecipients`. One file travels per
297
+ run, so such a campaign has no `html:` file. One runner per command in `scripts/ops/`, which the
298
+ app's Dockerfile bundles into `ops/<name>.mjs`:
299
+
300
+ ```ts
301
+ // scripts/ops/mail-campaign.ts
302
+ import { runMailCli } from "@softure-ai/mailing/cli";
303
+ import config from "../../softure.config";
304
+
305
+ process.exitCode = await runMailCli({ config, argv: ["campaign", ...process.argv.slice(2)] });
306
+ ```
307
+
308
+ ```bash
309
+ softure-deploy run --host=app-prod mail-campaign --content-file=launch.md --dry-run
310
+ softure-deploy run --host=app-prod mail-campaign --content-file=launch.md --limit=80
311
+ softure-deploy run --host=app-prod mail-test --content-file=launch.md # scripts/ops/mail-test.ts: argv ["test", ...]
312
+ ```
313
+
314
+ The exit status is the command's: 0 done, 1 failed or incomplete, 2 usage.
231
315
  - The command loads `softure.config.*` like `softure migrate` (or `--config <file>`), opens the
232
316
  config's `database.handle` when set, otherwise its own connection on `database.url`, and needs `MAILING_UNSUBSCRIBE_SECRET` (campaigns are list
233
317
  mail). Run `softure migrate` first.
@@ -249,6 +333,15 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
249
333
  - **A refused key or a spent quota stops the run** at the first such answer: the command prints why
250
334
  (with the HTTP status) and exits 1; that recipient and every one after it are left for the next
251
335
  run. `sendCampaign` returns the same as `halted: { reason, httpStatus }`.
336
+ - **Instalments with `--limit <n>`.** On a plan whose daily quota is shared with other mail (Resend's
337
+ free plan gives 100 a day), `--limit 80` hands at most 80 mails to the provider in this run and
338
+ leaves room for the day's transactional mail. Only mails that reach the provider count (sent,
339
+ rejected by it, or not taken now); done, unsubscribed, filtered, in-flight and uncertain recipients
340
+ never use a slot. The command prints `limit reached: <n> recipient(s) left for the next run` and
341
+ exits 0; run it again the next day. With `--dry-run` it also prints what one limited run would
342
+ send. `sendCampaign(ctx, input, { limit: 80 })` does the same and returns `remaining` (recipients
343
+ past the cut that the next run would send to, counted like `planCampaign`'s `toSend`, one
344
+ suppression lookup per address; `0` when the run reached the end of the list, `null` after a halt).
252
345
  - **Re-runs are safe.** Recipients with an outcome are skipped; the command exits 1 while some have
253
346
  none yet (`retry later`, `in flight`): run the same command again. It also exits 1 for `uncertain`
254
347
  recipients (an interrupted send older than `uncertainClaimMs`) and names `--resend-uncertain`. `mailing.campaigns` pins the
@@ -257,6 +350,31 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
257
350
  `runMailCli({ config, argv })` from `@softure-ai/mailing/cli`, or `sendCampaign(ctx, { campaign,
258
351
  recipients })` from `/server`, where `recipients` may be an async iterable.
259
352
 
353
+ ### Test send: `softure-mail test`
354
+
355
+ ```bash
356
+ softure-mail test # a short fixed mail, transactional
357
+ softure-mail test --kind newsletter # the same as list mail: footer and unsubscribe headers
358
+ softure-mail test launch.md # a campaign's content, as the campaign would send it
359
+ softure-mail test launch.md --preview # print it, send nothing
360
+ ```
361
+
362
+ It sends to `mailing({ testAddress })` and to no one else; without that option it refuses. A
363
+ campaign file is sent outside the ledger (nothing is registered or recorded), so it can be sent
364
+ again after every edit. `--kind` (an alias of `kindAliases` too) overrides the file's kind; list
365
+ mail opens the database for the suppression check, so an unsubscribed test address answers
366
+ `suppressed`. The output masks the address and names the provider's failure with its HTTP status.
367
+ `--preview` prints what `previewMail` returns, like the campaign's dry run. The preview reads the
368
+ secret from `runMailCli({ env })`, a real send from `process.env`, as every send does.
369
+
370
+ `previewMail(config, mail, { env, idempotencyKey })` (`/server`, and `previewMail(mail)` in `/next`) is
371
+ the function behind both: `ok(message)` with exactly the `ProviderMessage` `sendMail` would hand the
372
+ provider, `{ error: "mailing.invalid_input", fields }`, or `{ error: "mailing.unavailable", cause:
373
+ "no_unsubscribe_secret" }` for list mail without the secret. It sends, logs and reads nothing: a
374
+ suppressed recipient's mail renders too. Its links work for `mail.to`; show them to that person
375
+ only, or pass the text through `redactUnsubscribeSignatures`. `maskAddress(address)` masks an
376
+ address the same way the commands do.
377
+
260
378
  ### Sender DNS check: `softure-mail dns`
261
379
 
262
380
  ```bash
@@ -283,6 +401,11 @@ It reports each check as `pass`, `warn` or `fail`, exiting 1 on any `fail`:
283
401
  be a CNAME (to `targetDomain` or under it, when set) or have MX records. `resendReturnPath(domain)` (the flag) gives
284
402
  Resend's `send.<domain>` and `rsend.<domain>` with target `rmta.net`; a domain Resend set up with MX records on
285
403
  `send` and no `rsend` checks `--return-path send.<domain>` alone.
404
+ - **INBOUND** (`inbound: "cloudflare"`, `--inbound cloudflare`): Cloudflare Email Routing receives the reply-to
405
+ domain's mail (else the checked domain's). Every MX record must be under `mx.cloudflare.net`
406
+ (`unexpected-target` otherwise, `null-mx`, `missing`), and the domain must publish exactly one SPF record
407
+ (`multiple`) that includes `_spf.mx.cloudflare.net` (`missing-include`). The report's `inbound` holds the MX and
408
+ the SPF check, `[]` when not asked.
286
409
 
287
410
  It cannot see whether the provider signs with the published key. The function takes `resolveTxt`, `resolveMx` and
288
411
  `resolveCname` for tests; by default it asks the system resolver.
@@ -323,6 +446,8 @@ mailing({
323
446
  | `maxAttempts` | `number \| null` | `5` | Claims before `mailing.unavailable` closes a delivery as rejected, 1 to 100; `null` never closes it (section 1, Retries). |
324
447
  | `staleClaimMs` | `number` | `900000` (15 min) | How long a delivery claim may stay open before another sender takes it over. 1 minute to 23 hours. |
325
448
  | `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. |
449
+ | `testAddress` | `string` | none | The only address `softure-mail test` sends to (section 1, Test send). |
450
+ | `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. |
326
451
  | `routes` | `{ unsubscribe?, oneClick? }` | `/unsubscribe`, `/api/mailing/unsubscribe` | Where you mount the page and the route; links are built on `appOrigin` plus these paths. |
327
452
 
328
453
  `resend({ apiKey?, endpoint?, fetch? })`: without `apiKey` it reads `RESEND_API_KEY` on every send,
@@ -363,6 +488,28 @@ export { UnsubscribePage as default } from "@softure-ai/mailing/next";
363
488
  export { getUnsubscribeRoute as GET, postUnsubscribeRoute as POST } from "@softure-ai/mailing/next";
364
489
  ```
365
490
 
491
+ **The app's look.** `createUnsubscribePage({ classNames, unstyled, Layout })` gives the same page
492
+ with the app's classes on its slots (`root`, the `main` wrapper; `card`; `form`; `submit`), or with
493
+ its own layout around the copy and the form (the `root` and `card` classes then do not apply; the
494
+ `referrer` policy tag is kept):
495
+
496
+ ```tsx
497
+ // app/unsubscribe/page.tsx
498
+ import { createUnsubscribePage, type UnsubscribeLayoutProps } from "@softure-ai/mailing/next";
499
+
500
+ function AppShell({ title, lead, children }: UnsubscribeLayoutProps) {
501
+ return (
502
+ <SiteFrame>
503
+ <h1>{title}</h1>
504
+ <p>{lead}</p>
505
+ {children}
506
+ </SiteFrame>
507
+ );
508
+ }
509
+
510
+ export default createUnsubscribePage({ Layout: AppShell, classNames: { submit: "w-full" } });
511
+ ```
512
+
366
513
  Both must stay public: keep them out of the auth guard's `protect` list. The route has no per-IP
367
514
  rate limit on purpose: mail providers post from a few shared addresses that act for millions of
368
515
  people, so a bucket would refuse real unsubscribes; the signature is checked before any database
@@ -383,6 +530,31 @@ Schema `mailing`, applied by `softure migrate`:
383
530
  | | `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). |
384
531
  | `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). |
385
532
  | `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. |
533
+ | `0005_add_sql_surface.sql` | functions and views | the SQL surface below. |
534
+
535
+ **SQL for the app's own queries.** Reports and recipient selections use these, never the tables,
536
+ so a later change to the tables does not silently break them:
537
+
538
+ | Name | What it gives |
539
+ | --- | --- |
540
+ | `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. |
541
+ | `mailing.is_suppressed(address text) → boolean` | whether the address unsubscribed from list mail |
542
+ | `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) |
543
+ | view `mailing.delivery_outcomes` | `scope`, `recipient_key`, `kind`, `campaign_id`, `status` (`pending`, `claimed`, `sent`, `rejected`), `attempts`, `claimed_at`, `finished_at`, `imported_at` |
544
+ | view `mailing.suppressed_recipients` | `recipient_key`, `source` (`one-click`, `page`, `operator`), `created_at` |
545
+
546
+ ```sql
547
+ -- trials ending this week that were not told yet and did not unsubscribe
548
+ select s.id, u.email
549
+ from subscriptions s join users u on u.id = s.user_id
550
+ where s.trial_ends_at < now() + interval '7 days'
551
+ and not mailing.was_delivered('billing.trial-ending:' || s.id, u.email)
552
+ and not mailing.is_suppressed(u.email);
553
+ ```
554
+
555
+ `lower()` follows the database's ctype: under a UTF-8 locale it lowercases like JavaScript, under the
556
+ `C` locale only ASCII letters, so an address with non-ASCII capitals then keys differently in SQL.
557
+ The functions and views are granted like the tables (the app role reads them).
386
558
 
387
559
  The health check (`checkMailingTables`) runs `select 1 from mailing.<table> limit 0` for the three
388
560
  tables.
@@ -407,7 +579,8 @@ None.
407
579
  ## 8. Appearance
408
580
 
409
581
  The unsubscribe page is a `Card` from `@softure-ai/ui` with a plain form (no client JavaScript),
410
- styled by the app's tokens.
582
+ styled by the app's tokens. `createUnsubscribePage({ classNames, unstyled, Layout })` adds the app's
583
+ classes or its own layout (section 4).
411
584
 
412
585
  ## 9. Copy
413
586
 
@@ -443,7 +616,10 @@ also names ones it may carry, for an app that sent more than one form on the sam
443
616
  names in all, at least one required). A link without `r` that carries every required one
444
617
  (non-empty, at most 512 characters) is a legacy link, and its values also hold the optional ones it
445
618
  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,
446
- and the action and the one-click POST call `verify(values, ctx)`. It returns the recipient's
619
+ and the action and the one-click POST call `verify(values, ctx, env)`. `env` is the environment the
620
+ unsubscribe runs with (`process.env` in the page and the route; `unsubscribe(ctx, link, source, env)`
621
+ passes its own), so `verify` reads the old signing key from it and a test hands it one without
622
+ stubbing `process.env`. It returns the recipient's
447
623
  **address** when the link is genuine, else `null`; the module records the opt-out under that
448
624
  address's key and runs `onUnsubscribed`, exactly as for its own links. `verify` must check the
449
625
  link's signature itself (in constant time): whatever address it returns is unsubscribed. A throw is
@@ -454,7 +630,7 @@ page and route at the old paths too:
454
630
  mailing({
455
631
  from: "…",
456
632
  provider: resend(),
457
- legacyUnsubscribe: { params: ["u", "t"], verify: ({ u, t }, ctx) => verifyOldLink(ctx.db, u, t) },
633
+ legacyUnsubscribe: { params: ["u", "t"], verify: ({ u, t }, ctx, env) => verifyOldLink(ctx.db, env.OLD_UNSUBSCRIBE_KEY, u, t) },
458
634
  }),
459
635
 
460
636
  // app/old-unsubscribe/page.tsx
@@ -481,9 +657,10 @@ mailing({
481
657
  ```
482
658
 
483
659
  **`liftSuppression(ctx, address)`** is the other direction: a module that has just recorded a new
484
- explicit consent (the waitlist's sign-up) calls it in that consent's transaction. It deletes the
485
- row only when its source is `page` or `one-click`; an `operator` row stays. It returns whether it
486
- lifted one.
660
+ explicit consent calls it in that consent's transaction. The consent must be provably the
661
+ recipient's (the waitlist calls it only when a sign-up's confirmation link is used, never for an
662
+ address typed into a form). It deletes the row only when its source is `page` or `one-click`; an
663
+ `operator` row stays. It returns whether it lifted one.
487
664
 
488
665
  **Tests and e2e:** `fakeMailProvider()` keeps accepted mail in `provider.sent` (with the id it
489
666
  answered) and honours idempotency keys like a real provider. `respond: (message) => ({ status:
@@ -511,7 +688,7 @@ personal data. So the module neither exports nor deletes per user (`privacy: { e
511
688
  bounce or complaint webhooks yet.
512
689
  - Campaigns have no personalisation, scheduling or markdown: the body is sent as written.
513
690
  - Suppression is global per address: no per-list preferences. A person comes back in through a
514
- module's explicit consent (`liftSuppression`, e.g. a new waitlist sign-up); an operator row is
691
+ module's explicit consent (`liftSuppression`, e.g. a confirmed waitlist sign-up); an operator row is
515
692
  removed by hand (`getRecipientKey(address)`).
516
693
  - Lowercasing the whole address merges `Ada@` and `ada@` (allowed to differ by RFC 5321, never in
517
694
  practice); an unsubscribe then covers both.
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,QA6C8C,CAAC;AAqCtE,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"}