@softure-ai/mailing 0.2.3 → 0.2.5

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 (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +67 -9
  3. package/dist/attachments.d.ts +32 -0
  4. package/dist/attachments.d.ts.map +1 -0
  5. package/dist/attachments.js +68 -0
  6. package/dist/attachments.js.map +1 -0
  7. package/dist/cli/run.d.ts.map +1 -1
  8. package/dist/cli/run.js +76 -32
  9. package/dist/cli/run.js.map +1 -1
  10. package/dist/contract.d.ts +40 -1
  11. package/dist/contract.d.ts.map +1 -1
  12. package/dist/index.d.ts +8 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/jobs/delivery-job.d.ts +8 -3
  17. package/dist/jobs/delivery-job.d.ts.map +1 -1
  18. package/dist/jobs/delivery-job.js +18 -2
  19. package/dist/jobs/delivery-job.js.map +1 -1
  20. package/dist/options.d.ts +8 -0
  21. package/dist/options.d.ts.map +1 -1
  22. package/dist/options.js +28 -0
  23. package/dist/options.js.map +1 -1
  24. package/dist/providers/resend.d.ts.map +1 -1
  25. package/dist/providers/resend.js +10 -0
  26. package/dist/providers/resend.js.map +1 -1
  27. package/dist/server/deliveries.d.ts.map +1 -1
  28. package/dist/server/deliveries.js +3 -2
  29. package/dist/server/deliveries.js.map +1 -1
  30. package/dist/server/index.d.ts +1 -1
  31. package/dist/server/index.d.ts.map +1 -1
  32. package/dist/server/index.js +1 -1
  33. package/dist/server/index.js.map +1 -1
  34. package/dist/server/operator-output.d.ts +6 -0
  35. package/dist/server/operator-output.d.ts.map +1 -1
  36. package/dist/server/operator-output.js +23 -0
  37. package/dist/server/operator-output.js.map +1 -1
  38. package/dist/server/send-mail.d.ts +10 -3
  39. package/dist/server/send-mail.d.ts.map +1 -1
  40. package/dist/server/send-mail.js +64 -18
  41. package/dist/server/send-mail.js.map +1 -1
  42. package/dist/server/validate-mail.d.ts +10 -3
  43. package/dist/server/validate-mail.d.ts.map +1 -1
  44. package/dist/server/validate-mail.js +55 -12
  45. package/dist/server/validate-mail.js.map +1 -1
  46. package/dist/testing/fake-provider.d.ts.map +1 -1
  47. package/dist/testing/fake-provider.js +18 -3
  48. package/dist/testing/fake-provider.js.map +1 -1
  49. package/dist/testing/outbox.d.ts.map +1 -1
  50. package/dist/testing/outbox.js +10 -1
  51. package/dist/testing/outbox.js.map +1 -1
  52. package/module.json +1 -1
  53. package/package.json +1 -1
  54. package/src/attachments.ts +90 -0
  55. package/src/cli/run.ts +86 -32
  56. package/src/contract.ts +42 -1
  57. package/src/index.ts +12 -1
  58. package/src/jobs/delivery-job.ts +24 -4
  59. package/src/options.ts +31 -0
  60. package/src/providers/resend.ts +10 -0
  61. package/src/server/deliveries.ts +3 -2
  62. package/src/server/index.ts +1 -1
  63. package/src/server/operator-output.ts +24 -0
  64. package/src/server/send-mail.ts +63 -19
  65. package/src/server/validate-mail.ts +65 -13
  66. package/src/testing/fake-provider.ts +18 -3
  67. package/src/testing/outbox.ts +11 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@ 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.2.5
8
+
9
+ - Named senders (#798): `mailing({ senders: { alerts: "App <alerts@example.com>" } })` adds senders a mail picks by
10
+ name with `OutgoingMail.sender` (e.g. operator alerts from `alerts`, client mail from `from`). Each is checked like
11
+ `from`. An unknown name is `mailing.invalid_input` (`fields=sender`) and nothing is sent; a mail without `sender`
12
+ goes out from `from`, as before. `deliverOnce`, `runDeliveries` and `enqueueDelivery` (through `buildMail`) carry
13
+ it with the mail; `previewMail` shows the resolved `From`. `ValidMail` gains `sender` (the name, or `null`).
14
+ - `softure-mail dns` without `--domain` checks every distinct domain of `from` and `senders`, one report each, and
15
+ fails when any of them fails. The REPLY and INBOUND checks stay with the domain of `from`. With several domains,
16
+ `--spf-host` and `--return-path` hosts count for the domain they sit under; a host under none of them is an error.
17
+ A config whose senders all share `from`'s domain prints the same report as before.
18
+ - Migrate an app that sends operator alerts and client mail from one sender:
19
+ 1. Add the second sender: `mailing({ from: "App <hello@example.com>", senders: { alerts: "App <alerts@example.com>" } })`.
20
+ 2. Pass `sender: "alerts"` on the alert mail (in `sendMail`, `deliverOnce` or the alert's `buildMail`).
21
+ 3. Run `softure-mail dns` (with `--spf-host send.<domain>` for each domain on Resend) to check every sender domain.
22
+
23
+ ## 0.2.4
24
+
25
+ - Attachments on `sendMail` and `deliverOnce` (#655): `attachments?: { filename, contentType, content: Uint8Array }[]`
26
+ on a mail. Checked before sending (`mailing.invalid_input`, `fields=attachments`): at most `attachments.maxCount`
27
+ files (default 10), at most `attachments.maxTotalBytes` raw bytes (default and maximum 28,000,000, about 37.3 MB
28
+ base64, under the 40 MB cap), a filename without path separators or control characters, a content type on
29
+ `attachments.contentTypes` (default `DEFAULT_ATTACHMENT_CONTENT_TYPES`). New option `attachments` in
30
+ `mailing({ ... })`.
31
+ - `MailProvider.supportsAttachments`: a provider gets `ProviderMessage.attachments` only when it declares it;
32
+ otherwise a mail with attachments is refused as `invalid_input` (`cause=provider_without_attachments`). `resend()`
33
+ sends them as base64 with `content_type`; `fakeMailProvider()` captures them, and its outbox file stores them as
34
+ base64 that `readMailOutbox` decodes back to bytes.
35
+ - The failure log line adds `attachments=<type>:<bytes>,...` (never filenames or content). `describeAttachments`
36
+ returns `{ filename, contentType, size }[]`; `formatMailPreview` (`/server`) prints a `previewMail` value with an
37
+ attachments section, and `softure-mail` previews use it.
38
+ - Queued deliveries carry no bytes: `enqueueDelivery` throws for a payload with a `Uint8Array`, `Buffer` or
39
+ `ArrayBuffer`; `buildMail` loads attachments from the app's storage by reference.
40
+ - `validateMail(mail, options, limits?)` takes the attachment limits (default: the module defaults).
41
+ - Migrate an app:
42
+ 1. An app that bypasses mailing to send a file (a provider SDK or the user's mailbox API): call `sendMail` (or
43
+ `deliverOnce`, or `enqueueDelivery` with the file's id in the payload and the bytes loaded in `buildMail`) with
44
+ `attachments`, and delete the bypass.
45
+ 2. A content type outside the default list: pass `mailing({ attachments: { contentTypes: [...] } })` with the
46
+ full list the app needs.
47
+ 3. A custom `MailProvider` that should carry attachments: map `message.attachments` and set
48
+ `supportsAttachments: true`. Nothing to change otherwise.
49
+
7
50
  ## 0.2.3
8
51
 
9
52
  - `softure-mail test` sends a list-kind test mail without the app database (#573). With `database: null` it skips the
package/README.md CHANGED
@@ -8,7 +8,7 @@ can be imported into it, and `softure-mail` sends campaigns from a content file.
8
8
 
9
9
  ## 1. What it provides
10
10
 
11
- - `mailing({ from, replyTo, provider, timeoutMs, ... })` for `softure.config.ts` (section 3).
11
+ - `mailing({ from, senders, replyTo, provider, timeoutMs, ... })` for `softure.config.ts` (section 3).
12
12
  - `sendMail(context, mail, options)` in `@softure-ai/mailing/server` and `sendMail(mail, options)` in
13
13
  `@softure-ai/mailing/next` (on the registered config). The result is a core `Result`; a failure the
14
14
  provider answered with an HTTP status also carries it as `httpStatus`:
@@ -46,8 +46,10 @@ can be imported into it, and `softure-mail` sends campaigns from a content file.
46
46
  `importDeliveries` (and `softure-mail import`) seeds it with the app's earlier history.
47
47
  - **Deliveries through jobs.** `enqueueDelivery` and `defineDeliveryJobHandler` (`/jobs`) let an app enqueue a
48
48
  mail with its own write and have the `@softure-ai/jobs` worker send it, retrying while the provider is down.
49
+ - **Attachments.** `attachments: [{ filename, contentType, content }]` on a mail (an invoice PDF, a report, an
50
+ export), checked against a count, a total size and a content type allow-list before anything is sent.
49
51
  - **Preview and test send.** `previewMail(config, mail)` returns exactly what `sendMail` hands the
50
- provider; `softure-mail test` sends a mail to the configured `testAddress` and nowhere else.
52
+ provider, and `formatMailPreview(message)` prints it for a terminal; `softure-mail test` sends a mail to the configured `testAddress` and nowhere else.
51
53
  - **A SQL surface** for the app's own queries: `mailing.recipient_key(address)`,
52
54
  `mailing.is_suppressed(address)`, `mailing.was_delivered(scope, address)` and the views
53
55
  `mailing.delivery_outcomes` and `mailing.suppressed_recipients` (section 5).
@@ -83,6 +85,12 @@ Rules that hold for every send:
83
85
  instead of `mailing({ replyTo })`, e.g. the buyer of a request mailed to the operator:
84
86
  `sendMail({ to: operator, replyTo: request.account.email, subject, text })`. Without it the
85
87
  configured one is used; the `Reply-To` header stays refused.
88
+ - **Sender per mail, by name.** `sender` names one of `mailing({ senders })`, e.g. operator alerts from
89
+ `alerts` and client mail from `from`: `sendMail({ to: operator, sender: "alerts", subject, text })`.
90
+ Without it the mail goes out from `from`. It is never an address, so a request cannot choose the
91
+ sender; an unknown name is `mailing.invalid_input` (`fields=sender`) and nothing is sent.
92
+ `deliverOnce` and `enqueueDelivery` carry it as part of the mail (`buildMail` returns it), and
93
+ `previewMail` shows the resolved `From`.
86
94
  - **Text always, HTML optionally.** `text` is required; `html` is a string the app rendered (from a
87
95
  template string or React email components rendered to a string: the module does not render).
88
96
  - **Headers cannot take over the mail.** `headers` is for headers like `List-Unsubscribe`;
@@ -91,13 +99,52 @@ Rules that hold for every send:
91
99
  values and the subject.
92
100
  - **Nothing of the mail is logged.** One line per failure:
93
101
  `mailing: send failed provider=resend reason=rejected status=422` (with `fields=to,subject` for
94
- invalid input). Provider refusal messages are never read: they can echo the address.
102
+ invalid input, and `attachments=application/pdf:48213,text/csv:512` when the mail has attachments: content
103
+ type and size only). Provider refusal messages are never read: they can echo the address.
95
104
  - **Timeout.** After `timeoutMs` the provider's signal aborts and the send answers `unavailable`,
96
105
  even when the provider ignores the signal.
97
106
  - **List mail fails closed.** No secret or an unreadable suppression list means nothing is sent.
98
107
  A list mail cannot bring its own `List-Unsubscribe` or `List-Unsubscribe-Post`: the module writes
99
108
  them (`invalid_input`).
100
109
 
110
+ **Attachments.** A mail can carry files, e.g. a received cost invoice forwarded to a bookkeeping service's import
111
+ address, an invoice or monthly report the app generated, or an export the user asked for:
112
+
113
+ ```ts
114
+ import { sendMail } from "@softure-ai/mailing/next";
115
+
116
+ await sendMail(
117
+ {
118
+ to: bookkeeping.importAddress,
119
+ subject: `Cost invoice ${invoice.number}`,
120
+ text: "Cost invoice attached.",
121
+ attachments: [{ filename: `invoice-${invoice.number}.pdf`, contentType: "application/pdf", content: pdfBytes }],
122
+ },
123
+ { idempotencyKey: `cost-invoice:${invoice.id}` },
124
+ );
125
+ ```
126
+
127
+ - `content` is a `Uint8Array` (a Node `Buffer` is one), at least one byte. The module sends the bytes as given.
128
+ - Checked before anything leaves the process, else `mailing.invalid_input` with `fields=attachments`: at most
129
+ `attachments.maxCount` files (default 10); at most `attachments.maxTotalBytes` raw bytes in all (default and
130
+ maximum 28,000,000: base64 grows them by a third, about 37.3 MB on the wire, under the 40 MB a mail may have at
131
+ Resend); a filename of 1 to 255 characters without `/`, `\`, control characters, `.` or `..`; a content type
132
+ (`type/subtype`, parameters allowed) on the allow-list. The default list (`DEFAULT_ATTACHMENT_CONTENT_TYPES`) has
133
+ PDF, plain text, CSV, calendar, JSON, XML, ZIP, XLSX, DOCX, PNG, JPEG, GIF and WebP; HTML, SVG, scripts and
134
+ executables are left out. `mailing({ attachments: { contentTypes } })` replaces it with the app's own list.
135
+ - **Only a provider that delivers them gets them.** A provider declares `supportsAttachments: true` (`resend()`
136
+ and `fakeMailProvider()` do); for any other a mail with attachments is `mailing.invalid_input`
137
+ (`cause=provider_without_attachments`), so an adapter written before attachments existed never drops a file.
138
+ - **Logs and dry runs.** The failure line names content types and sizes, never filenames (they are the app's text
139
+ and often name a person or an invoice, like the subject) or bytes. `describeAttachments(attachments)` gives
140
+ `{ filename, contentType, size }[]` for the app's own records; `previewMail` returns the attachments and
141
+ `formatMailPreview` lists them as `invoice-42.pdf (application/pdf, 48213 bytes)`.
142
+ - **Queued mail references files.** A delivery through jobs (`enqueueDelivery`) never carries bytes: the job payload
143
+ holds the app's ids (`{ invoiceId }`), `buildMail` loads the file from the app's storage on every attempt, and
144
+ `enqueueDelivery` throws for a payload with a `Uint8Array`, `Buffer` or `ArrayBuffer` in it. The app keeps the file
145
+ (its storage, its retention) until the job is done; it must return the same bytes each attempt, like the body.
146
+ - The delivery ledger stores nothing of the mail, attachments included.
147
+
101
148
  **How a link works.** The link carries `r`, the base64url SHA-256 of the trimmed, lowercased
102
149
  address, and `t`, the base64url HMAC-SHA256 of a fixed prefix plus `r` under
103
150
  `MAILING_UNSUBSCRIBE_SECRET`. The address never appears in a URL, and the suppression table stores
@@ -194,7 +241,8 @@ await db.transaction(async (tx) => {
194
241
  must have no side effects and return the same mail each time: the provider refuses a different body under the
195
242
  same idempotency key (`mailing.rejected`). A link with a one-time token must be reproducible per request (derive
196
243
  the token from the request's row, or store it encrypted), never minted per attempt. `buildMail` returning `null`
197
- ends the job as done with nothing sent (the account is gone).
244
+ ends the job as done with nothing sent (the account is gone). Attachments are loaded in `buildMail` from the
245
+ app's storage by an id in the payload; bytes in the payload throw (section 1, Attachments).
198
246
  - **The scope is the ledger key and the job's dedup key.** One scope per mail the app means to send once:
199
247
  `auth.password-reset:<request id>`, `app.welcome:<user id>`. While a job for it is pending or running, a second
200
248
  `enqueueDelivery` returns that job (`isDuplicate: true`). `runAt`, `delaySeconds` and `maxAttempts` pass through to
@@ -454,13 +502,16 @@ address the same way the commands do.
454
502
  ### Sender DNS check: `softure-mail dns`
455
503
 
456
504
  ```bash
457
- softure-mail dns # the domain of `from` (and replyTo) in the config
505
+ softure-mail dns # every sender domain in the config (and replyTo)
458
506
  softure-mail dns --domain mail.example.com --spf-host send.mail.example.com
459
507
  softure-mail dns --spf-host send.mail.example.com --resend-return-path \
460
508
  --dmarc-policy reject --dmarc-sp reject --dmarc-adkim s --dmarc-aspf s
461
509
  ```
462
510
 
463
- It reports each check as `pass`, `warn` or `fail`, exiting 1 on any `fail`:
511
+ Without `--domain` it checks every distinct domain of `from` and `senders`, `from`'s first, one report each; the
512
+ REPLY and INBOUND checks belong to the first. With several domains, each `--spf-host` and `--return-path` counts for
513
+ the domain it sits under (`send.clients.example.net` for `clients.example.net`), and a host under none of them is an
514
+ error. It reports each check as `pass`, `warn` or `fail`, exiting 1 on any `fail`:
464
515
 
465
516
  - **SPF**: one `v=spf1` record on the domain or each `--spf-host`; `+all` warns. Resend publishes SPF on
466
517
  `send.<domain>`.
@@ -513,6 +564,7 @@ mailing({
513
564
  | Option | Type | Default | Meaning |
514
565
  | --- | --- | --- | --- |
515
566
  | `from` | `string` | required | `addr` or `Name <addr>` (no commas, semicolons or quotes in the name). Must be on the domain the provider signs (DKIM); with DMARC `adkim=s` exactly that subdomain. |
567
+ | `senders` | `Record<string, string>` | none | Named extra senders a mail picks with `sender`, e.g. `{ alerts: "App <alerts@example.com>" }`. Names: a letter, then letters, digits, `_` or `-`, at most 32 characters. Each value is checked like `from` and must be on a domain the provider signs. |
516
568
  | `replyTo` | `string` | none | One address for replies; may be on another domain (DMARC does not check it). |
517
569
  | `provider` | `MailProvider` | required | `resend()`, `fakeMailProvider()` or your own adapter. |
518
570
  | `timeoutMs` | `number` | `10000` | 1000 to 60000. |
@@ -526,6 +578,7 @@ mailing({
526
578
  | `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. |
527
579
  | `testAddress` | `string` | none | The only address `softure-mail test` sends to (section 1, Test send). |
528
580
  | `kindAliases` | `Record<string, string>` | none | Alias to kind for campaign files and `softure-mail test --kind`, e.g. `{ news: "newsletter" }`. Kebab-case on both sides; an alias never names a kind another alias points at. |
581
+ | `attachments` | `{ maxCount?, maxTotalBytes?, contentTypes? }` | `10`, `28000000`, `DEFAULT_ATTACHMENT_CONTENT_TYPES` | Limits on a mail's attachments: files 1 to 100; raw bytes in all, at most 28,000,000 (about 37.3 MB base64); the allowed content types, replacing the default list (section 1, Attachments). |
529
582
  | `routes` | `{ unsubscribe?, oneClick? }` | `/unsubscribe`, `/api/mailing/unsubscribe` | Where you mount the page and the route; links are built on `appOrigin` plus these paths. |
530
583
 
531
584
  `resend({ apiKey?, endpoint?, fetch? })`: without `apiKey` it reads `RESEND_API_KEY` on every send,
@@ -548,7 +601,8 @@ const provider: MailProvider = {
548
601
  ```
549
602
 
550
603
  A provider receives a validated `ProviderMessage` (`from`, `to`, `replyTo`, `subject`, `text`, `html`,
551
- `headers`, `idempotencyKey`, nulls for what is absent) and answers `sent` with an id, or `rejected`
604
+ `headers`, `idempotencyKey`, nulls for what is absent; `attachments` only when the mail has some and the provider
605
+ declares `supportsAttachments: true`) and answers `sent` with an id, or `rejected`
552
606
  (this mail), `refused` (the account or key), `quota_exceeded` (the account's quota) or `unavailable`
553
607
  (try later), each with an optional `httpStatus`. A throw or anything else reads as `unavailable`.
554
608
 
@@ -769,7 +823,9 @@ personal data. So the module neither exports nor deletes per user (`privacy: { e
769
823
 
770
824
  ## 12. Limitations
771
825
 
772
- - One recipient per call; no cc, bcc or attachments.
826
+ - One recipient per call; no cc or bcc.
827
+ - Attachments are sent as regular files: no inline images (`cid:`), no content scanning. Campaigns
828
+ (`softure-mail campaign`) carry none.
773
829
  - `sendMail` does not retry: the caller retries `unavailable` with the same `idempotencyKey` (Resend
774
830
  keeps keys for 24 hours), uses `deliverOnce`, which keeps the state between runs, or enqueues it with
775
831
  `enqueueDelivery`, which retries it from a job.
@@ -783,7 +839,9 @@ personal data. So the module neither exports nor deletes per user (`privacy: { e
783
839
  removed by hand (`getRecipientKey(address)`).
784
840
  - Lowercasing the whole address merges `Ada@` and `ada@` (allowed to differ by RFC 5321, never in
785
841
  practice); an unsubscribe then covers both.
786
- - The display name in `from` cannot contain commas, semicolons or quotes (no RFC 5322 quoting).
842
+ - A named sender carries no reply-to of its own: pass `replyTo` on the mail. Campaigns and
843
+ `softure-mail test` always send from `from`.
844
+ - The display name in `from` and `senders` cannot contain commas, semicolons or quotes (no RFC 5322 quoting).
787
845
  - Address checks are structural (one `@`, a dot in the domain, no separators); the provider has the
788
846
  last word.
789
847
 
@@ -0,0 +1,32 @@
1
+ import type { AttachmentSummary, MailAttachment } from "./contract.js";
2
+ /** Files per mail when `attachments.maxCount` is not set. */
3
+ export declare const DEFAULT_MAX_ATTACHMENTS = 10;
4
+ /**
5
+ * The most raw bytes all attachments of one mail may hold, and the default. Base64 grows them by a third on the
6
+ * wire: 28,000,000 bytes encode to about 37.3 MB, under the 40 MB a mail may have at Resend and similar providers,
7
+ * with room for the bodies. `attachments.maxTotalBytes` can only lower it.
8
+ */
9
+ export declare const MAX_ATTACHMENT_BYTES = 28000000;
10
+ /**
11
+ * The content types allowed when `attachments.contentTypes` is not set: documents, data exports, images and calendar
12
+ * invites. HTML, SVG, scripts and executables are left out: a mail client may run or render them.
13
+ */
14
+ export declare const DEFAULT_ATTACHMENT_CONTENT_TYPES: readonly string[];
15
+ export declare const MAX_ATTACHMENT_FILENAME_LENGTH = 255;
16
+ /** The limits a mail's attachments are checked against (`mailing({ attachments })`, resolved). */
17
+ export interface AttachmentLimits {
18
+ readonly maxCount: number;
19
+ readonly maxTotalBytes: number;
20
+ /** Lowercase `type/subtype` values. */
21
+ readonly contentTypes: readonly string[];
22
+ }
23
+ export declare const DEFAULT_ATTACHMENT_LIMITS: AttachmentLimits;
24
+ /** A plain filename: 1 to 255 characters, not blank, no `/` or `\`, no control characters, not `.` or `..`. */
25
+ export declare function isAttachmentFilename(value: string): boolean;
26
+ /** A MIME type `type/subtype`, optionally with parameters (`text/csv; charset=utf-8`). */
27
+ export declare function isContentType(value: string): boolean;
28
+ /** The `type/subtype` of a content type, lowercase, without its parameters. */
29
+ export declare function getContentTypeEssence(value: string): string;
30
+ /** Every attachment without its bytes: filename, content type and size. Safe to keep in an app's own records. */
31
+ export declare function describeAttachments(attachments: readonly MailAttachment[]): AttachmentSummary[];
32
+ //# sourceMappingURL=attachments.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachments.d.ts","sourceRoot":"","sources":["../src/attachments.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEvE,6DAA6D;AAC7D,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAE1C;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,WAAa,CAAC;AAE/C;;;GAGG;AACH,eAAO,MAAM,gCAAgC,EAAE,SAAS,MAAM,EAe7D,CAAC;AAEF,eAAO,MAAM,8BAA8B,MAAM,CAAC;AAElD,kGAAkG;AAClG,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,uCAAuC;IACvC,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED,eAAO,MAAM,yBAAyB,EAAE,gBAIvC,CAAC;AAKF,+GAA+G;AAC/G,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAU3D;AAWD,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,+EAA+E;AAC/E,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED,iHAAiH;AACjH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,SAAS,cAAc,EAAE,GAAG,iBAAiB,EAAE,CAE/F"}
@@ -0,0 +1,68 @@
1
+ /** Files per mail when `attachments.maxCount` is not set. */
2
+ export const DEFAULT_MAX_ATTACHMENTS = 10;
3
+ /**
4
+ * The most raw bytes all attachments of one mail may hold, and the default. Base64 grows them by a third on the
5
+ * wire: 28,000,000 bytes encode to about 37.3 MB, under the 40 MB a mail may have at Resend and similar providers,
6
+ * with room for the bodies. `attachments.maxTotalBytes` can only lower it.
7
+ */
8
+ export const MAX_ATTACHMENT_BYTES = 28_000_000;
9
+ /**
10
+ * The content types allowed when `attachments.contentTypes` is not set: documents, data exports, images and calendar
11
+ * invites. HTML, SVG, scripts and executables are left out: a mail client may run or render them.
12
+ */
13
+ export const DEFAULT_ATTACHMENT_CONTENT_TYPES = [
14
+ "application/pdf",
15
+ "text/plain",
16
+ "text/csv",
17
+ "text/calendar",
18
+ "application/json",
19
+ "application/xml",
20
+ "text/xml",
21
+ "application/zip",
22
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
23
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
24
+ "image/png",
25
+ "image/jpeg",
26
+ "image/gif",
27
+ "image/webp",
28
+ ];
29
+ export const MAX_ATTACHMENT_FILENAME_LENGTH = 255;
30
+ export const DEFAULT_ATTACHMENT_LIMITS = {
31
+ maxCount: DEFAULT_MAX_ATTACHMENTS,
32
+ maxTotalBytes: MAX_ATTACHMENT_BYTES,
33
+ contentTypes: DEFAULT_ATTACHMENT_CONTENT_TYPES,
34
+ };
35
+ const TOKEN = "[A-Za-z0-9!#$&^_.+-]+";
36
+ const CONTENT_TYPE = new RegExp(`^${TOKEN}/${TOKEN}(\\s*;\\s*${TOKEN}=("[^"\\r\\n]*"|${TOKEN}))*$`);
37
+ /** A plain filename: 1 to 255 characters, not blank, no `/` or `\`, no control characters, not `.` or `..`. */
38
+ export function isAttachmentFilename(value) {
39
+ return (value.trim() !== "" &&
40
+ value.length <= MAX_ATTACHMENT_FILENAME_LENGTH &&
41
+ !value.includes("/") &&
42
+ !value.includes("\\") &&
43
+ !hasControlCharacter(value) &&
44
+ value !== "." &&
45
+ value !== "..");
46
+ }
47
+ /** A C0 control character or DEL. */
48
+ function hasControlCharacter(value) {
49
+ for (let index = 0; index < value.length; index += 1) {
50
+ const code = value.charCodeAt(index);
51
+ if (code < 0x20 || code === 0x7f)
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+ /** A MIME type `type/subtype`, optionally with parameters (`text/csv; charset=utf-8`). */
57
+ export function isContentType(value) {
58
+ return CONTENT_TYPE.test(value);
59
+ }
60
+ /** The `type/subtype` of a content type, lowercase, without its parameters. */
61
+ export function getContentTypeEssence(value) {
62
+ return (value.split(";")[0] ?? "").trim().toLowerCase();
63
+ }
64
+ /** Every attachment without its bytes: filename, content type and size. Safe to keep in an app's own records. */
65
+ export function describeAttachments(attachments) {
66
+ return attachments.map(({ filename, contentType, content }) => ({ filename, contentType, size: content.byteLength }));
67
+ }
68
+ //# sourceMappingURL=attachments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachments.js","sourceRoot":"","sources":["../src/attachments.ts"],"names":[],"mappings":"AAIA,6DAA6D;AAC7D,MAAM,CAAC,MAAM,uBAAuB,GAAG,EAAE,CAAC;AAE1C;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,UAAU,CAAC;AAE/C;;;GAGG;AACH,MAAM,CAAC,MAAM,gCAAgC,GAAsB;IACjE,iBAAiB;IACjB,YAAY;IACZ,UAAU;IACV,eAAe;IACf,kBAAkB;IAClB,iBAAiB;IACjB,UAAU;IACV,iBAAiB;IACjB,mEAAmE;IACnE,yEAAyE;IACzE,WAAW;IACX,YAAY;IACZ,WAAW;IACX,YAAY;CACb,CAAC;AAEF,MAAM,CAAC,MAAM,8BAA8B,GAAG,GAAG,CAAC;AAUlD,MAAM,CAAC,MAAM,yBAAyB,GAAqB;IACzD,QAAQ,EAAE,uBAAuB;IACjC,aAAa,EAAE,oBAAoB;IACnC,YAAY,EAAE,gCAAgC;CAC/C,CAAC;AAEF,MAAM,KAAK,GAAG,uBAAuB,CAAC;AACtC,MAAM,YAAY,GAAG,IAAI,MAAM,CAAC,IAAI,KAAK,IAAI,KAAK,aAAa,KAAK,mBAAmB,KAAK,MAAM,CAAC,CAAC;AAEpG,+GAA+G;AAC/G,MAAM,UAAU,oBAAoB,CAAC,KAAa;IAChD,OAAO,CACL,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE;QACnB,KAAK,CAAC,MAAM,IAAI,8BAA8B;QAC9C,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;QACpB,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC;QACrB,CAAC,mBAAmB,CAAC,KAAK,CAAC;QAC3B,KAAK,KAAK,GAAG;QACb,KAAK,KAAK,IAAI,CACf,CAAC;AACJ,CAAC;AAED,qCAAqC;AACrC,SAAS,mBAAmB,CAAC,KAAa;IACxC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QACrD,MAAM,IAAI,GAAG,KAAK,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC;QACrC,IAAI,IAAI,GAAG,IAAI,IAAI,IAAI,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;IAChD,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,0FAA0F;AAC1F,MAAM,UAAU,aAAa,CAAC,KAAa;IACzC,OAAO,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAClC,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,qBAAqB,CAAC,KAAa;IACjD,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;AAC1D,CAAC;AAED,iHAAiH;AACjH,MAAM,UAAU,mBAAmB,CAAC,WAAsC;IACxE,OAAO,WAAW,CAAC,GAAG,CAAC,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,QAAQ,EAAE,WAAW,EAAE,IAAI,EAAE,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC,CAAC;AACxH,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAWA,OAAO,EAAe,KAAK,aAAa,EAA8B,MAAM,kBAAkB,CAAC;AAE/F,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAKhG,OAAO,EAAkI,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAuB,MAAM,kBAAkB,CAAC;AAK3O,OAAO,EAAiF,KAAK,GAAG,EAAE,MAAM,+BAA+B,CAAC;AAExI,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,iBAAiB;IAChC,oGAAoG;IACpG,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC,wGAAwG;IACxG,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,mEAAmE;IACnE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,iIAAiI;IACjI,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IACjE,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC;IACjC,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,QAAQ,CAAC,YAAY,CAAC,EAAE,YAAY,CAAC;IACrC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,QAAQ,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;IACnB,sFAAsF;IACtF,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;CAC5C;AAED,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,UAAU,IAAI,CAAC;AAE5B,8EAA8E;AAC9E,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,eAAO,MAAM,UAAU,QAiEwD,CAAC;AAmDhF,yGAAyG;AACzG,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAqB5E"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAWA,OAAO,EAAe,KAAK,aAAa,EAA8B,MAAM,kBAAkB,CAAC;AAE/F,OAAO,EAA6C,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAKhG,OAAO,EAAkI,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,KAAK,UAAU,EAAuB,MAAM,kBAAkB,CAAC;AAK3O,OAAO,EAAiF,KAAK,GAAG,EAAE,MAAM,+BAA+B,CAAC;AAExI,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,WAAW,iBAAiB;IAChC,oGAAoG;IACpG,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC,wGAAwG;IACxG,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,mEAAmE;IACnE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;IAC5B,iIAAiI;IACjI,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IACjE,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC;IACjC,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,QAAQ,CAAC,YAAY,CAAC,EAAE,YAAY,CAAC;IACrC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,QAAQ,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;IACnB,sFAAsF;IACtF,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;CAC5C;AAED,eAAO,MAAM,OAAO,IAAI,CAAC;AACzB,eAAO,MAAM,WAAW,IAAI,CAAC;AAC7B,eAAO,MAAM,UAAU,IAAI,CAAC;AAE5B,8EAA8E;AAC9E,eAAO,MAAM,gBAAgB,MAAM,CAAC;AAEpC,eAAO,MAAM,UAAU,QAoEwD,CAAC;AAmDhF,yGAAyG;AACzG,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAqB5E"}
package/dist/cli/run.js CHANGED
@@ -17,7 +17,7 @@ import { getCampaignProblems, parseCampaignFile, parseRecipientList } from "../s
17
17
  import { listConfiguredCampaignRecipients, planCampaign, sendCampaign } from "../server/campaigns.js";
18
18
  import { checkSenderDns, getSenderDomain, resendReturnPath } from "../server/dns.js";
19
19
  import { checkImportedDeliveries, importDeliveries } from "../server/import-deliveries.js";
20
- import { maskAddress, redactUnsubscribeSignatures } from "../server/operator-output.js";
20
+ import { formatMailPreview, maskAddress } from "../server/operator-output.js";
21
21
  import { getMailingOptions, resolveMailKind } from "../server/options.js";
22
22
  import { previewMail, sendMail, sendMailUnchecked } from "../server/send-mail.js";
23
23
  import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV } from "../server/unsubscribe-link.js";
@@ -68,11 +68,13 @@ import Writes deliveries the app made before it adopted the module into the l
68
68
  stops the import before anything is written.
69
69
  --dry-run check the file and change nothing
70
70
 
71
- dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain of "from").
71
+ dns Checks SPF, DKIM and DMARC for every sender domain (default: the domains of "from"
72
+ and "senders"; the reply-to and inbound checks go with the domain of "from").
72
73
  --domain <domain> check this domain instead
73
74
  --dkim-selector <name> DKIM selector to look up (default resend; repeatable)
74
75
  --spf-host <host> where SPF is published (default the domain; repeatable;
75
- Resend publishes it on send.<domain>)
76
+ Resend publishes it on send.<domain>); with several sender
77
+ domains, a host counts for the domain it sits under
76
78
  --dmarc-policy <p> require at least this DMARC policy (quarantine or reject)
77
79
  and pct=100; a weaker record fails
78
80
  --dmarc-sp <p> require at least this subdomain policy (sp, else p)
@@ -80,7 +82,8 @@ dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain
80
82
  --dmarc-aspf <a> require this SPF alignment (r or s; absent counts as r)
81
83
  --reply-to <address> check that this address's domain accepts mail (MX, one SPF)
82
84
  (default: replyTo of the config, when --domain is not given)
83
- --return-path <host> a return-path host that must be a CNAME or have MX (repeatable)
85
+ --return-path <host> a return-path host that must be a CNAME or have MX (repeatable;
86
+ with several sender domains, for the domain it sits under)
84
87
  --resend-return-path check Resend's send.<domain> and rsend.<domain> CNAMEs
85
88
  --inbound cloudflare check that Cloudflare Email Routing receives the reply-to domain's
86
89
  mail (its MX and one SPF record that includes it)
@@ -282,25 +285,76 @@ function parseDmarcExpectation(values) {
282
285
  return Object.keys(expectation).length === 0 ? undefined : expectation;
283
286
  }
284
287
  async function runDns(command, options, output) {
285
- let domain = command.domain;
286
- let replyTo = command.replyTo;
287
- if (domain === undefined) {
288
- if (options.config === undefined) {
289
- output.error("softure-mail dns: pass --domain <domain>, or run it with the app's config to check the sender's domain");
290
- return EXIT_FAILED;
291
- }
292
- const mailing = getMailingOptions(options.config);
293
- domain = getSenderDomain(mailing.from);
294
- replyTo ??= mailing.replyTo;
288
+ const targets = getDnsTargets(command, options.config);
289
+ if (typeof targets === "string") {
290
+ output.error(`softure-mail dns: ${targets}`);
291
+ return EXIT_FAILED;
295
292
  }
296
- const returnPath = [...(command.resendReturnPath ? resendReturnPath(domain) : []), ...command.returnPathHosts.map((host) => ({ host }))];
293
+ let failed = 0;
294
+ for (const target of targets)
295
+ failed += await checkDnsTarget(command, target, options, output);
296
+ return failed === 0 ? EXIT_OK : EXIT_FAILED;
297
+ }
298
+ /**
299
+ * The domains to check: `--domain` alone, else the distinct domains of `from` and `senders`, `from`'s first. With
300
+ * several, a host option goes to the domain it sits under (the longest that matches); one under none is an error.
301
+ */
302
+ function getDnsTargets(command, config) {
303
+ const single = (domain, replyTo) => [
304
+ { domain, spfHosts: command.spfHosts, returnPathHosts: command.returnPathHosts, replyTo, inbound: command.inbound },
305
+ ];
306
+ if (command.domain !== undefined)
307
+ return single(command.domain, command.replyTo);
308
+ if (config === undefined)
309
+ return "pass --domain <domain>, or run it with the app's config to check the sender's domain";
310
+ const mailing = getMailingOptions(config);
311
+ const domains = [...new Set([mailing.from, ...Object.values(mailing.senders ?? {})].map((sender) => getSenderDomain(sender).toLowerCase()))];
312
+ const replyTo = command.replyTo ?? mailing.replyTo;
313
+ const [first = ""] = domains;
314
+ if (domains.length === 1)
315
+ return single(first, replyTo);
316
+ const spfHosts = assignHostsToDomains("--spf-host", command.spfHosts ?? [], domains);
317
+ if (typeof spfHosts === "string")
318
+ return spfHosts;
319
+ const returnPathHosts = assignHostsToDomains("--return-path", command.returnPathHosts, domains);
320
+ if (typeof returnPathHosts === "string")
321
+ return returnPathHosts;
322
+ return domains.map((domain, index) => ({
323
+ domain,
324
+ spfHosts: spfHosts.get(domain),
325
+ returnPathHosts: returnPathHosts.get(domain) ?? [],
326
+ replyTo: index === 0 ? replyTo : undefined,
327
+ inbound: index === 0 ? command.inbound : undefined,
328
+ }));
329
+ }
330
+ /** The hosts of `option` by the domain each sits under, or the error for one under none of `domains`. */
331
+ function assignHostsToDomains(option, hosts, domains) {
332
+ const byDomain = new Map();
333
+ for (const host of hosts) {
334
+ const domain = findHostDomain(host, domains);
335
+ if (domain === null)
336
+ return `${option} ${host} sits under none of the sender domains (${domains.join(", ")})`;
337
+ byDomain.set(domain, [...(byDomain.get(domain) ?? []), host]);
338
+ }
339
+ return byDomain;
340
+ }
341
+ /** The longest of `domains` that `host` is or sits under, or `null`. */
342
+ function findHostDomain(host, domains) {
343
+ const name = host.trim().toLowerCase().replace(/\.$/, "");
344
+ const matches = domains.filter((domain) => name === domain || name.endsWith(`.${domain}`));
345
+ return matches.reduce((longest, domain) => (longest === null || domain.length > longest.length ? domain : longest), null);
346
+ }
347
+ /** Checks one domain and prints its report; returns how many checks failed. */
348
+ async function checkDnsTarget(command, target, options, output) {
349
+ const { domain, replyTo } = target;
350
+ const returnPath = [...(command.resendReturnPath ? resendReturnPath(domain) : []), ...target.returnPathHosts.map((host) => ({ host }))];
297
351
  const report = await checkSenderDns(domain, {
298
352
  ...(command.dkimSelectors === undefined ? {} : { dkimSelectors: command.dkimSelectors }),
299
- ...(command.spfHosts === undefined ? {} : { spfHosts: command.spfHosts }),
353
+ ...(target.spfHosts === undefined ? {} : { spfHosts: target.spfHosts }),
300
354
  ...(command.expectDmarc === undefined ? {} : { expectDmarc: command.expectDmarc }),
301
355
  ...(replyTo === undefined ? {} : { replyTo }),
302
356
  returnPath,
303
- ...(command.inbound === undefined ? {} : { inbound: command.inbound }),
357
+ ...(target.inbound === undefined ? {} : { inbound: target.inbound }),
304
358
  ...(options.resolveTxt === undefined ? {} : { resolveTxt: options.resolveTxt }),
305
359
  ...(options.resolveMx === undefined ? {} : { resolveMx: options.resolveMx }),
306
360
  ...(options.resolveCname === undefined ? {} : { resolveCname: options.resolveCname }),
@@ -326,9 +380,10 @@ async function runDns(command, options, output) {
326
380
  const failedReply = countFailed(reply);
327
381
  const failedPaths = countFailed(paths);
328
382
  const failedInbound = countFailed(inbound);
329
- if (failedAuthentication + failedReply + failedPaths + failedInbound === 0) {
383
+ const failed = failedAuthentication + failedReply + failedPaths + failedInbound;
384
+ if (failed === 0) {
330
385
  output.log("receivers can authenticate mail from this domain");
331
- return EXIT_OK;
386
+ return 0;
332
387
  }
333
388
  if (failedAuthentication > 0)
334
389
  output.log(`${String(failedAuthentication)} check(s) failed: list mail from this domain will land in spam or be refused`);
@@ -338,7 +393,7 @@ async function runDns(command, options, output) {
338
393
  output.log(`${String(failedPaths)} return-path host(s) failed: the provider cannot use them for bounces`);
339
394
  if (failedInbound > 0)
340
395
  output.log("the inbound service does not receive this domain's mail: replies will not reach it");
341
- return EXIT_FAILED;
396
+ return failed;
342
397
  }
343
398
  async function runCampaign(command, options, output) {
344
399
  const cwd = options.cwd ?? process.cwd();
@@ -578,18 +633,7 @@ async function reportPlan(ctx, loaded, run, output) {
578
633
  const PREVIEW_ADDRESS = "recipient@example.com";
579
634
  /** The mail as the provider would get it, one output line per line, the address masked and signatures redacted. */
580
635
  function printPreview(message, output) {
581
- const headers = [
582
- `From: ${message.from}`,
583
- `To: ${maskAddress(message.to)}`,
584
- ...(message.replyTo === null ? [] : [`Reply-To: ${maskAddress(message.replyTo)}`]),
585
- `Subject: ${message.subject}`,
586
- ...Object.entries(message.headers).map(([name, value]) => `${name}: ${value}`),
587
- ];
588
- const sections = [`--- preview (addresses masked, link signatures redacted) ---`, ...headers, "", "--- text ---", message.text];
589
- if (message.html !== null)
590
- sections.push("--- html ---", message.html);
591
- sections.push("--- end of preview ---");
592
- for (const line of redactUnsubscribeSignatures(sections.join("\n")).split("\n"))
636
+ for (const line of formatMailPreview(message))
593
637
  output.log(line);
594
638
  }
595
639
  /** The fixed mail of `softure-mail test` without a content file. Operator text: the test address is the operator's. */