@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.
- package/CHANGELOG.md +41 -0
- package/README.md +187 -10
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +202 -29
- 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 +15 -2
- package/dist/server/campaigns.d.ts.map +1 -1
- package/dist/server/campaigns.js +33 -11
- 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 +209 -29
- 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 +53 -12
- 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,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)`, `
|
|
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.
|
|
@@ -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)`.
|
|
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
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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[];
|
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"}
|