@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.
- package/CHANGELOG.md +32 -0
- package/README.md +173 -6
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +176 -24
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +5 -3
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/next/index.d.ts +3 -1
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +3 -1
- package/dist/next/index.js.map +1 -1
- package/dist/next/pages.d.ts +25 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +33 -10
- package/dist/next/pages.js.map +1 -1
- package/dist/next/preview-mail.d.ts +5 -0
- package/dist/next/preview-mail.d.ts.map +1 -0
- package/dist/next/preview-mail.js +8 -0
- package/dist/next/preview-mail.js.map +1 -0
- package/dist/next/run-deliveries.d.ts +4 -0
- package/dist/next/run-deliveries.d.ts.map +1 -0
- package/dist/next/run-deliveries.js +7 -0
- package/dist/next/run-deliveries.js.map +1 -0
- package/dist/options.d.ts +3 -1
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +16 -1
- package/dist/options.js.map +1 -1
- package/dist/server/campaign-file.d.ts +7 -2
- package/dist/server/campaign-file.d.ts.map +1 -1
- package/dist/server/campaign-file.js +35 -15
- package/dist/server/campaign-file.js.map +1 -1
- package/dist/server/campaigns.d.ts.map +1 -1
- package/dist/server/campaigns.js +4 -4
- package/dist/server/campaigns.js.map +1 -1
- package/dist/server/deliveries.d.ts +19 -0
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +29 -0
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/dns.d.ts +15 -1
- package/dist/server/dns.d.ts.map +1 -1
- package/dist/server/dns.js +45 -4
- package/dist/server/dns.js.map +1 -1
- package/dist/server/index.d.ts +6 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +6 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/operator-output.d.ts +5 -0
- package/dist/server/operator-output.d.ts.map +1 -0
- package/dist/server/operator-output.js +19 -0
- package/dist/server/operator-output.js.map +1 -0
- package/dist/server/options.d.ts +2 -0
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/options.js +5 -0
- package/dist/server/options.js.map +1 -1
- package/dist/server/run-deliveries.d.ts +55 -0
- package/dist/server/run-deliveries.d.ts.map +1 -0
- package/dist/server/run-deliveries.js +132 -0
- package/dist/server/run-deliveries.js.map +1 -0
- package/dist/server/send-mail.d.ts +21 -2
- package/dist/server/send-mail.d.ts.map +1 -1
- package/dist/server/send-mail.js +50 -30
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/suppressions.d.ts +5 -0
- package/dist/server/suppressions.d.ts.map +1 -1
- package/dist/server/suppressions.js +27 -1
- package/dist/server/suppressions.js.map +1 -1
- package/migrations/0005_add_sql_surface.sql +44 -0
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/cli/run.ts +179 -24
- package/src/contract.ts +9 -3
- package/src/index.ts +1 -1
- package/src/next/index.ts +3 -1
- package/src/next/pages.tsx +69 -14
- package/src/next/preview-mail.ts +9 -0
- package/src/next/run-deliveries.ts +10 -0
- package/src/options.ts +18 -1
- package/src/server/campaign-file.ts +36 -2
- package/src/server/campaigns.ts +5 -5
- package/src/server/deliveries.ts +33 -0
- package/src/server/dns.ts +60 -4
- package/src/server/index.ts +7 -3
- package/src/server/operator-output.ts +20 -0
- package/src/server/options.ts +6 -0
- package/src/server/run-deliveries.ts +184 -0
- package/src/server/send-mail.ts +63 -37
- 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)`, `
|
|
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
|
|
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)`.
|
|
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[];
|
package/dist/cli/run.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAWA,OAAO,EAAe,KAAK,aAAa,EAA8B,MAAM,kBAAkB,CAAC;AAE/F,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAWA,OAAO,EAAe,KAAK,aAAa,EAA8B,MAAM,kBAAkB,CAAC;AAE/F,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;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"}
|