@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +110 -16
  3. package/dist/cli/run.d.ts +3 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +202 -30
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +41 -6
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/index.d.ts +14 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/next/import-deliveries.d.ts +4 -0
  14. package/dist/next/import-deliveries.d.ts.map +1 -0
  15. package/dist/next/import-deliveries.js +9 -0
  16. package/dist/next/import-deliveries.js.map +1 -0
  17. package/dist/next/index.d.ts +1 -0
  18. package/dist/next/index.d.ts.map +1 -1
  19. package/dist/next/index.js +1 -0
  20. package/dist/next/index.js.map +1 -1
  21. package/dist/next/route.d.ts +3 -2
  22. package/dist/next/route.d.ts.map +1 -1
  23. package/dist/next/route.js +5 -4
  24. package/dist/next/route.js.map +1 -1
  25. package/dist/options.d.ts +19 -2
  26. package/dist/options.d.ts.map +1 -1
  27. package/dist/options.js +30 -6
  28. package/dist/options.js.map +1 -1
  29. package/dist/schema.d.ts +17 -0
  30. package/dist/schema.d.ts.map +1 -1
  31. package/dist/schema.js +2 -0
  32. package/dist/schema.js.map +1 -1
  33. package/dist/server/campaigns.d.ts +5 -0
  34. package/dist/server/campaigns.d.ts.map +1 -1
  35. package/dist/server/campaigns.js +10 -0
  36. package/dist/server/campaigns.js.map +1 -1
  37. package/dist/server/deliveries.d.ts +9 -5
  38. package/dist/server/deliveries.d.ts.map +1 -1
  39. package/dist/server/deliveries.js +9 -7
  40. package/dist/server/deliveries.js.map +1 -1
  41. package/dist/server/import-deliveries.d.ts +68 -0
  42. package/dist/server/import-deliveries.d.ts.map +1 -0
  43. package/dist/server/import-deliveries.js +105 -0
  44. package/dist/server/import-deliveries.js.map +1 -0
  45. package/dist/server/index.d.ts +3 -2
  46. package/dist/server/index.d.ts.map +1 -1
  47. package/dist/server/index.js +3 -2
  48. package/dist/server/index.js.map +1 -1
  49. package/dist/server/send-mail.js +6 -6
  50. package/dist/server/send-mail.js.map +1 -1
  51. package/dist/server/suppressions.d.ts.map +1 -1
  52. package/dist/server/suppressions.js +9 -6
  53. package/dist/server/suppressions.js.map +1 -1
  54. package/dist/server/unsubscribe-link.d.ts +3 -2
  55. package/dist/server/unsubscribe-link.d.ts.map +1 -1
  56. package/dist/server/unsubscribe-link.js +12 -3
  57. package/dist/server/unsubscribe-link.js.map +1 -1
  58. package/dist/server/validate-mail.d.ts +3 -1
  59. package/dist/server/validate-mail.d.ts.map +1 -1
  60. package/dist/server/validate-mail.js +4 -3
  61. package/dist/server/validate-mail.js.map +1 -1
  62. package/migrations/0004_allow_imported_deliveries.sql +20 -0
  63. package/module.json +1 -1
  64. package/package.json +1 -1
  65. package/src/cli/run.ts +208 -28
  66. package/src/contract.ts +36 -6
  67. package/src/index.ts +4 -1
  68. package/src/next/import-deliveries.ts +9 -0
  69. package/src/next/index.ts +1 -0
  70. package/src/next/route.ts +5 -4
  71. package/src/options.ts +36 -7
  72. package/src/schema.ts +2 -0
  73. package/src/server/campaigns.ts +13 -0
  74. package/src/server/deliveries.ts +15 -8
  75. package/src/server/import-deliveries.ts +149 -0
  76. package/src/server/index.ts +12 -0
  77. package/src/server/send-mail.ts +6 -6
  78. package/src/server/suppressions.ts +15 -8
  79. package/src/server/unsubscribe-link.ts +10 -3
  80. 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. Ported from FIRE_TRACKER `src/lib/mail.ts` (a hand-written Resend `fetch`, plain text only),
5
- with the sender, reply-to, provider and timeout taken from configuration, an HTML body, and a fake
6
- provider for tests. List mail gets signed one-click unsubscribe (RFC 8058) and a suppression list,
7
- ported from FIRE_TRACKER `src/lib/unsubscribe-*.ts` and `/wypisz`. A delivery ledger sends a mail
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) or 500 (database
94
- failure, so the client retries); a GET redirects to the page. The whole link is a credential: it is
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`. A halt (`provider_refused`, `quota_exceeded`)
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), `ctx.db` is the transaction. A throw rolls the opt-out back: the route answers 500 and the
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 (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,
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;
@@ -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,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"}
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 and checks the sender domain's DNS. The app
2
- // passes its validated config, so the same function serves the bin and an app script that a
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. 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.
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 both:
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 = ["recipients", "dry-run", "pause-ms", "resend-uncertain"].filter((option) => values[option] !== undefined);
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 === undefined)
130
- return "campaign needs --recipients <file>";
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 loaded = await loadCampaign(resolve(cwd, command.contentFile), resolve(cwd, command.recipientsFile));
251
- if (typeof loaded === "string") {
252
- output.error(`softure-mail campaign: ${loaded}`);
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
- async function loadCampaign(contentPath, recipientsPath) {
287
- const source = await readText(contentPath);
288
- if (source === null)
289
- return `cannot read the content file ${contentPath}`;
290
- const parsed = parseCampaignFile(source);
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 `${contentPath} is not a campaign:\n ${parsed.problems.join("\n ")}`;
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
- const htmlFile = resolve(dirname(contentPath), htmlPath);
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
- const list = await readText(recipientsPath);
304
- if (list === null)
305
- return `cannot read the recipients file ${recipientsPath}`;
306
- return { campaign: { ...content, html }, recipients: parseRecipientList(list) };
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");