@softure-ai/mailing 0.1.6 → 0.1.8

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 (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +108 -25
  3. package/dist/cli/run.d.ts +3 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +141 -25
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +48 -8
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/contract.js +2 -0
  10. package/dist/contract.js.map +1 -1
  11. package/dist/index.d.ts +12 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/messages/en.d.ts +2 -0
  16. package/dist/messages/en.d.ts.map +1 -1
  17. package/dist/messages/en.js +2 -0
  18. package/dist/messages/en.js.map +1 -1
  19. package/dist/messages/index.d.ts +4 -0
  20. package/dist/messages/index.d.ts.map +1 -1
  21. package/dist/messages/pl.d.ts.map +1 -1
  22. package/dist/messages/pl.js +2 -0
  23. package/dist/messages/pl.js.map +1 -1
  24. package/dist/next/actions.d.ts.map +1 -1
  25. package/dist/next/actions.js +6 -6
  26. package/dist/next/actions.js.map +1 -1
  27. package/dist/next/pages.d.ts.map +1 -1
  28. package/dist/next/pages.js +8 -6
  29. package/dist/next/pages.js.map +1 -1
  30. package/dist/next/route.d.ts +2 -1
  31. package/dist/next/route.d.ts.map +1 -1
  32. package/dist/next/route.js +6 -4
  33. package/dist/next/route.js.map +1 -1
  34. package/dist/options.d.ts +15 -1
  35. package/dist/options.d.ts.map +1 -1
  36. package/dist/options.js +32 -1
  37. package/dist/options.js.map +1 -1
  38. package/dist/providers/resend.d.ts.map +1 -1
  39. package/dist/providers/resend.js +12 -6
  40. package/dist/providers/resend.js.map +1 -1
  41. package/dist/schema.d.ts +17 -0
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +2 -0
  44. package/dist/schema.js.map +1 -1
  45. package/dist/server/campaigns.d.ts +30 -4
  46. package/dist/server/campaigns.d.ts.map +1 -1
  47. package/dist/server/campaigns.js +67 -12
  48. package/dist/server/campaigns.js.map +1 -1
  49. package/dist/server/deliveries.d.ts +30 -2
  50. package/dist/server/deliveries.d.ts.map +1 -1
  51. package/dist/server/deliveries.js +58 -23
  52. package/dist/server/deliveries.js.map +1 -1
  53. package/dist/server/dns.d.ts +48 -1
  54. package/dist/server/dns.d.ts.map +1 -1
  55. package/dist/server/dns.js +124 -15
  56. package/dist/server/dns.js.map +1 -1
  57. package/dist/server/index.d.ts +4 -4
  58. package/dist/server/index.d.ts.map +1 -1
  59. package/dist/server/index.js +3 -3
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/send-mail.d.ts +3 -1
  62. package/dist/server/send-mail.d.ts.map +1 -1
  63. package/dist/server/send-mail.js +18 -6
  64. package/dist/server/send-mail.js.map +1 -1
  65. package/dist/server/suppressions.d.ts +8 -6
  66. package/dist/server/suppressions.d.ts.map +1 -1
  67. package/dist/server/suppressions.js +23 -7
  68. package/dist/server/suppressions.js.map +1 -1
  69. package/dist/server/unsubscribe-link.d.ts +23 -0
  70. package/dist/server/unsubscribe-link.d.ts.map +1 -1
  71. package/dist/server/unsubscribe-link.js +30 -1
  72. package/dist/server/unsubscribe-link.js.map +1 -1
  73. package/migrations/0003_add_delivery_provider_status.sql +13 -0
  74. package/module.json +1 -1
  75. package/package.json +1 -1
  76. package/src/cli/run.ts +164 -29
  77. package/src/contract.ts +49 -6
  78. package/src/index.ts +8 -3
  79. package/src/messages/en.ts +2 -0
  80. package/src/messages/pl.ts +2 -0
  81. package/src/next/actions.ts +5 -6
  82. package/src/next/pages.tsx +10 -7
  83. package/src/next/route.ts +6 -4
  84. package/src/options.ts +36 -2
  85. package/src/providers/resend.ts +11 -7
  86. package/src/schema.ts +2 -0
  87. package/src/server/campaigns.ts +95 -17
  88. package/src/server/deliveries.ts +89 -29
  89. package/src/server/dns.ts +174 -16
  90. package/src/server/index.ts +17 -0
  91. package/src/server/send-mail.ts +30 -6
  92. package/src/server/suppressions.ts +23 -8
  93. package/src/server/unsubscribe-link.ts +41 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ 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.8
8
+
9
+ - `checkSenderDns` takes `expectDmarc: { policy, subdomainPolicy, adkim, aspf }`: a DMARC record weaker than it (a
10
+ lower policy, relaxed or absent alignment where strict is required, `pct` under 100) fails with the new finding
11
+ `weak`. Without the option nothing changes.
12
+ - `replyTo` checks that the reply domain accepts mail: MX records that are not a null MX (finding `null-mx`) and at
13
+ most one SPF record.
14
+ - `returnPath: [{ host, targetDomain? }]` checks return-path hosts: a CNAME (to the target domain, else finding
15
+ `unexpected-target`) or MX records. New `resendReturnPath(domain)` gives Resend's `send.` and `rsend.` hosts.
16
+ - `SenderDnsReport` has two new keys, `replyTo` (`null` when not asked) and `returnPath` (`[]` when not asked); new
17
+ resolver options `resolveMx` and `resolveCname`.
18
+ - `softure-mail dns`: `--dmarc-policy`, `--dmarc-sp`, `--dmarc-adkim`, `--dmarc-aspf`, `--reply-to`,
19
+ `--return-path`, `--resend-return-path`; without `--domain` it also checks the configured `replyTo`. New output
20
+ lines `REPLY` and `PATH`.
21
+
22
+ ## 0.1.7
23
+
24
+ - A failed send keeps the provider's HTTP status: `SendMailResult` failures and `deliverOnce` outcomes carry
25
+ `httpStatus`, and `mailing.deliveries.provider_status` stores it (migration `0003`).
26
+ - New codes `mailing.provider_refused` (401/403: the key or account is refused) and `mailing.quota_exceeded` (429
27
+ that is not a rate limit), with en/pl copy. Before, `resend()` read 401/403 as `rejected` and 429 as `unavailable`.
28
+ Provider adapters may answer the new `refused` and `quota_exceeded` statuses.
29
+ - `deliverOnce` returns `halted` for both: nothing is closed, the claim is released with its attempt given back.
30
+ `sendCampaign` (and `softure-mail campaign`) stops at the first one and reports `halted`; the rest of the list is
31
+ left for the next run.
32
+ - `legacyUnsubscribe: { params, verify }`: unsubscribe links an app sent before adopting the module keep working on
33
+ the page, its action and the one-click route. New `readUnsubscribeLink`; `unsubscribe` takes its result (a bare
34
+ token still works).
35
+ - `filterCampaignRecipient`: campaigns skip recipients the app does not want to mail (e.g. by consent scope), without
36
+ a ledger row; summaries and plans count them as `filtered`.
37
+ - `staleClaimMs` and `uncertainClaimMs` module options. **Changed default:** a claim older than 23 hours is no longer
38
+ taken over by itself (`uncertain`), since the provider may have forgotten its idempotency key; pass
39
+ `retakeUncertain` (`--resend-uncertain`) to send it anyway.
40
+
7
41
  ## 0.1.6
8
42
 
9
43
  - Adapters and commands use the configured database handle; `@softure-ai/ui` is a peer dependency.
package/README.md CHANGED
@@ -10,16 +10,19 @@ FIRE ran deployment-specific scripts.
10
10
 
11
11
  ## 1. What it provides
12
12
 
13
- - `mailing({ from, replyTo, provider, timeoutMs })` for `softure.config.ts`.
13
+ - `mailing({ from, replyTo, provider, timeoutMs, ... })` for `softure.config.ts` (section 3).
14
14
  - `sendMail(context, mail, options)` in `@softure-ai/mailing/server` and `sendMail(mail, options)` in
15
- `@softure-ai/mailing/next` (on the registered config). The result is a core `Result`:
15
+ `@softure-ai/mailing/next` (on the registered config). The result is a core `Result`; a failure the
16
+ provider answered with an HTTP status also carries it as `httpStatus`:
16
17
 
17
18
  | Result | Meaning | What the caller does |
18
19
  | --- | --- | --- |
19
20
  | `ok({ id, provider })` | the provider accepted the mail | keep `id` if it needs one |
20
21
  | `err("mailing.invalid_input")` | refused before anything left the process | fix the input; never retry |
21
- | `err("mailing.rejected")` | the provider refused it (4xx: bad address, bad key, key reused with another body) | do not retry the same request |
22
- | `err("mailing.unavailable")` | provider down (5xx), 408, 429, a busy idempotency key, network, timeout, an answer without an id, no API key; for list mail also no unsubscribe secret or an unreadable suppression list | retry later with the same `idempotencyKey` |
22
+ | `err("mailing.rejected")` | the provider refused this mail (4xx: bad address, key reused with another body) | do not retry the same request |
23
+ | `err("mailing.provider_refused")` | the provider refused the account (401/403: missing, wrong or restricted API key, account not allowed to send) | stop sending: every mail fails the same way; fix the key or account, then retry |
24
+ | `err("mailing.quota_exceeded")` | the account's sending quota is spent (429 that is not a rate limit: Resend's daily or monthly quota) | stop sending; retry when the quota renews |
25
+ | `err("mailing.unavailable")` | provider down (5xx), 408, a rate limit (429 `rate_limit_exceeded`), a busy idempotency key, network, timeout, an answer without an id, no API key; for list mail also no unsubscribe secret or an unreadable suppression list | retry later with the same `idempotencyKey` |
23
26
  | `err("mailing.suppressed")` | a list mail to a recipient who unsubscribed; nothing was sent | skip the recipient; never retry |
