@rivi-software/workers-mail 0.0.0-stage → 0.1.1
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 +163 -2
- package/dist/address.d.ts +11 -0
- package/dist/address.js +24 -0
- package/dist/index.d.ts +125 -0
- package/dist/index.js +238 -0
- package/package.json +46 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RIVI Software
|
|
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,164 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rivi-software/workers-mail
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Mail from Cloudflare Workers through [Amazon SES](https://aws.amazon.com/ses/) once a Worker holds SES's key, and through [Cloudflare Email Service](https://developers.cloudflare.com/email-service/)'s `send_email` binding until then and whenever SES refuses a message. One call, `sendMail(env, mail)`, picks the transport, formats the addresses and headers for it, and answers with the `Message-ID` the message went out with, so a reply threads back.
|
|
4
|
+
|
|
5
|
+
The binding needs no keys and sends from the day a domain is onboarded; SES takes larger messages and list mail, but needs a verified identity, production access and an IAM key first. With both set up, mail keeps going while SES is not ready yet, and when SES refuses a message (an identity not verified yet, a policy that does not allow the address, a throttle) the binding sends it instead. SES goes through the [Email SDK](https://email-sdk.dev/docs/adapters/ses) (`@opencoredev/email-sdk`), which signs SES's API v2 requests with `fetch` and Web Crypto, so the Worker needs no AWS SDK.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @rivi-software/workers-mail
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The Worker needs Node.js compatibility, since the Email SDK imports Node's `fs`, `os`, `path` and `crypto` modules: it is on by default from compatibility date 2026-08-04, and an earlier date needs the `nodejs_compat` flag, without which the Worker fails to start (`No such module "node:fs/promises"`). The package is ESM only, and runs in Node 22 and later as well, for tests.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```jsonc
|
|
18
|
+
// wrangler.jsonc
|
|
19
|
+
{
|
|
20
|
+
// Node.js compatibility is on from this date; an earlier one needs "compatibility_flags": ["nodejs_compat"].
|
|
21
|
+
"compatibility_date": "2026-10-15",
|
|
22
|
+
// Cloudflare Email Service, for a domain onboarded to Email Sending.
|
|
23
|
+
"send_email": [{ "name": "EMAIL" }],
|
|
24
|
+
// Amazon SES: the region here, the key as two secrets (below).
|
|
25
|
+
"vars": { "SES_REGION": "eu-west-1" },
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx wrangler secret put SES_ACCESS_KEY_ID
|
|
31
|
+
npx wrangler secret put SES_SECRET_ACCESS_KEY
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { MailError, sendMail } from "@rivi-software/workers-mail";
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
async fetch(request, env) {
|
|
39
|
+
try {
|
|
40
|
+
const sent = await sendMail(env, {
|
|
41
|
+
from: "Acme <no-reply@example.com>",
|
|
42
|
+
to: "jane@example.org",
|
|
43
|
+
subject: "Your sign-in link",
|
|
44
|
+
text: "Sign in: https://example.com/sign-in/…",
|
|
45
|
+
html: '<p><a href="https://example.com/sign-in/…">Sign in</a></p>',
|
|
46
|
+
});
|
|
47
|
+
// { transport: "ses" | "email-service", id, messageId: "<…>" }
|
|
48
|
+
return Response.json(sent);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (!(err instanceof MailError)) throw err;
|
|
51
|
+
console.error(JSON.stringify({ event: "mail.failed", transport: err.transport, code: err.code }));
|
|
52
|
+
return new Response("Mail is unavailable", { status: 503 });
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
} satisfies ExportedHandler<Env>;
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`sendMail` reads these names from the object it is handed, the Worker's `env` as a rule:
|
|
59
|
+
|
|
60
|
+
| Name | Kind | What |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| `EMAIL` | `send_email` binding | Cloudflare Email Service. |
|
|
63
|
+
| `SES_REGION` | var | The AWS region SES sends from, such as `eu-west-1`. |
|
|
64
|
+
| `SES_ACCESS_KEY_ID`, `SES_SECRET_ACCESS_KEY` | secrets | The access key of an IAM user that may send ([below](#setting-up-ses)). |
|
|
65
|
+
|
|
66
|
+
SES is used once all three SES values are set; until then, or with any of them empty, the binding. A Worker that binds other names hands them over itself: `sendMail({ EMAIL: env.MAILER, SES_REGION: env.AWS_REGION, … }, mail)`.
|
|
67
|
+
|
|
68
|
+
## How it works
|
|
69
|
+
|
|
70
|
+
`sendMail(env, mail)` sends through SES when `env` has its region and key, else through `env.EMAIL`. When SES answers with an error status, the binding sends the message instead. A refusal (4xx) means SES sent nothing. A server error (5xx) almost always means the same, but does not prove it, so in that rare case the message can arrive twice. `console.warn` then writes one line for the logs with SES's status and error type, never its message, which can name the recipient:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{"event":"mail.ses_refused","status":403,"code":"AccessDeniedException"}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Without an answer from SES (the network, a timeout) the message may have gone out, so the error stands: sending it again through the binding could deliver it twice. Without the binding, SES's error stands too. What a transport refuses or fails at comes as a `MailError`, and so does a Worker with neither transport set up.
|
|
77
|
+
|
|
78
|
+
What differs between the two transports, and what the package does about it:
|
|
79
|
+
|
|
80
|
+
- **Message-ID.** Both write their own and take none from the sender. SES's is its `MessageId` at the region's sending domain, `<…@eu-west-1.amazonses.com>` (`<…@email.amazonses.com>` in us-east-1); the binding answers with its own. `SentMail.messageId` is that header either way, so a reply that names it in `In-Reply-To` threads back.
|
|
81
|
+
- **Display names.** SES takes a name quoted, or MIME-encoded (RFC 2047) when it is not ASCII (`formatSesAddress`). Through the binding, recipients go as bare addresses, since the Email SDK's Cloudflare adapter refuses named ones, so their names are dropped there; the sender's and the Reply-To's stay.
|
|
82
|
+
- **Headers.** SES takes a header of at most 996 characters, its name and value together, so a long thread's `References` is cut to the first message and the latest that fit, the way RFC 5537 (3.4.4) trims it (`fitReferences`). Email Service takes a value of up to 2,048 bytes, refuses a longer one with `E_HEADER_VALUE_TOO_LONG`, and takes only the headers on [its allowlist](https://developers.cloudflare.com/email-service/reference/headers/) and `X-` headers. The package hands the binding the headers as they are, so a long thread passes `References: fitReferences(references)`, which fits both. Neither takes `Message-ID`, `Date` or the address headers as custom headers.
|
|
83
|
+
- **A To.** The Email SDK asks for one: a message to Cc or Bcc alone goes To its sender, as many mail clients do.
|
|
84
|
+
- **Size.** Email Service takes up to 5 MiB, attachments included (25 MiB to the account's verified destination addresses), and 32 attachments; SES 40 MB, the base64 encoding counted. Both take 50 recipients.
|
|
85
|
+
- **Tags.** SES message tags (`mail.tags`) come back on every SES event about the message; the binding has none and drops them.
|
|
86
|
+
- **List mail.** Email Service takes transactional mail only, so newsletters and other list mail go through `sendWithSes`, which never falls back.
|
|
87
|
+
- **Idempotency.** Neither has any; a caller that retries can deliver twice.
|
|
88
|
+
|
|
89
|
+
## Exports
|
|
90
|
+
|
|
91
|
+
From `@rivi-software/workers-mail`:
|
|
92
|
+
|
|
93
|
+
| Export | What |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `sendMail(env, mail)` | Transactional mail: SES when `env` holds its region and key, else `env.EMAIL`, and `env.EMAIL` when SES refuses the message ([How it works](#how-it-works)). Answers `SentMail`: the `transport`, the provider's `id` and the `messageId` header. |
|
|
96
|
+
| `sendWithSes(config, mail, { configurationSet })` | SES alone, never the binding: list mail, which Email Service does not take, with the configuration set whose event destinations report its bounces and complaints. |
|
|
97
|
+
| `sendWithBinding(binding, mail)` | The binding alone. |
|
|
98
|
+
| `canSendMail(env)`, `sesConfig(env)` | Whether a Worker can send at all; SES's region and key, or null while one of them is missing. |
|
|
99
|
+
| `MailError` | A message that was not sent: `transport`, SES's HTTP `status` (absent when SES was not reached or the binding failed), `code` (SES's error type such as `MessageRejected`, or the binding's such as `E_RATE_LIMIT_EXCEEDED`) and `refusedMessage`, true when SES refused the message itself, so that sending it again fails the same way. |
|
|
100
|
+
| `sesError(response)` | The `MailError` for an error answer from SES's API, for calls the Email SDK does not make, such as reading the account's quota with `GetAccount`. |
|
|
101
|
+
| `addressParts(address)` | `{ email, name }` for any `MailAddress`. |
|
|
102
|
+
| `formatSesAddress(address)` | The address as SES takes it: the name quoted, or MIME-encoded when it is not ASCII. |
|
|
103
|
+
| `fitReferences(value, max = 986)` | A `References` value cut to `max` characters: the first message and the latest. |
|
|
104
|
+
| `sesMessageId(region, id)` | The `Message-ID` header SES gives the message it answered `id` for. |
|
|
105
|
+
| Types | `Mail`, `MailAddress`, `MailAttachment`, `MailEnv`, `MailTransport`, `SentMail`, `SesConfig`, `EmailBinding`. |
|
|
106
|
+
|
|
107
|
+
A `Mail` has `from`, `to` (one address or a list), `cc`, `bcc` and `replyTo`, each address as `jane@example.com`, `Jane <jane@example.com>` or `{ email, name }`; a `subject`; `text` and/or `html`; `headers`; `attachments` as base64 (`disposition` `attachment`, or `inline` with the `contentId` the HTML names as `cid:`); and SES message `tags`.
|
|
108
|
+
|
|
109
|
+
From `@rivi-software/workers-mail/address`:
|
|
110
|
+
|
|
111
|
+
| Export | What |
|
|
112
|
+
| --- | --- |
|
|
113
|
+
| `parseMailAddress(raw)` | `{ email, name }` for `jane@example.com`, `Jane <jane@example.com>` or `"Jane Doe" <jane@example.com>`, and anything else as an address alone. It reads each character a fixed number of times, so a long or crafted name costs its length and no more. The entry point is its own so a browser bundle, an address field in a form, leaves the Email SDK out. |
|
|
114
|
+
|
|
115
|
+
## Telemetry
|
|
116
|
+
|
|
117
|
+
The Email SDK reports every client it creates and every send to its maker's PostHog unless told not to. When the package loads, it calls the SDK's `disableTelemetry()`, which turns that off for the whole process, Email SDK clients that your own code creates included, and every client the package creates passes `telemetry: false` as well. [`tests/mail.test.ts`](https://github.com/rivi-software/tools/blob/main/packages/workers-mail/tests/mail.test.ts) checks that a send makes one request, to SES, with `NODE_ENV` and the SDK's opt-out variables out of the way.
|
|
118
|
+
|
|
119
|
+
## Setting up SES
|
|
120
|
+
|
|
121
|
+
Until these steps are done the binding sends everything, so they can wait until SES is needed. In the AWS console, in the region the Worker will send from, and in the DNS of each domain it sends from:
|
|
122
|
+
|
|
123
|
+
1. **Identity.** SES → Identities → Create identity → Domain: `<domain>`, with Easy DKIM (RSA 2048-bit), and its three DKIM CNAME records in the domain's DNS (DNS only, if Cloudflare serves it). They use selectors of their own, so they sit beside Email Service's DKIM record and both keep signing. A custom MAIL FROM domain, such as `ses-bounce.<domain>` (Email Service uses `cf-bounce.<domain>`), with the MX and TXT records SES lists, aligns SPF as well; DKIM alone already passes DMARC.
|
|
124
|
+
2. **Production access.** A new SES account is in the sandbox: it sends only to verified addresses, 200 messages a day. Ask for production access on SES's account dashboard; until SES grants it, it refuses other recipients with `MessageRejected` and the binding sends their mail.
|
|
125
|
+
3. **Key.** An IAM user for each Worker with only `ses:SendEmail` on the identity, for the addresses the Worker sends from, and an access key for it, which goes into the Worker's two secrets; `SES_REGION` is a var in its config ([Usage](#usage)).
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"Version": "2012-10-17",
|
|
130
|
+
"Statement": [
|
|
131
|
+
{
|
|
132
|
+
"Effect": "Allow",
|
|
133
|
+
"Action": "ses:SendEmail",
|
|
134
|
+
"Resource": "arn:aws:ses:<region>:<account id>:identity/<domain>",
|
|
135
|
+
"Condition": { "StringLike": { "ses:FromAddress": ["<address>"] } }
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`ses:FromAddress` takes wildcards (`*@<domain>`). A Worker that sends with a configuration set (`sendWithSes`) also needs `ses:SendEmail` on that set: add `arn:aws:ses:<region>:<account id>:configuration-set/<name>` to `Resource`.
|
|
142
|
+
4. **Check.** Send what the Worker sends (a sign-in link, a password reset) and look in its logs (Workers Logs, `wrangler tail`): no `mail.ses_refused` line means SES took the message. A line names SES's error type: `UnrecognizedClientException` or `SignatureDoesNotMatch` is the key, `AccessDeniedException` the IAM policy, `MessageRejected` with "not verified" the identity or the sandbox. Until it is fixed the binding keeps sending. Deleting the two secrets goes back to the binding.
|
|
143
|
+
|
|
144
|
+
SES's account-level suppression list (SES → Configuration → Suppression list) keeps it from sending again to an address that bounced or complained; check that it covers both. `sendMail` uses no configuration set, so its mail has no event destination.
|
|
145
|
+
|
|
146
|
+
## Testing a Worker that sends
|
|
147
|
+
|
|
148
|
+
The package's own tests run in Node with Vitest: SES is answered by a spy on the global `fetch`, which the package looks up on each call, and the binding by an object that records what it was handed. A Worker's tests can do the same: leave the three SES values empty and pass an `EMAIL` that records the mail, or set them and spy on `fetch` to answer as SES does ([`tests/mail.test.ts`](https://github.com/rivi-software/tools/blob/main/packages/workers-mail/tests/mail.test.ts) has both).
|
|
149
|
+
|
|
150
|
+
## Limits
|
|
151
|
+
|
|
152
|
+
- Workers need Node.js compatibility for the Email SDK's Node imports: the default from compatibility date 2026-08-04, the `nodejs_compat` flag before it.
|
|
153
|
+
- No retries, and no idempotency in either transport: a caller that retries can deliver twice.
|
|
154
|
+
- `References` is fitted for SES alone; Email Service refuses a header value over 2,048 bytes ([Headers](#how-it-works)).
|
|
155
|
+
- Attachments are base64 strings: Email Service's local simulator in `wrangler dev` cannot take an `ArrayBuffer`, and SES wants base64.
|
|
156
|
+
- Through the binding, recipients' display names are dropped.
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
The tests in `tests/` cover both transports and the fallback between them against a stand-in SES and binding, the addresses and headers each transport takes, the telemetry opt-out (`mail.test.ts`) and the address parser (`address.test.ts`). None of them sends mail. In this directory, run `pnpm run test`, `pnpm run check` and `pnpm run build`.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jane@example.com`, `Jane <jane@example.com>` or `"Jane Doe" <jane@example.com>` as the address and its
|
|
3
|
+
* display name, the address as written; anything else is an address alone. Each character is read a fixed number
|
|
4
|
+
* of times: a regex that lets the name and the space before `<` take the same whitespace backtracks, so a name of
|
|
5
|
+
* 20,000 spaces takes a second. An entry point of its own, `@rivi-software/workers-mail/address`, so a browser
|
|
6
|
+
* bundle leaves the Email SDK out.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseMailAddress(raw: string): {
|
|
9
|
+
email: string;
|
|
10
|
+
name: string | null;
|
|
11
|
+
};
|
package/dist/address.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jane@example.com`, `Jane <jane@example.com>` or `"Jane Doe" <jane@example.com>` as the address and its
|
|
3
|
+
* display name, the address as written; anything else is an address alone. Each character is read a fixed number
|
|
4
|
+
* of times: a regex that lets the name and the space before `<` take the same whitespace backtracks, so a name of
|
|
5
|
+
* 20,000 spaces takes a second. An entry point of its own, `@rivi-software/workers-mail/address`, so a browser
|
|
6
|
+
* bundle leaves the Email SDK out.
|
|
7
|
+
*/
|
|
8
|
+
export function parseMailAddress(raw) {
|
|
9
|
+
const address = raw.trim();
|
|
10
|
+
// The address is the last `<…>`: a quoted display name may hold a `<` of its own.
|
|
11
|
+
const open = address.lastIndexOf("<");
|
|
12
|
+
if (open >= 0 && address.endsWith(">")) {
|
|
13
|
+
const email = address.slice(open + 1, -1);
|
|
14
|
+
let name = address.slice(0, open).trim();
|
|
15
|
+
if (name.startsWith('"'))
|
|
16
|
+
name = name.slice(1);
|
|
17
|
+
if (name.endsWith('"'))
|
|
18
|
+
name = name.slice(0, -1);
|
|
19
|
+
// A quote inside the name, or a `>` inside the brackets, is not a form this reads.
|
|
20
|
+
if (email && !email.includes(">") && !name.includes('"'))
|
|
21
|
+
return { email: email.trim(), name: name.trim() || null };
|
|
22
|
+
}
|
|
23
|
+
return { email: address, name: null };
|
|
24
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** `jane@example.com`, `Jane <jane@example.com>`, or the address with its display name apart. */
|
|
2
|
+
export type MailAddress = string | {
|
|
3
|
+
email: string;
|
|
4
|
+
name?: string | null;
|
|
5
|
+
};
|
|
6
|
+
/** A file sent with the message: an attachment, or an inline image the HTML names as `cid:<contentId>`. */
|
|
7
|
+
export type MailAttachment = {
|
|
8
|
+
filename: string;
|
|
9
|
+
/** The MIME type. */
|
|
10
|
+
type: string;
|
|
11
|
+
/** The bytes, base64-encoded: local dev's binding cannot take an ArrayBuffer, and SES wants base64. */
|
|
12
|
+
content: string;
|
|
13
|
+
} & ({
|
|
14
|
+
disposition: "attachment";
|
|
15
|
+
contentId?: undefined;
|
|
16
|
+
} | {
|
|
17
|
+
disposition: "inline";
|
|
18
|
+
contentId: string;
|
|
19
|
+
});
|
|
20
|
+
/** A message for `sendMail`, `sendWithSes` or `sendWithBinding`; `text`, `html` or both. */
|
|
21
|
+
export type Mail = {
|
|
22
|
+
from: MailAddress;
|
|
23
|
+
to: MailAddress | readonly MailAddress[];
|
|
24
|
+
cc?: readonly MailAddress[];
|
|
25
|
+
bcc?: readonly MailAddress[];
|
|
26
|
+
replyTo?: MailAddress;
|
|
27
|
+
subject: string;
|
|
28
|
+
text?: string;
|
|
29
|
+
html?: string;
|
|
30
|
+
/** Headers SES and Email Service let a sender set: `In-Reply-To`, `References`, `List-Unsubscribe`, `Auto-Submitted`… */
|
|
31
|
+
headers?: Record<string, string>;
|
|
32
|
+
attachments?: readonly MailAttachment[];
|
|
33
|
+
/** SES message tags, handed back on every event about the message; the binding has none and drops them. */
|
|
34
|
+
tags?: Record<string, string>;
|
|
35
|
+
};
|
|
36
|
+
/** Which transport sent a message: Amazon SES, or Cloudflare Email Service's binding. */
|
|
37
|
+
export type MailTransport = "ses" | "email-service";
|
|
38
|
+
/** What `sendMail` answers once a message went out. */
|
|
39
|
+
export type SentMail = {
|
|
40
|
+
transport: MailTransport;
|
|
41
|
+
/** The provider's id for the message: SES's `MessageId`, or the binding's `messageId`. */
|
|
42
|
+
id: string | null;
|
|
43
|
+
/** The `Message-ID` header the message went out with, `<…>`, which a reply names in `In-Reply-To`. */
|
|
44
|
+
messageId: string | null;
|
|
45
|
+
};
|
|
46
|
+
/** Cloudflare Email Service's `send_email` binding, typed loosely so `SendEmail` and a test's stand-in both fit. */
|
|
47
|
+
export type EmailBinding = {
|
|
48
|
+
send(message: never): Promise<unknown>;
|
|
49
|
+
};
|
|
50
|
+
/** What a Worker sends with: SES's region and an IAM user's key, the `EMAIL` binding, or both. */
|
|
51
|
+
export type MailEnv = {
|
|
52
|
+
EMAIL?: EmailBinding;
|
|
53
|
+
SES_REGION?: string;
|
|
54
|
+
SES_ACCESS_KEY_ID?: string;
|
|
55
|
+
SES_SECRET_ACCESS_KEY?: string;
|
|
56
|
+
};
|
|
57
|
+
/** SES's region and an IAM user's access key, as `sesConfig` reads them from the Worker's env. */
|
|
58
|
+
export type SesConfig = {
|
|
59
|
+
region: string;
|
|
60
|
+
accessKeyId: string;
|
|
61
|
+
secretAccessKey: string;
|
|
62
|
+
};
|
|
63
|
+
/** A message that was not sent: what the transport answered, with SES's error type or the binding's code. */
|
|
64
|
+
export declare class MailError extends Error {
|
|
65
|
+
readonly transport: MailTransport;
|
|
66
|
+
/** The HTTP status SES answered with; absent when SES was not reached or the binding failed. */
|
|
67
|
+
readonly status: number | undefined;
|
|
68
|
+
/** SES's error type (`MessageRejected`, `TooManyRequestsException`) or the binding's (`E_RATE_LIMIT_EXCEEDED`). */
|
|
69
|
+
readonly code: string | undefined;
|
|
70
|
+
constructor(message: string, options: {
|
|
71
|
+
transport: MailTransport;
|
|
72
|
+
status?: number;
|
|
73
|
+
code?: string;
|
|
74
|
+
cause?: unknown;
|
|
75
|
+
});
|
|
76
|
+
/**
|
|
77
|
+
* Whether SES refused this message itself (an address it will not take, a recipient the sandbox has not
|
|
78
|
+
* verified), so sending it again fails the same way. Anything else is about the account, its quota, its setup
|
|
79
|
+
* or the network, and passes.
|
|
80
|
+
*/
|
|
81
|
+
get refusedMessage(): boolean;
|
|
82
|
+
}
|
|
83
|
+
/** SES's region and key from the Worker's vars and secrets, or null while any of the three is missing. */
|
|
84
|
+
export declare function sesConfig(env: MailEnv): SesConfig | null;
|
|
85
|
+
/** Whether the Worker can send at all: through SES or the binding. */
|
|
86
|
+
export declare function canSendMail(env: MailEnv): boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Sends a transactional message: through Amazon SES when the Worker holds its key, else through the `EMAIL`
|
|
89
|
+
* binding. When SES answers with a refusal (its identity not verified yet, the IAM user not allowed to send as
|
|
90
|
+
* the address, a throttle) nothing went out, so the binding sends it instead and a `mail.ses_refused` line says
|
|
91
|
+
* why. Without an answer from SES (the network, a timeout) the message may have gone out, and the error stands:
|
|
92
|
+
* sending it again could deliver it twice. List mail never comes here (`sendWithSes`): Email Service takes
|
|
93
|
+
* transactional mail only.
|
|
94
|
+
*/
|
|
95
|
+
export declare function sendMail(env: MailEnv, mail: Mail): Promise<SentMail>;
|
|
96
|
+
/** Sends through Amazon SES (API v2) alone; `configurationSet` names the set whose event destinations see it. */
|
|
97
|
+
export declare function sendWithSes(config: SesConfig, mail: Mail, options?: {
|
|
98
|
+
configurationSet?: string;
|
|
99
|
+
}): Promise<SentMail>;
|
|
100
|
+
/** Sends through Email Service's `send_email` binding alone. Recipients go as bare addresses, as it requires. */
|
|
101
|
+
export declare function sendWithBinding(binding: EmailBinding, mail: Mail): Promise<SentMail>;
|
|
102
|
+
/** An address with its display name apart; `Jane <jane@example.com>` and `"Jane" <jane@example.com>` are read too. */
|
|
103
|
+
export declare function addressParts(address: MailAddress): {
|
|
104
|
+
email: string;
|
|
105
|
+
name: string | null;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* An address as SES's `FromEmailAddress`, `ToAddresses` and `ReplyToAddresses` take it: the name quoted, or
|
|
109
|
+
* MIME-encoded (RFC 2047) when it is not plain ASCII, since SES refuses raw UTF-8 there.
|
|
110
|
+
*/
|
|
111
|
+
export declare function formatSesAddress(address: MailAddress): string;
|
|
112
|
+
/**
|
|
113
|
+
* `References` cut to `max` characters the way RFC 5537 (3.4.4) trims it: the thread's first message, then as many
|
|
114
|
+
* of the latest as fit, in order. Clients thread on the first and the last, which both stay. `max` defaults to what
|
|
115
|
+
* SES takes, 986.
|
|
116
|
+
*/
|
|
117
|
+
export declare function fitReferences(value: string, max?: number): string;
|
|
118
|
+
/**
|
|
119
|
+
* The `Message-ID` SES gives a message it sent: its `MessageId` at the region's sending domain (`email` stands for
|
|
120
|
+
* us-east-1). SES writes the Message-ID itself and takes none from a sender, so this is how a reply finds its way
|
|
121
|
+
* back to the thread.
|
|
122
|
+
*/
|
|
123
|
+
export declare function sesMessageId(region: string, id: string): string;
|
|
124
|
+
/** The `MailError` for an error answer from SES: the type from `x-amzn-ErrorType` or the body's `__type`. */
|
|
125
|
+
export declare function sesError(res: Response): Promise<MailError>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createEmailClient, disableTelemetry, } from "@opencoredev/email-sdk";
|
|
2
|
+
import { cloudflare } from "@opencoredev/email-sdk/cloudflare";
|
|
3
|
+
import { ses } from "@opencoredev/email-sdk/ses";
|
|
4
|
+
import { parseMailAddress } from "./address.js";
|
|
5
|
+
// The SDK reports every client and send to its maker's PostHog unless told not to. Off for the whole process,
|
|
6
|
+
// clients that other code creates included; every client here is created with `telemetry: false` as well.
|
|
7
|
+
disableTelemetry();
|
|
8
|
+
/** A message that was not sent: what the transport answered, with SES's error type or the binding's code. */
|
|
9
|
+
export class MailError extends Error {
|
|
10
|
+
transport;
|
|
11
|
+
/** The HTTP status SES answered with; absent when SES was not reached or the binding failed. */
|
|
12
|
+
status;
|
|
13
|
+
/** SES's error type (`MessageRejected`, `TooManyRequestsException`) or the binding's (`E_RATE_LIMIT_EXCEEDED`). */
|
|
14
|
+
code;
|
|
15
|
+
constructor(message, options) {
|
|
16
|
+
super(message, { cause: options.cause });
|
|
17
|
+
this.name = "MailError";
|
|
18
|
+
this.transport = options.transport;
|
|
19
|
+
this.status = options.status;
|
|
20
|
+
this.code = options.code;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether SES refused this message itself (an address it will not take, a recipient the sandbox has not
|
|
24
|
+
* verified), so sending it again fails the same way. Anything else is about the account, its quota, its setup
|
|
25
|
+
* or the network, and passes.
|
|
26
|
+
*/
|
|
27
|
+
get refusedMessage() {
|
|
28
|
+
return this.code === "MessageRejected" || this.code === "BadRequestException";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** SES's region and key from the Worker's vars and secrets, or null while any of the three is missing. */
|
|
32
|
+
export function sesConfig(env) {
|
|
33
|
+
const region = env.SES_REGION?.trim();
|
|
34
|
+
const accessKeyId = env.SES_ACCESS_KEY_ID?.trim();
|
|
35
|
+
const secretAccessKey = env.SES_SECRET_ACCESS_KEY?.trim();
|
|
36
|
+
return region && accessKeyId && secretAccessKey ? { region, accessKeyId, secretAccessKey } : null;
|
|
37
|
+
}
|
|
38
|
+
/** Whether the Worker can send at all: through SES or the binding. */
|
|
39
|
+
export function canSendMail(env) {
|
|
40
|
+
return Boolean(sesConfig(env) || env.EMAIL);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Sends a transactional message: through Amazon SES when the Worker holds its key, else through the `EMAIL`
|
|
44
|
+
* binding. When SES answers with a refusal (its identity not verified yet, the IAM user not allowed to send as
|
|
45
|
+
* the address, a throttle) nothing went out, so the binding sends it instead and a `mail.ses_refused` line says
|
|
46
|
+
* why. Without an answer from SES (the network, a timeout) the message may have gone out, and the error stands:
|
|
47
|
+
* sending it again could deliver it twice. List mail never comes here (`sendWithSes`): Email Service takes
|
|
48
|
+
* transactional mail only.
|
|
49
|
+
*/
|
|
50
|
+
export async function sendMail(env, mail) {
|
|
51
|
+
const config = sesConfig(env);
|
|
52
|
+
if (config) {
|
|
53
|
+
try {
|
|
54
|
+
return await sendWithSes(config, mail);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (!(err instanceof MailError && err.status !== undefined && env.EMAIL))
|
|
58
|
+
throw err;
|
|
59
|
+
// The code and status only: SES's message can name the recipient.
|
|
60
|
+
console.warn(JSON.stringify({ event: "mail.ses_refused", status: err.status, code: err.code }));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!env.EMAIL)
|
|
64
|
+
throw new MailError("No mail transport: neither Amazon SES nor the EMAIL binding is set up.", {
|
|
65
|
+
transport: "email-service",
|
|
66
|
+
});
|
|
67
|
+
return sendWithBinding(env.EMAIL, mail);
|
|
68
|
+
}
|
|
69
|
+
/** Sends through Amazon SES (API v2) alone; `configurationSet` names the set whose event destinations see it. */
|
|
70
|
+
export async function sendWithSes(config, mail, options = {}) {
|
|
71
|
+
const adapter = ses({
|
|
72
|
+
...config,
|
|
73
|
+
configurationSetName: options.configurationSet?.trim() || undefined,
|
|
74
|
+
fetch: sesFetch,
|
|
75
|
+
});
|
|
76
|
+
const result = await send(adapter, "ses", {
|
|
77
|
+
...envelope(mail, formatSesAddress, formatSesAddress),
|
|
78
|
+
headers: mail.headers && sesHeaders(mail.headers),
|
|
79
|
+
tags: mail.tags && Object.entries(mail.tags).map(([name, value]) => ({ name, value })),
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
transport: "ses",
|
|
83
|
+
id: result.id,
|
|
84
|
+
messageId: result.id ? sesMessageId(config.region, result.id) : null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Sends through Email Service's `send_email` binding alone. Recipients go as bare addresses, as it requires. */
|
|
88
|
+
export async function sendWithBinding(binding, mail) {
|
|
89
|
+
const adapter = cloudflare({ binding: binding });
|
|
90
|
+
const result = await send(adapter, "email-service", {
|
|
91
|
+
...envelope(mail, (a) => addressParts(a).email, bindingAddress),
|
|
92
|
+
headers: mail.headers && Object.entries(mail.headers).map(([name, value]) => ({ name, value })),
|
|
93
|
+
});
|
|
94
|
+
return { transport: "email-service", id: result.id, messageId: result.id };
|
|
95
|
+
}
|
|
96
|
+
async function send(adapter, transport, message) {
|
|
97
|
+
try {
|
|
98
|
+
const client = createEmailClient({ adapters: [adapter], telemetry: false });
|
|
99
|
+
const result = await client.send(message);
|
|
100
|
+
return { id: result.id ?? null };
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
throw mailError(err, transport);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** The Email SDK's message from ours: recipients and senders formatted for the transport, the parts as they are. */
|
|
107
|
+
function envelope(mail, recipient, sender) {
|
|
108
|
+
const list = (addresses) => addresses === undefined
|
|
109
|
+
? undefined
|
|
110
|
+
: (Array.isArray(addresses) ? addresses : [addresses]).map(recipient);
|
|
111
|
+
const to = list(mail.to) ?? [];
|
|
112
|
+
const cc = list(mail.cc);
|
|
113
|
+
const bcc = list(mail.bcc);
|
|
114
|
+
return {
|
|
115
|
+
from: sender(mail.from),
|
|
116
|
+
// The Email SDK asks for a To: a message to Cc or Bcc alone goes To its sender, as many mail clients do.
|
|
117
|
+
to: to.length || !(cc?.length || bcc?.length) ? to : [recipient(mail.from)],
|
|
118
|
+
...(cc?.length ? { cc } : {}),
|
|
119
|
+
...(bcc?.length ? { bcc } : {}),
|
|
120
|
+
...(mail.replyTo ? { replyTo: sender(mail.replyTo) } : {}),
|
|
121
|
+
subject: mail.subject,
|
|
122
|
+
...(mail.text !== undefined ? { text: mail.text } : {}),
|
|
123
|
+
...(mail.html !== undefined ? { html: mail.html } : {}),
|
|
124
|
+
...(mail.attachments?.length
|
|
125
|
+
? {
|
|
126
|
+
attachments: mail.attachments.map((a) => ({
|
|
127
|
+
filename: a.filename,
|
|
128
|
+
contentType: a.type,
|
|
129
|
+
content: a.content,
|
|
130
|
+
contentEncoding: "base64",
|
|
131
|
+
disposition: a.disposition,
|
|
132
|
+
...(a.contentId ? { contentId: a.contentId } : {}),
|
|
133
|
+
})),
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** An address with its display name apart; `Jane <jane@example.com>` and `"Jane" <jane@example.com>` are read too. */
|
|
139
|
+
export function addressParts(address) {
|
|
140
|
+
if (typeof address !== "string")
|
|
141
|
+
return { email: address.email, name: address.name?.trim() || null };
|
|
142
|
+
return parseMailAddress(address);
|
|
143
|
+
}
|
|
144
|
+
function bindingAddress(address) {
|
|
145
|
+
const { email, name } = addressParts(address);
|
|
146
|
+
return name ? { email, name } : email;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* An address as SES's `FromEmailAddress`, `ToAddresses` and `ReplyToAddresses` take it: the name quoted, or
|
|
150
|
+
* MIME-encoded (RFC 2047) when it is not plain ASCII, since SES refuses raw UTF-8 there.
|
|
151
|
+
*/
|
|
152
|
+
export function formatSesAddress(address) {
|
|
153
|
+
const { email, name } = addressParts(address);
|
|
154
|
+
if (!name)
|
|
155
|
+
return email;
|
|
156
|
+
if (/^[ -~]*$/.test(name))
|
|
157
|
+
return `"${name.replace(/["\\]/g, "\\$&")}" <${email}>`;
|
|
158
|
+
const bytes = new TextEncoder().encode(name);
|
|
159
|
+
return `=?UTF-8?B?${btoa(String.fromCharCode(...bytes))}?= <${email}>`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* SES takes a header of at most 996 characters, its name and value together (a line of 998 with the `: `), so a
|
|
163
|
+
* long thread's `References` is shortened to the 986 that leaves it.
|
|
164
|
+
*/
|
|
165
|
+
const SES_HEADER_MAX = 996;
|
|
166
|
+
function sesHeaders(headers) {
|
|
167
|
+
return Object.entries(headers).map(([name, value]) => ({
|
|
168
|
+
name,
|
|
169
|
+
value: name.toLowerCase() === "references"
|
|
170
|
+
? fitReferences(value, SES_HEADER_MAX - name.length)
|
|
171
|
+
: value,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* `References` cut to `max` characters the way RFC 5537 (3.4.4) trims it: the thread's first message, then as many
|
|
176
|
+
* of the latest as fit, in order. Clients thread on the first and the last, which both stay. `max` defaults to what
|
|
177
|
+
* SES takes, 986.
|
|
178
|
+
*/
|
|
179
|
+
export function fitReferences(value, max = SES_HEADER_MAX - "References".length) {
|
|
180
|
+
if (value.length <= max)
|
|
181
|
+
return value;
|
|
182
|
+
const ids = value.split(/\s+/).filter(Boolean);
|
|
183
|
+
const first = ids[0];
|
|
184
|
+
const latest = [];
|
|
185
|
+
let length = first.length;
|
|
186
|
+
for (let i = ids.length - 1; i > 0; i--) {
|
|
187
|
+
if (length + 1 + ids[i].length > max)
|
|
188
|
+
break;
|
|
189
|
+
latest.unshift(ids[i]);
|
|
190
|
+
length += 1 + ids[i].length;
|
|
191
|
+
}
|
|
192
|
+
return [first, ...latest].join(" ");
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The `Message-ID` SES gives a message it sent: its `MessageId` at the region's sending domain (`email` stands for
|
|
196
|
+
* us-east-1). SES writes the Message-ID itself and takes none from a sender, so this is how a reply finds its way
|
|
197
|
+
* back to the thread.
|
|
198
|
+
*/
|
|
199
|
+
export function sesMessageId(region, id) {
|
|
200
|
+
return `<${id}@${region === "us-east-1" ? "email" : region}.amazonses.com>`;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* `fetch` for the SES adapter: an error answer becomes a `MailError` with SES's error type, which the Email SDK
|
|
204
|
+
* would otherwise fold into its message. The global `fetch` is looked up on each call, so a test's spy reaches it.
|
|
205
|
+
*/
|
|
206
|
+
async function sesFetch(input, init) {
|
|
207
|
+
const res = await fetch(input, init);
|
|
208
|
+
if (res.ok)
|
|
209
|
+
return res;
|
|
210
|
+
throw await sesError(res);
|
|
211
|
+
}
|
|
212
|
+
/** The `MailError` for an error answer from SES: the type from `x-amzn-ErrorType` or the body's `__type`. */
|
|
213
|
+
export async function sesError(res) {
|
|
214
|
+
const body = (await res.json().catch(() => ({})));
|
|
215
|
+
// "TooManyRequestsException:http://internal.amazon.com/coral/…" or "com.amazonaws…#MessageRejected".
|
|
216
|
+
const type = res.headers.get("x-amzn-errortype") ?? String(body.__type ?? `HTTP${res.status}`);
|
|
217
|
+
const code = type.split(":")[0].split("#").pop();
|
|
218
|
+
return new MailError(String(body.message ?? body.Message ?? res.statusText), {
|
|
219
|
+
transport: "ses",
|
|
220
|
+
status: res.status,
|
|
221
|
+
code,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/** What the Email SDK threw, as a `MailError`: SES's own when it answered, else the innermost error's words and code. */
|
|
225
|
+
function mailError(err, transport) {
|
|
226
|
+
let root = err;
|
|
227
|
+
for (let e = err; e instanceof Error; e = e.cause) {
|
|
228
|
+
if (e instanceof MailError)
|
|
229
|
+
return e;
|
|
230
|
+
root = e;
|
|
231
|
+
}
|
|
232
|
+
const code = root?.code;
|
|
233
|
+
return new MailError(root instanceof Error ? root.message : String(root), {
|
|
234
|
+
transport,
|
|
235
|
+
code: typeof code === "string" ? code : undefined,
|
|
236
|
+
cause: err,
|
|
237
|
+
});
|
|
238
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rivi-software/workers-mail",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Mail from Cloudflare Workers: Amazon SES once the Worker holds its key, Cloudflare Email Service's send_email binding until then and whenever SES refuses a message",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cloudflare",
|
|
7
|
+
"cloudflare-workers",
|
|
8
|
+
"email",
|
|
9
|
+
"transactional-email",
|
|
10
|
+
"amazon-ses",
|
|
11
|
+
"ses",
|
|
12
|
+
"cloudflare-email-service",
|
|
13
|
+
"send-email"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./address": {
|
|
23
|
+
"types": "./dist/address.d.ts",
|
|
24
|
+
"default": "./dist/address.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@opencoredev/email-sdk": "^3.0.1"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/rivi-software/tools.git",
|
|
37
|
+
"directory": "packages/workers-mail"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/rivi-software/tools/tree/main/packages/workers-mail#readme",
|
|
40
|
+
"bugs": "https://github.com/rivi-software/tools/issues",
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
46
|
+
"check": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run"
|
|
48
|
+
}
|
|
6
49
|
}
|