@rivi-software/workers-mail 0.1.1 → 0.2.0
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/README.md +53 -5
- package/dist/index.d.ts +37 -0
- package/dist/index.js +120 -2
- package/dist/sigv4.d.ts +24 -0
- package/dist/sigv4.js +72 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# @rivi-software/workers-mail
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@rivi-software/workers-mail) [](https://github.com/rivi-software/tools/actions/workflows/ci.yml) [](LICENSE)
|
|
4
|
+
|
|
3
5
|
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
6
|
|
|
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.
|
|
7
|
+
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. A message written out in full, such as signed or encrypted PGP/MIME, goes out unchanged through `sendRawMail` ([Raw mail](#raw-mail)).
|
|
6
8
|
|
|
7
9
|
## Install
|
|
8
10
|
|
|
@@ -86,6 +88,31 @@ What differs between the two transports, and what the package does about it:
|
|
|
86
88
|
- **List mail.** Email Service takes transactional mail only, so newsletters and other list mail go through `sendWithSes`, which never falls back.
|
|
87
89
|
- **Idempotency.** Neither has any; a caller that retries can deliver twice.
|
|
88
90
|
|
|
91
|
+
## Raw mail
|
|
92
|
+
|
|
93
|
+
`sendMail` hands the transport fields, and the transport writes the message. A message that has to go out byte for byte as it was written, such as PGP/MIME (RFC 3156: `multipart/signed`, `multipart/encrypted`), whose signature covers the bytes of a part, goes through `sendRawMail(env, mail)` instead. It chooses the transport as `sendMail` does, hands a message SES refused to the binding on the same terms, and writes the same `mail.ses_refused` line.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { sendRawMail } from "@rivi-software/workers-mail";
|
|
97
|
+
|
|
98
|
+
const sent = await sendRawMail(env, {
|
|
99
|
+
from: "jane@example.com",
|
|
100
|
+
// To, Cc and Bcc together: the message names no Bcc.
|
|
101
|
+
recipients: ["bob@example.org", "carol@example.org", "dave@example.org"],
|
|
102
|
+
// Its headers, a blank line, the body; CRLF line ends, 7-bit.
|
|
103
|
+
raw,
|
|
104
|
+
sesOnlyHeaders: { Autocrypt: "addr=jane@example.com; keydata=…" },
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- **The envelope.** `from` and `recipients` are bare addresses, and they are where the message goes: neither transport reads a recipient from the message's headers. A Bcc recipient is in `recipients` and nowhere in `raw`, or every recipient would see it. A message without recipients is refused.
|
|
109
|
+
- **SES** takes the message as `Content.Raw` of API v2's `SendEmail`. The Email SDK's adapter sends structured content only, so the package signs this call itself, with Signature Version 4 in Web Crypto. SES writes its own `Message-ID` over the message's, and `SentMail.messageId` names it, as for `sendMail`.
|
|
110
|
+
- **The binding** takes a message written out in full only as the `EmailMessage` of `cloudflare:email`, which has one envelope recipient. So the message goes once per recipient, in order, the same bytes each time, and each send counts as a message against Email Service's limits; `SentMail` names the first send's `messageId`. When a send fails after others went, the `MailError` has the binding's `code` and says how many of how many recipients took the message, counts and never addresses, since it reaches logs: sending it again would deliver it twice to those. The package imports `cloudflare:email` only when the binding sends, so it still loads outside the Workers runtime.
|
|
111
|
+
- **SES-only headers.** Email Service takes only the headers on [its allowlist](https://developers.cloudflare.com/email-service/reference/headers/), so a header it refuses, such as `Autocrypt`, goes in `sesOnlyHeaders`: SES gets it on a line above the message, and the binding gets the message without it. PGP/MIME signs a part and never the message's own headers, so the signature still holds. A value may be folded (CRLF, then a space or tab, then more than white space: a line of white space alone reads as the end of the header to some parsers). A name of anything but letters, digits and hyphens, or a value with any other CR or LF, would write a header or a body of its own into the message, so it is refused before anything is sent, with a `MailError` that has no status, whichever transport is set up.
|
|
112
|
+
- **Tags.** SES gets `tags` as for `sendMail`; the binding drops them.
|
|
113
|
+
|
|
114
|
+
`sendRawWithSes(config, mail, { configurationSet })` and `sendRawWithBinding(binding, mail)` send through one transport alone, and never fall back.
|
|
115
|
+
|
|
89
116
|
## Exports
|
|
90
117
|
|
|
91
118
|
From `@rivi-software/workers-mail`:
|
|
@@ -95,6 +122,9 @@ From `@rivi-software/workers-mail`:
|
|
|
95
122
|
| `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
123
|
| `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
124
|
| `sendWithBinding(binding, mail)` | The binding alone. |
|
|
125
|
+
| `sendRawMail(env, mail)` | A message written out in full (`RawMail`), with `sendMail`'s choice of transport and fallback ([Raw mail](#raw-mail)). |
|
|
126
|
+
| `sendRawWithSes(config, mail, { configurationSet })` | A raw message through SES alone. |
|
|
127
|
+
| `sendRawWithBinding(binding, mail)` | A raw message through the binding alone: one send per recipient. |
|
|
98
128
|
| `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
129
|
| `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
130
|
| `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`. |
|
|
@@ -102,10 +132,12 @@ From `@rivi-software/workers-mail`:
|
|
|
102
132
|
| `formatSesAddress(address)` | The address as SES takes it: the name quoted, or MIME-encoded when it is not ASCII. |
|
|
103
133
|
| `fitReferences(value, max = 986)` | A `References` value cut to `max` characters: the first message and the latest. |
|
|
104
134
|
| `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`. |
|
|
135
|
+
| Types | `Mail`, `RawMail`, `MailAddress`, `MailAttachment`, `MailEnv`, `MailTransport`, `SentMail`, `SesConfig`, `EmailBinding`. |
|
|
106
136
|
|
|
107
137
|
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
138
|
|
|
139
|
+
A `RawMail` has the envelope's `from` and `recipients` as bare addresses, the message as `raw`, `sesOnlyHeaders` and SES message `tags`.
|
|
140
|
+
|
|
109
141
|
From `@rivi-software/workers-mail/address`:
|
|
110
142
|
|
|
111
143
|
| Export | What |
|
|
@@ -145,7 +177,7 @@ SES's account-level suppression list (SES → Configuration → Suppression list
|
|
|
145
177
|
|
|
146
178
|
## Testing a Worker that sends
|
|
147
179
|
|
|
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).
|
|
180
|
+
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). Raw mail through the binding needs `cloudflare:email`, which Node lacks: the package's `vitest.config.ts` points it at a stand-in `EmailMessage` that keeps the message readable ([`tests/raw.test.ts`](https://github.com/rivi-software/tools/blob/main/packages/workers-mail/tests/raw.test.ts)).
|
|
149
181
|
|
|
150
182
|
## Limits
|
|
151
183
|
|
|
@@ -154,11 +186,27 @@ The package's own tests run in Node with Vitest: SES is answered by a spy on the
|
|
|
154
186
|
- `References` is fitted for SES alone; Email Service refuses a header value over 2,048 bytes ([Headers](#how-it-works)).
|
|
155
187
|
- Attachments are base64 strings: Email Service's local simulator in `wrangler dev` cannot take an `ArrayBuffer`, and SES wants base64.
|
|
156
188
|
- Through the binding, recipients' display names are dropped.
|
|
189
|
+
- Raw mail through the binding goes once per recipient, and a failure partway leaves it sent to some of them ([Raw mail](#raw-mail)).
|
|
157
190
|
|
|
158
191
|
## Development
|
|
159
192
|
|
|
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`.
|
|
193
|
+
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`), raw mail and the Signature Version 4 signer against AWS's own example (`raw.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
194
|
|
|
162
195
|
## License
|
|
163
196
|
|
|
164
|
-
[MIT](LICENSE)
|
|
197
|
+
Licensed under the [MIT License](LICENSE). Copyright © 2026 RIVI Software.
|
|
198
|
+
|
|
199
|
+
<br>
|
|
200
|
+
|
|
201
|
+
<p align="center">
|
|
202
|
+
<a href="https://rivi.software">
|
|
203
|
+
<picture>
|
|
204
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rivi-software/tools/main/assets/rivi-mark-on-dark.svg">
|
|
205
|
+
<img src="https://raw.githubusercontent.com/rivi-software/tools/main/assets/rivi-mark.svg" alt="RIVI Software" width="36" height="36">
|
|
206
|
+
</picture>
|
|
207
|
+
</a>
|
|
208
|
+
</p>
|
|
209
|
+
|
|
210
|
+
<p align="center">
|
|
211
|
+
<sub>Part of <a href="https://github.com/rivi-software/tools#readme">RIVI tools</a>, the packages <a href="https://rivi.software">RIVI Software</a> builds its own apps with.</sub>
|
|
212
|
+
</p>
|
package/dist/index.d.ts
CHANGED
|
@@ -99,6 +99,43 @@ export declare function sendWithSes(config: SesConfig, mail: Mail, options?: {
|
|
|
99
99
|
}): Promise<SentMail>;
|
|
100
100
|
/** Sends through Email Service's `send_email` binding alone. Recipients go as bare addresses, as it requires. */
|
|
101
101
|
export declare function sendWithBinding(binding: EmailBinding, mail: Mail): Promise<SentMail>;
|
|
102
|
+
/** A message already written out in full, for `sendRawMail`, `sendRawWithSes` or `sendRawWithBinding`. */
|
|
103
|
+
export type RawMail = {
|
|
104
|
+
/** The envelope sender: a bare address on a domain the transport may send from. */
|
|
105
|
+
from: string;
|
|
106
|
+
/** The envelope recipients (To, Cc and Bcc together), bare addresses. The message itself names no Bcc. */
|
|
107
|
+
recipients: readonly string[];
|
|
108
|
+
/** The RFC 5322 message: its headers, a blank line, the body; CRLF line ends, 7-bit. */
|
|
109
|
+
raw: string;
|
|
110
|
+
/**
|
|
111
|
+
* Headers SES carries above `raw` that Email Service's allowlist refuses (`Autocrypt`): the binding sends the
|
|
112
|
+
* message without them. A value may be folded (CRLF, a space or tab, then more than white space); anything else
|
|
113
|
+
* with CR or LF is refused.
|
|
114
|
+
*/
|
|
115
|
+
sesOnlyHeaders?: Record<string, string>;
|
|
116
|
+
/** SES message tags, as in `Mail`; the binding has none and drops them. */
|
|
117
|
+
tags?: Record<string, string>;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Sends a message the caller wrote out in full, such as signed or encrypted PGP/MIME, which has to go out byte for
|
|
121
|
+
* byte as it was written; `sendMail` builds its message from fields and cannot carry one. The transport is chosen
|
|
122
|
+
* as `sendMail` chooses it: SES when the Worker holds its key, else the `EMAIL` binding, and the binding when SES
|
|
123
|
+
* answers with a refusal, never after an SES call that got no answer.
|
|
124
|
+
*/
|
|
125
|
+
export declare function sendRawMail(env: MailEnv, mail: RawMail): Promise<SentMail>;
|
|
126
|
+
/**
|
|
127
|
+
* Sends a raw message through Amazon SES (API v2) alone, `sesOnlyHeaders` above the message; `configurationSet`
|
|
128
|
+
* names the set whose event destinations see it.
|
|
129
|
+
*/
|
|
130
|
+
export declare function sendRawWithSes(config: SesConfig, mail: RawMail, options?: {
|
|
131
|
+
configurationSet?: string;
|
|
132
|
+
}): Promise<SentMail>;
|
|
133
|
+
/**
|
|
134
|
+
* Sends a raw message through Email Service's binding alone. The binding takes raw MIME only as the `EmailMessage`
|
|
135
|
+
* of `cloudflare:email`, which has one envelope recipient, so the message goes once per recipient, in order, the
|
|
136
|
+
* same bytes each time and without `sesOnlyHeaders`. Answers the first send's `messageId`.
|
|
137
|
+
*/
|
|
138
|
+
export declare function sendRawWithBinding(binding: EmailBinding, mail: RawMail): Promise<SentMail>;
|
|
102
139
|
/** An address with its display name apart; `Jane <jane@example.com>` and `"Jane" <jane@example.com>` are read too. */
|
|
103
140
|
export declare function addressParts(address: MailAddress): {
|
|
104
141
|
email: string;
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createEmailClient, disableTelemetry, } from "@opencoredev/email-sdk";
|
|
|
2
2
|
import { cloudflare } from "@opencoredev/email-sdk/cloudflare";
|
|
3
3
|
import { ses } from "@opencoredev/email-sdk/ses";
|
|
4
4
|
import { parseMailAddress } from "./address.js";
|
|
5
|
+
import { signAwsRequest } from "./sigv4.js";
|
|
5
6
|
// The SDK reports every client and send to its maker's PostHog unless told not to. Off for the whole process,
|
|
6
7
|
// clients that other code creates included; every client here is created with `telemetry: false` as well.
|
|
7
8
|
disableTelemetry();
|
|
@@ -48,10 +49,14 @@ export function canSendMail(env) {
|
|
|
48
49
|
* transactional mail only.
|
|
49
50
|
*/
|
|
50
51
|
export async function sendMail(env, mail) {
|
|
52
|
+
return sendThrough(env, (config) => sendWithSes(config, mail), (binding) => sendWithBinding(binding, mail));
|
|
53
|
+
}
|
|
54
|
+
/** The transport `sendMail` and `sendRawMail` both choose, and the fallback from SES to the binding. */
|
|
55
|
+
async function sendThrough(env, withSes, withBinding) {
|
|
51
56
|
const config = sesConfig(env);
|
|
52
57
|
if (config) {
|
|
53
58
|
try {
|
|
54
|
-
return await
|
|
59
|
+
return await withSes(config);
|
|
55
60
|
}
|
|
56
61
|
catch (err) {
|
|
57
62
|
if (!(err instanceof MailError && err.status !== undefined && env.EMAIL))
|
|
@@ -64,7 +69,7 @@ export async function sendMail(env, mail) {
|
|
|
64
69
|
throw new MailError("No mail transport: neither Amazon SES nor the EMAIL binding is set up.", {
|
|
65
70
|
transport: "email-service",
|
|
66
71
|
});
|
|
67
|
-
return
|
|
72
|
+
return withBinding(env.EMAIL);
|
|
68
73
|
}
|
|
69
74
|
/** Sends through Amazon SES (API v2) alone; `configurationSet` names the set whose event destinations see it. */
|
|
70
75
|
export async function sendWithSes(config, mail, options = {}) {
|
|
@@ -93,6 +98,119 @@ export async function sendWithBinding(binding, mail) {
|
|
|
93
98
|
});
|
|
94
99
|
return { transport: "email-service", id: result.id, messageId: result.id };
|
|
95
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Sends a message the caller wrote out in full, such as signed or encrypted PGP/MIME, which has to go out byte for
|
|
103
|
+
* byte as it was written; `sendMail` builds its message from fields and cannot carry one. The transport is chosen
|
|
104
|
+
* as `sendMail` chooses it: SES when the Worker holds its key, else the `EMAIL` binding, and the binding when SES
|
|
105
|
+
* answers with a refusal, never after an SES call that got no answer.
|
|
106
|
+
*/
|
|
107
|
+
export async function sendRawMail(env, mail) {
|
|
108
|
+
// Refused whichever transport is set up, so a bad header fails where only the binding sends (local development)
|
|
109
|
+
// as it would through SES.
|
|
110
|
+
sesHeaderLines(mail.sesOnlyHeaders);
|
|
111
|
+
return sendThrough(env, (config) => sendRawWithSes(config, mail), (binding) => sendRawWithBinding(binding, mail));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Sends a raw message through Amazon SES (API v2) alone, `sesOnlyHeaders` above the message; `configurationSet`
|
|
115
|
+
* names the set whose event destinations see it.
|
|
116
|
+
*/
|
|
117
|
+
export async function sendRawWithSes(config, mail, options = {}) {
|
|
118
|
+
const headerLines = sesHeaderLines(mail.sesOnlyHeaders);
|
|
119
|
+
needsRecipients(mail, "ses");
|
|
120
|
+
const url = new URL("/v2/email/outbound-emails", `https://email.${config.region}.amazonaws.com`);
|
|
121
|
+
const body = JSON.stringify({
|
|
122
|
+
FromEmailAddress: mail.from,
|
|
123
|
+
// The envelope: SES delivers to these and reads no recipient from the message's headers.
|
|
124
|
+
Destination: { ToAddresses: mail.recipients },
|
|
125
|
+
Content: { Raw: { Data: base64(headerLines + mail.raw) } },
|
|
126
|
+
ConfigurationSetName: options.configurationSet?.trim() || undefined,
|
|
127
|
+
EmailTags: mail.tags && Object.entries(mail.tags).map(([Name, Value]) => ({ Name, Value })),
|
|
128
|
+
});
|
|
129
|
+
let answer;
|
|
130
|
+
try {
|
|
131
|
+
// The headers the Email SDK's SES adapter sends, and no timeout, as it has none.
|
|
132
|
+
const headers = await signAwsRequest({ method: "POST", url, headers: { "content-type": "application/json" }, body }, { ...config, service: "ses" });
|
|
133
|
+
const res = await sesFetch(url, { method: "POST", headers, body });
|
|
134
|
+
// SES took the message once it answered with success, so a body that does not read is no failure.
|
|
135
|
+
answer = (await res.json().catch(() => ({})));
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
throw mailError(err, "ses");
|
|
139
|
+
}
|
|
140
|
+
const id = typeof answer.MessageId === "string" ? answer.MessageId : null;
|
|
141
|
+
return { transport: "ses", id, messageId: id ? sesMessageId(config.region, id) : null };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Sends a raw message through Email Service's binding alone. The binding takes raw MIME only as the `EmailMessage`
|
|
145
|
+
* of `cloudflare:email`, which has one envelope recipient, so the message goes once per recipient, in order, the
|
|
146
|
+
* same bytes each time and without `sesOnlyHeaders`. Answers the first send's `messageId`.
|
|
147
|
+
*/
|
|
148
|
+
export async function sendRawWithBinding(binding, mail) {
|
|
149
|
+
needsRecipients(mail, "email-service");
|
|
150
|
+
// Imported here rather than with the package, so the package still loads outside the Workers runtime.
|
|
151
|
+
const { EmailMessage } = await import("cloudflare:email").catch((err) => {
|
|
152
|
+
throw mailError(err, "email-service");
|
|
153
|
+
});
|
|
154
|
+
let first = null;
|
|
155
|
+
for (const [sent, to] of mail.recipients.entries()) {
|
|
156
|
+
let answer;
|
|
157
|
+
try {
|
|
158
|
+
answer = await binding.send(new EmailMessage(mail.from, to, mail.raw));
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
const error = mailError(err, "email-service");
|
|
162
|
+
if (sent === 0)
|
|
163
|
+
throw error;
|
|
164
|
+
// Counts only, never the addresses: the message reaches logs.
|
|
165
|
+
throw new MailError(`The EMAIL binding sent the message to ${sent} of ${mail.recipients.length} recipients, then failed` +
|
|
166
|
+
`${error.code ? ` with ${error.code}` : ""}; sending it again would deliver it twice to those ${sent}.`, { transport: "email-service", code: error.code, cause: err });
|
|
167
|
+
}
|
|
168
|
+
if (sent === 0) {
|
|
169
|
+
const id = answer?.messageId;
|
|
170
|
+
first = typeof id === "string" ? id : null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { transport: "email-service", id: first, messageId: first };
|
|
174
|
+
}
|
|
175
|
+
const HEADER_NAME = /^[A-Za-z0-9-]+$/;
|
|
176
|
+
// No CR or LF but in a fold: CRLF, then a space or tab, and a line that is more than white space. A line of
|
|
177
|
+
// white space alone is obsolete syntax (RFC 5322 §4.2) that some parsers read as the end of the header.
|
|
178
|
+
const HEADER_VALUE = /^[^\r\n]*(?:\r\n[ \t]+[^\r\n \t][^\r\n]*)*$/;
|
|
179
|
+
/**
|
|
180
|
+
* `sesOnlyHeaders` as the lines that go above the message. A line break that is not a fold would end the header
|
|
181
|
+
* and write headers or a body of its own into the message, so it is refused before anything is sent, and without
|
|
182
|
+
* a status, which would let the binding send the message instead.
|
|
183
|
+
*/
|
|
184
|
+
function sesHeaderLines(headers) {
|
|
185
|
+
let lines = "";
|
|
186
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
187
|
+
if (!HEADER_NAME.test(name))
|
|
188
|
+
throw new MailError("A header name in sesOnlyHeaders is not letters, digits and hyphens.", {
|
|
189
|
+
transport: "ses",
|
|
190
|
+
code: "validation_error",
|
|
191
|
+
});
|
|
192
|
+
if (!HEADER_VALUE.test(value))
|
|
193
|
+
throw new MailError(`The ${name} header in sesOnlyHeaders has a line break that is not a fold (CRLF, then a space or tab).`, { transport: "ses", code: "validation_error" });
|
|
194
|
+
lines += `${name}: ${value}\r\n`;
|
|
195
|
+
}
|
|
196
|
+
return lines;
|
|
197
|
+
}
|
|
198
|
+
// Without one, SES would read the recipients from the message's headers and the binding would send nothing.
|
|
199
|
+
function needsRecipients(mail, transport) {
|
|
200
|
+
if (mail.recipients.length === 0)
|
|
201
|
+
throw new MailError("A raw message needs at least one recipient.", {
|
|
202
|
+
transport,
|
|
203
|
+
code: "validation_error",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** The UTF-8 bytes of `text` in base64, a slice at a time: a message of megabytes is too many arguments at once. */
|
|
207
|
+
function base64(text) {
|
|
208
|
+
const bytes = new TextEncoder().encode(text);
|
|
209
|
+
let binary = "";
|
|
210
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
211
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
212
|
+
return btoa(binary);
|
|
213
|
+
}
|
|
96
214
|
async function send(adapter, transport, message) {
|
|
97
215
|
try {
|
|
98
216
|
const client = createEmailClient({ adapters: [adapter], telemetry: false });
|
package/dist/sigv4.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Signature Version 4 with Web Crypto alone, for the SES calls the Email SDK does not make. The SDK has a
|
|
3
|
+
* signer of its own but does not export it. This one signs the same headers the SDK's SES adapter sends, so a
|
|
4
|
+
* request signed here reaches SES as one from the adapter does.
|
|
5
|
+
*/
|
|
6
|
+
/** An AWS access key, and the region and service a request is signed for. */
|
|
7
|
+
export type AwsSigning = {
|
|
8
|
+
accessKeyId: string;
|
|
9
|
+
secretAccessKey: string;
|
|
10
|
+
region: string;
|
|
11
|
+
service: string;
|
|
12
|
+
};
|
|
13
|
+
/** A request to sign: the body as the string that is sent, the headers besides `host` and the `x-amz-*` ones. */
|
|
14
|
+
export type AwsRequest = {
|
|
15
|
+
method: string;
|
|
16
|
+
url: URL;
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
body: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The headers to send `request` with: its own, `x-amz-content-sha256`, `x-amz-date` and `Authorization`. `host`
|
|
22
|
+
* is signed but left out, since `fetch` sets it from the URL. `now` is for a test's fixed clock.
|
|
23
|
+
*/
|
|
24
|
+
export declare function signAwsRequest(request: AwsRequest, signing: AwsSigning, now?: Date): Promise<Record<string, string>>;
|
package/dist/sigv4.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Signature Version 4 with Web Crypto alone, for the SES calls the Email SDK does not make. The SDK has a
|
|
3
|
+
* signer of its own but does not export it. This one signs the same headers the SDK's SES adapter sends, so a
|
|
4
|
+
* request signed here reaches SES as one from the adapter does.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The headers to send `request` with: its own, `x-amz-content-sha256`, `x-amz-date` and `Authorization`. `host`
|
|
8
|
+
* is signed but left out, since `fetch` sets it from the URL. `now` is for a test's fixed clock.
|
|
9
|
+
*/
|
|
10
|
+
export async function signAwsRequest(request, signing, now = new Date()) {
|
|
11
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
12
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
13
|
+
const payloadHash = await sha256Hex(request.body);
|
|
14
|
+
const headers = {
|
|
15
|
+
...request.headers,
|
|
16
|
+
"x-amz-content-sha256": payloadHash,
|
|
17
|
+
"x-amz-date": amzDate,
|
|
18
|
+
};
|
|
19
|
+
const canonicalHeaders = Object.entries({ ...headers, host: request.url.host })
|
|
20
|
+
.map(([name, value]) => [name.toLowerCase(), value.trim().replace(/\s+/g, " ")])
|
|
21
|
+
.sort(([a], [b]) => byCodeUnit(a, b));
|
|
22
|
+
const signedHeaders = canonicalHeaders.map(([name]) => name).join(";");
|
|
23
|
+
const scope = `${dateStamp}/${signing.region}/${signing.service}/aws4_request`;
|
|
24
|
+
const canonicalRequest = [
|
|
25
|
+
request.method.toUpperCase(),
|
|
26
|
+
// As the URL holds it, encoded once: what S3 signs, and the same as twice for a path of unreserved
|
|
27
|
+
// characters alone, such as SES's.
|
|
28
|
+
request.url.pathname || "/",
|
|
29
|
+
canonicalQuery(request.url.searchParams),
|
|
30
|
+
canonicalHeaders.map(([name, value]) => `${name}:${value}\n`).join(""),
|
|
31
|
+
signedHeaders,
|
|
32
|
+
payloadHash,
|
|
33
|
+
].join("\n");
|
|
34
|
+
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, await sha256Hex(canonicalRequest)].join("\n");
|
|
35
|
+
let key = await hmac(utf8(`AWS4${signing.secretAccessKey}`), dateStamp);
|
|
36
|
+
for (const part of [signing.region, signing.service, "aws4_request"])
|
|
37
|
+
key = await hmac(key, part);
|
|
38
|
+
const signature = hex(await hmac(key, stringToSign));
|
|
39
|
+
return {
|
|
40
|
+
...headers,
|
|
41
|
+
Authorization: `AWS4-HMAC-SHA256 Credential=${signing.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** The query as SigV4 signs it: each name and value encoded as RFC 3986 says, sorted by name, then by value. */
|
|
45
|
+
function canonicalQuery(params) {
|
|
46
|
+
return [...params]
|
|
47
|
+
.map(([name, value]) => [uriEncode(name), uriEncode(value)])
|
|
48
|
+
.sort(([an, av], [bn, bv]) => byCodeUnit(an, bn) || byCodeUnit(av, bv))
|
|
49
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
50
|
+
.join("&");
|
|
51
|
+
}
|
|
52
|
+
// SigV4 sorts by character code; localeCompare would follow the runtime's locale.
|
|
53
|
+
function byCodeUnit(a, b) {
|
|
54
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
55
|
+
}
|
|
56
|
+
// encodeURIComponent leaves !'()* alone, which SigV4 wants encoded.
|
|
57
|
+
function uriEncode(value) {
|
|
58
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
59
|
+
}
|
|
60
|
+
async function sha256Hex(value) {
|
|
61
|
+
return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8(value))));
|
|
62
|
+
}
|
|
63
|
+
async function hmac(key, value) {
|
|
64
|
+
const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
65
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, utf8(value)));
|
|
66
|
+
}
|
|
67
|
+
function utf8(value) {
|
|
68
|
+
return new TextEncoder().encode(value);
|
|
69
|
+
}
|
|
70
|
+
function hex(bytes) {
|
|
71
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rivi-software/workers-mail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|