24
27
 
25
28
  - **List mail.** `kind: "transactional"` (the default) is mail the recipient needs whatever they
@@ -31,16 +34,19 @@ FIRE ran deployment-specific scripts.
31
34
  - The unsubscribe page (`/unsubscribe`) and the one-click route (`/api/mailing/unsubscribe`) in `/next`,
32
35
  each mounted with one line (section 4).
33
36
  - `@softure-ai/mailing/server`: `isSuppressed(ctx, address)`, `suppressRecipient(ctx, address)` (for
34
- scripts, bounce or complaint handlers), `unsubscribe(ctx, token, source)`,
37
+ scripts, bounce or complaint handlers), `unsubscribe(ctx, link, source)` (a link from
38
+ `readUnsubscribeLink(params, config)`, or a bare token),
35
39
  `liftSuppression(ctx, address)` (a new explicit consent lifts the person's own opt-out),
36
40
  `buildUnsubscribeLinks`, `getRecipientKey`, and the footer and header helpers.
37
41
  - **An `onUnsubscribed` hook**, run in the opt-out's transaction, so the consent ledger can record
38
- the withdrawal (section 10).
42
+ the withdrawal, and **`legacyUnsubscribe`**, which keeps unsubscribe links the app sent before it
43
+ adopted the module working (section 10).
39
44
  - **Sending once.** `deliverOnce(ctx, { scope, mail })` (`/server`, and `deliverOnce({ scope, mail })`
40
45
  in `/next`) sends a mail at most once per scope and recipient through the delivery ledger.
41
46
  - **Campaigns.** `sendCampaign`, `planCampaign` and the content file parser in `/server`; the
42
47
  `softure-mail campaign` command sends a campaign from a content file.
43
- - **Sender DNS.** `checkSenderDns(domain)` and `softure-mail dns` report SPF, DKIM and DMARC.
48
+ - **Sender DNS.** `checkSenderDns(domain)` and `softure-mail dns` report SPF, DKIM and DMARC, hold DMARC to a
49
+ required minimum, and check the reply-to domain's MX and the provider's return-path hosts.
44
50
  - The `MailProvider` contract and `resend()`, the first adapter.
45
51
  - `@softure-ai/mailing/testing`: `fakeMailProvider()` and `readMailOutbox(file)`.
46
52
 
@@ -106,20 +112,29 @@ const outcome = await deliverOnce({
106
112
  | Outcome | Meaning |
107
113
  | --- | --- |
108
114
  | `{ status: "sent", id }` | sent now |
109
- | `{ status: "rejected", reason }` | refused now, for good: `mailing.suppressed`, `rejected`, `invalid_input`, or `unavailable` on the last attempt |
115
+ | `{ status: "rejected", reason, httpStatus? }` | refused now, for good: `mailing.suppressed`, `rejected`, `invalid_input`, or `unavailable` on the last attempt |
110
116
  | `{ status: "done", outcome }` | an earlier call closed it (`sent` or `rejected`); nothing sent |
111
117
  | `{ status: "in-flight" }` | another sender holds a fresh claim; nothing sent |
112
- | `{ status: "retry-later" }` | the provider was unavailable; the claim is released, call again later |
118
+ | `{ status: "uncertain" }` | a claim older than `uncertainClaimMs` is open: its send may have gone out; nothing sent (see Retries) |
119
+ | `{ status: "retry-later", httpStatus? }` | the provider was unavailable; the claim is released, call again later |
120
+ | `{ status: "halted", reason, httpStatus? }` | `mailing.provider_refused` or `mailing.quota_exceeded`: the account cannot send. The claim is released with its attempt given back; stop sending and call again once the account can send |
113
121
 
114
122
  - **Scopes** name what the mail is about and are the only registration a lifecycle mail needs:
115
123
  `<module>.<event>:<entity>` (`billing.trial-ending:sub_42`, `waitlist.welcome:<signup id>`).
116
124
  Lowercase letters, digits and `._:-`, at most 128 characters. Campaigns use `campaign:<id>`.
117
125
  - **Idempotency key.** The ledger sends with `<scope>:<recipient key>`; callers do not pass one.
118
126
  - **Retries.** `unavailable` releases the claim (`pending`); the fifth attempt (`maxAttempts`) that
119
- is still unavailable closes the row as `rejected`. A process that dies between the send and the
120
- outcome leaves a claim; after 15 minutes (`staleClaimMs`) another call takes it over and sends
121
- again with the same idempotency key, which the provider folds into the first send while it keeps
122
- the key (Resend: 24 hours). Outcomes are fenced by the attempt number, so a sender that lost its
127
+ is still unavailable closes the row as `rejected`. A halt (`provider_refused`, `quota_exceeded`)
128
+ never closes a row and never uses up an attempt, so a run with a bad key loses no recipient. The
129
+ provider's HTTP status of the last failed answer is kept in `provider_status`.
130
+ - **Interrupted sends.** A process that dies between the send and the outcome leaves a claim; after
131
+ `staleClaimMs` (15 minutes) another call takes it over and sends again with the same idempotency
132
+ key, which the provider folds into the first send while it keeps the key (Resend: 24 hours). A
133
+ claim older than `uncertainClaimMs` (23 hours) is `uncertain`: the provider may have forgotten the
134
+ key, so a retake could mail twice, and it waits for an operator. Check the provider's log, then
135
+ pass `retakeUncertain: true` (`softure-mail campaign --resend-uncertain`) to send it anyway. Both
136
+ windows are module options, and `deliverOnce(ctx, delivery, { staleClaimMs, uncertainClaimMs })`
137
+ overrides them per call. Outcomes are fenced by the attempt number, so a sender that lost its
123
138
  claim cannot overwrite the one that took over.
124
139
  - A malformed scope or kind throws (a bug); a database failure propagates.
125
140
 
@@ -151,8 +166,25 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
151
166
  mail). Run `softure migrate` first.
152
167
  - Sends go one at a time with a 500 ms pause (`--pause-ms`; Resend allows 2 requests per second by
153
168
  default). Unsubscribed recipients are rejected without a send and never retried.
169
+ - **Recipient filter.** With `mailing({ filterCampaignRecipient })` every recipient is checked before
170
+ the ledger is touched, e.g. against the consent scope the app stored for the address. A refused
171
+ recipient is counted as `filtered out` and gets no row, so a later run sends to them once they
172
+ qualify:
173
+
174
+ ```ts
175
+ mailing({
176
+ from: "…",
177
+ provider: resend(),
178
+ filterCampaignRecipient: async ({ address }, ctx) => hasNewsletterConsent(ctx.db, address),
179
+ }),
180
+ ```
181
+
182
+ - **A refused key or a spent quota stops the run** at the first such answer: the command prints why
183
+ (with the HTTP status) and exits 1; that recipient and every one after it are left for the next
184
+ run. `sendCampaign` returns the same as `halted: { reason, httpStatus }`.
154
185
  - **Re-runs are safe.** Recipients with an outcome are skipped; the command exits 1 while some have
155
- none yet (`retry later`, `in flight`): run the same command again. `mailing.campaigns` pins the
186
+ none yet (`retry later`, `in flight`): run the same command again. It also exits 1 for `uncertain`
187
+ recipients (an interrupted send older than `uncertainClaimMs`) and names `--resend-uncertain`. `mailing.campaigns` pins the
156
188
  content by hash: other content under the same id is refused, so a changed campaign needs a new id.
157
189
  - From a script (a bundled container, a list built from the database), call
158
190
  `runMailCli({ config, argv })` from `@softure-ai/mailing/cli`, or `sendCampaign(ctx, { campaign,
@@ -161,15 +193,32 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
161
193
  ### Sender DNS check: `softure-mail dns`
162
194
 
163
195
  ```bash
164
- softure-mail dns # the domain of `from` in the config
196
+ softure-mail dns # the domain of `from` (and replyTo) in the config
165
197
  softure-mail dns --domain mail.example.com --spf-host send.mail.example.com
198
+ softure-mail dns --spf-host send.mail.example.com --resend-return-path \
199
+ --dmarc-policy reject --dmarc-sp reject --dmarc-adkim s --dmarc-aspf s
166
200
  ```
167
201
 
168
- It reads TXT records and reports each check as `pass`, `warn` or `fail`, exiting 1 on any `fail`:
169
- SPF (one `v=spf1` record on the domain or each `--spf-host`; `+all` warns), DKIM (a non-empty `p=`
170
- at `<selector>._domainkey.<domain>`; selector `resend` unless `--dkim-selector` is given) and DMARC
171
- (`_dmarc.<domain>` or a parent domain's record; `p=none` warns: it only reports). Resend publishes
172
- SPF on `send.<domain>`. It cannot see whether the provider signs with the published key.
202
+ It reports each check as `pass`, `warn` or `fail`, exiting 1 on any `fail`:
203
+
204
+ - **SPF**: one `v=spf1` record on the domain or each `--spf-host`; `+all` warns. Resend publishes SPF on
205
+ `send.<domain>`.
206
+ - **DKIM**: a non-empty `p=` at `<selector>._domainkey.<domain>`; selector `resend` unless `--dkim-selector` is given.
207
+ - **DMARC**: `_dmarc.<domain>` or a parent domain's record; `p=none` warns: it only reports. With a required minimum
208
+ (`expectDmarc: { policy, subdomainPolicy, adkim, aspf }`, or the `--dmarc-*` flags) a weaker record fails as
209
+ `weak`: `p=quarantine` where `reject` is required, `adkim=r` (or no `adkim`) where `s` is required, `pct` under 100
210
+ when a policy is required. A stricter record passes, so adding `rua=` or tightening a tag never turns it red. For
211
+ an inherited record the domain's policy is `sp`, else `p`.
212
+ - **REPLY** (when `replyTo` is configured and `--domain` is not given, or with `--reply-to`; `replyTo` option of the
213
+ function): the reply domain must have MX records that are not a null MX (`0 .`, "accepts no mail") and at most one
214
+ SPF record. Without it replies bounce while every sender check is green.
215
+ - **PATH** (`returnPath: [{ host, targetDomain? }]`, `--return-path <host>`, `--resend-return-path`): each host must
216
+ be a CNAME (to `targetDomain` or under it, when set) or have MX records. `resendReturnPath(domain)` (the flag) gives
217
+ Resend's `send.<domain>` and `rsend.<domain>` with target `rmta.net`; a domain Resend set up with MX records on
218
+ `send` and no `rsend` checks `--return-path send.<domain>` alone.
219
+
220
+ It cannot see whether the provider signs with the published key. The function takes `resolveTxt`, `resolveMx` and
221
+ `resolveCname` for tests; by default it asks the system resolver.
173
222
 
174
223
  ## 2. Installation
175
224
 
@@ -200,6 +249,10 @@ mailing({
200
249
  | `provider` | `MailProvider` | required | `resend()`, `fakeMailProvider()` or your own adapter. |
201
250
  | `timeoutMs` | `number` | `10000` | 1000 to 60000. |
202
251
  | `onUnsubscribed` | `(event, ctx) => Promise<void>` | — | Runs on every verified unsubscribe, in its transaction (section 10). |
252
+ | `legacyUnsubscribe` | `{ params, verify }` | — | Verifies unsubscribe links the app sent before it adopted the module (section 10). |
253
+ | `filterCampaignRecipient` | `(recipient, ctx) => Promise<boolean>` | — | Decides per recipient whether a campaign goes to them (section 1, Campaigns). |
254
+ | `staleClaimMs` | `number` | `900000` (15 min) | How long a delivery claim may stay open before another sender takes it over. 1 minute to 23 hours. |
255
+ | `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. |
203
256
  | `routes` | `{ unsubscribe?, oneClick? }` | `/unsubscribe`, `/api/mailing/unsubscribe` | Where you mount the page and the route; links are built on `appOrigin` plus these paths. |
204
257
 
205
258
  `resend({ apiKey?, endpoint?, fetch? })`: without `apiKey` it reads `RESEND_API_KEY` on every send,
@@ -213,13 +266,18 @@ const provider: MailProvider = {
213
266
  async send(message, { signal }) {
214
267
  const response = await fetch(URL, { method: "POST", body: toBody(message), signal });
215
268
  if (response.ok) return { status: "sent", id: (await response.json()).MessageID };
216
- return { status: response.status >= 500 || response.status === 429 ? "unavailable" : "rejected", httpStatus: response.status };
269
+ const httpStatus = response.status;
270
+ if (httpStatus === 401 || httpStatus === 403) return { status: "refused", httpStatus };
271
+ if (httpStatus === 429) return { status: "unavailable", httpStatus }; // or "quota_exceeded" for a spent quota
272
+ return { status: httpStatus >= 500 ? "unavailable" : "rejected", httpStatus };
217
273
  },
218
274
  };
219
275
  ```
220
276
 
221
277
  A provider receives a validated `ProviderMessage` (`from`, `to`, `replyTo`, `subject`, `text`, `html`,
222
- `headers`, `idempotencyKey`, nulls for what is absent). A throw reads as `unavailable`.
278
+ `headers`, `idempotencyKey`, nulls for what is absent) and answers `sent` with an id, or `rejected`
279
+ (this mail), `refused` (the account or key), `quota_exceeded` (the account's quota) or `unavailable`
280
+ (try later), each with an optional `httpStatus`. A throw or anything else reads as `unavailable`.
223
281
 
224
282
  ## 4. Mounting
225
283
 
@@ -253,6 +311,7 @@ Schema `mailing`, applied by `softure migrate`:
253
311
  | `0001_create_suppressions.sql` | `mailing.suppressions` | `recipient_key` (primary key, 43-character base64url), `source` (`one-click`, `page`, `operator`), `created_at`. One row per address, the first opt-out kept. |
254
312
  | `0002_create_campaigns_and_deliveries.sql` | `mailing.campaigns` | `id` (kebab-case), `kind` (never `transactional`), `subject`, `content_hash` (sha256 of kind, subject and bodies), `created_at`. |
255
313
  | | `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), `reason` (exactly when rejected). |
314
+ | `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). |
256
315
 
257
316
  The health check (`checkMailingTables`) runs `select 1 from mailing.<table> limit 0` for the three
258
317
  tables.
@@ -281,7 +340,7 @@ styled by the app's tokens.
281
340
 
282
341
  ## 9. Copy
283
342
 
284
- `src/messages/{en,pl}.ts`: `errors.mailing.{invalid_input,rejected,unavailable,suppressed}`,
343
+ `src/messages/{en,pl}.ts`: `errors.mailing.{invalid_input,rejected,unavailable,suppressed,provider_refused,quota_exceeded}`,
285
344
  `footer.{text,htmlLead,htmlLink}` (the list-mail footer, in the app's locale) and
286
345
  `unsubscribe.*` (the page). `getMailingErrorMessage(messages, code)` returns the copy for a code;
287
346
  override any text with `mailing({ messages: { en: { footer: { text: "…" } } } })`.
@@ -304,6 +363,29 @@ import { withdrawWaitlistConsents } from "@softure-ai/waitlist/server";
304
363
  mailing({ from: "…", provider: resend(), onUnsubscribed: withdrawWaitlistConsents }),
305
364
  ```
306
365
 
366
+ **`legacyUnsubscribe: { params, verify }`** keeps unsubscribe links working that the app sent
367
+ before it adopted the module, in its own scheme (an HMAC over a sign-up id, say, on its own path).
368
+ `params` are the old link's query names (1 to 8; never `r` or `status`, which the module's links and
369
+ page own; `t` may be shared). A link without `r` that carries every one of them (non-empty, at most
370
+ 512 characters) is a legacy link: the page shows the same button with the values in hidden fields,
371
+ and the action and the one-click POST call `verify(values, ctx)`. It returns the recipient's
372
+ **address** when the link is genuine, else `null`; the module records the opt-out under that
373
+ address's key and runs `onUnsubscribed`, exactly as for its own links. `verify` must check the
374
+ link's signature itself (in constant time): whatever address it returns is unsubscribed. A throw is
375
+ a failure (the page offers a retry, the route answers 500), not an invalid link. Mount the module's
376
+ page and route at the old paths too:
377
+
378
+ ```ts
379
+ mailing({
380
+ from: "…",
381
+ provider: resend(),
382
+ legacyUnsubscribe: { params: ["u", "t"], verify: ({ u, t }, ctx) => verifyOldLink(ctx.db, u, t) },
383
+ }),
384
+
385
+ // app/old-unsubscribe/page.tsx
386
+ export { UnsubscribePage as default } from "@softure-ai/mailing/next";
387
+ ```
388
+
307
389
  **`liftSuppression(ctx, address)`** is the other direction: a module that has just recorded a new
