@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.
- package/CHANGELOG.md +34 -0
- package/README.md +108 -25
- package/dist/cli/run.d.ts +3 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +141 -25
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +48 -8
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/messages/en.d.ts +2 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +2 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +4 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/pl.d.ts.map +1 -1
- package/dist/messages/pl.js +2 -0
- package/dist/messages/pl.js.map +1 -1
- package/dist/next/actions.d.ts.map +1 -1
- package/dist/next/actions.js +6 -6
- package/dist/next/actions.js.map +1 -1
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +8 -6
- package/dist/next/pages.js.map +1 -1
- package/dist/next/route.d.ts +2 -1
- package/dist/next/route.d.ts.map +1 -1
- package/dist/next/route.js +6 -4
- package/dist/next/route.js.map +1 -1
- package/dist/options.d.ts +15 -1
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +32 -1
- package/dist/options.js.map +1 -1
- package/dist/providers/resend.d.ts.map +1 -1
- package/dist/providers/resend.js +12 -6
- package/dist/providers/resend.js.map +1 -1
- package/dist/schema.d.ts +17 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -0
- package/dist/schema.js.map +1 -1
- package/dist/server/campaigns.d.ts +30 -4
- package/dist/server/campaigns.d.ts.map +1 -1
- package/dist/server/campaigns.js +67 -12
- package/dist/server/campaigns.js.map +1 -1
- package/dist/server/deliveries.d.ts +30 -2
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +58 -23
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/dns.d.ts +48 -1
- package/dist/server/dns.d.ts.map +1 -1
- package/dist/server/dns.js +124 -15
- package/dist/server/dns.js.map +1 -1
- package/dist/server/index.d.ts +4 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/send-mail.d.ts +3 -1
- package/dist/server/send-mail.d.ts.map +1 -1
- package/dist/server/send-mail.js +18 -6
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/suppressions.d.ts +8 -6
- package/dist/server/suppressions.d.ts.map +1 -1
- package/dist/server/suppressions.js +23 -7
- package/dist/server/suppressions.js.map +1 -1
- package/dist/server/unsubscribe-link.d.ts +23 -0
- package/dist/server/unsubscribe-link.d.ts.map +1 -1
- package/dist/server/unsubscribe-link.js +30 -1
- package/dist/server/unsubscribe-link.js.map +1 -1
- package/migrations/0003_add_delivery_provider_status.sql +13 -0
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/cli/run.ts +164 -29
- package/src/contract.ts +49 -6
- package/src/index.ts +8 -3
- package/src/messages/en.ts +2 -0
- package/src/messages/pl.ts +2 -0
- package/src/next/actions.ts +5 -6
- package/src/next/pages.tsx +10 -7
- package/src/next/route.ts +6 -4
- package/src/options.ts +36 -2
- package/src/providers/resend.ts +11 -7
- package/src/schema.ts +2 -0
- package/src/server/campaigns.ts +95 -17
- package/src/server/deliveries.ts +89 -29
- package/src/server/dns.ts +174 -16
- package/src/server/index.ts +17 -0
- package/src/server/send-mail.ts +30 -6
- package/src/server/suppressions.ts +23 -8
- 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
|
|
22
|
-
| `err("mailing.
|
|
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,
|
|
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
|
|
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: "
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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. `
|
|
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
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
334
|
-
|
|
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
|
}
|
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;AAC/F,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIhG,OAAO,
|
|
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
|
-
|
|
32
|
-
--
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
144
|
-
const line = `${name.padEnd(6)}${check.status.padEnd(6)}${check.finding
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
353
|
+
code = EXIT_FAILED;
|
|
248
354
|
}
|
|
249
|
-
|
|
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) {
|