@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.
- package/CHANGELOG.md +43 -0
- package/README.md +67 -9
- package/dist/attachments.d.ts +32 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +68 -0
- package/dist/attachments.js.map +1 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +76 -32
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +40 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/jobs/delivery-job.d.ts +8 -3
- package/dist/jobs/delivery-job.d.ts.map +1 -1
- package/dist/jobs/delivery-job.js +18 -2
- package/dist/jobs/delivery-job.js.map +1 -1
- package/dist/options.d.ts +8 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +28 -0
- package/dist/options.js.map +1 -1
- package/dist/providers/resend.d.ts.map +1 -1
- package/dist/providers/resend.js +10 -0
- package/dist/providers/resend.js.map +1 -1
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +3 -2
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/operator-output.d.ts +6 -0
- package/dist/server/operator-output.d.ts.map +1 -1
- package/dist/server/operator-output.js +23 -0
- package/dist/server/operator-output.js.map +1 -1
- package/dist/server/send-mail.d.ts +10 -3
- package/dist/server/send-mail.d.ts.map +1 -1
- package/dist/server/send-mail.js +64 -18
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/validate-mail.d.ts +10 -3
- package/dist/server/validate-mail.d.ts.map +1 -1
- package/dist/server/validate-mail.js +55 -12
- package/dist/server/validate-mail.js.map +1 -1
- package/dist/testing/fake-provider.d.ts.map +1 -1
- package/dist/testing/fake-provider.js +18 -3
- package/dist/testing/fake-provider.js.map +1 -1
- package/dist/testing/outbox.d.ts.map +1 -1
- package/dist/testing/outbox.js +10 -1
- package/dist/testing/outbox.js.map +1 -1
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/attachments.ts +90 -0
- package/src/cli/run.ts +86 -32
- package/src/contract.ts +42 -1
- package/src/index.ts +12 -1
- package/src/jobs/delivery-job.ts +24 -4
- package/src/options.ts +31 -0
- package/src/providers/resend.ts +10 -0
- package/src/server/deliveries.ts +3 -2
- package/src/server/index.ts +1 -1
- package/src/server/operator-output.ts +24 -0
- package/src/server/send-mail.ts +63 -19
- package/src/server/validate-mail.ts +65 -13
- package/src/testing/fake-provider.ts +18 -3
- 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
|
|
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 #
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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"}
|
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;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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
353
|
+
...(target.spfHosts === undefined ? {} : { spfHosts: target.spfHosts }),
|
|
300
354
|
...(command.expectDmarc === undefined ? {} : { expectDmarc: command.expectDmarc }),
|
|
301
355
|
...(replyTo === undefined ? {} : { replyTo }),
|
|
302
356
|
returnPath,
|
|
303
|
-
...(
|
|
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
|
-
|
|
383
|
+
const failed = failedAuthentication + failedReply + failedPaths + failedInbound;
|
|
384
|
+
if (failed === 0) {
|
|
330
385
|
output.log("receivers can authenticate mail from this domain");
|
|
331
|
-
return
|
|
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
|
|
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
|
|
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. */
|