@softure-ai/mailing 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +110 -16
- package/dist/cli/run.d.ts +3 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +202 -30
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +41 -6
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/next/import-deliveries.d.ts +4 -0
- package/dist/next/import-deliveries.d.ts.map +1 -0
- package/dist/next/import-deliveries.js +9 -0
- package/dist/next/import-deliveries.js.map +1 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +1 -0
- package/dist/next/index.js.map +1 -1
- package/dist/next/route.d.ts +3 -2
- package/dist/next/route.d.ts.map +1 -1
- package/dist/next/route.js +5 -4
- package/dist/next/route.js.map +1 -1
- package/dist/options.d.ts +19 -2
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +30 -6
- package/dist/options.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 +5 -0
- package/dist/server/campaigns.d.ts.map +1 -1
- package/dist/server/campaigns.js +10 -0
- package/dist/server/campaigns.js.map +1 -1
- package/dist/server/deliveries.d.ts +9 -5
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +9 -7
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/import-deliveries.d.ts +68 -0
- package/dist/server/import-deliveries.d.ts.map +1 -0
- package/dist/server/import-deliveries.js +105 -0
- package/dist/server/import-deliveries.js.map +1 -0
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/send-mail.js +6 -6
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/suppressions.d.ts.map +1 -1
- package/dist/server/suppressions.js +9 -6
- package/dist/server/suppressions.js.map +1 -1
- package/dist/server/unsubscribe-link.d.ts +3 -2
- package/dist/server/unsubscribe-link.d.ts.map +1 -1
- package/dist/server/unsubscribe-link.js +12 -3
- package/dist/server/unsubscribe-link.js.map +1 -1
- package/dist/server/validate-mail.d.ts +3 -1
- package/dist/server/validate-mail.d.ts.map +1 -1
- package/dist/server/validate-mail.js +4 -3
- package/dist/server/validate-mail.js.map +1 -1
- package/migrations/0004_allow_imported_deliveries.sql +20 -0
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/cli/run.ts +208 -28
- package/src/contract.ts +36 -6
- package/src/index.ts +4 -1
- package/src/next/import-deliveries.ts +9 -0
- package/src/next/index.ts +1 -0
- package/src/next/route.ts +5 -4
- package/src/options.ts +36 -7
- package/src/schema.ts +2 -0
- package/src/server/campaigns.ts +13 -0
- package/src/server/deliveries.ts +15 -8
- package/src/server/import-deliveries.ts +149 -0
- package/src/server/index.ts +12 -0
- package/src/server/send-mail.ts +6 -6
- package/src/server/suppressions.ts +15 -8
- package/src/server/unsubscribe-link.ts +10 -3
- package/src/server/validate-mail.ts +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ Newest first. Each version lists what changed for an app that uses `@softure-ai/
|
|
|
4
4
|
production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
|
|
5
5
|
"Definition of done"). Versions before the first one below are described in their GitHub Releases (`mailing@x.y.z`).
|
|
6
6
|
|
|
7
|
+
## 0.1.10
|
|
8
|
+
|
|
9
|
+
- `OutgoingMail.replyTo`: an optional reply-to address for one mail (exactly one address, checked like `to`, failure
|
|
10
|
+
field `replyTo`), sent instead of `mailing({ replyTo })`; without it the configured one is used. `ValidMail` carries
|
|
11
|
+
it as `replyTo: string | null`. The `Reply-To` header stays reserved.
|
|
12
|
+
|
|
13
|
+
## 0.1.9
|
|
14
|
+
|
|
15
|
+
- New `importDeliveries(ctx, rows)` (`/server`, and `importDeliveries(rows)` in `/next`) and `softure-mail import
|
|
16
|
+
<file|->` (JSON Lines): seed `mailing.deliveries` with the deliveries an app made before it adopted the module, so
|
|
17
|
+
the first `deliverOnce` or campaign run skips them. Rows `{ scope, address, status: "sent" | "rejected",
|
|
18
|
+
finishedAt, providerMessageId?, reason?, kind? }`; every row is checked before anything is written, rows already
|
|
19
|
+
in the ledger are left as they are, a re-run imports nothing twice. `checkImportedDeliveries` checks without
|
|
20
|
+
writing (`--dry-run`).
|
|
21
|
+
- Migration `0004`: `mailing.deliveries.imported_at`; an imported `sent` row may have no `provider_message_id`.
|
|
22
|
+
Run `softure migrate`.
|
|
23
|
+
- `maxAttempts` is a module option (default 5) and may be `null`, in the module or per `deliverOnce` call: then
|
|
24
|
+
`mailing.unavailable` always releases the claim and never closes the delivery.
|
|
25
|
+
- `listCampaignRecipients(campaign, ctx)` module option: `softure-mail campaign` without `--recipients` takes the
|
|
26
|
+
recipients from it. New `listConfiguredCampaignRecipients` in `/server`.
|
|
27
|
+
- `softure-mail` reads a file given as `-` from standard input (the campaign's content file or `--recipients`, the
|
|
28
|
+
import's history file), so it runs inside the app's container with `docker compose exec -T`. The README documents
|
|
29
|
+
that topology.
|
|
30
|
+
- `legacyUnsubscribe.params` also takes `{ required, optional }`: a link is legacy when it carries every required
|
|
31
|
+
name, and the optional names it carries go to `verify` too, so two old link forms on one path both keep working.
|
|
32
|
+
The array form still means "all required".
|
|
33
|
+
- `mailing({ oneClickInvalidLinkStatus: 200 })`: the one-click route answers 200 for a link that does not verify, so
|
|
34
|
+
the answer never tells whether a token is live. The default stays 400; a failure still answers 500.
|
|
35
|
+
- `onUnsubscribed` receives `event.link`: `{ scheme: "signed" }` or `{ scheme: "legacy", values }` with the values
|
|
36
|
+
`verify` accepted, so the hook can find the row a legacy link named. New types `LegacyUnsubscribeParams` and
|
|
37
|
+
`VerifiedUnsubscribeLink`.
|
|
38
|
+
|
|
7
39
|
## 0.1.8
|
|
8
40
|
|
|
9
41
|
- `checkSenderDns` takes `expectDmarc: { policy, subdomainPolicy, adkim, aspf }`: a DMARC record weaker than it (a
|
package/README.md
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# @softure-ai/mailing
|
|
2
2
|
|
|
3
3
|
Sends one mail to one recipient through a provider adapter and answers with a typed result, never a
|
|
4
|
-
throw
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
at most once per scope and recipient, and `softure-mail` sends campaigns from a content file where
|
|
9
|
-
FIRE ran deployment-specific scripts.
|
|
4
|
+
throw, with the sender, reply-to, provider and timeout taken from configuration, an HTML body, and a
|
|
5
|
+
fake provider for tests. List mail gets signed one-click unsubscribe (RFC 8058) and a suppression
|
|
6
|
+
list. A delivery ledger sends a mail at most once per scope and recipient, an app's earlier history
|
|
7
|
+
can be imported into it, and `softure-mail` sends campaigns from a content file.
|
|
10
8
|
|
|
11
9
|
## 1. What it provides
|
|
12
10
|
|
|
@@ -42,7 +40,8 @@ FIRE ran deployment-specific scripts.
|
|
|
42
40
|
the withdrawal, and **`legacyUnsubscribe`**, which keeps unsubscribe links the app sent before it
|
|
43
41
|
adopted the module working (section 10).
|
|
44
42
|
- **Sending once.** `deliverOnce(ctx, { scope, mail })` (`/server`, and `deliverOnce({ scope, mail })`
|
|
45
|
-
in `/next`) sends a mail at most once per scope and recipient through the delivery ledger
|
|
43
|
+
in `/next`) sends a mail at most once per scope and recipient through the delivery ledger;
|
|
44
|
+
`importDeliveries` (and `softure-mail import`) seeds it with the app's earlier history.
|
|
46
45
|
- **Campaigns.** `sendCampaign`, `planCampaign` and the content file parser in `/server`; the
|
|
47
46
|
`softure-mail campaign` command sends a campaign from a content file.
|
|
48
47
|
- **Sender DNS.** `checkSenderDns(domain)` and `softure-mail dns` report SPF, DKIM and DMARC, hold DMARC to a
|
|
@@ -70,6 +69,10 @@ the database; list mail without `db` throws, as a wiring bug).
|
|
|
70
69
|
Rules that hold for every send:
|
|
71
70
|
|
|
72
71
|
- **One recipient.** `to` is exactly one address; a list is a loop. No cc or bcc.
|
|
72
|
+
- **Reply-To per mail.** `replyTo` (one address, checked like `to`) sends replies to that address
|
|
73
|
+
instead of `mailing({ replyTo })`, e.g. the buyer of a request mailed to the operator:
|
|
74
|
+
`sendMail({ to: operator, replyTo: request.account.email, subject, text })`. Without it the
|
|
75
|
+
configured one is used; the `Reply-To` header stays refused.
|
|
73
76
|
- **Text always, HTML optionally.** `text` is required; `html` is a string the app rendered (from a
|
|
74
77
|
template string or React email components rendered to a string: the module does not render).
|
|
75
78
|
- **Headers cannot take over the mail.** `headers` is for headers like `List-Unsubscribe`;
|
|
@@ -90,8 +93,9 @@ address, and `t`, the base64url HMAC-SHA256 of a fixed prefix plus `r` under
|
|
|
90
93
|
`MAILING_UNSUBSCRIBE_SECRET`. The address never appears in a URL, and the suppression table stores
|
|
91
94
|
only `r`. The page (footer link) shows a button and changes nothing on open: mail scanners open
|
|
92
95
|
links. The one-click route takes a mail client's POST without a session, verifies before touching
|
|
93
|
-
the database and answers 200 (recorded, also again), 400 (link does not verify
|
|
94
|
-
failure, so the client retries); a GET redirects
|
|
96
|
+
the database and answers 200 (recorded, also again), 400 (link does not verify; 200 with
|
|
97
|
+
`oneClickInvalidLinkStatus: 200`) or 500 (database failure, so the client retries); a GET redirects
|
|
98
|
+
to the page. The whole link is a credential: it is
|
|
95
99
|
never logged.
|
|
96
100
|
|
|
97
101
|
### Sending once: the delivery ledger
|
|
@@ -124,7 +128,10 @@ const outcome = await deliverOnce({
|
|
|
124
128
|
Lowercase letters, digits and `._:-`, at most 128 characters. Campaigns use `campaign:<id>`.
|
|
125
129
|
- **Idempotency key.** The ledger sends with `<scope>:<recipient key>`; callers do not pass one.
|
|
126
130
|
- **Retries.** `unavailable` releases the claim (`pending`); the fifth attempt (`maxAttempts`) that
|
|
127
|
-
is still unavailable closes the row as `rejected`.
|
|
131
|
+
is still unavailable closes the row as `rejected`. Five rides out a short provider outage across
|
|
132
|
+
a few runs, while a provider that keeps failing on one mail does not keep it open forever. An app
|
|
133
|
+
that retries on its own schedule sets `mailing({ maxAttempts: null })` (or passes it per call):
|
|
134
|
+
`unavailable` then always releases the claim and never closes the row. A halt (`provider_refused`, `quota_exceeded`)
|
|
128
135
|
never closes a row and never uses up an attempt, so a run with a bad key loses no recipient. The
|
|
129
136
|
provider's HTTP status of the last failed answer is kept in `provider_status`.
|
|
130
137
|
- **Interrupted sends.** A process that dies between the send and the outcome leaves a claim; after
|
|
@@ -138,6 +145,47 @@ const outcome = await deliverOnce({
|
|
|
138
145
|
claim cannot overwrite the one that took over.
|
|
139
146
|
- A malformed scope or kind throws (a bug); a database failure propagates.
|
|
140
147
|
|
|
148
|
+
### Importing an existing history
|
|
149
|
+
|
|
150
|
+
An app that already kept its own once-only records (which campaign went to which signup, which
|
|
151
|
+
notice went to which user for which period) seeds the ledger before its first `deliverOnce` or
|
|
152
|
+
campaign run; otherwise that run mails everyone again. Map each record to the scope the module
|
|
153
|
+
will use for it and import:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
import { importDeliveries } from "@softure-ai/mailing/server";
|
|
157
|
+
|
|
158
|
+
const result = await importDeliveries(ctx, [
|
|
159
|
+
{ scope: "campaign:2026-09-launch", address: "ada@example.org", status: "sent", finishedAt: "2026-09-20T10:15:00Z", kind: "newsletter" },
|
|
160
|
+
{ scope: "account.trial-ending:user_7", address: "bob@example.org", status: "rejected", finishedAt: row.sentAt, reason: "mailing.rejected" },
|
|
161
|
+
]);
|
|
162
|
+
if (!result.ok) console.error(result.problems); // [{ index, problem }]: nothing was written
|
|
163
|
+
else console.log(result.value); // { rows, imported, alreadyPresent, duplicates }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Or from a file, one JSON object per line with the same fields:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
softure-mail import history.jsonl --dry-run # checks the file, writes nothing
|
|
170
|
+
softure-mail import history.jsonl
|
|
171
|
+
docker compose exec -T app npx softure-mail import - < history.jsonl # inside the app's container
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- Rows: `scope`, `address` (only its key is stored), `status` (`sent` or `rejected`), `finishedAt`
|
|
175
|
+
(a date or ISO string, not in the future), optional `providerMessageId` (sent rows; history
|
|
176
|
+
rarely kept it, and an imported sent row may go without), `reason` (rejected rows: one of
|
|
177
|
+
`mailing.invalid_input`, `rejected`, `unavailable`, `suppressed`; default `mailing.rejected`) and
|
|
178
|
+
`kind` (default `transactional`).
|
|
179
|
+
- **Every row is checked before anything is written**; problems name the row (the file's line) and
|
|
180
|
+
the field, never the address.
|
|
181
|
+
- **Idempotent.** A row whose scope and recipient the ledger already has (imported earlier, or sent
|
|
182
|
+
by the module) is left as it is and counted as `alreadyPresent`; re-run the same import after a
|
|
183
|
+
failure. Two input rows for the same scope and recipient count once (`duplicates`).
|
|
184
|
+
- Imported rows are closed (`sent` or `rejected`), with `imported_at` set and no `campaign_id`:
|
|
185
|
+
`deliverOnce`, `planCampaign` and `sendCampaign` match a delivery by scope, so they skip it.
|
|
186
|
+
- Unsubscribes the app recorded go to `suppressRecipient`, not here: the suppression list is what
|
|
187
|
+
stops future campaigns.
|
|
188
|
+
|
|
141
189
|
### Campaigns: `softure-mail campaign`
|
|
142
190
|
|
|
143
191
|
A campaign is one list mail to many recipients through the ledger. The content file is frontmatter
|
|
@@ -161,6 +209,25 @@ softure-mail campaign launch.md --recipients recipients.txt # sends
|
|
|
161
209
|
```
|
|
162
210
|
|
|
163
211
|
- The recipients file holds one address per line (`#` comments and blank lines skipped).
|
|
212
|
+
- **Recipients from the database.** Without `--recipients` the command asks
|
|
213
|
+
`mailing({ listCampaignRecipients })`, which lists the addresses from the app's own data
|
|
214
|
+
(duplicates are fine; the filter and the suppression list still apply):
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
listCampaignRecipients: async ({ kind }, ctx) => listOptedInAddresses(ctx.db, kind),
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
- **Where it runs.** The command needs the database (the ledger) and the provider key, so it runs
|
|
221
|
+
where both are: inside the app's container. When the database is not reachable from the
|
|
222
|
+
operator's machine, send the content file on standard input (`-`) and let the recipients come
|
|
223
|
+
from `listCampaignRecipients` (or pass `--recipients -` with the content as a file in the image):
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
ssh app-host 'cd /srv/app && docker compose exec -T app npx softure-mail campaign - --dry-run' < launch.md
|
|
227
|
+
ssh app-host 'cd /srv/app && docker compose exec -T app npx softure-mail campaign -' < launch.md
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
With the content on standard input, its `html:` file is looked up in the working directory.
|
|
164
231
|
- The command loads `softure.config.*` like `softure migrate` (or `--config <file>`), opens the
|
|
165
232
|
config's `database.handle` when set, otherwise its own connection on `database.url`, and needs `MAILING_UNSUBSCRIBE_SECRET` (campaigns are list
|
|
166
233
|
mail). Run `softure migrate` first.
|
|
@@ -250,7 +317,10 @@ mailing({
|
|
|
250
317
|
| `timeoutMs` | `number` | `10000` | 1000 to 60000. |
|
|
251
318
|
| `onUnsubscribed` | `(event, ctx) => Promise<void>` | — | Runs on every verified unsubscribe, in its transaction (section 10). |
|
|
252
319
|
| `legacyUnsubscribe` | `{ params, verify }` | — | Verifies unsubscribe links the app sent before it adopted the module (section 10). |
|
|
320
|
+
| `oneClickInvalidLinkStatus` | `200 \| 400` | `400` | What the one-click route answers for a link that does not verify. `200` gives no oracle on whether a token is live; a failure still answers 500, and the page still shows its message. |
|
|
253
321
|
| `filterCampaignRecipient` | `(recipient, ctx) => Promise<boolean>` | — | Decides per recipient whether a campaign goes to them (section 1, Campaigns). |
|
|
322
|
+
| `listCampaignRecipients` | `(campaign, ctx) => Iterable<string> \| AsyncIterable<string> \| Promise<Iterable<string>>` | — | Lists a campaign's recipients from the app's data; `softure-mail campaign` uses it without `--recipients` (section 1, Campaigns). |
|
|
323
|
+
| `maxAttempts` | `number \| null` | `5` | Claims before `mailing.unavailable` closes a delivery as rejected, 1 to 100; `null` never closes it (section 1, Retries). |
|
|
254
324
|
| `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
325
|
| `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. |
|
|
256
326
|
| `routes` | `{ unsubscribe?, oneClick? }` | `/unsubscribe`, `/api/mailing/unsubscribe` | Where you mount the page and the route; links are built on `appOrigin` plus these paths. |
|
|
@@ -310,8 +380,9 @@ Schema `mailing`, applied by `softure migrate`:
|
|
|
310
380
|
| --- | --- | --- |
|
|
311
381
|
| `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. |
|
|
312
382
|
| `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`. |
|
|
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). |
|
|
383
|
+
| | `mailing.deliveries` | primary key (`scope`, `recipient_key`), `kind`, `campaign_id` (then `scope` is `campaign:<id>`), `status` (`pending`, `claimed`, `sent`, `rejected`), `attempts`, `claimed_at`, `finished_at`, `provider_message_id` (exactly when sent; see `0004`), `reason` (exactly when rejected). |
|
|
314
384
|
| `0003_add_delivery_provider_status.sql` | `mailing.deliveries` | `provider_status` (the HTTP status of the last failed or released answer, 100 to 599, else null); `attempts` may be 0 on a `pending` row (a halt gave its attempt back). |
|
|
385
|
+
| `0004_allow_imported_deliveries.sql` | `mailing.deliveries` | `imported_at` (set by `importDeliveries`, only on `sent`/`rejected` rows); `provider_message_id` only on `sent` rows, and required there unless the row was imported. |
|
|
315
386
|
|
|
316
387
|
The health check (`checkMailingTables`) runs `select 1 from mailing.<table> limit 0` for the three
|
|
317
388
|
tables.
|
|
@@ -351,8 +422,9 @@ The provider is the extension point (section 3).
|
|
|
351
422
|
|
|
352
423
|
**`onUnsubscribed(event, ctx)`** runs on every verified unsubscribe (the page's button or a mail
|
|
353
424
|
client's one-click POST), also a repeated one, in the transaction that stores the opt-out:
|
|
354
|
-
`event` is `{ recipientKey, source }` (the key is privacy's email key of the same address, never the
|
|
355
|
-
address
|
|
425
|
+
`event` is `{ recipientKey, source, link }` (the key is privacy's email key of the same address, never the
|
|
426
|
+
address; `link` is `{ scheme: "signed" }` or `{ scheme: "legacy", values }` with the values `verify`
|
|
427
|
+
accepted, so a hook can find the row a legacy link named), `ctx.db` is the transaction. A throw rolls the opt-out back: the route answers 500 and the
|
|
356
428
|
page offers a retry, so the opt-out and what the hook records never disagree. It does not run for
|
|
357
429
|
`suppressRecipient` (a bounce or a script is not the person's choice). Wire the waitlist's handler,
|
|
358
430
|
which withdraws its consents in privacy's ledger:
|
|
@@ -365,9 +437,12 @@ mailing({ from: "…", provider: resend(), onUnsubscribed: withdrawWaitlistConse
|
|
|
365
437
|
|
|
366
438
|
**`legacyUnsubscribe: { params, verify }`** keeps unsubscribe links working that the app sent
|
|
367
439
|
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 (
|
|
369
|
-
|
|
370
|
-
|
|
440
|
+
`params` are the old link's query names (never `r` or `status`, which the module's links and page
|
|
441
|
+
own; `t` may be shared). An array names parameters the link must all carry; `{ required, optional }`
|
|
442
|
+
also names ones it may carry, for an app that sent more than one form on the same path (at most 8
|
|
443
|
+
names in all, at least one required). A link without `r` that carries every required one
|
|
444
|
+
(non-empty, at most 512 characters) is a legacy link, and its values also hold the optional ones it
|
|
445
|
+
has (an empty one counts as absent, one over 512 characters makes the link invalid): the page shows the same button with the values in hidden fields,
|
|
371
446
|
and the action and the one-click POST call `verify(values, ctx)`. It returns the recipient's
|
|
372
447
|
**address** when the link is genuine, else `null`; the module records the opt-out under that
|
|
373
448
|
address's key and runs `onUnsubscribed`, exactly as for its own links. `verify` must check the
|
|
@@ -386,6 +461,25 @@ mailing({
|
|
|
386
461
|
export { UnsubscribePage as default } from "@softure-ai/mailing/next";
|
|
387
462
|
```
|
|
388
463
|
|
|
464
|
+
Two forms on one path (a signed `?u=<id>&t=<hmac>` in mail and a bare `?t=<token>` shown after
|
|
465
|
+
sign-up), an opt-out recorded on the app's own row, and a one-click route that never tells a live
|
|
466
|
+
token from a dead one:
|
|
467
|
+
|
|
468
|
+
```ts
|
|
469
|
+
mailing({
|
|
470
|
+
from: "…",
|
|
471
|
+
provider: resend(),
|
|
472
|
+
oneClickInvalidLinkStatus: 200,
|
|
473
|
+
legacyUnsubscribe: {
|
|
474
|
+
params: { required: ["t"], optional: ["u"] },
|
|
475
|
+
verify: ({ u, t }, ctx) => (u === undefined ? findAddressByToken(ctx.db, t) : verifyOldLink(ctx.db, u, t)),
|
|
476
|
+
},
|
|
477
|
+
onUnsubscribed: async (event, ctx) => {
|
|
478
|
+
if (event.link.scheme === "legacy") await markSignupUnsubscribed(ctx.db, event.link.values);
|
|
479
|
+
},
|
|
480
|
+
}),
|
|
481
|
+
```
|
|
482
|
+
|
|
389
483
|
**`liftSuppression(ctx, address)`** is the other direction: a module that has just recorded a new
|
|
390
484
|
explicit consent (the waitlist's sign-up) calls it in that consent's transaction. It deletes the
|
|
391
485
|
row only when its source is `page` or `one-click`; an `operator` row stays. It returns whether it
|
package/dist/cli/run.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface CliOutput {
|
|
|
7
7
|
readonly error: (line: string) => void;
|
|
8
8
|
}
|
|
9
9
|
export interface RunMailCliOptions {
|
|
10
|
-
/** The app's config. Needed by `campaign`, and by `dns` without `--domain`. */
|
|
10
|
+
/** The app's config. Needed by `campaign` and `import`, and by `dns` without `--domain`. */
|
|
11
11
|
readonly config?: SoftureConfig;
|
|
12
12
|
/** The arguments after the executable, e.g. `["campaign", "launch.md", "--recipients", "list.txt"]`. */
|
|
13
13
|
readonly argv: readonly string[];
|
|
@@ -21,6 +21,8 @@ export interface RunMailCliOptions {
|
|
|
21
21
|
readonly resolveCname?: ResolveCname;
|
|
22
22
|
readonly sleep?: (ms: number) => Promise<void>;
|
|
23
23
|
readonly env?: Env;
|
|
24
|
+
/** Reads all of standard input, for a file given as `-`. Default: `process.stdin`. */
|
|
25
|
+
readonly readStdin?: () => Promise<string>;
|
|
24
26
|
}
|
|
25
27
|
export declare const EXIT_OK = 0;
|
|
26
28
|
export declare const EXIT_FAILED = 1;
|
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;
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAWA,OAAO,EAAe,KAAK,aAAa,EAA8B,MAAM,kBAAkB,CAAC;AAE/F,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIhG,OAAO,EAAkI,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAuB,MAAM,kBAAkB,CAAC;AAG3O,OAAO,EAAiF,KAAK,GAAG,EAAE,MAAM,+BAA+B,CAAC;AAExI,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,iBAAiB;IAChC,4FAA4F;IAC5F,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC,wGAAwG;IACxG,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,mEAAmE;IACnE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,iIAAiI;IACjI,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IACjE,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC;IACjC,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,QAAQ,CAAC,YAAY,CAAC,EAAE,YAAY,CAAC;IACrC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,QAAQ,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;IACnB,sFAAsF;IACtF,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;CAC5C;AAED,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,UAAU,IAAI,CAAC;AAE5B,8EAA8E;AAC9E,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,eAAO,MAAM,UAAU,QA6C8C,CAAC;AAqCtE,yGAAyG;AACzG,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAmB5E"}
|
package/dist/cli/run.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// `softure-mail`: sends a campaign from a content file
|
|
2
|
-
//
|
|
3
|
-
// bundler packs for a container:
|
|
1
|
+
// `softure-mail`: sends a campaign from a content file, imports an app's delivery history into the
|
|
2
|
+
// ledger and checks the sender domain's DNS. The app passes its validated config, so the same
|
|
3
|
+
// function serves the bin and an app script that a bundler packs for a container:
|
|
4
4
|
//
|
|
5
5
|
// // scripts/mail.ts
|
|
6
6
|
// import { runMailCli } from "@softure-ai/mailing/cli";
|
|
@@ -10,10 +10,12 @@ import { readFile } from "node:fs/promises";
|
|
|
10
10
|
import { dirname, resolve } from "node:path";
|
|
11
11
|
import { parseArgs } from "node:util";
|
|
12
12
|
import { systemClock } from "@softure-ai/core";
|
|
13
|
+
import { z } from "zod";
|
|
13
14
|
import { openCommandDatabase } from "@softure-ai/db";
|
|
14
15
|
import { parseCampaignFile, parseRecipientList } from "../server/campaign-file.js";
|
|
15
|
-
import { planCampaign, sendCampaign } from "../server/campaigns.js";
|
|
16
|
+
import { listConfiguredCampaignRecipients, planCampaign, sendCampaign } from "../server/campaigns.js";
|
|
16
17
|
import { checkSenderDns, getSenderDomain, resendReturnPath } from "../server/dns.js";
|
|
18
|
+
import { checkImportedDeliveries, importDeliveries } from "../server/import-deliveries.js";
|
|
17
19
|
import { getMailingOptions } from "../server/options.js";
|
|
18
20
|
import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV } from "../server/unsubscribe-link.js";
|
|
19
21
|
export const EXIT_OK = 0;
|
|
@@ -22,20 +24,30 @@ export const EXIT_USAGE = 2;
|
|
|
22
24
|
/** Pause between sends: Resend's default limit is two requests per second. */
|
|
23
25
|
export const DEFAULT_PAUSE_MS = 500;
|
|
24
26
|
export const MAIL_USAGE = `Usage:
|
|
25
|
-
softure-mail campaign <content-file> --recipients <file> [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
|
|
27
|
+
softure-mail campaign <content-file> [--recipients <file>] [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
|
|
28
|
+
softure-mail import <history-file> [--dry-run]
|
|
26
29
|
softure-mail dns [--domain <domain>] [--dkim-selector <name>]... [--spf-host <host>]...
|
|
27
30
|
[--dmarc-policy <p>] [--dmarc-sp <p>] [--dmarc-adkim <a>] [--dmarc-aspf <a>]
|
|
28
31
|
[--reply-to <address>] [--return-path <host>]... [--resend-return-path]
|
|
29
32
|
|
|
30
33
|
campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, optional html,
|
|
31
34
|
then the text body) to every address in the recipients file (one per line), at most
|
|
32
|
-
once each.
|
|
33
|
-
|
|
35
|
+
once each. Without --recipients, the recipients come from listCampaignRecipients in
|
|
36
|
+
the mailing options. Re-running sends only to recipients without an outcome yet. A
|
|
37
|
+
refused API key or a spent quota stops the run; the rest go out on the next one.
|
|
38
|
+
--recipients <file> the recipients, one address per line
|
|
34
39
|
--dry-run count what would be sent and change nothing
|
|
35
40
|
--pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
|
|
36
41
|
--resend-uncertain also send to recipients whose send was interrupted long ago
|
|
37
42
|
(it may have gone out: they may get the mail twice)
|
|
38
43
|
|
|
44
|
+
import Writes deliveries the app made before it adopted the module into the ledger, so
|
|
45
|
+
later sends skip them. <history-file> holds one JSON object per line: scope,
|
|
46
|
+
address, status (sent or rejected), finishedAt, and optional providerMessageId,
|
|
47
|
+
reason, kind. Rows already in the ledger are left as they are; any invalid row
|
|
48
|
+
stops the import before anything is written.
|
|
49
|
+
--dry-run check the file and change nothing
|
|
50
|
+
|
|
39
51
|
dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain of "from").
|
|
40
52
|
--domain <domain> check this domain instead
|
|
41
53
|
--dkim-selector <name> DKIM selector to look up (default resend; repeatable)
|
|
@@ -51,15 +63,21 @@ dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain
|
|
|
51
63
|
--return-path <host> a return-path host that must be a CNAME or have MX (repeatable)
|
|
52
64
|
--resend-return-path check Resend's send.<domain> and rsend.<domain> CNAMEs
|
|
53
65
|
|
|
54
|
-
Options for
|
|
66
|
+
Options for all:
|
|
55
67
|
--config <file> the app's softure.config file (bin only)
|
|
56
|
-
--help show this help
|
|
68
|
+
--help show this help
|
|
69
|
+
|
|
70
|
+
A file given as - is read from standard input (one of them per command), e.g.
|
|
71
|
+
docker compose exec -T app npx softure-mail campaign - < launch.md`;
|
|
57
72
|
const consoleOutput = {
|
|
58
73
|
log: (line) => console.log(line),
|
|
59
74
|
error: (line) => console.error(line),
|
|
60
75
|
};
|
|
61
76
|
const DMARC_POLICIES = ["quarantine", "reject"];
|
|
62
77
|
const DMARC_ALIGNMENTS = ["r", "s"];
|
|
78
|
+
/** A file name that means standard input. */
|
|
79
|
+
const STDIN = "-";
|
|
80
|
+
const CAMPAIGN_ONLY_OPTIONS = ["recipients", "pause-ms", "resend-uncertain"];
|
|
63
81
|
const DNS_ONLY_OPTIONS = ["domain", "dkim-selector", "spf-host", "dmarc-policy", "dmarc-sp", "dmarc-adkim", "dmarc-aspf", "reply-to", "return-path", "resend-return-path"];
|
|
64
82
|
/** Runs the command and returns the process exit code: 0 done, 1 failed or incomplete, 2 usage error. */
|
|
65
83
|
export async function runMailCli(options) {
|
|
@@ -78,16 +96,18 @@ export async function runMailCli(options) {
|
|
|
78
96
|
return runDns(command, options, output);
|
|
79
97
|
case "campaign":
|
|
80
98
|
return runCampaign(command, options, output);
|
|
99
|
+
case "import":
|
|
100
|
+
return runImport(command, options, output);
|
|
81
101
|
}
|
|
82
102
|
}
|
|
83
103
|
function parseCommand(argv) {
|
|
84
104
|
const [name, ...rest] = argv;
|
|
85
105
|
if (name === undefined)
|
|
86
|
-
return "missing command; use campaign or dns";
|
|
106
|
+
return "missing command; use campaign, import or dns";
|
|
87
107
|
if (name === "--help")
|
|
88
108
|
return { kind: "help" };
|
|
89
|
-
if (name !== "campaign" && name !== "dns")
|
|
90
|
-
return `unknown command "${name}"; use campaign or dns`;
|
|
109
|
+
if (name !== "campaign" && name !== "import" && name !== "dns")
|
|
110
|
+
return `unknown command "${name}"; use campaign, import or dns`;
|
|
91
111
|
let parsed;
|
|
92
112
|
try {
|
|
93
113
|
parsed = parseCommandArgs(rest);
|
|
@@ -99,7 +119,7 @@ function parseCommand(argv) {
|
|
|
99
119
|
if (values.help === true)
|
|
100
120
|
return { kind: "help" };
|
|
101
121
|
if (name === "dns") {
|
|
102
|
-
const misplaced = [
|
|
122
|
+
const misplaced = [...CAMPAIGN_ONLY_OPTIONS, "dry-run"].filter((option) => values[option] !== undefined);
|
|
103
123
|
if (misplaced.length > 0)
|
|
104
124
|
return `dns does not take --${misplaced.join(", --")}`;
|
|
105
125
|
if (positionals.length > 0)
|
|
@@ -118,6 +138,17 @@ function parseCommand(argv) {
|
|
|
118
138
|
resendReturnPath: values["resend-return-path"] === true,
|
|
119
139
|
};
|
|
120
140
|
}
|
|
141
|
+
if (name === "import") {
|
|
142
|
+
const misplaced = [...CAMPAIGN_ONLY_OPTIONS, ...DNS_ONLY_OPTIONS].filter((option) => values[option] !== undefined);
|
|
143
|
+
if (misplaced.length > 0)
|
|
144
|
+
return `import does not take --${misplaced.join(", --")}`;
|
|
145
|
+
const [historyFile, ...extra] = positionals;
|
|
146
|
+
if (historyFile === undefined)
|
|
147
|
+
return "import needs a history file (- for standard input)";
|
|
148
|
+
if (extra.length > 0)
|
|
149
|
+
return `import takes one history file, got also "${extra.join(" ")}"`;
|
|
150
|
+
return { kind: "import", historyFile, dryRun: values["dry-run"] === true };
|
|
151
|
+
}
|
|
121
152
|
const misplaced = DNS_ONLY_OPTIONS.filter((option) => values[option] !== undefined);
|
|
122
153
|
if (misplaced.length > 0)
|
|
123
154
|
return `campaign does not take --${misplaced.join(", --")}`;
|
|
@@ -126,12 +157,12 @@ function parseCommand(argv) {
|
|
|
126
157
|
return "campaign needs a content file";
|
|
127
158
|
if (extra.length > 0)
|
|
128
159
|
return `campaign takes one content file, got also "${extra.join(" ")}"`;
|
|
129
|
-
if (values.recipients ===
|
|
130
|
-
return "campaign
|
|
160
|
+
if (contentFile === STDIN && values.recipients === STDIN)
|
|
161
|
+
return "campaign can read only one of the content file and --recipients from standard input";
|
|
131
162
|
const pauseMs = values["pause-ms"] === undefined ? DEFAULT_PAUSE_MS : Number(values["pause-ms"]);
|
|
132
163
|
if (!Number.isInteger(pauseMs) || pauseMs < 0 || pauseMs > 60_000)
|
|
133
164
|
return `--pause-ms expects whole milliseconds from 0 to 60000, got "${values["pause-ms"] ?? ""}"`;
|
|
134
|
-
return { kind: "campaign", contentFile, recipientsFile: values.recipients, dryRun: values["dry-run"] === true, pauseMs, resendUncertain: values["resend-uncertain"] === true };
|
|
165
|
+
return { kind: "campaign", contentFile, recipientsFile: values.recipients ?? null, dryRun: values["dry-run"] === true, pauseMs, resendUncertain: values["resend-uncertain"] === true };
|
|
135
166
|
}
|
|
136
167
|
function parseCommandArgs(args) {
|
|
137
168
|
return parseArgs({
|
|
@@ -247,9 +278,19 @@ async function runCampaign(command, options, output) {
|
|
|
247
278
|
output.error("softure-mail campaign: needs the app's config");
|
|
248
279
|
return EXIT_FAILED;
|
|
249
280
|
}
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
281
|
+
const input = createInputReader(cwd, options.readStdin ?? readProcessStdin);
|
|
282
|
+
const content = await loadCampaignContent(command.contentFile, input);
|
|
283
|
+
if (typeof content === "string") {
|
|
284
|
+
output.error(`softure-mail campaign: ${content}`);
|
|
285
|
+
return EXIT_FAILED;
|
|
286
|
+
}
|
|
287
|
+
const listed = command.recipientsFile === null ? null : await loadRecipientFile(command.recipientsFile, input);
|
|
288
|
+
if (typeof listed === "string") {
|
|
289
|
+
output.error(`softure-mail campaign: ${listed}`);
|
|
290
|
+
return EXIT_FAILED;
|
|
291
|
+
}
|
|
292
|
+
if (listed === null && getMailingOptions(config).listCampaignRecipients === undefined) {
|
|
293
|
+
output.error("softure-mail campaign: pass --recipients <file>, or set listCampaignRecipients in the mailing options");
|
|
253
294
|
return EXIT_FAILED;
|
|
254
295
|
}
|
|
255
296
|
if (!command.dryRun && readUnsubscribeSecrets(options.env ?? process.env).current === null) {
|
|
@@ -270,6 +311,7 @@ async function runCampaign(command, options, output) {
|
|
|
270
311
|
}
|
|
271
312
|
const ctx = { db: opened.handle.db, clock: systemClock, config };
|
|
272
313
|
try {
|
|
314
|
+
const loaded = { campaign: content, recipients: listed ?? (await collectRecipients(ctx, content)) };
|
|
273
315
|
return command.dryRun
|
|
274
316
|
? await reportPlan(ctx, loaded, command.resendUncertain, output)
|
|
275
317
|
: await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep, retakeUncertain: command.resendUncertain }, output);
|
|
@@ -283,27 +325,151 @@ async function runCampaign(command, options, output) {
|
|
|
283
325
|
await opened.close();
|
|
284
326
|
}
|
|
285
327
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
328
|
+
function createInputReader(cwd, readStdin) {
|
|
329
|
+
return {
|
|
330
|
+
read: async (file) => {
|
|
331
|
+
if (file === STDIN)
|
|
332
|
+
return { text: await readStdin(), name: "standard input", dir: cwd };
|
|
333
|
+
const path = resolve(cwd, file);
|
|
334
|
+
return { text: await readText(path), name: path, dir: dirname(path) };
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async function loadCampaignContent(file, input) {
|
|
339
|
+
const source = await input.read(file);
|
|
340
|
+
if (source.text === null)
|
|
341
|
+
return `cannot read the content file ${source.name}`;
|
|
342
|
+
const parsed = parseCampaignFile(source.text);
|
|
291
343
|
if (!parsed.ok)
|
|
292
|
-
return `${
|
|
344
|
+
return `${source.name} is not a campaign:\n ${parsed.problems.join("\n ")}`;
|
|
293
345
|
const { htmlPath, ...content } = parsed.value;
|
|
294
346
|
let html = null;
|
|
295
347
|
if (htmlPath !== null) {
|
|
296
|
-
|
|
348
|
+
// Next to the content file; for standard input, the working directory.
|
|
349
|
+
const htmlFile = resolve(source.dir, htmlPath);
|
|
297
350
|
html = await readText(htmlFile);
|
|
298
351
|
if (html === null)
|
|
299
352
|
return `cannot read the HTML body ${htmlFile}`;
|
|
300
353
|
if (html.trim() === "")
|
|
301
354
|
return `the HTML body ${htmlFile} is empty`;
|
|
302
355
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
356
|
+
return { ...content, html };
|
|
357
|
+
}
|
|
358
|
+
async function loadRecipientFile(file, input) {
|
|
359
|
+
const list = await input.read(file);
|
|
360
|
+
if (list.text === null)
|
|
361
|
+
return `cannot read the recipients file ${list.name}`;
|
|
362
|
+
return parseRecipientList(list.text);
|
|
363
|
+
}
|
|
364
|
+
/** The recipients from `listCampaignRecipients`, which the caller checked is set. */
|
|
365
|
+
async function collectRecipients(ctx, campaign) {
|
|
366
|
+
const recipients = [];
|
|
367
|
+
const source = await listConfiguredCampaignRecipients(ctx, campaign);
|
|
368
|
+
if (source === null)
|
|
369
|
+
return recipients;
|
|
370
|
+
for await (const address of source)
|
|
371
|
+
recipients.push(address);
|
|
372
|
+
return recipients;
|
|
373
|
+
}
|
|
374
|
+
/** One line of a history file. Unknown keys are refused, so a typo does not drop a field. */
|
|
375
|
+
const historyRowSchema = z.strictObject({
|
|
376
|
+
scope: z.string(),
|
|
377
|
+
address: z.string(),
|
|
378
|
+
status: z.enum(["sent", "rejected"]),
|
|
379
|
+
finishedAt: z.string(),
|
|
380
|
+
providerMessageId: z.string().optional(),
|
|
381
|
+
reason: z.enum(["mailing.invalid_input", "mailing.rejected", "mailing.unavailable", "mailing.suppressed"]).optional(),
|
|
382
|
+
kind: z.string().optional(),
|
|
383
|
+
});
|
|
384
|
+
async function runImport(command, options, output) {
|
|
385
|
+
const config = options.config;
|
|
386
|
+
if (config === undefined) {
|
|
387
|
+
output.error("softure-mail import: needs the app's config");
|
|
388
|
+
return EXIT_FAILED;
|
|
389
|
+
}
|
|
390
|
+
const source = await createInputReader(options.cwd ?? process.cwd(), options.readStdin ?? readProcessStdin).read(command.historyFile);
|
|
391
|
+
if (source.text === null) {
|
|
392
|
+
output.error(`softure-mail import: cannot read the history file ${source.name}`);
|
|
393
|
+
return EXIT_FAILED;
|
|
394
|
+
}
|
|
395
|
+
const parsed = parseHistory(source.text);
|
|
396
|
+
if (parsed.problems.length > 0)
|
|
397
|
+
return reportImportProblems(parsed.problems, output);
|
|
398
|
+
const rows = parsed.rows.map((row) => row.delivery);
|
|
399
|
+
const lineOf = (problem) => parsed.rows[problem.index]?.line ?? 0;
|
|
400
|
+
// Checked before the database is opened, so a bad file costs no connection.
|
|
401
|
+
const checked = checkImportedDeliveries({ clock: systemClock }, rows);
|
|
402
|
+
if (!checked.ok)
|
|
403
|
+
return reportImportProblems(checked.problems.map((problem) => ({ line: lineOf(problem), problem: problem.problem })), output);
|
|
404
|
+
if (command.dryRun) {
|
|
405
|
+
output.log(`import, dry run: nothing written; rows ${String(rows.length)}, duplicates ${String(checked.value.duplicates)}`);
|
|
406
|
+
return EXIT_OK;
|
|
407
|
+
}
|
|
408
|
+
if (config.database === null) {
|
|
409
|
+
output.error("softure-mail import: the config has no database; set database.url in softure.config");
|
|
410
|
+
return EXIT_FAILED;
|
|
411
|
+
}
|
|
412
|
+
let opened;
|
|
413
|
+
try {
|
|
414
|
+
opened = await openDatabase(config.database, options.openDatabase);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
output.error(`softure-mail import: ${describeError(error)}`);
|
|
418
|
+
return EXIT_FAILED;
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
const result = await importDeliveries({ db: opened.handle.db, clock: systemClock, config }, rows);
|
|
422
|
+
if (!result.ok)
|
|
423
|
+
return reportImportProblems(result.problems.map((problem) => ({ line: lineOf(problem), problem: problem.problem })), output);
|
|
424
|
+
const summary = result.value;
|
|
425
|
+
output.log(`import: rows ${String(summary.rows)}, imported ${String(summary.imported)}, already present ${String(summary.alreadyPresent)}, duplicates ${String(summary.duplicates)}`);
|
|
426
|
+
return EXIT_OK;
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
output.error(`softure-mail import: ${describeError(error)} (did softure migrate run?)`);
|
|
430
|
+
return EXIT_FAILED;
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
await opened.close();
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/** The history file's rows with their line numbers; problems name lines and fields, never values. */
|
|
437
|
+
function parseHistory(text) {
|
|
438
|
+
const rows = [];
|
|
439
|
+
const problems = [];
|
|
440
|
+
text.split(/\r?\n/).forEach((source, index) => {
|
|
441
|
+
const line = index + 1;
|
|
442
|
+
if (source.trim() === "")
|
|
443
|
+
return;
|
|
444
|
+
let value;
|
|
445
|
+
try {
|
|
446
|
+
value = JSON.parse(source);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
problems.push({ line, problem: "not a JSON object" });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const parsed = historyRowSchema.safeParse(value);
|
|
453
|
+
if (!parsed.success) {
|
|
454
|
+
for (const issue of parsed.error.issues) {
|
|
455
|
+
const field = issue.path.length === 0 ? "row" : issue.path.join(".");
|
|
456
|
+
problems.push({ line, problem: issue.code === "unrecognized_keys" ? `unknown field(s) ${issue.keys.join(", ")}` : `${field}: ${issue.message}` });
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const { providerMessageId, reason, kind, ...required } = parsed.data;
|
|
461
|
+
rows.push({
|
|
462
|
+
line,
|
|
463
|
+
delivery: { ...required, ...(providerMessageId === undefined ? {} : { providerMessageId }), ...(reason === undefined ? {} : { reason }), ...(kind === undefined ? {} : { kind }) },
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
return { rows, problems };
|
|
467
|
+
}
|
|
468
|
+
function reportImportProblems(problems, output) {
|
|
469
|
+
output.error(`softure-mail import: nothing written, ${String(problems.length)} problem(s):`);
|
|
470
|
+
for (const { line, problem } of problems)
|
|
471
|
+
output.error(` line ${String(line)}: ${problem}`);
|
|
472
|
+
return EXIT_FAILED;
|
|
307
473
|
}
|
|
308
474
|
async function reportPlan(ctx, loaded, retakeUncertain, output) {
|
|
309
475
|
const plan = await planCampaign(ctx, loaded, { retakeUncertain });
|
|
@@ -383,6 +549,12 @@ async function openDatabase(database, open) {
|
|
|
383
549
|
const handle = await open(database.url);
|
|
384
550
|
return { handle, close: handle.close };
|
|
385
551
|
}
|
|
552
|
+
async function readProcessStdin() {
|
|
553
|
+
const chunks = [];
|
|
554
|
+
for await (const chunk of process.stdin)
|
|
555
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
556
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
557
|
+
}
|
|
386
558
|
async function readText(path) {
|
|
387
559
|
try {
|
|
388
560
|
return await readFile(path, "utf8");
|