@softure-ai/mailing 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +349 -2
- package/dist/address.d.ts +27 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +76 -0
- package/dist/address.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +9 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +36 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +31 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +285 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/contract.d.ts +102 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +3 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/messages/en.d.ts +26 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +26 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +59 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +11 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +27 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/actions.d.ts +2 -0
- package/dist/next/actions.d.ts.map +1 -0
- package/dist/next/actions.js +35 -0
- package/dist/next/actions.js.map +1 -0
- package/dist/next/context.d.ts +4 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +14 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/deliver-once.d.ts +4 -0
- package/dist/next/deliver-once.d.ts.map +1 -0
- package/dist/next/deliver-once.js +7 -0
- package/dist/next/deliver-once.js.map +1 -0
- package/dist/next/index.d.ts +8 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +11 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/messages.d.ts +5 -0
- package/dist/next/messages.d.ts.map +1 -0
- package/dist/next/messages.js +7 -0
- package/dist/next/messages.js.map +1 -0
- package/dist/next/pages.d.ts +7 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +40 -0
- package/dist/next/pages.js.map +1 -0
- package/dist/next/params.d.ts +4 -0
- package/dist/next/params.d.ts.map +1 -0
- package/dist/next/params.js +4 -0
- package/dist/next/params.js.map +1 -0
- package/dist/next/route.d.ts +14 -0
- package/dist/next/route.d.ts.map +1 -0
- package/dist/next/route.js +44 -0
- package/dist/next/route.js.map +1 -0
- package/dist/next/send-mail.d.ts +4 -0
- package/dist/next/send-mail.d.ts.map +1 -0
- package/dist/next/send-mail.js +16 -0
- package/dist/next/send-mail.js.map +1 -0
- package/dist/options.d.ts +13 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +29 -0
- package/dist/options.js.map +1 -0
- package/dist/providers/resend.d.ts +17 -0
- package/dist/providers/resend.d.ts.map +1 -0
- package/dist/providers/resend.js +64 -0
- package/dist/providers/resend.js.map +1 -0
- package/dist/schema.d.ts +350 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +30 -0
- package/dist/schema.js.map +1 -0
- package/dist/server/campaign-file.d.ts +28 -0
- package/dist/server/campaign-file.d.ts.map +1 -0
- package/dist/server/campaign-file.js +87 -0
- package/dist/server/campaign-file.js.map +1 -0
- package/dist/server/campaigns.d.ts +58 -0
- package/dist/server/campaigns.d.ts.map +1 -0
- package/dist/server/campaigns.js +118 -0
- package/dist/server/campaigns.js.map +1 -0
- package/dist/server/deliveries.d.ts +56 -0
- package/dist/server/deliveries.d.ts.map +1 -0
- package/dist/server/deliveries.js +100 -0
- package/dist/server/deliveries.js.map +1 -0
- package/dist/server/dns.d.ts +33 -0
- package/dist/server/dns.d.ts.map +1 -0
- package/dist/server/dns.js +113 -0
- package/dist/server/dns.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +13 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +14 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/list-mail.d.ts +20 -0
- package/dist/server/list-mail.d.ts.map +1 -0
- package/dist/server/list-mail.js +31 -0
- package/dist/server/list-mail.js.map +1 -0
- package/dist/server/options.d.ts +14 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +22 -0
- package/dist/server/options.js.map +1 -0
- package/dist/server/send-mail.d.ts +19 -0
- package/dist/server/send-mail.d.ts.map +1 -0
- package/dist/server/send-mail.js +129 -0
- package/dist/server/send-mail.js.map +1 -0
- package/dist/server/suppressions.d.ts +27 -0
- package/dist/server/suppressions.d.ts.map +1 -0
- package/dist/server/suppressions.js +64 -0
- package/dist/server/suppressions.js.map +1 -0
- package/dist/server/unsubscribe-link.d.ts +51 -0
- package/dist/server/unsubscribe-link.d.ts.map +1 -0
- package/dist/server/unsubscribe-link.js +78 -0
- package/dist/server/unsubscribe-link.js.map +1 -0
- package/dist/server/validate-mail.d.ts +23 -0
- package/dist/server/validate-mail.d.ts.map +1 -0
- package/dist/server/validate-mail.js +52 -0
- package/dist/server/validate-mail.js.map +1 -0
- package/dist/testing/fake-provider.d.ts +24 -0
- package/dist/testing/fake-provider.d.ts.map +1 -0
- package/dist/testing/fake-provider.js +43 -0
- package/dist/testing/fake-provider.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/outbox.d.ts +6 -0
- package/dist/testing/outbox.d.ts.map +1 -0
- package/dist/testing/outbox.js +32 -0
- package/dist/testing/outbox.js.map +1 -0
- package/migrations/0001_create_suppressions.sql +13 -0
- package/migrations/0002_create_campaigns_and_deliveries.sql +41 -0
- package/module.json +32 -0
- package/package.json +70 -4
- package/src/address.ts +89 -0
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +44 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/run.ts +315 -0
- package/src/contract.ts +108 -0
- package/src/index.ts +85 -0
- package/src/messages/en.ts +25 -0
- package/src/messages/index.ts +15 -0
- package/src/messages/pl.ts +28 -0
- package/src/next/actions.ts +35 -0
- package/src/next/context.ts +15 -0
- package/src/next/deliver-once.ts +10 -0
- package/src/next/index.ts +10 -0
- package/src/next/messages.ts +9 -0
- package/src/next/next-modules.d.ts +12 -0
- package/src/next/pages.tsx +69 -0
- package/src/next/params.ts +6 -0
- package/src/next/route.ts +45 -0
- package/src/next/send-mail.ts +15 -0
- package/src/options.ts +34 -0
- package/src/providers/resend.ts +86 -0
- package/src/schema.ts +40 -0
- package/src/server/campaign-file.ts +101 -0
- package/src/server/campaigns.ts +175 -0
- package/src/server/deliveries.ts +155 -0
- package/src/server/dns.ts +153 -0
- package/src/server/health.ts +14 -0
- package/src/server/index.ts +56 -0
- package/src/server/list-mail.ts +42 -0
- package/src/server/options.ts +32 -0
- package/src/server/send-mail.ts +152 -0
- package/src/server/suppressions.ts +77 -0
- package/src/server/unsubscribe-link.ts +109 -0
- package/src/server/validate-mail.ts +68 -0
- package/src/testing/fake-provider.ts +66 -0
- package/src/testing/index.ts +4 -0
- package/src/testing/outbox.ts +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SOFTURE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,350 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @softure-ai/mailing
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Status:** wave 2 · transport (EN-1), unsubscribe (EN-2), delivery ledger, campaigns and sender DNS
|
|
4
|
+
check (EN-3).
|
|
5
|
+
|
|
6
|
+
Sends one mail to one recipient through a provider adapter and answers with a typed result, never a
|
|
7
|
+
throw. Ported from FIRE_TRACKER `src/lib/mail.ts` (a hand-written Resend `fetch`, plain text only),
|
|
8
|
+
with the sender, reply-to, provider and timeout taken from configuration, an HTML body, and a fake
|
|
9
|
+
provider for tests. List mail gets signed one-click unsubscribe (RFC 8058) and a suppression list,
|
|
10
|
+
ported from FIRE_TRACKER `src/lib/unsubscribe-*.ts` and `/wypisz`. A delivery ledger sends a mail
|
|
11
|
+
at most once per scope and recipient, and `softure-mail` sends campaigns from a content file where
|
|
12
|
+
FIRE ran deployment-specific scripts.
|
|
13
|
+
|
|
14
|
+
## 1. What it provides
|
|
15
|
+
|
|
16
|
+
- `mailing({ from, replyTo, provider, timeoutMs })` for `softure.config.ts`.
|
|
17
|
+
- `sendMail(context, mail, options)` in `@softure-ai/mailing/server` and `sendMail(mail, options)` in
|
|
18
|
+
`@softure-ai/mailing/next` (on the registered config). The result is a core `Result`:
|
|
19
|
+
|
|
20
|
+
| Result | Meaning | What the caller does |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| `ok({ id, provider })` | the provider accepted the mail | keep `id` if it needs one |
|
|
23
|
+
| `err("mailing.invalid_input")` | refused before anything left the process | fix the input; never retry |
|
|
24
|
+
| `err("mailing.rejected")` | the provider refused it (4xx: bad address, bad key, key reused with another body) | do not retry the same request |
|
|
25
|
+
| `err("mailing.unavailable")` | provider down (5xx), 408, 429, a busy idempotency key, network, timeout, an answer without an id, no API key; for list mail also no unsubscribe secret or an unreadable suppression list | retry later with the same `idempotencyKey` |
|
|
26
|
+
| `err("mailing.suppressed")` | a list mail to a recipient who unsubscribed; nothing was sent | skip the recipient; never retry |
|
|
27
|
+
|
|
28
|
+
- **List mail.** `kind: "transactional"` (the default) is mail the recipient needs whatever they
|
|
29
|
+
unsubscribed from: resets, receipts, account notices. Any other kebab-case kind (`newsletter`,
|
|
30
|
+
`product-updates`) is list mail: `sendMail` appends a footer with a signed unsubscribe link to the
|
|
31
|
+
text and HTML bodies, adds `List-Unsubscribe: <one-click URL>` and
|
|
32
|
+
`List-Unsubscribe-Post: List-Unsubscribe=One-Click`, and refuses the mail with `mailing.suppressed`
|
|
33
|
+
when the recipient unsubscribed. An unsubscribe covers every list kind.
|
|
34
|
+
- The unsubscribe page (`/unsubscribe`) and the one-click route (`/api/mailing/unsubscribe`) in `/next`,
|
|
35
|
+
each mounted with one line (section 4).
|
|
36
|
+
- `@softure-ai/mailing/server`: `isSuppressed(ctx, address)`, `suppressRecipient(ctx, address)` (for
|
|
37
|
+
scripts, bounce or complaint handlers), `unsubscribe(ctx, token, source)`,
|
|
38
|
+
`liftSuppression(ctx, address)` (a new explicit consent lifts the person's own opt-out),
|
|
39
|
+
`buildUnsubscribeLinks`, `getRecipientKey`, and the footer and header helpers.
|
|
40
|
+
- **An `onUnsubscribed` hook**, run in the opt-out's transaction, so the consent ledger can record
|
|
41
|
+
the withdrawal (section 10).
|
|
42
|
+
- **Sending once.** `deliverOnce(ctx, { scope, mail })` (`/server`, and `deliverOnce({ scope, mail })`
|
|
43
|
+
in `/next`) sends a mail at most once per scope and recipient through the delivery ledger.
|
|
44
|
+
- **Campaigns.** `sendCampaign`, `planCampaign` and the content file parser in `/server`; the
|
|
45
|
+
`softure-mail campaign` command sends a campaign from a content file.
|
|
46
|
+
- **Sender DNS.** `checkSenderDns(domain)` and `softure-mail dns` report SPF, DKIM and DMARC.
|
|
47
|
+
- The `MailProvider` contract and `resend()`, the first adapter.
|
|
48
|
+
- `@softure-ai/mailing/testing`: `fakeMailProvider()` and `readMailOutbox(file)`.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { sendMail } from "@softure-ai/mailing/next";
|
|
52
|
+
|
|
53
|
+
const result = await sendMail(
|
|
54
|
+
{ to: user.email, subject: messages.summary.subject, text, html },
|
|
55
|
+
{ idempotencyKey: `weekly-summary:${user.id}:${week}` },
|
|
56
|
+
);
|
|
57
|
+
if (!result.ok) return result; // a code; the UI shows getMailingErrorMessage(messages, result.error)
|
|
58
|
+
|
|
59
|
+
// List mail: footer, headers and the suppression check come with the kind.
|
|
60
|
+
await sendMail({ to: user.email, subject, text, html, kind: "newsletter" }, { idempotencyKey: `newsletter-12:${user.id}` });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
From scripts and other modules, `/server` takes the context: `sendMail({ config }, mail)` for
|
|
64
|
+
transactional mail, `sendMail({ config, db }, mail)` for list mail (the suppression check reads
|
|
65
|
+
the database; list mail without `db` throws, as a wiring bug).
|
|
66
|
+
|
|
67
|
+
Rules that hold for every send:
|
|
68
|
+
|
|
69
|
+
- **One recipient.** `to` is exactly one address; a list is a loop. No cc or bcc.
|
|
70
|
+
- **Text always, HTML optionally.** `text` is required; `html` is a string the app rendered (from a
|
|
71
|
+
template string or React email components rendered to a string: the module does not render).
|
|
72
|
+
- **Headers cannot take over the mail.** `headers` is for headers like `List-Unsubscribe`;
|
|
73
|
+
`From`, `Sender`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Return-Path`, `Content-Type`,
|
|
74
|
+
`Content-Transfer-Encoding` and `MIME-Version` are refused in any case, as are line breaks in
|
|
75
|
+
values and the subject.
|
|
76
|
+
- **Nothing of the mail is logged.** One line per failure:
|
|
77
|
+
`mailing: send failed provider=resend reason=rejected status=422` (with `fields=to,subject` for
|
|
78
|
+
invalid input). Provider refusal messages are never read: they can echo the address.
|
|
79
|
+
- **Timeout.** After `timeoutMs` the provider's signal aborts and the send answers `unavailable`,
|
|
80
|
+
even when the provider ignores the signal.
|
|
81
|
+
- **List mail fails closed.** No secret or an unreadable suppression list means nothing is sent.
|
|
82
|
+
A list mail cannot bring its own `List-Unsubscribe` or `List-Unsubscribe-Post`: the module writes
|
|
83
|
+
them (`invalid_input`).
|
|
84
|
+
|
|
85
|
+
**How a link works.** The link carries `r`, the base64url SHA-256 of the trimmed, lowercased
|
|
86
|
+
address, and `t`, the base64url HMAC-SHA256 of a fixed prefix plus `r` under
|
|
87
|
+
`MAILING_UNSUBSCRIBE_SECRET`. The address never appears in a URL, and the suppression table stores
|
|
88
|
+
only `r`. The page (footer link) shows a button and changes nothing on open: mail scanners open
|
|
89
|
+
links. The one-click route takes a mail client's POST without a session, verifies before touching
|
|
90
|
+
the database and answers 200 (recorded, also again), 400 (link does not verify) or 500 (database
|
|
91
|
+
failure, so the client retries); a GET redirects to the page. The whole link is a credential: it is
|
|
92
|
+
never logged.
|
|
93
|
+
|
|
94
|
+
### Sending once: the delivery ledger
|
|
95
|
+
|
|
96
|
+
`deliverOnce` claims a row in `mailing.deliveries` for the scope and the recipient before it calls
|
|
97
|
+
`sendMail`, and closes it with one outcome. A second call for the same scope and recipient (any
|
|
98
|
+
case or spacing of the address) sends nothing:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { deliverOnce } from "@softure-ai/mailing/next";
|
|
102
|
+
|
|
103
|
+
const outcome = await deliverOnce({
|
|
104
|
+
scope: `billing.trial-ending:${subscription.id}`,
|
|
105
|
+
mail: { to: user.email, subject, text, kind: "account-notices" },
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Outcome | Meaning |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| `{ status: "sent", id }` | sent now |
|
|
112
|
+
| `{ status: "rejected", reason }` | refused now, for good: `mailing.suppressed`, `rejected`, `invalid_input`, or `unavailable` on the last attempt |
|
|
113
|
+
| `{ status: "done", outcome }` | an earlier call closed it (`sent` or `rejected`); nothing sent |
|
|
114
|
+
| `{ status: "in-flight" }` | another sender holds a fresh claim; nothing sent |
|
|
115
|
+
| `{ status: "retry-later" }` | the provider was unavailable; the claim is released, call again later |
|
|
116
|
+
|
|
117
|
+
- **Scopes** name what the mail is about and are the only registration a lifecycle mail needs:
|
|
118
|
+
`<module>.<event>:<entity>` (`billing.trial-ending:sub_42`, `waitlist.welcome:<signup id>`).
|
|
119
|
+
Lowercase letters, digits and `._:-`, at most 128 characters. Campaigns use `campaign:<id>`.
|
|
120
|
+
- **Idempotency key.** The ledger sends with `<scope>:<recipient key>`; callers do not pass one.
|
|
121
|
+
- **Retries.** `unavailable` releases the claim (`pending`); the fifth attempt (`maxAttempts`) that
|
|
122
|
+
is still unavailable closes the row as `rejected`. A process that dies between the send and the
|
|
123
|
+
outcome leaves a claim; after 15 minutes (`staleClaimMs`) another call takes it over and sends
|
|
124
|
+
again with the same idempotency key, which the provider folds into the first send while it keeps
|
|
125
|
+
the key (Resend: 24 hours). Outcomes are fenced by the attempt number, so a sender that lost its
|
|
126
|
+
claim cannot overwrite the one that took over.
|
|
127
|
+
- A malformed scope or kind throws (a bug); a database failure propagates.
|
|
128
|
+
|
|
129
|
+
### Campaigns: `softure-mail campaign`
|
|
130
|
+
|
|
131
|
+
A campaign is one list mail to many recipients through the ledger. The content file is frontmatter
|
|
132
|
+
and a plain-text body; `html` (optional) names a file next to it:
|
|
133
|
+
|
|
134
|
+
```text
|
|
135
|
+
---
|
|
136
|
+
id: 2026-10-launch
|
|
137
|
+
kind: newsletter
|
|
138
|
+
subject: Something new in Plan
|
|
139
|
+
html: launch.html
|
|
140
|
+
---
|
|
141
|
+
Hello,
|
|
142
|
+
|
|
143
|
+
we shipped something.
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
softure-mail campaign launch.md --recipients recipients.txt --dry-run # counts, sends nothing
|
|
148
|
+
softure-mail campaign launch.md --recipients recipients.txt # sends
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- The recipients file holds one address per line (`#` comments and blank lines skipped).
|
|
152
|
+
- The command loads `softure.config.*` like `softure migrate` (or `--config <file>`), opens its own
|
|
153
|
+
database connection (`database.url`) and needs `MAILING_UNSUBSCRIBE_SECRET` (campaigns are list
|
|
154
|
+
mail). Run `softure migrate` first.
|
|
155
|
+
- Sends go one at a time with a 500 ms pause (`--pause-ms`; Resend allows 2 requests per second by
|
|
156
|
+
default). Unsubscribed recipients are rejected without a send and never retried.
|
|
157
|
+
- **Re-runs are safe.** Recipients with an outcome are skipped; the command exits 1 while some have
|
|
158
|
+
none yet (`retry later`, `in flight`): run the same command again. `mailing.campaigns` pins the
|
|
159
|
+
content by hash: other content under the same id is refused, so a changed campaign needs a new id.
|
|
160
|
+
- From a script (a bundled container, a list built from the database), call
|
|
161
|
+
`runMailCli({ config, argv })` from `@softure-ai/mailing/cli`, or `sendCampaign(ctx, { campaign,
|
|
162
|
+
recipients })` from `/server`, where `recipients` may be an async iterable.
|
|
163
|
+
|
|
164
|
+
### Sender DNS check: `softure-mail dns`
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
softure-mail dns # the domain of `from` in the config
|
|
168
|
+
softure-mail dns --domain mail.example.com --spf-host send.mail.example.com
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
It reads TXT records and reports each check as `pass`, `warn` or `fail`, exiting 1 on any `fail`:
|
|
172
|
+
SPF (one `v=spf1` record on the domain or each `--spf-host`; `+all` warns), DKIM (a non-empty `p=`
|
|
173
|
+
at `<selector>._domainkey.<domain>`; selector `resend` unless `--dkim-selector` is given) and DMARC
|
|
174
|
+
(`_dmarc.<domain>` or a parent domain's record; `p=none` warns: it only reports). Resend publishes
|
|
175
|
+
SPF on `send.<domain>`. It cannot see whether the provider signs with the published key.
|
|
176
|
+
|
|
177
|
+
## 2. Installation
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
npm install @softure-ai/mailing @softure-ai/core @softure-ai/db @softure-ai/ui drizzle-orm
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Peer dependencies: `drizzle-orm`, and `next` and `react` for the `/next` adapter. The module needs a
|
|
184
|
+
database (its suppression list). The `/testing` entry imports `node:fs`.
|
|
185
|
+
|
|
186
|
+
## 3. Configuration
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { mailing, resend } from "@softure-ai/mailing";
|
|
190
|
+
|
|
191
|
+
// in defineSoftureConfig({ modules: [...] }):
|
|
192
|
+
mailing({
|
|
193
|
+
from: "Plan <hello@mail.example.com>",
|
|
194
|
+
replyTo: "support@example.com",
|
|
195
|
+
provider: resend(),
|
|
196
|
+
}),
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
| Option | Type | Default | Meaning |
|
|
200
|
+
| --- | --- | --- | --- |
|
|
201
|
+
| `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. |
|
|
202
|
+
| `replyTo` | `string` | none | One address for replies; may be on another domain (DMARC does not check it). |
|
|
203
|
+
| `provider` | `MailProvider` | required | `resend()`, `fakeMailProvider()` or your own adapter. |
|
|
204
|
+
| `timeoutMs` | `number` | `10000` | 1000 to 60000. |
|
|
205
|
+
| `onUnsubscribed` | `(event, ctx) => Promise<void>` | — | Runs on every verified unsubscribe, in its transaction (section 10). |
|
|
206
|
+
| `routes` | `{ unsubscribe?, oneClick? }` | `/unsubscribe`, `/api/mailing/unsubscribe` | Where you mount the page and the route; links are built on `appOrigin` plus these paths. |
|
|
207
|
+
|
|
208
|
+
`resend({ apiKey?, endpoint?, fetch? })`: without `apiKey` it reads `RESEND_API_KEY` on every send,
|
|
209
|
+
so the configuration loads at build time without the secret.
|
|
210
|
+
|
|
211
|
+
**Your own provider:**
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
const provider: MailProvider = {
|
|
215
|
+
name: "postmark",
|
|
216
|
+
async send(message, { signal }) {
|
|
217
|
+
const response = await fetch(URL, { method: "POST", body: toBody(message), signal });
|
|
218
|
+
if (response.ok) return { status: "sent", id: (await response.json()).MessageID };
|
|
219
|
+
return { status: response.status >= 500 || response.status === 429 ? "unavailable" : "rejected", httpStatus: response.status };
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A provider receives a validated `ProviderMessage` (`from`, `to`, `replyTo`, `subject`, `text`, `html`,
|
|
225
|
+
`headers`, `idempotencyKey`, nulls for what is absent). A throw reads as `unavailable`.
|
|
226
|
+
|
|
227
|
+
## 4. Mounting
|
|
228
|
+
|
|
229
|
+
Two files, one line each:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
// app/unsubscribe/page.tsx
|
|
233
|
+
export { UnsubscribePage as default } from "@softure-ai/mailing/next";
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
// app/api/mailing/unsubscribe/route.ts
|
|
238
|
+
export { getUnsubscribeRoute as GET, postUnsubscribeRoute as POST } from "@softure-ai/mailing/next";
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Both must stay public: keep them out of the auth guard's `protect` list. The route has no per-IP
|
|
242
|
+
rate limit on purpose: mail providers post from a few shared addresses that act for millions of
|
|
243
|
+
people, so a bucket would refuse real unsubscribes; the signature is checked before any database
|
|
244
|
+
access and the only write is an idempotent insert. Behind a proxy that logs failed requests with
|
|
245
|
+
their query, a 500 puts the link in that log; it is the one place it can appear.
|
|
246
|
+
|
|
247
|
+
Call `sendMail` from server actions, route handlers (`/next`) or scripts and other modules
|
|
248
|
+
(`/server`, with `{ config }` or `{ config, db }`).
|
|
249
|
+
|
|
250
|
+
## 5. Migrations and tables
|
|
251
|
+
|
|
252
|
+
Schema `mailing`, applied by `softure migrate`:
|
|
253
|
+
|
|
254
|
+
| Migration | Table | What it holds |
|
|
255
|
+
| --- | --- | --- |
|
|
256
|
+
| `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. |
|
|
257
|
+
| `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`. |
|
|
258
|
+
| | `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). |
|
|
259
|
+
|
|
260
|
+
The health check (`checkMailingTables`) runs `select 1 from mailing.<table> limit 0` for the three
|
|
261
|
+
tables.
|
|
262
|
+
|
|
263
|
+
## 6. Environment variables
|
|
264
|
+
|
|
265
|
+
| Variable | Required | What it does |
|
|
266
|
+
| --- | --- | --- |
|
|
267
|
+
| `RESEND_API_KEY` | with `resend()` and no `apiKey` | Resend API key. Missing at send time: `unavailable` and a log line naming the variable. |
|
|
268
|
+
| `MAILING_UNSUBSCRIBE_SECRET` | to send list mail | Signs and verifies unsubscribe links; at least 32 characters (`openssl rand -base64 32`), a shorter one counts as missing. Missing at send time: list mail answers `unavailable` with a log line naming the variable; transactional mail is unaffected. |
|
|
269
|
+
| `MAILING_UNSUBSCRIBE_SECRET_PREVIOUS` | during a rotation | The secret before the current one: verifies links in mail already sent, never signs. |
|
|
270
|
+
|
|
271
|
+
**Rotating the secret.** Links live as long as the mail that carries them. Never replace the
|
|
272
|
+
secret outright: move the current value to `MAILING_UNSUBSCRIBE_SECRET_PREVIOUS`, set a new
|
|
273
|
+
`MAILING_UNSUBSCRIBE_SECRET`, and keep the previous one for as long as old mail may still be
|
|
274
|
+
clicked. Losing the secret means every link already sent stops working.
|
|
275
|
+
|
|
276
|
+
## 7. Switches
|
|
277
|
+
|
|
278
|
+
None.
|
|
279
|
+
|
|
280
|
+
## 8. Appearance
|
|
281
|
+
|
|
282
|
+
The unsubscribe page is a `Card` from `@softure-ai/ui` with a plain form (no client JavaScript),
|
|
283
|
+
styled by the app's tokens.
|
|
284
|
+
|
|
285
|
+
## 9. Copy
|
|
286
|
+
|
|
287
|
+
`src/messages/{en,pl}.ts`: `errors.mailing.{invalid_input,rejected,unavailable,suppressed}`,
|
|
288
|
+
`footer.{text,htmlLead,htmlLink}` (the list-mail footer, in the app's locale) and
|
|
289
|
+
`unsubscribe.*` (the page). `getMailingErrorMessage(messages, code)` returns the copy for a code;
|
|
290
|
+
override any text with `mailing({ messages: { en: { footer: { text: "…" } } } })`.
|
|
291
|
+
|
|
292
|
+
## 10. Hooks
|
|
293
|
+
|
|
294
|
+
The provider is the extension point (section 3).
|
|
295
|
+
|
|
296
|
+
**`onUnsubscribed(event, ctx)`** runs on every verified unsubscribe (the page's button or a mail
|
|
297
|
+
client's one-click POST), also a repeated one, in the transaction that stores the opt-out:
|
|
298
|
+
`event` is `{ recipientKey, source }` (the key is privacy's email key of the same address, never the
|
|
299
|
+
address), `ctx.db` is the transaction. A throw rolls the opt-out back: the route answers 500 and the
|
|
300
|
+
page offers a retry, so the opt-out and what the hook records never disagree. It does not run for
|
|
301
|
+
`suppressRecipient` (a bounce or a script is not the person's choice). Wire the waitlist's handler,
|
|
302
|
+
which withdraws its consents in privacy's ledger:
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
import { withdrawWaitlistConsents } from "@softure-ai/waitlist/server";
|
|
306
|
+
|
|
307
|
+
mailing({ from: "…", provider: resend(), onUnsubscribed: withdrawWaitlistConsents }),
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
**`liftSuppression(ctx, address)`** is the other direction: a module that has just recorded a new
|
|
311
|
+
explicit consent (the waitlist's sign-up) calls it in that consent's transaction. It deletes the
|
|
312
|
+
row only when its source is `page` or `one-click`; an `operator` row stays. It returns whether it
|
|
313
|
+
lifted one.
|
|
314
|
+
|
|
315
|
+
**Tests and e2e:** `fakeMailProvider()` keeps accepted mail in `provider.sent` (with the id it
|
|
316
|
+
answered) and honours idempotency keys like a real provider. `respond: (message) => ({ status:
|
|
317
|
+
"rejected" })` simulates failures. With `outboxFile` it also appends each mail as one JSON line, so a
|
|
318
|
+
test in another process (Playwright against `next start`) reads it with `readMailOutbox(file, { to })`.
|
|
319
|
+
Without an outbox file it refuses to run under `NODE_ENV=production`, where in-memory mail would
|
|
320
|
+
vanish. The example app sets `MAIL_OUTBOX` for its e2e (`examples/next-app/e2e/mailing-transport.spec.ts`).
|
|
321
|
+
|
|
322
|
+
## 11. GDPR
|
|
323
|
+
|
|
324
|
+
Addresses and content go to the provider only; never to logs or results. The suppression list
|
|
325
|
+
stores a SHA-256 of the address, not the address: pseudonymous data (it can be matched against a
|
|
326
|
+
known address), kept after an account is deleted because it records the person's objection to
|
|
327
|
+
mail. The delivery ledger also stores recipient keys, never addresses, and campaigns hold no
|
|
328
|
+
personal data. So the module neither exports nor deletes per user (`privacy: { exports: false, deletes: false }`).
|
|
329
|
+
|
|
330
|
+
## 12. Limitations
|
|
331
|
+
|
|
332
|
+
- One recipient per call; no cc, bcc or attachments.
|
|
333
|
+
- `sendMail` does not retry: the caller retries `unavailable` with the same `idempotencyKey` (Resend
|
|
334
|
+
keeps keys for 24 hours), or uses `deliverOnce`, which keeps the state between runs.
|
|
335
|
+
- Exactly-once ends where the provider's idempotency window ends: a sender that dies after the
|
|
336
|
+
provider accepted a mail and before the outcome was written, with the row taken over more than
|
|
337
|
+
24 hours later, sends that mail twice. No delivery, bounce or complaint webhooks yet.
|
|
338
|
+
- Campaigns have no personalisation, scheduling or markdown: the body is sent as written.
|
|
339
|
+
- Suppression is global per address: no per-list preferences. A person comes back in through a
|
|
340
|
+
module's explicit consent (`liftSuppression`, e.g. a new waitlist sign-up); an operator row is
|
|
341
|
+
removed by hand (`getRecipientKey(address)`).
|
|
342
|
+
- Lowercasing the whole address merges `Ada@` and `ada@` (allowed to differ by RFC 5321, never in
|
|
343
|
+
practice); an unsubscribe then covers both.
|
|
344
|
+
- The display name in `from` cannot contain commas, semicolons or quotes (no RFC 5322 quoting).
|
|
345
|
+
- Address checks are structural (one `@`, a dot in the domain, no separators); the provider has the
|
|
346
|
+
last word.
|
|
347
|
+
|
|
348
|
+
## Build
|
|
349
|
+
|
|
350
|
+
`npm run build -w modules/mailing` runs `tsc -p tsconfig.build.json`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The longest address SMTP carries (RFC 5321 path limit). */
|
|
2
|
+
export declare const MAX_ADDRESS_LENGTH = 254;
|
|
3
|
+
/**
|
|
4
|
+
* Headers that would take over the envelope, the sender or the body's encoding. The `headers`
|
|
5
|
+
* field is for list headers (RFC 8058) and similar; a different `From:` fails DMARC, an added
|
|
6
|
+
* recipient is mail nobody asked for.
|
|
7
|
+
*/
|
|
8
|
+
export declare const RESERVED_HEADERS: ReadonlySet<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Headers the module writes on every list mail. A list mail that brings its own is refused: two
|
|
11
|
+
* `List-Unsubscribe` headers, one of them unsigned, is worse than none.
|
|
12
|
+
*/
|
|
13
|
+
export declare const LIST_MAIL_HEADERS: ReadonlySet<string>;
|
|
14
|
+
export declare const MAX_MAIL_KIND_LENGTH = 64;
|
|
15
|
+
export declare const MAX_SUBJECT_LENGTH = 998;
|
|
16
|
+
export declare const MAX_IDEMPOTENCY_KEY_LENGTH = 256;
|
|
17
|
+
export declare function isSingleAddress(value: string): boolean;
|
|
18
|
+
/** `addr` or `Name <addr>`, on one line. */
|
|
19
|
+
export declare function isMailbox(value: string): boolean;
|
|
20
|
+
export declare function hasLineBreak(value: string): boolean;
|
|
21
|
+
export declare function isHeaderName(name: string): boolean;
|
|
22
|
+
export declare function isReservedHeader(name: string): boolean;
|
|
23
|
+
export declare function isListMailHeader(name: string): boolean;
|
|
24
|
+
export declare function isMailKind(kind: string): boolean;
|
|
25
|
+
/** 1 to 256 visible ASCII characters. */
|
|
26
|
+
export declare function isIdempotencyKey(key: string): boolean;
|
|
27
|
+
//# sourceMappingURL=address.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"address.d.ts","sourceRoot":"","sources":["../src/address.ts"],"names":[],"mappings":"AAcA,8DAA8D;AAC9D,eAAO,MAAM,kBAAkB,MAAM,CAAC;AAKtC;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,EAAE,WAAW,CAAC,MAAM,CAY/C,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAA0D,CAAC;AAI7G,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC,eAAO,MAAM,kBAAkB,MAAM,CAAC;AACtC,eAAO,MAAM,0BAA0B,MAAM,CAAC;AAE9C,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED,4CAA4C;AAC5C,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAMhD;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAElD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD;AAED,yCAAyC;AACzC,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAErD"}
|
package/dist/address.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Address and header rules shared by the options schema and the mail validation.
|
|
2
|
+
/**
|
|
3
|
+
* One address: an `@`, a dot-separated domain, no list separators, no whitespace, no angle
|
|
4
|
+
* brackets. Each part excludes its own separator, so matching stays linear on any input.
|
|
5
|
+
*/
|
|
6
|
+
const ADDRESS = /^[^\s@,;<>"]+@[^\s@,;<>".]+(?:\.[^\s@,;<>".]+)+$/;
|
|
7
|
+
/**
|
|
8
|
+
* A display name: no commas, semicolons or quotes. Unquoted, RFC 5322 reads a comma as a second
|
|
9
|
+
* address, and quoting is left out on purpose.
|
|
10
|
+
*/
|
|
11
|
+
const DISPLAY_NAME = /^[^<>",;\r\n]+$/;
|
|
12
|
+
/** The longest address SMTP carries (RFC 5321 path limit). */
|
|
13
|
+
export const MAX_ADDRESS_LENGTH = 254;
|
|
14
|
+
/** RFC 5322 field name: printable ASCII except the colon. */
|
|
15
|
+
const HEADER_NAME = /^[!-9;-~]+$/;
|
|
16
|
+
/**
|
|
17
|
+
* Headers that would take over the envelope, the sender or the body's encoding. The `headers`
|
|
18
|
+
* field is for list headers (RFC 8058) and similar; a different `From:` fails DMARC, an added
|
|
19
|
+
* recipient is mail nobody asked for.
|
|
20
|
+
*/
|
|
21
|
+
export const RESERVED_HEADERS = new Set([
|
|
22
|
+
"from",
|
|
23
|
+
"sender",
|
|
24
|
+
"to",
|
|
25
|
+
"cc",
|
|
26
|
+
"bcc",
|
|
27
|
+
"reply-to",
|
|
28
|
+
"subject",
|
|
29
|
+
"return-path",
|
|
30
|
+
"content-type",
|
|
31
|
+
"content-transfer-encoding",
|
|
32
|
+
"mime-version",
|
|
33
|
+
]);
|
|
34
|
+
/**
|
|
35
|
+
* Headers the module writes on every list mail. A list mail that brings its own is refused: two
|
|
36
|
+
* `List-Unsubscribe` headers, one of them unsigned, is worse than none.
|
|
37
|
+
*/
|
|
38
|
+
export const LIST_MAIL_HEADERS = new Set(["list-unsubscribe", "list-unsubscribe-post"]);
|
|
39
|
+
/** A mail kind: kebab-case, 1 to 64 characters, e.g. `newsletter`. */
|
|
40
|
+
const MAIL_KIND = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
41
|
+
export const MAX_MAIL_KIND_LENGTH = 64;
|
|
42
|
+
export const MAX_SUBJECT_LENGTH = 998;
|
|
43
|
+
export const MAX_IDEMPOTENCY_KEY_LENGTH = 256;
|
|
44
|
+
export function isSingleAddress(value) {
|
|
45
|
+
return value.length <= MAX_ADDRESS_LENGTH && ADDRESS.test(value);
|
|
46
|
+
}
|
|
47
|
+
/** `addr` or `Name <addr>`, on one line. */
|
|
48
|
+
export function isMailbox(value) {
|
|
49
|
+
if (hasLineBreak(value))
|
|
50
|
+
return false;
|
|
51
|
+
if (!value.endsWith(">"))
|
|
52
|
+
return isSingleAddress(value);
|
|
53
|
+
const open = value.lastIndexOf("<");
|
|
54
|
+
const name = value.slice(0, Math.max(open, 0)).trim();
|
|
55
|
+
return open > 0 && name !== "" && DISPLAY_NAME.test(name) && isSingleAddress(value.slice(open + 1, -1));
|
|
56
|
+
}
|
|
57
|
+
export function hasLineBreak(value) {
|
|
58
|
+
return /[\r\n]/.test(value);
|
|
59
|
+
}
|
|
60
|
+
export function isHeaderName(name) {
|
|
61
|
+
return HEADER_NAME.test(name);
|
|
62
|
+
}
|
|
63
|
+
export function isReservedHeader(name) {
|
|
64
|
+
return RESERVED_HEADERS.has(name.toLowerCase());
|
|
65
|
+
}
|
|
66
|
+
export function isListMailHeader(name) {
|
|
67
|
+
return LIST_MAIL_HEADERS.has(name.toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
export function isMailKind(kind) {
|
|
70
|
+
return kind.length <= MAX_MAIL_KIND_LENGTH && MAIL_KIND.test(kind);
|
|
71
|
+
}
|
|
72
|
+
/** 1 to 256 visible ASCII characters. */
|
|
73
|
+
export function isIdempotencyKey(key) {
|
|
74
|
+
return key.length <= MAX_IDEMPOTENCY_KEY_LENGTH && /^[!-~]+$/.test(key);
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=address.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"address.js","sourceRoot":"","sources":["../src/address.ts"],"names":[],"mappings":"AAAA,iFAAiF;AAEjF;;;GAGG;AACH,MAAM,OAAO,GAAG,kDAAkD,CAAC;AAEnE;;;GAGG;AACH,MAAM,YAAY,GAAG,iBAAiB,CAAC;AAEvC,8DAA8D;AAC9D,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAC;AAEtC,6DAA6D;AAC7D,MAAM,WAAW,GAAG,aAAa,CAAC;AAElC;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAwB,IAAI,GAAG,CAAC;IAC3D,MAAM;IACN,QAAQ;IACR,IAAI;IACJ,IAAI;IACJ,KAAK;IACL,UAAU;IACV,SAAS;IACT,aAAa;IACb,cAAc;IACd,2BAA2B;IAC3B,cAAc;CACf,CAAC,CAAC;AAEH;;;GAGG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAwB,IAAI,GAAG,CAAC,CAAC,kBAAkB,EAAE,uBAAuB,CAAC,CAAC,CAAC;AAE7G,sEAAsE;AACtE,MAAM,SAAS,GAAG,iCAAiC,CAAC;AACpD,MAAM,CAAC,MAAM,oBAAoB,GAAG,EAAE,CAAC;AAEvC,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAC;AACtC,MAAM,CAAC,MAAM,0BAA0B,GAAG,GAAG,CAAC;AAE9C,MAAM,UAAU,eAAe,CAAC,KAAa;IAC3C,OAAO,KAAK,CAAC,MAAM,IAAI,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACnE,CAAC;AAED,4CAA4C;AAC5C,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,IAAI,YAAY,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACtC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,eAAe,CAAC,KAAK,CAAC,CAAC;IACxD,MAAM,IAAI,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IACpC,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACtD,OAAO,IAAI,GAAG,CAAC,IAAI,IAAI,KAAK,EAAE,IAAI,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,eAAe,CAAC,KAAK,CAAC,KAAK,CAAC,IAAI,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;AAC1G,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,KAAa;IACxC,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAC9B,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,OAAO,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAChC,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,OAAO,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;AAClD,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,OAAO,iBAAiB,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;AACnD,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,IAAY;IACrC,OAAO,IAAI,CAAC,MAAM,IAAI,oBAAoB,IAAI,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACrE,CAAC;AAED,yCAAyC;AACzC,MAAM,UAAU,gBAAgB,CAAC,GAAW;IAC1C,OAAO,GAAG,CAAC,MAAM,IAAI,0BAA0B,IAAI,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC1E,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":""}
|
package/dist/cli/bin.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `softure-mail` executable. All logic lives in command.ts and run.ts, which the tests call directly.
|
|
3
|
+
import { runMailCommand } from "./command.js";
|
|
4
|
+
process.exitCode = await runMailCommand({ argv: process.argv.slice(2), cwd: process.cwd() });
|
|
5
|
+
//# sourceMappingURL=bin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":";AACA,0GAA0G;AAC1G,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,CAAC,QAAQ,GAAG,MAAM,cAAc,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type CliOutput } from "./run.js";
|
|
2
|
+
export interface RunMailCommandOptions {
|
|
3
|
+
readonly argv: readonly string[];
|
|
4
|
+
readonly cwd: string;
|
|
5
|
+
readonly output?: CliOutput;
|
|
6
|
+
}
|
|
7
|
+
/** Loads the config (unless the command needs none) and runs `runMailCli`. */
|
|
8
|
+
export declare function runMailCommand(options: RunMailCommandOptions): Promise<number>;
|
|
9
|
+
//# sourceMappingURL=command.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../../src/cli/command.ts"],"names":[],"mappings":"AAIA,OAAO,EAAmD,KAAK,SAAS,EAAE,MAAM,UAAU,CAAC;AAE3F,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC;CAC7B;AASD,8EAA8E;AAC9E,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiBpF"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The `softure-mail` bin. It loads the app's config through `@softure-ai/core/cli`, like
|
|
2
|
+
// `softure migrate` (@softure-ai/db): the file must be loadable by Node itself (`.js`/`.mjs`, or `.ts`
|
|
3
|
+
// where Node strips types). Anything else uses an app script with `runMailCli` instead (see run.ts).
|
|
4
|
+
import { loadAppConfig, takeConfigOption } from "@softure-ai/core/cli";
|
|
5
|
+
import { EXIT_FAILED, EXIT_USAGE, MAIL_USAGE, runMailCli } from "./run.js";
|
|
6
|
+
const APP_SCRIPT = { runner: "runMailCli", packageName: "@softure-ai/mailing" };
|
|
7
|
+
const consoleOutput = {
|
|
8
|
+
log: (line) => console.log(line),
|
|
9
|
+
error: (line) => console.error(line),
|
|
10
|
+
};
|
|
11
|
+
/** Loads the config (unless the command needs none) and runs `runMailCli`. */
|
|
12
|
+
export async function runMailCommand(options) {
|
|
13
|
+
const output = options.output ?? consoleOutput;
|
|
14
|
+
const taken = takeConfigOption(options.argv);
|
|
15
|
+
if (!taken.ok) {
|
|
16
|
+
output.error(`softure-mail: ${taken.problem}`);
|
|
17
|
+
output.error(MAIL_USAGE);
|
|
18
|
+
return EXIT_USAGE;
|
|
19
|
+
}
|
|
20
|
+
const { configPath, argv } = taken;
|
|
21
|
+
if (!needsConfig(argv) && configPath === undefined)
|
|
22
|
+
return runMailCli({ argv, cwd: options.cwd, output });
|
|
23
|
+
const loaded = await loadAppConfig({ cwd: options.cwd, configPath, appScript: APP_SCRIPT });
|
|
24
|
+
if (!loaded.ok) {
|
|
25
|
+
output.error(`softure-mail: ${loaded.problem}`);
|
|
26
|
+
return EXIT_FAILED;
|
|
27
|
+
}
|
|
28
|
+
return runMailCli({ config: loaded.config, argv, cwd: options.cwd, output });
|
|
29
|
+
}
|
|
30
|
+
/** `dns --domain` and help need no config; everything else reads it. */
|
|
31
|
+
function needsConfig(argv) {
|
|
32
|
+
if (argv.length === 0 || argv.includes("--help"))
|
|
33
|
+
return false;
|
|
34
|
+
return !(argv[0] === "dns" && argv.some((arg) => arg === "--domain" || arg.startsWith("--domain=")));
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=command.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"command.js","sourceRoot":"","sources":["../../src/cli/command.ts"],"names":[],"mappings":"AAAA,yFAAyF;AACzF,uGAAuG;AACvG,qGAAqG;AACrG,OAAO,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AACvE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAkB,MAAM,UAAU,CAAC;AAQ3F,MAAM,UAAU,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,WAAW,EAAE,qBAAqB,EAAE,CAAC;AAEhF,MAAM,aAAa,GAAc;IAC/B,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC;IAChC,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC;CACrC,CAAC;AAEF,8EAA8E;AAC9E,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,OAA8B;IACjE,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,aAAa,CAAC;IAC/C,MAAM,KAAK,GAAG,gBAAgB,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC;QACd,MAAM,CAAC,KAAK,CAAC,iBAAiB,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;QAC/C,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;QACzB,OAAO,UAAU,CAAC;IACpB,CAAC;IACD,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,GAAG,KAAK,CAAC;IACnC,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,UAAU,KAAK,SAAS;QAAE,OAAO,UAAU,CAAC,EAAE,IAAI,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,CAAC,CAAC;IAE1G,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;IAC5F,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;QACf,MAAM,CAAC,KAAK,CAAC,iBAAiB,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;QAChD,OAAO,WAAW,CAAC;IACrB,CAAC;IACD,OAAO,UAAU,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,CAAC,CAAC;AAC/E,CAAC;AAED,wEAAwE;AACxE,SAAS,WAAW,CAAC,IAAuB;IAC1C,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,OAAO,KAAK,CAAC;IAC/D,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,KAAK,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,UAAU,IAAI,GAAG,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC;AACvG,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,KAAK,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC1E,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAAE,KAAK,SAAS,EAAE,KAAK,iBAAiB,EAAE,MAAM,UAAU,CAAC"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// `@softure-ai/mailing/cli`: the `softure-mail` command for app scripts and container bundles.
|
|
2
|
+
export { runMailCommand } from "./command.js";
|
|
3
|
+
export { DEFAULT_PAUSE_MS, EXIT_FAILED, EXIT_OK, EXIT_USAGE, MAIL_USAGE, runMailCli } from "./run.js";
|
|
4
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,OAAO,EAAE,cAAc,EAA8B,MAAM,cAAc,CAAC;AAC1E,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,UAAU,EAA0C,MAAM,UAAU,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type SoftureConfig } from "@softure-ai/core";
|
|
2
|
+
import { type DatabaseHandle } from "@softure-ai/db";
|
|
3
|
+
import { type ResolveTxt } from "../server/dns.js";
|
|
4
|
+
import { type Env } from "../server/unsubscribe-link.js";
|
|
5
|
+
export interface CliOutput {
|
|
6
|
+
readonly log: (line: string) => void;
|
|
7
|
+
readonly error: (line: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export interface RunMailCliOptions {
|
|
10
|
+
/** The app's config. Needed by `campaign`, and by `dns` without `--domain`. */
|
|
11
|
+
readonly config?: SoftureConfig;
|
|
12
|
+
/** The arguments after the executable, e.g. `["campaign", "launch.md", "--recipients", "list.txt"]`. */
|
|
13
|
+
readonly argv: readonly string[];
|
|
14
|
+
/** Relative paths resolve against it. Default: `process.cwd()`. */
|
|
15
|
+
readonly cwd?: string;
|
|
16
|
+
readonly output?: CliOutput;
|
|
17
|
+
/** Opens the campaign's database connection. Default: `createDatabase(url, { max: 1 })`. */
|
|
18
|
+
readonly openDatabase?: (url: string) => Promise<DatabaseHandle>;
|
|
19
|
+
readonly resolveTxt?: ResolveTxt;
|
|
20
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
21
|
+
readonly env?: Env;
|
|
22
|
+
}
|
|
23
|
+
export declare const EXIT_OK = 0;
|
|
24
|
+
export declare const EXIT_FAILED = 1;
|
|
25
|
+
export declare const EXIT_USAGE = 2;
|
|
26
|
+
/** Pause between sends: Resend's default limit is two requests per second. */
|
|
27
|
+
export declare const DEFAULT_PAUSE_MS = 500;
|
|
28
|
+
export declare const MAIL_USAGE: string;
|
|
29
|
+
/** Runs the command and returns the process exit code: 0 done, 1 failed or incomplete, 2 usage error. */
|
|
30
|
+
export declare function runMailCli(options: RunMailCliOptions): Promise<number>;
|
|
31
|
+
//# sourceMappingURL=run.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../../src/cli/run.ts"],"names":[],"mappings":"AAWA,OAAO,EAAe,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACnE,OAAO,EAAkB,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAIrE,OAAO,EAAkD,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnG,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,4FAA4F;IAC5F,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,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,QAkBa,CAAC;AAYrC,yGAAyG;AACzG,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiB5E"}
|