@coderize/email 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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @coderize/email
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - First release through the coderize-platform release workflow (npm trusted publishing). No code changes from 0.1.0.
8
+
9
+ ## 0.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - First release. `createMailer({ from, configurationSet, client, region, logger, env })` returns `send()` and `trySend()`: SES v2 only when `EMAIL_PROVIDER=ses`, a log line otherwise (error level and no body in production), `{ status: 'sent' | 'logged' | 'skipped', id }` results, and a `SendError` with `kind` `'transient'` or `'permanent'` when a message did not go out. `to`, `cc`, `bcc` and `replyTo` take one address or a flat list; custom headers (RFC 8058 List-Unsubscribe) work on both paths; any attachment sends a hand-built RFC 2045 raw MIME message. Also `buildRawMessage`, `isTransientAwsError`, `esc`, and `@coderize/email/sns` (`verifySnsMessage`, `confirmSubscription` and the pieces) with the exact `sns.<topic region>.amazonaws.com` certificate host rule. No configuration set is named unless an app asks for one.
package/README.md CHANGED
@@ -1,3 +1,345 @@
1
- # Temporary Holding Version
1
+ # @coderize/email
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ How every CoderizeDEV app sends transactional email through Amazon SES: one switch that decides whether mail
4
+ really goes out, Simple content or a hand-built raw MIME message when there are attachments, errors that say
5
+ whether a retry could help, logs that never carry a body in production, an HTML escaper, and the SNS signature
6
+ check for the route that receives SES bounce and complaint notices.
7
+
8
+ ```bash
9
+ pnpm add @coderize/email
10
+ ```
11
+
12
+ Peer dependency: `@aws-sdk/client-sesv2` 3 (3.1127 or later, the oldest the fleet runs). Each app already has it,
13
+ so this package never brings a second copy of the SDK. Plain JavaScript (ESM) with TypeScript types included.
14
+ Templates stay in each app: this package sends what they build.
15
+
16
+ ## What it fixes
17
+
18
+ Nine fleet APIs and three Apps each had their own SES sender. They disagreed in ways that cost mail:
19
+
20
+ | Problem in the fleet copies | Here |
21
+ | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
22
+ | coderize-dev, GRO and icrotary sent real mail from any machine with AWS credentials | SES only when `EMAIL_PROVIDER=ses`; anything else logs |
23
+ | refuge and TTB logged 800 characters of every body, at error level in production | No body, and no address, in a production log line |
24
+ | icrotary wrapped `to` in a list again and lost 11 raffle notifications | `to` is one address or a flat list; a nested list is refused |
25
+ | coderize-dev's raw MIME wrote the subject and body unencoded | Every header 7-bit (RFC 2047), every body part base64 in 76-character lines |
26
+ | SES `Simple.Attachments` PDFs opened blank (icrotary, July 2026) | Attachments always go as raw MIME |
27
+ | Failures thrown, returned or swallowed, with `id` in some apps and `messageId` in others | `send()` throws a classified `SendError`; `trySend()` never throws |
28
+ | 21 HTML escapers, two of them missing `"` or `'` | `esc()` |
29
+ | gronetworking and coderize-dev accepted SNS certificates from `sns.s3.amazonaws.com` | `@coderize/email/sns` accepts exactly `sns.<topic region>.amazonaws.com` |
30
+
31
+ ## 1. The mailer
32
+
33
+ Build one per app, at startup, and reuse it. In a Fastify API it is a plugin:
34
+
35
+ ```js
36
+ // src/plugins/email.js
37
+ import fp from 'fastify-plugin'
38
+ import { createMailer } from '@coderize/email'
39
+
40
+ export default fp(async function emailPlugin(app) {
41
+ const mailer = createMailer({
42
+ from: process.env.EMAIL_FROM ?? 'ThawWatch <alerts@thawwatch.com>',
43
+ logger: app.log
44
+ })
45
+ if (mailer.mode === 'log' && process.env.NODE_ENV === 'production') {
46
+ app.log.error('EMAIL_PROVIDER is not ses: no email will be sent')
47
+ }
48
+ app.decorate('mailer', mailer)
49
+ })
50
+ ```
51
+
52
+ `createMailer(options)`:
53
+
54
+ | Option | Default | What it is |
55
+ | ------------------ | ------------------------------ | ----------------------------------------------------------------------------------------- |
56
+ | `from` | none | The app's default From. A message's own `from` overrides it. |
57
+ | `configurationSet` | none | A configuration set named on every send. See [Configuration sets](#5-configuration-sets). |
58
+ | `logger` | `console` | pino (`app.log`, `request.log`) or anything with `info` and `error`. |
59
+ | `client` | an `SESv2Client`, built lazily | Inject a fake in tests. Credentials always come from the instance or task role. |
60
+ | `region` | `AWS_REGION`, then `us-east-1` | Where the SES identities live. |
61
+ | `env` | `process.env` | Read once, when the mailer is created. |
62
+
63
+ ### The switch: `EMAIL_PROVIDER=ses`
64
+
65
+ | Env | What `send()` does |
66
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------- |
67
+ | `EMAIL_PROVIDER=ses` | Sends through SES. |
68
+ | anything else, in production (`NODE_ENV`) | Logs one **error** line (`EMAIL NOT SENT`), the subject and the recipient count. No address, no body. |
69
+ | anything else, elsewhere | Logs one **info** line with the recipients, the subject and the first 800 characters of the body. |
70
+
71
+ That is the only switch. A laptop or CI runner with AWS credentials sends nothing unless someone sets
72
+ `EMAIL_PROVIDER=ses` on purpose, and production without it is loud on every send instead of looking like
73
+ success. Never set a "sent" flag on a logged result: refugelapeer's receipts were stamped sent for weeks while
74
+ its mailer only logged.
75
+
76
+ ## 2. Sending
77
+
78
+ ```js
79
+ const result = await app.mailer.send({
80
+ to: user.email, // one address, or a flat list
81
+ subject: 'Reset your password',
82
+ text,
83
+ html,
84
+ replyTo: 'support@thawwatch.com' // optional, as are cc, bcc, from, headers, attachments
85
+ })
86
+ ```
87
+
88
+ | Field | Notes |
89
+ | ---------------------------- | --------------------------------------------------------------------------------------------------------------- |
90
+ | `to`, `cc`, `bcc`, `replyTo` | `a@b.com` or `Name <a@b.com>`, or a flat list of them. One address per string: `'a@b.com, c@d.com'` is refused. |
91
+ | `from` | Overrides the mailer's default. |
92
+ | `subject` | Required. A line break in it (from an interpolated name, say) becomes a space. |
93
+ | `text`, `html` | At least one. Send both: the text part is the fallback and helps with spam filters. |
94
+ | `headers` | `{ 'List-Unsubscribe': '<https://...>', 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' }` |
95
+ | `attachments` | `[{ filename, content, contentType? }]`; see [Attachments](#3-attachments). |
96
+ | `configurationSet` | Overrides the mailer's set for this message. |
97
+
98
+ `send()` resolves one of three results:
99
+
100
+ | `status` | `id` | When |
101
+ | --------- | --------------- | ------------------------------------------------------------ |
102
+ | `sent` | SES's MessageId | SES accepted the message. |
103
+ | `logged` | `null` | `EMAIL_PROVIDER` is not `ses`: the message was not sent. |
104
+ | `skipped` | `null` | `to` is empty (or holds only empty entries). Nothing logged. |
105
+
106
+ It **throws a `SendError`** when the message did not go out: SES refused it, or this package refused to build it
107
+ (no subject, a bad address, a header it owns). The failure is already logged by then, so a caller doesn't log
108
+ it again.
109
+
110
+ `trySend()` takes the same message and **never throws**. It resolves `{ ok: true, status, id, error: null }`, or
111
+ `{ ok: false, status: 'failed', id: null, error }` with the `SendError`. Use it where a failed email must not
112
+ fail the request, such as a password reset that answers the same whether or not the address exists:
113
+
114
+ ```js
115
+ app.post('/auth/forgot', async (request) => {
116
+ // ...create the token...
117
+ app.mailer.trySend({ to: user.email, ...passwordResetEmail({ url }) }) // not awaited: the answer never waits on SES
118
+ return { ok: true }
119
+ })
120
+ ```
121
+
122
+ Await `send()` where delivery matters to the caller (a receipt row stamped with the MessageId, a job that
123
+ retries); use `trySend()` or a `.catch()` everywhere else.
124
+
125
+ ## 3. Attachments
126
+
127
+ ```js
128
+ await app.mailer.send({
129
+ to: organizer.email,
130
+ replyTo: 'billing@takeurown.com',
131
+ subject: `Invoice ${invoice.number}`,
132
+ text,
133
+ html,
134
+ attachments: [
135
+ { filename: `invoice-${invoice.number}.pdf`, content: pdfBuffer },
136
+ { filename: 'ticket.png', content: qrPng, contentType: 'image/png' }
137
+ ]
138
+ })
139
+ ```
140
+
141
+ - `content` is a `Buffer` or `Uint8Array`, or a base64 string (what Resend took). Any other string is refused:
142
+ a CSV passed as text would otherwise be decoded as base64 into garbage.
143
+ - `contentType` comes from the extension when left out (pdf, png, jpg, gif, webp, svg, csv, txt, ics, json,
144
+ xml, zip, html), `application/octet-stream` otherwise.
145
+ - `filename` loses any path and control characters. Non-ASCII names work.
146
+
147
+ A message with any attachment goes to SES as **raw MIME** (`Content.Raw`), built here without nodemailer:
148
+ `multipart/mixed` around a `multipart/alternative` text and html pair, each attachment base64 in 76-character
149
+ lines, CRLF line ends, RFC 2047 encoded-words for a non-ASCII subject, display name or filename (plus the RFC
150
+ 2231 `filename*` form). The tests decode a real PDF and a PNG back out of the message and compare them byte for
151
+ byte. Bcc is never written into the message: it goes in the SES envelope only, which keeps it blind.
152
+
153
+ Why not SES's own `Simple.Attachments`: PDFs sent that way opened blank in real mail clients (icrotary,
154
+ 2026-07), and SES sends a binary as text unless each attachment sets `ContentTransferEncoding: 'BASE64'`. Raw is
155
+ the fleet rule until a Simple path is proved against real clients. `buildRawMessage(message, { from })` returns
156
+ the same bytes `send()` would hand SES, for an `.eml` download or a test.
157
+
158
+ ## 4. Headers and one-click unsubscribe
159
+
160
+ Custom headers work on both paths: Simple content carries them as SES `Headers`, raw MIME writes them into the
161
+ message. A digest or any other mail a user can opt out of carries the RFC 8058 pair:
162
+
163
+ ```js
164
+ headers: {
165
+ 'List-Unsubscribe': `<${unsubscribeUrl}>, <mailto:unsubscribe@dmarc.ws>`,
166
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
167
+ }
168
+ ```
169
+
170
+ Refused: a name that isn't a valid header name, a value with a line break, and the headers this package writes
171
+ itself (From, To, Cc, Bcc, Reply-To, Subject, MIME-Version, Content-Type, Content-Transfer-Encoding,
172
+ Content-Disposition). Use the message fields for those.
173
+
174
+ ## 5. Configuration sets
175
+
176
+ **The default is none: no `ConfigurationSetName` is sent.** SES still applies the identity's own default set
177
+ (`coderize-default` on most fleet identities), so events keep flowing exactly as they do today. Why not name
178
+ `coderize-default` by default:
179
+
180
+ - Where the identity's default already is `coderize-default`, naming it changes nothing.
181
+ - Where it isn't, naming it breaks mail. Any configuration set on a send, named or the identity default, makes
182
+ SES check the sender's IAM policy against **the set's ARN as well as the identity's**, and a miss fails every
183
+ send with `AccessDenied` while the app answers 200 (icrotary, takeurown and xceptionalheroes on 2026-09-17;
184
+ the `coderize-seo-report` role for four days after). `notifications.gronetworking.com` defaults to the
185
+ `gronetworking` set and `boulanmanor.com` to none: a package-wide name would move GRO's events and fail any
186
+ sender whose policy doesn't list `coderize-default`.
187
+ - A set named in code also overrides the identity's default for good, so a later change to an identity's
188
+ default set would silently not apply.
189
+
190
+ Pass `configurationSet` (on the mailer, or per message) only when the app wants a set other than its identity's
191
+ default, such as one of its own for event tracking. Before you do, add that set's ARN to the app's
192
+ `<app>-ses-send` policy on `coderize-apps-v3-instance`, and to every other principal that sends as the identity:
193
+
194
+ ```json
195
+ "Resource": [
196
+ "arn:aws:ses:us-east-1:651706755822:identity/<domain>",
197
+ "arn:aws:ses:us-east-1:651706755822:configuration-set/<set>"
198
+ ]
199
+ ```
200
+
201
+ Never turn on open or click tracking on `coderize-default`: it is the fleet-wide default set, so it would add a
202
+ tracking pixel and rewrite the links in every app's mail. An app that wants tracking gets a set of its own.
203
+
204
+ ## 6. Errors and retries
205
+
206
+ `SendError` has `kind`:
207
+
208
+ - `transient`: throttling, an SES 5xx, a retryable SDK error, or no answer from AWS at all (DNS, a reset, a
209
+ timeout). Worth trying again later.
210
+ - `permanent`: everything else, including AccessDenied, an unverified identity, a suppressed address rejected
211
+ as `MessageRejected`, and any message this package refused to build. Retrying the same message won't help.
212
+
213
+ `error.cause` is the AWS error. `error.message` is `Email not sent`, or `Email not sent: <why>` for a refused
214
+ message; it never quotes an address or any content. `isTransientAwsError(err)` is the classifier on its own, for
215
+ an SMS sender (SNS, End User Messaging) that fails the same ways.
216
+
217
+ ## 7. Logs
218
+
219
+ The mailer logs through the logger you pass, and never the body or the whole error object:
220
+
221
+ | Event | Level | Fields |
222
+ | ---------------------- | ----- | -------------------------------------------------------------------------------------------- |
223
+ | sent | info | `messageId`, `to`, `subject` |
224
+ | SES refused it | error | `kind`, `error` (the AWS name), `statusCode`, `requestId`, `detail`, `recipients`, `subject` |
225
+ | refused before sending | error | `kind`, `reason` |
226
+ | log only, production | error | `recipients`, `subject` |
227
+ | log only, elsewhere | info | `to`, `cc`, `bcc`, `subject`, attachment names, `body` (first 800 characters) |
228
+
229
+ `detail` is the AWS error message with every email address replaced by `[address]`. It keeps what an
230
+ AccessDenied says (the ARN missing from the IAM policy, the usual cause) and drops what a MessageRejected says
231
+ about the recipient. A message that "never arrived" with no error line at all is a suppression or a
232
+ deliverability question: see the `ses-deliverability` skill.
233
+
234
+ ## 8. `esc()`
235
+
236
+ ```js
237
+ import { esc } from '@coderize/email'
238
+
239
+ html: `<p>Hi ${esc(user.firstName)},</p><p>${esc(org.name)} invited you.</p>`
240
+ ```
241
+
242
+ Escapes `& < > " '`, so it is safe in text and in a quoted attribute. `null` and `undefined` become `''`, not
243
+ `"null"`. Escape every interpolated value in HTML and none in the text body. A URL in `href` needs a check that
244
+ it is http(s) before it is escaped: `esc()` doesn't make `javascript:` safe. A display name built from user data
245
+ goes in `from` as it is: the mailer quotes or encodes it.
246
+
247
+ ## 9. Testing an app that sends
248
+
249
+ Inject a fake client and set the env, so the SES path runs without AWS:
250
+
251
+ ```js
252
+ const client = { send: vi.fn().mockResolvedValue({ MessageId: 'test-1' }) }
253
+ const mailer = createMailer({ from: 'App <no-reply@app.com>', client, logger: app.log, env: { EMAIL_PROVIDER: 'ses' } })
254
+
255
+ await mailer.send(message)
256
+ expect(client.send.mock.calls[0][0].input).toMatchObject({ Destination: { ToAddresses: ['jane@test.com'] } })
257
+ ```
258
+
259
+ Or leave `EMAIL_PROVIDER` unset in the test env and assert on the `logged` result. Never set
260
+ `EMAIL_PROVIDER=ses` in a test without injecting `client`: the mailer would build a real one from whatever AWS
261
+ credentials the machine has.
262
+
263
+ ## 10. SNS notices: `@coderize/email/sns`
264
+
265
+ The route that receives SES bounce, complaint and delivery events (or inbound mail) over an SNS HTTPS
266
+ subscription is public, so nothing in a message is trusted until its signature checks out. No AWS SDK and no
267
+ Fastify inside, so a Lambda can use it too.
268
+
269
+ ```js
270
+ // src/routes/sesEvents.js
271
+ import { confirmSubscription, verifySnsMessage } from '@coderize/email/sns'
272
+
273
+ export default async function sesEventRoutes(app) {
274
+ // SNS posts JSON as text/plain.
275
+ app.addContentTypeParser('text/plain', { parseAs: 'string' }, (request, body, done) => {
276
+ try {
277
+ done(null, JSON.parse(body))
278
+ } catch (err) {
279
+ err.statusCode = 400
280
+ done(err)
281
+ }
282
+ })
283
+
284
+ app.post('/', async (request, reply) => {
285
+ const message = request.body
286
+ if (!(await verifySnsMessage(message, { topicArn: process.env.SES_SNS_TOPIC_ARN }))) {
287
+ return reply.code(403).send({ error: 'Invalid SNS message', code: 'SNS_INVALID' })
288
+ }
289
+ if (message.Type === 'SubscriptionConfirmation') {
290
+ const { ok } = await confirmSubscription(message)
291
+ return { confirmed: ok }
292
+ }
293
+ // message.Type === 'Notification': JSON.parse(message.Message) is the SES event.
294
+ return { received: true }
295
+ })
296
+ }
297
+ ```
298
+
299
+ **`verifySnsMessage(message, { topicArn, fetchCert })`** resolves `true` or `false` and never throws. It
300
+ requires, in order:
301
+
302
+ 1. `SignatureVersion` 1 (SHA1withRSA) or 2 (SHA256withRSA), and a `Signature`.
303
+ 2. The `TopicArn` is `topicArn` (one ARN or a list), when given. Always pin it: without it, any topic in any
304
+ account can deliver a validly signed message to the route.
305
+ 3. `SigningCertURL` is HTTPS on **exactly** `sns.<region>.amazonaws.com`, the region read from the message's own
306
+ `TopicArn`, with no port and no credentials, ending in `.pem`. A pattern such as `sns.<anything>.amazonaws.com`
307
+ also accepts `sns.s3.amazonaws.com`, an S3 bucket hostname anyone could have created, which would let them
308
+ sign forged notices with a key of their own (gronetworking #207, coderize-dev-mono #410). A subscription
309
+ message's `SubscribeURL` is held to the same rule.
310
+ 4. The signature verifies over the canonical string with that certificate's public key.
311
+
312
+ The certificate is fetched with a 5-second timeout and no redirects, must be valid now, and is cached by URL
313
+ (at most 20) until it expires. `fetchCert` replaces the download in tests; `fetchSigningCert(url, { fetch })` is
314
+ the default.
315
+
316
+ **`confirmSubscription(message, { fetch })`** GETs the `SubscribeURL` of a verified `SubscriptionConfirmation`,
317
+ after checking it against the same host rule, with the same timeout and no redirects. It throws when the URL
318
+ fails the check and resolves `{ ok, status }` otherwise.
319
+
320
+ `topicRegion(arn)`, `isSnsUrl(url, { region, pem })` and `signingString(message)` are the pieces, exported for
321
+ routes with their own error handling.
322
+
323
+ ## Moving an app onto it
324
+
325
+ One PR per app, keeping every call site's behaviour:
326
+
327
+ 1. Add the dependency, build the mailer once (above), and point the app's own `sendEmail` wrapper at
328
+ `mailer.send` or `mailer.trySend`, or replace it. Delete the app's SES client, MIME builder and escaper.
329
+ 2. Check what each caller passes as `to`: a string or a flat list, never a list wrapped again.
330
+ 3. Map the result the callers read: ThawWatch's `messageId` is `id`; dmarc's `{ id }` is unchanged; TakeUrOwn's
331
+ boolean is `result.ok && result.status === 'sent'`.
332
+ 4. Move the switch to `EMAIL_PROVIDER=ses` in SSM where the app used something else (TakeUrOwn's
333
+ `EMAIL_ENABLED=1`), in the same deploy.
334
+ 5. Leave `configurationSet` unset unless the app already passed one (coderize-dev, GRO).
335
+ 6. After the deploy, send through the app's own mailer inside the running container to
336
+ `success@simulator.amazonses.com` and check for a MessageId, then send one message with an attachment to a
337
+ real mailbox and open the file.
338
+
339
+ ## Versions
340
+
341
+ Semver from what a consuming app feels: a **patch** changes nothing it can see, a **minor** adds an export or an
342
+ option, and a **major** changes a result, an error or what goes over the wire. Renovate merges minors and
343
+ patches by itself and holds majors for review.
344
+
345
+ Every release is staged on npm and goes live only once a maintainer approves it with 2FA.
package/encode.js ADDED
@@ -0,0 +1,80 @@
1
+ /*
2
+ Header and body encodings for the MIME message (RFC 2045, RFC 2047, RFC 2231) and for the addresses SES
3
+ takes on both paths. Everything that leaves here is 7-bit ASCII.
4
+ */
5
+
6
+ export const CRLF = '\r\n'
7
+
8
+ /** Anything a header can't carry as it is: a control character (tab aside) or a non-ASCII one. */
9
+ export const NEEDS_ENCODING = /[^\t\x20-\x7e]/
10
+
11
+ // 42 bytes of UTF-8 is 56 characters of base64, so "Subject: " plus one encoded-word stays inside the
12
+ // 78-character line RFC 5322 asks for. 42 is a multiple of 3, so no word but the last carries padding.
13
+ const WORD_BYTES = 42
14
+
15
+ /**
16
+ * RFC 2047 encoded-words (`=?UTF-8?B?...?=`) for `text`, split on character boundaries so no word cuts a
17
+ * multi-byte character in half. Decoders drop the whitespace between adjacent encoded-words, so the
18
+ * split is invisible to the reader. `separator` is CRLF plus a space (a folded header) by default; a
19
+ * display name inside an address uses a plain space, because SES reads those as one line.
20
+ * @param {string} text
21
+ * @param {string} [separator]
22
+ * @returns {string}
23
+ */
24
+ export function encodedWords(text, separator = `${CRLF} `) {
25
+ const words = []
26
+ let word = ''
27
+ let bytes = 0
28
+ for (const char of text) {
29
+ const size = Buffer.byteLength(char, 'utf8')
30
+ if (word && bytes + size > WORD_BYTES) {
31
+ words.push(word)
32
+ word = ''
33
+ bytes = 0
34
+ }
35
+ word += char
36
+ bytes += size
37
+ }
38
+ if (word) words.push(word)
39
+ return words.map((w) => `=?UTF-8?B?${Buffer.from(w, 'utf8').toString('base64')}?=`).join(separator)
40
+ }
41
+
42
+ /**
43
+ * An unstructured header value (Subject, a custom header): line breaks become spaces, so a value can
44
+ * never start a header of its own, and anything non-ASCII or too long for one line goes out as
45
+ * encoded-words.
46
+ * @param {string} value
47
+ * @returns {string}
48
+ */
49
+ export function headerValue(value) {
50
+ const flat = String(value).replace(/[\r\n]+/g, ' ')
51
+ return NEEDS_ENCODING.test(flat) || flat.length > 900 ? encodedWords(flat) : flat
52
+ }
53
+
54
+ /**
55
+ * Base64 in lines of 76 characters joined by CRLF, as RFC 2045 requires of a base64 body. Longer lines
56
+ * are legal nowhere, and a gateway that truncates them leaves a corrupt file.
57
+ * @param {Uint8Array} bytes
58
+ * @returns {string}
59
+ */
60
+ export function base64Lines(bytes) {
61
+ return Buffer.from(bytes)
62
+ .toString('base64')
63
+ .replace(/.{76}(?=.)/g, `$&${CRLF}`)
64
+ }
65
+
66
+ /**
67
+ * A MIME parameter value for a quoted-string: `"` and `\` escaped.
68
+ * @param {string} value
69
+ */
70
+ export function quoted(value) {
71
+ return value.replace(/["\\]/g, '\\$&')
72
+ }
73
+
74
+ /**
75
+ * RFC 2231 / RFC 5987 percent-encoding for `filename*=UTF-8''...`.
76
+ * @param {string} value
77
+ */
78
+ export function percentEncoded(value) {
79
+ return encodeURIComponent(value).replace(/['()*!]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
80
+ }
package/escape.js ADDED
@@ -0,0 +1,15 @@
1
+ const HTML_ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
2
+
3
+ /**
4
+ * Escapes a value for HTML text or a quoted attribute (`"` and `'` both). `null` and `undefined` become an
5
+ * empty string rather than the word "null"; anything else goes through `String()`.
6
+ *
7
+ * Use it on every value an email template interpolates into its HTML. The plain-text body keeps the raw
8
+ * value. A URL in `href` needs more than this: escape it, and only after checking it is http(s).
9
+ * @param {unknown} value
10
+ * @returns {string}
11
+ */
12
+ export function esc(value) {
13
+ if (value === null || value === undefined) return ''
14
+ return String(value).replace(/[&<>"']/g, (c) => HTML_ESCAPES[/** @type {keyof typeof HTML_ESCAPES} */ (c)])
15
+ }
package/index.d.ts ADDED
@@ -0,0 +1,95 @@
1
+ // Types for TypeScript consumers. Hand-written to match index.js; test/types/consumer.ts type-checks them
2
+ // on every `pnpm test`.
3
+
4
+ /** One address (`a@b.com` or `Name <a@b.com>`) or a flat list of them. Never a nested list. */
5
+ export type Recipients = string | ReadonlyArray<string | null | undefined> | null | undefined
6
+
7
+ export interface Attachment {
8
+ /** Shown to the recipient. Path parts and control characters are dropped. */
9
+ filename: string
10
+ /** The bytes, or a base64 string (Resend's shape). Any other string is refused. */
11
+ content: Uint8Array | string
12
+ /** Inferred from the filename's extension when left out; `application/octet-stream` otherwise. */
13
+ contentType?: string
14
+ }
15
+
16
+ export interface EmailMessage {
17
+ /** Overrides the mailer's default From. */
18
+ from?: string
19
+ /** Empty (or only empty entries) means the send is skipped. */
20
+ to: Recipients
21
+ cc?: Recipients
22
+ /** Envelope only: never written into the message. */
23
+ bcc?: Recipients
24
+ replyTo?: Recipients
25
+ subject: string
26
+ /** At least one of `text` and `html` is required. Send both where you can. */
27
+ text?: string
28
+ html?: string
29
+ /** Extra headers, e.g. `List-Unsubscribe` and `List-Unsubscribe-Post`. Not From, To, Subject and the like. */
30
+ headers?: Record<string, string | number>
31
+ /** Any attachment sends the message as raw MIME. */
32
+ attachments?: ReadonlyArray<Attachment>
33
+ /** Overrides the mailer's configuration set for this message. Its ARN must be in the sender's IAM policy. */
34
+ configurationSet?: string
35
+ }
36
+
37
+ export type SendResult =
38
+ { status: 'sent'; id: string | null } | { status: 'logged'; id: null } | { status: 'skipped'; id: null }
39
+
40
+ export type TrySendResult =
41
+ ({ ok: true; error: null } & SendResult) | { ok: false; status: 'failed'; id: null; error: SendError }
42
+
43
+ export interface Logger {
44
+ info(obj: object, msg?: string): unknown
45
+ error(obj: object, msg?: string): unknown
46
+ }
47
+
48
+ /** The part of SESv2Client the mailer uses. */
49
+ export interface SesClientLike {
50
+ send(command: any): Promise<{ MessageId?: string }>
51
+ }
52
+
53
+ export interface MailerOptions {
54
+ /** The app's default From. */
55
+ from?: string
56
+ /** A configuration set to name on every send. None by default: the identity's default set applies. */
57
+ configurationSet?: string
58
+ /** An SESv2Client. Inject a fake in tests; by default one is built on the first real send. */
59
+ client?: SesClientLike
60
+ /** Defaults to `AWS_REGION`, then us-east-1. */
61
+ region?: string
62
+ /** pino (`app.log`) or console, the default. */
63
+ logger?: Logger
64
+ /** Defaults to `process.env`. Read once, when the mailer is created. */
65
+ env?: Record<string, string | undefined>
66
+ }
67
+
68
+ export interface Mailer {
69
+ /** `ses` when EMAIL_PROVIDER is `ses`; `log` otherwise. */
70
+ readonly mode: 'ses' | 'log'
71
+ /** Sends, logs or skips. Throws a SendError (already logged) when the message did not go out. */
72
+ send(message: EmailMessage): Promise<SendResult>
73
+ /** `send()` that never throws. */
74
+ trySend(message: EmailMessage): Promise<TrySendResult>
75
+ }
76
+
77
+ /** A mailer for one app: SES when EMAIL_PROVIDER is `ses`, a log line otherwise. */
78
+ export declare function createMailer(options?: MailerOptions): Mailer
79
+
80
+ /** What `send()` throws when a message did not go out. */
81
+ export declare class SendError extends Error {
82
+ constructor(message: string, kind: 'transient' | 'permanent', options?: { cause?: unknown })
83
+ name: 'SendError'
84
+ /** `transient`: worth retrying later. `permanent`: retrying the same message won't help. */
85
+ kind: 'transient' | 'permanent'
86
+ }
87
+
88
+ /** Whether an AWS SDK v3 error is worth retrying (throttle, 5xx, retryable, or no answer at all). */
89
+ export declare function isTransientAwsError(error: unknown): boolean
90
+
91
+ /** The raw MIME message `send()` builds for a message with attachments. */
92
+ export declare function buildRawMessage(message: EmailMessage, defaults?: { from?: string }): Buffer
93
+
94
+ /** Escapes a value for HTML text or a quoted attribute; null and undefined become ''. */
95
+ export declare function esc(value: unknown): string
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { esc } from './escape.js'
2
+ export { createMailer } from './mailer.js'
3
+ export { buildRawMessage } from './mime.js'
4
+ export { isTransientAwsError, SendError } from './sendError.js'