@strav/mail 1.0.0-alpha.25

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.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * `AlibabaDmTransport` — sends mail via Alibaba Cloud DirectMail (DM).
3
+ *
4
+ * POST {endpoint}/?{rpc-v1 form-encoded params}
5
+ * Content-Type: application/x-www-form-urlencoded
6
+ *
7
+ * Why this is here: DirectMail is the dominant transactional-email
8
+ * provider for apps deployed inside Alibaba Cloud and for senders
9
+ * targeting Chinese / South-East Asian inboxes — domestic deliverability
10
+ * to QQ, 163, NetEase, etc. routinely outperforms Western providers.
11
+ *
12
+ * Wire shape — DirectMail exposes a classic Alibaba-Cloud RPC API:
13
+ *
14
+ * - Authentication: HMAC-SHA1 signature ("Signature V1") over a
15
+ * URL-encoded sorted-key canonical query string. Key is
16
+ * `{accessKeySecret}&`. We compute it per request — no SDK
17
+ * dependency.
18
+ * - Payload: form-urlencoded, NOT JSON. JSON is what the API
19
+ * *responds* with (`Format=JSON`), not what we send.
20
+ * - Action: `SingleSendMail` for transactional sends. The other
21
+ * action (`BatchSendMail`) requires a pre-uploaded template and
22
+ * is out of scope for an outbound `Transport`.
23
+ *
24
+ * Limitations forced by the API surface:
25
+ *
26
+ * - **No attachments.** `SingleSendMail` has no attachment field;
27
+ * attachments require SMTP relay or `BatchSendMail` with a
28
+ * template-uploaded file. We throw `MailTransportError` rather
29
+ * than silently drop bytes the caller expected to send.
30
+ * - **No cc / bcc.** `SingleSendMail` accepts a comma-separated
31
+ * `ToAddress` (up to 100) but exposes no cc / bcc parameters.
32
+ * Merging cc / bcc into `to` would silently expose recipient
33
+ * addresses, so we throw instead.
34
+ * - **Custom headers are dropped.** `SingleSendMail` does not
35
+ * expose arbitrary header injection. DirectMail's `TagName`
36
+ * field covers the common "tag this send" use-case — set
37
+ * `tagName` on the transport options if you need it.
38
+ *
39
+ * Regions — DirectMail is region-scoped. The transport defaults to
40
+ * the global endpoint (`https://dm.aliyuncs.com`); SEA-region
41
+ * customers override `endpoint` to e.g.
42
+ * `https://dm.ap-southeast-1.aliyuncs.com` (Singapore) or
43
+ * `https://dm.ap-southeast-5.aliyuncs.com` (Jakarta).
44
+ *
45
+ * @see https://www.alibabacloud.com/help/en/direct-mail/developer-reference/api-dm-2015-11-23-singlesendmail
46
+ */
47
+
48
+ import { createHmac, randomUUID } from 'node:crypto'
49
+ import type { Message } from '../message.ts'
50
+ import type { Transport } from '../transport.ts'
51
+ import { MailTransportError } from '../transport_error.ts'
52
+ import { isRetryableStatus, mapRecipients, toStructured } from './internal/normalize.ts'
53
+
54
+ const DEFAULT_ENDPOINT = 'https://dm.aliyuncs.com'
55
+ const API_VERSION = '2015-11-23'
56
+
57
+ export interface AlibabaDmTransportOptions {
58
+ /** Alibaba Cloud AccessKey ID. Pull from env in `config/mail.ts`; never hard-code. */
59
+ accessKeyId: string
60
+ /** Alibaba Cloud AccessKey Secret. */
61
+ accessKeySecret: string
62
+ /**
63
+ * Verified DirectMail sender address (the "AccountName" — must be
64
+ * pre-registered in the DirectMail console). DM enforces that
65
+ * outbound senders match a configured account; using `message.from`
66
+ * as `AccountName` would fail for every send that uses a per-user
67
+ * `from`. Configure the verified account here, set the display name
68
+ * via `message.from.name`.
69
+ */
70
+ accountName: string
71
+ /**
72
+ * Base URL of the DirectMail API. Defaults to `https://dm.aliyuncs.com`
73
+ * (global). Region overrides — common in SEA deployments:
74
+ *
75
+ * - `https://dm.ap-southeast-1.aliyuncs.com` — Singapore
76
+ * - `https://dm.ap-southeast-2.aliyuncs.com` — Sydney
77
+ * - `https://dm.ap-southeast-3.aliyuncs.com` — Kuala Lumpur
78
+ * - `https://dm.ap-southeast-5.aliyuncs.com` — Jakarta
79
+ */
80
+ endpoint?: string
81
+ /**
82
+ * Optional `TagName` attached to every send — surfaces in DirectMail
83
+ * console analytics. Equivalent to a fixed `X-Tag` header on
84
+ * Western providers.
85
+ */
86
+ tagName?: string
87
+ /**
88
+ * Enable click-tracking — DirectMail rewrites links in the HTML
89
+ * body. Off by default; turn on per-deployment if you actually
90
+ * consume the analytics.
91
+ */
92
+ clickTrace?: boolean
93
+ /** Custom `fetch` for tests. */
94
+ fetch?: typeof fetch
95
+ /** Override clock for deterministic signatures in tests. */
96
+ now?: () => Date
97
+ /** Override SignatureNonce generation for deterministic signatures in tests. */
98
+ nonce?: () => string
99
+ }
100
+
101
+ export class AlibabaDmTransport implements Transport {
102
+ private readonly accessKeyId: string
103
+ private readonly accessKeySecret: string
104
+ private readonly accountName: string
105
+ private readonly endpoint: string
106
+ private readonly tagName: string | undefined
107
+ private readonly clickTrace: '0' | '1'
108
+ private readonly fetchFn: typeof fetch
109
+ private readonly nowFn: () => Date
110
+ private readonly nonceFn: () => string
111
+
112
+ constructor(opts: AlibabaDmTransportOptions) {
113
+ this.accessKeyId = opts.accessKeyId
114
+ this.accessKeySecret = opts.accessKeySecret
115
+ this.accountName = opts.accountName
116
+ this.endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, '')
117
+ this.tagName = opts.tagName
118
+ this.clickTrace = opts.clickTrace ? '1' : '0'
119
+ this.fetchFn = opts.fetch ?? fetch
120
+ this.nowFn = opts.now ?? (() => new Date())
121
+ this.nonceFn = opts.nonce ?? (() => randomUUID())
122
+ }
123
+
124
+ async send(message: Message): Promise<void> {
125
+ if (message.from === undefined) {
126
+ throw new MailTransportError('Alibaba DM requires `from` — none on the message or default.', {
127
+ context: { provider: 'alibaba', retryable: false },
128
+ })
129
+ }
130
+ if (message.cc !== undefined || message.bcc !== undefined) {
131
+ throw new MailTransportError(
132
+ 'Alibaba DM SingleSendMail does not support cc/bcc — send a separate message per recipient set.',
133
+ { context: { provider: 'alibaba', retryable: false } },
134
+ )
135
+ }
136
+ if (message.attachments !== undefined && message.attachments.length > 0) {
137
+ throw new MailTransportError(
138
+ 'Alibaba DM SingleSendMail does not support attachments. Use SMTP relay or BatchSendMail with a template-uploaded file.',
139
+ { context: { provider: 'alibaba', retryable: false } },
140
+ )
141
+ }
142
+
143
+ const fromAddress = toStructured(message.from)
144
+ const toAddresses = mapRecipients(message.to, toStructured)
145
+ if (toAddresses.length === 0) {
146
+ throw new MailTransportError('Alibaba DM requires at least one `to` recipient.', {
147
+ context: { provider: 'alibaba', retryable: false },
148
+ })
149
+ }
150
+
151
+ const params: Record<string, string> = {
152
+ // Common RPC parameters.
153
+ Action: 'SingleSendMail',
154
+ Version: API_VERSION,
155
+ Format: 'JSON',
156
+ AccessKeyId: this.accessKeyId,
157
+ SignatureMethod: 'HMAC-SHA1',
158
+ SignatureVersion: '1.0',
159
+ SignatureNonce: this.nonceFn(),
160
+ Timestamp: toAlibabaTimestamp(this.nowFn()),
161
+ // Action-specific parameters.
162
+ AccountName: this.accountName,
163
+ // AddressType=1 → send from a configured sender account (the normal
164
+ // path). 0 is reserved for "random sender" which we don't expose.
165
+ AddressType: '1',
166
+ ReplyToAddress: message.replyTo === undefined ? 'false' : 'true',
167
+ ToAddress: toAddresses.map((a) => a.email).join(','),
168
+ Subject: message.subject,
169
+ ClickTrace: this.clickTrace,
170
+ }
171
+
172
+ if (fromAddress.name !== undefined) params['FromAlias'] = fromAddress.name
173
+ if (message.html !== undefined) params['HtmlBody'] = message.html
174
+ if (message.text !== undefined) params['TextBody'] = message.text
175
+ if (this.tagName !== undefined) params['TagName'] = this.tagName
176
+
177
+ if (message.replyTo !== undefined) {
178
+ // DM SingleSendMail accepts a single ReplyAddress. If the caller
179
+ // passed multiple, we use the first — matches what DM would do if
180
+ // we crammed extras into a header it doesn't read.
181
+ const [first] = mapRecipients(message.replyTo, toStructured)
182
+ if (first !== undefined) {
183
+ params['ReplyAddress'] = first.email
184
+ if (first.name !== undefined) params['ReplyAddressAlias'] = first.name
185
+ }
186
+ }
187
+
188
+ params['Signature'] = signRpcV1(params, 'POST', this.accessKeySecret)
189
+
190
+ let response: Response
191
+ try {
192
+ response = await this.fetchFn(this.endpoint, {
193
+ method: 'POST',
194
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
195
+ body: encodeForm(params),
196
+ })
197
+ } catch (cause) {
198
+ throw new MailTransportError(
199
+ `Alibaba DM send failed at the network layer: ${(cause as Error).message ?? String(cause)}`,
200
+ { context: { provider: 'alibaba', retryable: true }, cause },
201
+ )
202
+ }
203
+
204
+ if (response.ok) return
205
+
206
+ let providerError: unknown
207
+ try {
208
+ providerError = await response.json()
209
+ } catch {
210
+ providerError = await response.text().catch(() => undefined)
211
+ }
212
+
213
+ throw new MailTransportError(
214
+ `Alibaba DM rejected the send (HTTP ${response.status} ${response.statusText}).`,
215
+ {
216
+ context: {
217
+ provider: 'alibaba',
218
+ status: response.status,
219
+ retryable: isRetryableStatus(response.status),
220
+ providerError,
221
+ },
222
+ },
223
+ )
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Alibaba Cloud RPC v1 signature.
229
+ *
230
+ * StringToSign = HTTPMethod + "&" + pct(/) + "&" + pct(canonicalQueryString)
231
+ * Signature = base64(HMAC-SHA1(StringToSign, accessKeySecret + "&"))
232
+ *
233
+ * The trailing `&` on the HMAC key is mandated by the spec — it is
234
+ * NOT a bug. Same with the literal `&` separators in `StringToSign`.
235
+ */
236
+ function signRpcV1(
237
+ params: Record<string, string>,
238
+ method: string,
239
+ accessKeySecret: string,
240
+ ): string {
241
+ const sortedKeys = Object.keys(params).sort()
242
+ const canonical = sortedKeys
243
+ .map((k) => `${percentEncode(k)}=${percentEncode(params[k] as string)}`)
244
+ .join('&')
245
+ const stringToSign = `${method}&${percentEncode('/')}&${percentEncode(canonical)}`
246
+ return createHmac('sha1', `${accessKeySecret}&`).update(stringToSign).digest('base64')
247
+ }
248
+
249
+ /**
250
+ * Alibaba's percent-encoding rules — RFC 3986 strict, with the
251
+ * additional fix-ups that bring `encodeURIComponent` into line:
252
+ * encode `!`, `'`, `(`, `)`, `*`, but leave `~` alone (which
253
+ * `encodeURIComponent` already does in modern engines).
254
+ */
255
+ function percentEncode(value: string): string {
256
+ return encodeURIComponent(value)
257
+ .replace(/!/g, '%21')
258
+ .replace(/'/g, '%27')
259
+ .replace(/\(/g, '%28')
260
+ .replace(/\)/g, '%29')
261
+ .replace(/\*/g, '%2A')
262
+ }
263
+
264
+ function encodeForm(params: Record<string, string>): string {
265
+ return Object.entries(params)
266
+ .map(([k, v]) => `${percentEncode(k)}=${percentEncode(v)}`)
267
+ .join('&')
268
+ }
269
+
270
+ /** ISO 8601 in UTC with seconds precision — `2025-01-15T08:30:00Z`. */
271
+ function toAlibabaTimestamp(d: Date): string {
272
+ return `${d.toISOString().slice(0, 19)}Z`
273
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `ArrayTransport` — in-memory mail sink for tests.
3
+ *
4
+ * Records every `send()` in insertion order. Apps wire it as the
5
+ * default transport during tests, then assert on `transport.messages`
6
+ * to verify what would have left the process. No I/O, no encoding —
7
+ * the recorded `Message` is the same object passed to `send()` (a
8
+ * shallow copy, so downstream mutation by the caller doesn't disturb
9
+ * recorded history).
10
+ */
11
+
12
+ import type { Message } from '../message.ts'
13
+ import type { Transport } from '../transport.ts'
14
+
15
+ export class ArrayTransport implements Transport {
16
+ private readonly _messages: Message[] = []
17
+
18
+ async send(message: Message): Promise<void> {
19
+ this._messages.push({ ...message })
20
+ }
21
+
22
+ /** Frozen view of every message recorded since the last `clear()`. */
23
+ get messages(): readonly Message[] {
24
+ return this._messages
25
+ }
26
+
27
+ /** Number of messages recorded. Equivalent to `messages.length`. */
28
+ get count(): number {
29
+ return this._messages.length
30
+ }
31
+
32
+ /** Drop all recorded messages. Use between tests. */
33
+ clear(): void {
34
+ this._messages.length = 0
35
+ }
36
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Internal helpers shared by HTTP-based transports (Resend, SendGrid,
3
+ * future Postmark / Mailgun).
4
+ *
5
+ * These are NOT exported from the package barrel — they're an
6
+ * implementation detail of the transports. Tests reach in via the
7
+ * relative path; user code constructs a `Message` and lets the
8
+ * transport format it.
9
+ */
10
+
11
+ import type { MailAddress, MailRecipient, MessageAttachment } from '../../message.ts'
12
+
13
+ // ─── Recipient normalisation ─────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Coerce a `MailRecipient` into `{ email, name? }` form.
17
+ * Used by transports whose API takes structured-recipient JSON
18
+ * (SendGrid: `[{ "email": "...", "name": "..." }]`).
19
+ */
20
+ export function toStructured(r: MailRecipient): MailAddress {
21
+ return typeof r === 'string' ? { email: r } : r
22
+ }
23
+
24
+ /**
25
+ * Coerce a `MailRecipient` into RFC 5322 "Name <email>" form.
26
+ * Used by transports that accept that as a single string (Resend:
27
+ * `"to": "Alice <a@x>"`).
28
+ */
29
+ export function toRfc5322(r: MailRecipient): string {
30
+ if (typeof r === 'string') return r
31
+ if (r.name === undefined) return r.email
32
+ return `"${escapeQuotes(r.name)}" <${r.email}>`
33
+ }
34
+
35
+ function escapeQuotes(s: string): string {
36
+ return s.replace(/"/g, '\\"')
37
+ }
38
+
39
+ /** Apply `mapper` to either a single recipient or a list. */
40
+ export function mapRecipients<T>(
41
+ value: MailRecipient | MailRecipient[],
42
+ mapper: (r: MailRecipient) => T,
43
+ ): T[] {
44
+ return Array.isArray(value) ? value.map(mapper) : [mapper(value)]
45
+ }
46
+
47
+ // ─── Attachment encoding ─────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Encode a `MessageAttachment`'s `content` to base64 — the wire format
51
+ * every HTTP mail provider accepts. `Uint8Array` → btoa; `string` is
52
+ * pass-through if `encoding: 'base64'`, otherwise UTF-8 → btoa.
53
+ */
54
+ export function attachmentToBase64(a: MessageAttachment): string {
55
+ if (typeof a.content === 'string') {
56
+ if (a.encoding === 'base64') return a.content
57
+ // UTF-8 string → bytes → base64.
58
+ return uint8ToBase64(new TextEncoder().encode(a.content))
59
+ }
60
+ return uint8ToBase64(a.content)
61
+ }
62
+
63
+ function uint8ToBase64(bytes: Uint8Array): string {
64
+ // Bun supports btoa + the binary-string trick used in browsers; this
65
+ // path stays portable to plain Node without pulling Buffer in.
66
+ let binary = ''
67
+ const chunk = 0x8000
68
+ for (let i = 0; i < bytes.length; i += chunk) {
69
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
70
+ }
71
+ return btoa(binary)
72
+ }
73
+
74
+ // ─── Retry classification ────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Best-effort guess at whether a transport failure is worth retrying.
78
+ * Used in the `context.retryable` field of `MailTransportError` so
79
+ * the Worker's `failed()` hook can log the hint (the Worker itself
80
+ * doesn't read it — retry policy lives in `Job.maxAttempts` /
81
+ * `Job.backoff`).
82
+ *
83
+ * Rule of thumb (matches HTTP semantics):
84
+ * - 5xx / network → retryable
85
+ * - 408 / 429 → retryable (timeout + rate-limit)
86
+ * - other 4xx → permanent
87
+ */
88
+ export function isRetryableStatus(status: number): boolean {
89
+ if (status >= 500) return true
90
+ if (status === 408 || status === 429) return true
91
+ return false
92
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `LogTransport` — writes outgoing mail to a `Logger` channel instead
3
+ * of a real transport.
4
+ *
5
+ * Local-dev default. Apps point `config.mail.default` at the `'log'`
6
+ * transport so that `bun dev` prints what would have been sent without
7
+ * touching SMTP / Resend / SendGrid. Production never uses this — the
8
+ * Mail body sits in logs verbatim.
9
+ *
10
+ * Output shape
11
+ * The transport emits one structured record per `send()`:
12
+ *
13
+ * logger.info('mail.sent', { mail: { to, from, subject, ... } })
14
+ *
15
+ * The Logger contract is `(msg, fields?)`, so the event identifier
16
+ * `'mail.sent'` is the first arg and the structured payload is the
17
+ * second. Bodies are not logged by default — putting full HTML into
18
+ * a log channel is wasteful in dev and unsafe in shared environments.
19
+ * Set `includeBody: true` to opt in (intended only for local debugging).
20
+ */
21
+
22
+ import type { Logger } from '@strav/kernel'
23
+ import type { Message } from '../message.ts'
24
+ import type { Transport } from '../transport.ts'
25
+
26
+ export interface LogTransportOptions {
27
+ /** Logger to write records to. Typically a named channel like `'mail'`. */
28
+ logger: Logger
29
+ /** Log level for outgoing records. Default `'info'`. */
30
+ level?: 'debug' | 'info'
31
+ /**
32
+ * Include `html` / `text` bodies in the log record. Default `false`
33
+ * — bodies in logs are noisy and can leak PII. Flip on only for
34
+ * local debugging.
35
+ */
36
+ includeBody?: boolean
37
+ }
38
+
39
+ export class LogTransport implements Transport {
40
+ private readonly logger: Logger
41
+ private readonly level: 'debug' | 'info'
42
+ private readonly includeBody: boolean
43
+
44
+ constructor(opts: LogTransportOptions) {
45
+ this.logger = opts.logger
46
+ this.level = opts.level ?? 'info'
47
+ this.includeBody = opts.includeBody ?? false
48
+ }
49
+
50
+ async send(message: Message): Promise<void> {
51
+ const record: Record<string, unknown> = {
52
+ to: message.to,
53
+ from: message.from,
54
+ subject: message.subject,
55
+ hasHtml: message.html !== undefined,
56
+ hasText: message.text !== undefined,
57
+ }
58
+ if (message.cc !== undefined) record.cc = message.cc
59
+ if (message.bcc !== undefined) record.bcc = message.bcc
60
+ if (message.replyTo !== undefined) record.replyTo = message.replyTo
61
+ if (message.headers !== undefined) record.headers = message.headers
62
+ if (message.attachments !== undefined) {
63
+ record.attachments = message.attachments.map((a) => ({
64
+ filename: a.filename,
65
+ contentType: a.contentType,
66
+ }))
67
+ }
68
+ if (this.includeBody) {
69
+ if (message.html !== undefined) record.html = message.html
70
+ if (message.text !== undefined) record.text = message.text
71
+ }
72
+ this.logger[this.level]('mail.sent', { mail: record })
73
+ }
74
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `MailgunTransport` — sends mail via the Mailgun HTTP API.
3
+ *
4
+ * POST {endpoint}/v3/{domain}/messages
5
+ * Authorization: Basic base64('api:{apiKey}')
6
+ * Content-Type: multipart/form-data (set automatically by fetch)
7
+ *
8
+ * Mailgun differs from Resend / SendGrid in two ways:
9
+ *
10
+ * 1. Auth is HTTP Basic with a fixed `"api"` username — the API key
11
+ * is the password. We construct the credential internally; the
12
+ * config only collects the key.
13
+ * 2. The request body is `FormData`, not JSON. Recipients are
14
+ * comma-separated strings on `to` / `cc` / `bcc`; custom headers
15
+ * ride as `h:X-Header-Name` form fields; attachments are `Blob`
16
+ * parts on the `attachment` field.
17
+ *
18
+ * Region routing — EU customers override `endpoint` to
19
+ * `https://api.eu.mailgun.net`. The default is the US endpoint.
20
+ *
21
+ * @see https://documentation.mailgun.com/docs/mailgun/api-reference/openapi-final/tag/Messages/
22
+ */
23
+
24
+ import type { Message, MessageAttachment } from '../message.ts'
25
+ import type { Transport } from '../transport.ts'
26
+ import { MailTransportError } from '../transport_error.ts'
27
+ import { isRetryableStatus, mapRecipients, toRfc5322 } from './internal/normalize.ts'
28
+
29
+ export interface MailgunTransportOptions {
30
+ /** Mailgun API key. Pull from env in `config/mail.ts`; never hard-code. */
31
+ apiKey: string
32
+ /**
33
+ * Sending domain registered with Mailgun (e.g. `mg.acme.com`). Used
34
+ * as the path component of the API URL. Distinct from the recipient
35
+ * domain — this is YOUR Mailgun-verified domain.
36
+ */
37
+ domain: string
38
+ /**
39
+ * Base URL of the Mailgun API. Defaults to `https://api.mailgun.net`.
40
+ * Set to `https://api.eu.mailgun.net` for EU-region accounts.
41
+ */
42
+ endpoint?: string
43
+ /** Custom `fetch` for tests. */
44
+ fetch?: typeof fetch
45
+ }
46
+
47
+ export class MailgunTransport implements Transport {
48
+ private readonly apiKey: string
49
+ private readonly domain: string
50
+ private readonly endpoint: string
51
+ private readonly fetchFn: typeof fetch
52
+
53
+ constructor(opts: MailgunTransportOptions) {
54
+ this.apiKey = opts.apiKey
55
+ this.domain = opts.domain
56
+ this.endpoint = (opts.endpoint ?? 'https://api.mailgun.net').replace(/\/$/, '')
57
+ this.fetchFn = opts.fetch ?? fetch
58
+ }
59
+
60
+ async send(message: Message): Promise<void> {
61
+ if (message.from === undefined) {
62
+ throw new MailTransportError('Mailgun requires `from` — none on the message or default.', {
63
+ context: { provider: 'mailgun', retryable: false },
64
+ })
65
+ }
66
+
67
+ const form = new FormData()
68
+ form.append('from', toRfc5322(message.from))
69
+ form.append('to', mapRecipients(message.to, toRfc5322).join(', '))
70
+ form.append('subject', message.subject)
71
+
72
+ if (message.cc !== undefined) {
73
+ form.append('cc', mapRecipients(message.cc, toRfc5322).join(', '))
74
+ }
75
+ if (message.bcc !== undefined) {
76
+ form.append('bcc', mapRecipients(message.bcc, toRfc5322).join(', '))
77
+ }
78
+ if (message.replyTo !== undefined) {
79
+ // Mailgun expects a single Reply-To header value. Multiple
80
+ // reply-tos collapse to a comma-separated list inside the
81
+ // single header — RFC 5322 allows that.
82
+ form.append('h:Reply-To', mapRecipients(message.replyTo, toRfc5322).join(', '))
83
+ }
84
+ if (message.html !== undefined) form.append('html', message.html)
85
+ if (message.text !== undefined) form.append('text', message.text)
86
+
87
+ if (message.headers !== undefined) {
88
+ for (const [name, value] of Object.entries(message.headers)) {
89
+ // `h:` prefix turns the form field into an outbound header.
90
+ form.append(`h:${name}`, value)
91
+ }
92
+ }
93
+
94
+ if (message.attachments !== undefined) {
95
+ for (const a of message.attachments) {
96
+ form.append('attachment', attachmentToBlob(a), a.filename)
97
+ }
98
+ }
99
+
100
+ const credentials = btoa(`api:${this.apiKey}`)
101
+ let response: Response
102
+ try {
103
+ response = await this.fetchFn(`${this.endpoint}/v3/${this.domain}/messages`, {
104
+ method: 'POST',
105
+ headers: { authorization: `Basic ${credentials}` },
106
+ body: form,
107
+ })
108
+ } catch (cause) {
109
+ throw new MailTransportError(
110
+ `Mailgun send failed at the network layer: ${(cause as Error).message ?? String(cause)}`,
111
+ { context: { provider: 'mailgun', retryable: true }, cause },
112
+ )
113
+ }
114
+
115
+ if (response.ok) return
116
+
117
+ let providerError: unknown
118
+ try {
119
+ providerError = await response.json()
120
+ } catch {
121
+ providerError = await response.text().catch(() => undefined)
122
+ }
123
+
124
+ throw new MailTransportError(
125
+ `Mailgun rejected the send (HTTP ${response.status} ${response.statusText}).`,
126
+ {
127
+ context: {
128
+ provider: 'mailgun',
129
+ status: response.status,
130
+ retryable: isRetryableStatus(response.status),
131
+ providerError,
132
+ },
133
+ },
134
+ )
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Mailgun accepts attachments as `Blob` parts on the multipart form —
140
+ * the same byte stream the user supplied, wrapped with the declared
141
+ * MIME type. Unlike Resend / SendGrid we don't base64-encode here;
142
+ * `fetch` handles the multipart boundary and binary framing.
143
+ *
144
+ * For `encoding: 'base64'` string inputs we DO need to decode first —
145
+ * Mailgun expects raw bytes on the wire, not base64 text.
146
+ */
147
+ function attachmentToBlob(a: MessageAttachment): Blob {
148
+ const type = a.contentType ?? 'application/octet-stream'
149
+ if (typeof a.content === 'string') {
150
+ if (a.encoding === 'base64') {
151
+ // Decode base64 → bytes so the wire payload is the actual file.
152
+ const binary = atob(a.content)
153
+ const bytes = new Uint8Array(binary.length)
154
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
155
+ return new Blob([bytes], { type })
156
+ }
157
+ return new Blob([a.content], { type })
158
+ }
159
+ return new Blob([a.content], { type })
160
+ }