@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.
- package/README.md +112 -0
- package/package.json +29 -0
- package/src/inbound/loop_guard.ts +18 -0
- package/src/inbound/mailgun_parser.ts +218 -0
- package/src/inbound/postmark_parser.ts +159 -0
- package/src/inbound/types.ts +82 -0
- package/src/inbound_error.ts +22 -0
- package/src/index.ts +56 -0
- package/src/mail_manager.ts +322 -0
- package/src/mail_provider.ts +58 -0
- package/src/mailable.ts +107 -0
- package/src/message.ts +72 -0
- package/src/transport.ts +34 -0
- package/src/transport_error.ts +31 -0
- package/src/transports/alibaba_transport.ts +273 -0
- package/src/transports/array_transport.ts +36 -0
- package/src/transports/internal/normalize.ts +92 -0
- package/src/transports/log_transport.ts +74 -0
- package/src/transports/mailgun_transport.ts +160 -0
- package/src/transports/resend_transport.ts +149 -0
- package/src/transports/sendgrid_transport.ts +179 -0
|
@@ -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
|
+
}
|