@softure-ai/mailing 0.1.11 → 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 (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +173 -6
  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 +176 -24
  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.map +1 -1
  37. package/dist/server/campaigns.js +4 -4
  38. package/dist/server/campaigns.js.map +1 -1
  39. package/dist/server/deliveries.d.ts +19 -0
  40. package/dist/server/deliveries.d.ts.map +1 -1
  41. package/dist/server/deliveries.js +29 -0
  42. package/dist/server/deliveries.js.map +1 -1
  43. package/dist/server/dns.d.ts +15 -1
  44. package/dist/server/dns.d.ts.map +1 -1
  45. package/dist/server/dns.js +45 -4
  46. package/dist/server/dns.js.map +1 -1
  47. package/dist/server/index.d.ts +6 -4
  48. package/dist/server/index.d.ts.map +1 -1
  49. package/dist/server/index.js +6 -4
  50. package/dist/server/index.js.map +1 -1
  51. package/dist/server/operator-output.d.ts +5 -0
  52. package/dist/server/operator-output.d.ts.map +1 -0
  53. package/dist/server/operator-output.js +19 -0
  54. package/dist/server/operator-output.js.map +1 -0
  55. package/dist/server/options.d.ts +2 -0
  56. package/dist/server/options.d.ts.map +1 -1
  57. package/dist/server/options.js +5 -0
  58. package/dist/server/options.js.map +1 -1
  59. package/dist/server/run-deliveries.d.ts +55 -0
  60. package/dist/server/run-deliveries.d.ts.map +1 -0
  61. package/dist/server/run-deliveries.js +132 -0
  62. package/dist/server/run-deliveries.js.map +1 -0
  63. package/dist/server/send-mail.d.ts +21 -2
  64. package/dist/server/send-mail.d.ts.map +1 -1
  65. package/dist/server/send-mail.js +50 -30
  66. package/dist/server/send-mail.js.map +1 -1
  67. package/dist/server/suppressions.d.ts +5 -0
  68. package/dist/server/suppressions.d.ts.map +1 -1
  69. package/dist/server/suppressions.js +27 -1
  70. package/dist/server/suppressions.js.map +1 -1
  71. package/migrations/0005_add_sql_surface.sql +44 -0
  72. package/module.json +1 -1
  73. package/package.json +1 -1
  74. package/src/cli/run.ts +179 -24
  75. package/src/contract.ts +9 -3
  76. package/src/index.ts +1 -1
  77. package/src/next/index.ts +3 -1
  78. package/src/next/pages.tsx +69 -14
  79. package/src/next/preview-mail.ts +9 -0
  80. package/src/next/run-deliveries.ts +10 -0
  81. package/src/options.ts +18 -1
  82. package/src/server/campaign-file.ts +36 -2
  83. package/src/server/campaigns.ts +5 -5
  84. package/src/server/deliveries.ts +33 -0
  85. package/src/server/dns.ts +60 -4
  86. package/src/server/index.ts +7 -3
  87. package/src/server/operator-output.ts +20 -0
  88. package/src/server/options.ts +6 -0
  89. package/src/server/run-deliveries.ts +184 -0
  90. package/src/server/send-mail.ts +63 -37
  91. package/src/server/suppressions.ts +28 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,38 @@ 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
+
7
39
  ## 0.1.11
8
40
 
9
41
  - `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,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.
@@ -266,6 +350,31 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
266
350
  `runMailCli({ config, argv })` from `@softure-ai/mailing/cli`, or `sendCampaign(ctx, { campaign,
267
351
  recipients })` from `/server`, where `recipients` may be an async iterable.
268
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
+
269
378
  ### Sender DNS check: `softure-mail dns`
270
379
 
271
380
  ```bash
@@ -292,6 +401,11 @@ It reports each check as `pass`, `warn` or `fail`, exiting 1 on any `fail`:
292
401
  be a CNAME (to `targetDomain` or under it, when set) or have MX records. `resendReturnPath(domain)` (the flag) gives
293
402
  Resend's `send.<domain>` and `rsend.<domain>` with target `rmta.net`; a domain Resend set up with MX records on
294
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.
295
409
 
296
410
  It cannot see whether the provider signs with the published key. The function takes `resolveTxt`, `resolveMx` and
297
411
  `resolveCname` for tests; by default it asks the system resolver.
@@ -332,6 +446,8 @@ mailing({
332
446
  | `maxAttempts` | `number \| null` | `5` | Claims before `mailing.unavailable` closes a delivery as rejected, 1 to 100; `null` never closes it (section 1, Retries). |
333
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. |
334
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. |
335
451
  | `routes` | `{ unsubscribe?, oneClick? }` | `/unsubscribe`, `/api/mailing/unsubscribe` | Where you mount the page and the route; links are built on `appOrigin` plus these paths. |
336
452
 
337
453
  `resend({ apiKey?, endpoint?, fetch? })`: without `apiKey` it reads `RESEND_API_KEY` on every send,
@@ -372,6 +488,28 @@ export { UnsubscribePage as default } from "@softure-ai/mailing/next";
372
488
  export { getUnsubscribeRoute as GET, postUnsubscribeRoute as POST } from "@softure-ai/mailing/next";
373
489
  ```
374
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
+
375
513
  Both must stay public: keep them out of the auth guard's `protect` list. The route has no per-IP
376
514
  rate limit on purpose: mail providers post from a few shared addresses that act for millions of
377
515
  people, so a bucket would refuse real unsubscribes; the signature is checked before any database
@@ -392,6 +530,31 @@ Schema `mailing`, applied by `softure migrate`:
392
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). |
393
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). |
394
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).
395
558
 
396
559
  The health check (`checkMailingTables`) runs `select 1 from mailing.<table> limit 0` for the three
397
560
  tables.
@@ -416,7 +579,8 @@ None.
416
579
  ## 8. Appearance
417
580
 
418
581
  The unsubscribe page is a `Card` from `@softure-ai/ui` with a plain form (no client JavaScript),
419
- 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).
420
584
 
421
585
  ## 9. Copy
422
586
 
@@ -452,7 +616,10 @@ also names ones it may carry, for an app that sent more than one form on the sam
452
616
  names in all, at least one required). A link without `r` that carries every required one
453
617
  (non-empty, at most 512 characters) is a legacy link, and its values also hold the optional ones it
454
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,
455
- 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
456
623
  **address** when the link is genuine, else `null`; the module records the opt-out under that
457
624
  address's key and runs `onUnsubscribed`, exactly as for its own links. `verify` must check the
458
625
  link's signature itself (in constant time): whatever address it returns is unsubscribed. A throw is
@@ -463,7 +630,7 @@ page and route at the old paths too:
463
630
  mailing({
464
631
  from: "…",
465
632
  provider: resend(),
466
- 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) },
467
634
  }),
468
635
 
469
636
  // 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"}