308
390
  explicit consent (the waitlist's sign-up) calls it in that consent's transaction. It deletes the
309
391
  row only when its source is `page` or `one-click`; an `operator` row stays. It returns whether it
@@ -330,8 +412,9 @@ personal data. So the module neither exports nor deletes per user (`privacy: { e
330
412
  - `sendMail` does not retry: the caller retries `unavailable` with the same `idempotencyKey` (Resend
331
413
  keeps keys for 24 hours), or uses `deliverOnce`, which keeps the state between runs.
332
414
  - Exactly-once ends where the provider's idempotency window ends: a sender that dies after the
333
- provider accepted a mail and before the outcome was written, with the row taken over more than
334
- 24 hours later, sends that mail twice. No delivery, bounce or complaint webhooks yet.
415
+ provider accepted a mail and before the outcome was written leaves an `uncertain` claim once
416
+ `uncertainClaimMs` passes; retaking it (`retakeUncertain`) may send that mail twice. No delivery,
417
+ bounce or complaint webhooks yet.
335
418
  - Campaigns have no personalisation, scheduling or markdown: the body is sent as written.
336
419
  - Suppression is global per address: no per-list preferences. A person comes back in through a
337
420
  module's explicit consent (`liftSuppression`, e.g. a new waitlist sign-up); an operator row is
package/dist/cli/run.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type SoftureConfig } from "@softure-ai/core";
2
2
  import { type DatabaseHandle } from "@softure-ai/db";
3
- import { type ResolveTxt } from "../server/dns.js";
3
+ import { type ResolveCname, type ResolveMx, type ResolveTxt } from "../server/dns.js";
4
4
  import { type Env } from "../server/unsubscribe-link.js";
5
5
  export interface CliOutput {
6
6
  readonly log: (line: string) => void;
@@ -17,6 +17,8 @@ export interface RunMailCliOptions {
17
17
  /** Opens the campaign's database connection. Default: the config's `database.handle`, else `createDatabase(url, { max: 1 })`. */
18
18
  readonly openDatabase?: (url: string) => Promise<DatabaseHandle>;
19
19
  readonly resolveTxt?: ResolveTxt;
20
+ readonly resolveMx?: ResolveMx;
21
+ readonly resolveCname?: ResolveCname;
20
22
  readonly sleep?: (ms: number) => Promise<void>;
21
23
  readonly env?: Env;
22
24
  }
@@ -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;AAC/F,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIhG,OAAO,EAAkD,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnG,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,+EAA+E;IAC/E,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,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,QAAQ,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;CACpB;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,QAkBa,CAAC;AAYrC,yGAAyG;AACzG,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiB5E"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAWA,OAAO,EAAe,KAAK,aAAa,EAA8B,MAAM,kBAAkB,CAAC;AAC/F,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIhG,OAAO,EAAkI,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAuB,MAAM,kBAAkB,CAAC;AAE3O,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,+EAA+E;IAC/E,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;CACpB;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,QAgCa,CAAC;AAgCrC,yGAAyG;AACzG,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiB5E"}
package/dist/cli/run.js CHANGED
@@ -13,7 +13,7 @@ import { systemClock } from "@softure-ai/core";
13
13
  import { openCommandDatabase } from "@softure-ai/db";
14
14
  import { parseCampaignFile, parseRecipientList } from "../server/campaign-file.js";
15
15
  import { planCampaign, sendCampaign } from "../server/campaigns.js";
16
- import { checkSenderDns, getSenderDomain } from "../server/dns.js";
16
+ import { checkSenderDns, getSenderDomain, resendReturnPath } from "../server/dns.js";
17
17
  import { getMailingOptions } from "../server/options.js";
18
18
  import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV } from "../server/unsubscribe-link.js";
19
19
  export const EXIT_OK = 0;
@@ -22,20 +22,34 @@ export const EXIT_USAGE = 2;
22
22
  /** Pause between sends: Resend's default limit is two requests per second. */
23
23
  export const DEFAULT_PAUSE_MS = 500;
24
24
  export const MAIL_USAGE = `Usage:
25
- softure-mail campaign <content-file> --recipients <file> [--dry-run] [--pause-ms <ms>]
25
+ softure-mail campaign <content-file> --recipients <file> [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
26
26
  softure-mail dns [--domain <domain>] [--dkim-selector <name>]... [--spf-host <host>]...
27
+ [--dmarc-policy <p>] [--dmarc-sp <p>] [--dmarc-adkim <a>] [--dmarc-aspf <a>]
28
+ [--reply-to <address>] [--return-path <host>]... [--resend-return-path]
27
29
 
28
30
  campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, optional html,
29
31
  then the text body) to every address in the recipients file (one per line), at most
30
- once each. Re-running sends only to recipients without an outcome yet.
31
- --dry-run count what would be sent and change nothing
32
- --pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
32
+ once each. Re-running sends only to recipients without an outcome yet. A refused
33
+ API key or a spent quota stops the run; the rest go out on the next one.
34
+ --dry-run count what would be sent and change nothing
35
+ --pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
36
+ --resend-uncertain also send to recipients whose send was interrupted long ago
37
+ (it may have gone out: they may get the mail twice)
33
38
 
34
39
  dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain of "from").
35
40
  --domain <domain> check this domain instead
36
41
  --dkim-selector <name> DKIM selector to look up (default resend; repeatable)
37
42
  --spf-host <host> where SPF is published (default the domain; repeatable;
38
43
  Resend publishes it on send.<domain>)
44
+ --dmarc-policy <p> require at least this DMARC policy (quarantine or reject)
45
+ and pct=100; a weaker record fails
46
+ --dmarc-sp <p> require at least this subdomain policy (sp, else p)
47
+ --dmarc-adkim <a> require this DKIM alignment (r or s; absent counts as r)
48
+ --dmarc-aspf <a> require this SPF alignment (r or s; absent counts as r)
49
+ --reply-to <address> check that this address's domain accepts mail (MX, one SPF)
50
+ (default: replyTo of the config, when --domain is not given)
51
+ --return-path <host> a return-path host that must be a CNAME or have MX (repeatable)
52
+ --resend-return-path check Resend's send.<domain> and rsend.<domain> CNAMEs
39
53
 
40
54
  Options for both:
41
55
  --config <file> the app's softure.config file (bin only)
@@ -44,6 +58,9 @@ const consoleOutput = {
44
58
  log: (line) => console.log(line),
45
59
  error: (line) => console.error(line),
46
60
  };
61
+ const DMARC_POLICIES = ["quarantine", "reject"];
62
+ const DMARC_ALIGNMENTS = ["r", "s"];
63
+ const DNS_ONLY_OPTIONS = ["domain", "dkim-selector", "spf-host", "dmarc-policy", "dmarc-sp", "dmarc-adkim", "dmarc-aspf", "reply-to", "return-path", "resend-return-path"];
47
64
  /** Runs the command and returns the process exit code: 0 done, 1 failed or incomplete, 2 usage error. */
48
65
  export async function runMailCli(options) {
49
66
  const output = options.output ?? consoleOutput;
@@ -82,14 +99,26 @@ function parseCommand(argv) {
82
99
  if (values.help === true)
83
100
  return { kind: "help" };
84
101
  if (name === "dns") {
85
- const misplaced = ["recipients", "dry-run", "pause-ms"].filter((option) => values[option] !== undefined);
102
+ const misplaced = ["recipients", "dry-run", "pause-ms", "resend-uncertain"].filter((option) => values[option] !== undefined);
86
103
  if (misplaced.length > 0)
87
104
  return `dns does not take --${misplaced.join(", --")}`;
88
105
  if (positionals.length > 0)
89
106
  return `dns takes no file, got "${positionals.join(" ")}"`;
90
- return { kind: "dns", domain: values.domain, dkimSelectors: values["dkim-selector"], spfHosts: values["spf-host"] };
107
+ const expectDmarc = parseDmarcExpectation(values);
108
+ if (typeof expectDmarc === "string")
109
+ return expectDmarc;
110
+ return {
111
+ kind: "dns",
112
+ domain: values.domain,
113
+ dkimSelectors: values["dkim-selector"],
114
+ spfHosts: values["spf-host"],
115
+ expectDmarc,
116
+ replyTo: values["reply-to"],
117
+ returnPathHosts: values["return-path"] ?? [],
118
+ resendReturnPath: values["resend-return-path"] === true,
119
+ };
91
120
  }
92
- const misplaced = ["domain", "dkim-selector", "spf-host"].filter((option) => values[option] !== undefined);
121
+ const misplaced = DNS_ONLY_OPTIONS.filter((option) => values[option] !== undefined);
93
122
  if (misplaced.length > 0)
94
123
  return `campaign does not take --${misplaced.join(", --")}`;
95
124
  const [contentFile, ...extra] = positionals;
@@ -102,7 +131,7 @@ function parseCommand(argv) {
102
131
  const pauseMs = values["pause-ms"] === undefined ? DEFAULT_PAUSE_MS : Number(values["pause-ms"]);
103
132
  if (!Number.isInteger(pauseMs) || pauseMs < 0 || pauseMs > 60_000)
104
133
  return `--pause-ms expects whole milliseconds from 0 to 60000, got "${values["pause-ms"] ?? ""}"`;
105
- return { kind: "campaign", contentFile, recipientsFile: values.recipients, dryRun: values["dry-run"] === true, pauseMs };
134
+ return { kind: "campaign", contentFile, recipientsFile: values.recipients, dryRun: values["dry-run"] === true, pauseMs, resendUncertain: values["resend-uncertain"] === true };
106
135
  }
107
136
  function parseCommandArgs(args) {
108
137
  return parseArgs({
@@ -111,45 +140,105 @@ function parseCommandArgs(args) {
111
140
  recipients: { type: "string" },
112
141
  "dry-run": { type: "boolean" },
113
142
  "pause-ms": { type: "string" },
143
+ "resend-uncertain": { type: "boolean" },
114
144
  domain: { type: "string" },
115
145
  "dkim-selector": { type: "string", multiple: true },
116
146
  "spf-host": { type: "string", multiple: true },
147
+ "dmarc-policy": { type: "string" },
148
+ "dmarc-sp": { type: "string" },
149
+ "dmarc-adkim": { type: "string" },
150
+ "dmarc-aspf": { type: "string" },
151
+ "reply-to": { type: "string" },
152
+ "return-path": { type: "string", multiple: true },
153
+ "resend-return-path": { type: "boolean" },
117
154
  help: { type: "boolean" },
118
155
  },
119
156
  strict: true,
120
157
  allowPositionals: true,
121
158
  });
122
159
  }
160
+ /** One choice of a fixed list, case-insensitive; `undefined` when the option was not given. */
161
+ function pickChoice(values, option, allowed) {
162
+ const given = values[option];
163
+ if (given === undefined)
164
+ return { value: undefined };
165
+ const found = allowed.find((candidate) => candidate === given.trim().toLowerCase());
166
+ return found === undefined ? `--${option} expects ${allowed.join(" or ")}, got "${given}"` : { value: found };
167
+ }
168
+ function parseDmarcExpectation(values) {
169
+ const policy = pickChoice(values, "dmarc-policy", DMARC_POLICIES);
170
+ if (typeof policy === "string")
171
+ return policy;
172
+ const subdomainPolicy = pickChoice(values, "dmarc-sp", DMARC_POLICIES);
173
+ if (typeof subdomainPolicy === "string")
174
+ return subdomainPolicy;
175
+ const adkim = pickChoice(values, "dmarc-adkim", DMARC_ALIGNMENTS);
176
+ if (typeof adkim === "string")
177
+ return adkim;
178
+ const aspf = pickChoice(values, "dmarc-aspf", DMARC_ALIGNMENTS);
179
+ if (typeof aspf === "string")
180
+ return aspf;
181
+ const expectation = {
182
+ ...(policy.value === undefined ? {} : { policy: policy.value }),
183
+ ...(subdomainPolicy.value === undefined ? {} : { subdomainPolicy: subdomainPolicy.value }),
184
+ ...(adkim.value === undefined ? {} : { adkim: adkim.value }),
185
+ ...(aspf.value === undefined ? {} : { aspf: aspf.value }),
186
+ };
187
+ return Object.keys(expectation).length === 0 ? undefined : expectation;
188
+ }
123
189
  async function runDns(command, options, output) {
124
190
  let domain = command.domain;
191
+ let replyTo = command.replyTo;
125
192
  if (domain === undefined) {
126
193
  if (options.config === undefined) {
127
194
  output.error("softure-mail dns: pass --domain <domain>, or run it with the app's config to check the sender's domain");
128
195
  return EXIT_FAILED;
129
196
  }
130
- domain = getSenderDomain(getMailingOptions(options.config).from);
197
+ const mailing = getMailingOptions(options.config);
198
+ domain = getSenderDomain(mailing.from);
199
+ replyTo ??= mailing.replyTo;
131
200
  }
201
+ const returnPath = [...(command.resendReturnPath ? resendReturnPath(domain) : []), ...command.returnPathHosts.map((host) => ({ host }))];
132
202
  const report = await checkSenderDns(domain, {
133
203
  ...(command.dkimSelectors === undefined ? {} : { dkimSelectors: command.dkimSelectors }),
134
204
  ...(command.spfHosts === undefined ? {} : { spfHosts: command.spfHosts }),
205
+ ...(command.expectDmarc === undefined ? {} : { expectDmarc: command.expectDmarc }),
206
+ ...(replyTo === undefined ? {} : { replyTo }),
207
+ returnPath,
135
208
  ...(options.resolveTxt === undefined ? {} : { resolveTxt: options.resolveTxt }),
209
+ ...(options.resolveMx === undefined ? {} : { resolveMx: options.resolveMx }),
210
+ ...(options.resolveCname === undefined ? {} : { resolveCname: options.resolveCname }),
136
211
  });
137
- const checks = [
212
+ const authentication = [
138
213
  ["SPF", report.spf],
139
214
  ["DKIM", report.dkim],
140
215
  ["DMARC", report.dmarc],
141
216
  ];
217
+ const reply = report.replyTo === null ? [] : [["REPLY", report.replyTo]];
218
+ const paths = report.returnPath.map((check) => ["PATH", check]);
142
219
  output.log(`sender domain ${report.domain}`);
143
- for (const [name, check] of checks) {
144
- const line = `${name.padEnd(6)}${check.status.padEnd(6)}${check.finding.padEnd(15)}${check.host}${check.record === null ? "" : ` ${check.record}`}`;
220
+ for (const [name, check] of [...authentication, ...reply, ...paths]) {
221
+ const line = `${name.padEnd(6)}${check.status.padEnd(6)}${`${check.finding} `.padEnd(15)}${check.host}${check.record === null ? "" : ` ${check.record}`}`;
145
222
  if (check.status === "fail")
146
223
  output.error(line);
147
224
  else
148
225
  output.log(line);
149
226
  }
150
- const failed = checks.filter(([, check]) => check.status === "fail").length;
151
- output.log(failed === 0 ? "receivers can authenticate mail from this domain" : `${String(failed)} check(s) failed: list mail from this domain will land in spam or be refused`);
152
- return failed === 0 ? EXIT_OK : EXIT_FAILED;
227
+ const countFailed = (checks) => checks.filter(([, check]) => check.status === "fail").length;
228
+ const failedAuthentication = countFailed(authentication);
229
+ const failedReply = countFailed(reply);
230
+ const failedPaths = countFailed(paths);
231
+ if (failedAuthentication + failedReply + failedPaths === 0) {
232
+ output.log("receivers can authenticate mail from this domain");
233
+ return EXIT_OK;
234
+ }
235
+ if (failedAuthentication > 0)
236
+ output.log(`${String(failedAuthentication)} check(s) failed: list mail from this domain will land in spam or be refused`);
237
+ if (failedReply > 0)
238
+ output.log("the reply-to domain does not accept mail: replies will bounce");
239
+ if (failedPaths > 0)
240
+ output.log(`${String(failedPaths)} return-path host(s) failed: the provider cannot use them for bounces`);
241
+ return EXIT_FAILED;
153
242
  }
154
243
  async function runCampaign(command, options, output) {
155
244
  const cwd = options.cwd ?? process.cwd();
@@ -181,7 +270,9 @@ async function runCampaign(command, options, output) {
181
270
  }
182
271
  const ctx = { db: opened.handle.db, clock: systemClock, config };
183
272
  try {
184
- return command.dryRun ? await reportPlan(ctx, loaded, output) : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep }, output);
273
+ return command.dryRun
274
+ ? await reportPlan(ctx, loaded, command.resendUncertain, output)
275
+ : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep, retakeUncertain: command.resendUncertain }, output);
185
276
  }
186
277
  catch (error) {
187
278
  // Driver errors: the message only, never a stack, an address or the database URL.
@@ -214,10 +305,17 @@ async function loadCampaign(contentPath, recipientsPath) {
214
305
  return `cannot read the recipients file ${recipientsPath}`;
215
306
  return { campaign: { ...content, html }, recipients: parseRecipientList(list) };
216
307
  }
217
- async function reportPlan(ctx, loaded, output) {
218
- const plan = await planCampaign(ctx, loaded);
308
+ async function reportPlan(ctx, loaded, retakeUncertain, output) {
309
+ const plan = await planCampaign(ctx, loaded, { retakeUncertain });
219
310
  output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}), dry run: nothing sent or written`);
220
- output.log(`recipients ${String(plan.recipients)}, already done ${String(plan.done)}, unsubscribed ${String(plan.suppressed)}, to send ${String(plan.toSend)}`);
311
+ output.log([
312
+ `recipients ${String(plan.recipients)}`,
313
+ `already done ${String(plan.done)}`,
314
+ `unsubscribed ${String(plan.suppressed)}`,
315
+ `filtered out ${String(plan.filtered)}`,
316
+ `uncertain ${String(plan.uncertain)}`,
317
+ `to send ${String(plan.toSend)}`,
318
+ ].join(", "));
221
319
  if (plan.contentChanged) {
222
320
  output.error(`campaign ${loaded.campaign.id} was sent with other content; give this content a new id`);
223
321
  return EXIT_FAILED;
@@ -229,6 +327,7 @@ async function reportSend(ctx, loaded, pace, output) {
229
327
  const total = loaded.recipients.length;
230
328
  const result = await sendCampaign(ctx, loaded, {
231
329
  pauseMs: pace.pauseMs,
330
+ retakeUncertain: pace.retakeUncertain,
232
331
  ...(pace.sleep === undefined ? {} : { sleep: pace.sleep }),
233
332
  onDelivery: () => {
234
333
  handled += 1;
@@ -240,14 +339,29 @@ async function reportSend(ctx, loaded, pace, output) {
240
339
  output.error(`campaign ${loaded.campaign.id} was sent with other content; give this content a new id`);
241
340
  return EXIT_FAILED;
242
341
  }
243
- output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}): ${formatSummary(result.value)}`);
244
- const open = result.value.inFlight + result.value.retryLater;
245
- if (open > 0) {
342
+ const summary = result.value;
343
+ output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}): ${formatSummary(summary)}`);
344
+ let code = EXIT_OK;
345
+ if (summary.halted !== null) {
346
+ const status = summary.halted.httpStatus === undefined ? "" : ` (HTTP ${String(summary.halted.httpStatus)})`;
347
+ output.error(`stopped: ${HALT_EXPLANATIONS[summary.halted.reason]}${status}; the remaining recipients were not touched, run the same command again once it is fixed`);
348
+ code = EXIT_FAILED;
349
+ }
350
+ const open = summary.inFlight + summary.retryLater;
351
+ if (open > 0 && summary.halted === null) {
246
352
  output.error(`${String(open)} recipient(s) have no outcome yet; run the same command again later`);
247
- return EXIT_FAILED;
353
+ code = EXIT_FAILED;
248
354
  }
249
- return EXIT_OK;
355
+ if (summary.uncertain > 0) {
356
+ output.error(`${String(summary.uncertain)} recipient(s) are uncertain: a send was interrupted long ago and may have gone out; check the provider's log, then run again with --resend-uncertain to send them anyway`);
357
+ code = EXIT_FAILED;
358
+ }
359
+ return code;
250
360
  }
361
+ const HALT_EXPLANATIONS = {
362
+ "mailing.provider_refused": "the provider refused the account (check the API key and the account)",
363
+ "mailing.quota_exceeded": "the account's sending quota is spent",
364
+ };
251
365
  function formatSummary(summary) {
252
366
  const rejected = Object.entries(summary.rejected).filter(([, count]) => count > 0);
253
367
  const rejectedTotal = rejected.reduce((sum, [, count]) => sum + count, 0);
@@ -259,6 +373,8 @@ function formatSummary(summary) {
259
373
  `already done ${String(summary.done)}`,
260
374
  `in flight ${String(summary.inFlight)}`,
261
375
  `retry later ${String(summary.retryLater)}`,
376
+ `filtered out ${String(summary.filtered)}`,
377
+ `uncertain ${String(summary.uncertain)}`,
262
378
  ].join(", ");
263
379
  }
264
380
  async function openDatabase(database, open) {