@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,149 @@
1
+ /**
2
+ * `ResendTransport` — sends mail via the Resend HTTP API.
3
+ *
4
+ * POST {endpoint}/emails
5
+ * Authorization: Bearer {apiKey}
6
+ * Content-Type: application/json
7
+ *
8
+ * { from, to, subject, html?, text?, cc?, bcc?, reply_to?,
9
+ * headers?, attachments?: [{ filename, content (base64) }] }
10
+ *
11
+ * Resend accepts recipients as either `"Name <email>"` strings or bare
12
+ * emails — this transport normalises to the RFC 5322 form so display
13
+ * names always render.
14
+ *
15
+ * Failure model: any non-2xx response throws `MailTransportError`.
16
+ * The `context` payload carries `provider`, `status`, `retryable`
17
+ * (heuristic), and the parsed provider error body when present — the
18
+ * Worker's `failed()` hook can log it as-is.
19
+ *
20
+ * Networking: a transient `fetch` rejection (DNS, TCP reset, TLS
21
+ * timeout) wraps as `MailTransportError` with `context.retryable: true`
22
+ * — the underlying `Error.cause` carries the original.
23
+ *
24
+ * @see https://resend.com/docs/api-reference/emails/send-email
25
+ */
26
+
27
+ import type { Message } from '../message.ts'
28
+ import type { Transport } from '../transport.ts'
29
+ import { MailTransportError } from '../transport_error.ts'
30
+ import {
31
+ attachmentToBase64,
32
+ isRetryableStatus,
33
+ mapRecipients,
34
+ toRfc5322,
35
+ } from './internal/normalize.ts'
36
+
37
+ export interface ResendTransportOptions {
38
+ /** Resend API key (`re_…`). Read from env / config, never hard-coded. */
39
+ apiKey: string
40
+ /**
41
+ * Base URL of the Resend API. Defaults to `https://api.resend.com`.
42
+ * Override for self-hosted / regional / mocked endpoints.
43
+ */
44
+ endpoint?: string
45
+ /**
46
+ * Custom `fetch` for tests. Defaults to the global `fetch`. The
47
+ * transport doesn't validate its return shape beyond `.ok` + `.json()`
48
+ * — pass a plain stub.
49
+ */
50
+ fetch?: typeof fetch
51
+ }
52
+
53
+ interface ResendRequestBody {
54
+ from: string
55
+ to: string[]
56
+ subject: string
57
+ html?: string
58
+ text?: string
59
+ cc?: string[]
60
+ bcc?: string[]
61
+ reply_to?: string | string[]
62
+ headers?: Record<string, string>
63
+ attachments?: Array<{ filename: string; content: string; content_type?: string }>
64
+ }
65
+
66
+ export class ResendTransport implements Transport {
67
+ private readonly apiKey: string
68
+ private readonly endpoint: string
69
+ private readonly fetchFn: typeof fetch
70
+
71
+ constructor(opts: ResendTransportOptions) {
72
+ this.apiKey = opts.apiKey
73
+ this.endpoint = (opts.endpoint ?? 'https://api.resend.com').replace(/\/$/, '')
74
+ this.fetchFn = opts.fetch ?? fetch
75
+ }
76
+
77
+ async send(message: Message): Promise<void> {
78
+ if (message.from === undefined) {
79
+ throw new MailTransportError('Resend requires `from` — none on the message or default.', {
80
+ context: { provider: 'resend', retryable: false },
81
+ })
82
+ }
83
+
84
+ const body: ResendRequestBody = {
85
+ from: toRfc5322(message.from),
86
+ to: mapRecipients(message.to, toRfc5322),
87
+ subject: message.subject,
88
+ }
89
+ if (message.html !== undefined) body.html = message.html
90
+ if (message.text !== undefined) body.text = message.text
91
+ if (message.cc !== undefined) body.cc = mapRecipients(message.cc, toRfc5322)
92
+ if (message.bcc !== undefined) body.bcc = mapRecipients(message.bcc, toRfc5322)
93
+ if (message.replyTo !== undefined) {
94
+ const list = mapRecipients(message.replyTo, toRfc5322)
95
+ const [first] = list
96
+ body.reply_to = list.length === 1 && first !== undefined ? first : list
97
+ }
98
+ if (message.headers !== undefined) body.headers = message.headers
99
+ if (message.attachments !== undefined && message.attachments.length > 0) {
100
+ body.attachments = message.attachments.map((a) => {
101
+ const out: { filename: string; content: string; content_type?: string } = {
102
+ filename: a.filename,
103
+ content: attachmentToBase64(a),
104
+ }
105
+ if (a.contentType !== undefined) out.content_type = a.contentType
106
+ return out
107
+ })
108
+ }
109
+
110
+ let response: Response
111
+ try {
112
+ response = await this.fetchFn(`${this.endpoint}/emails`, {
113
+ method: 'POST',
114
+ headers: {
115
+ authorization: `Bearer ${this.apiKey}`,
116
+ 'content-type': 'application/json',
117
+ },
118
+ body: JSON.stringify(body),
119
+ })
120
+ } catch (cause) {
121
+ // Network-level failure — no HTTP response. Treat as retryable.
122
+ throw new MailTransportError(
123
+ `Resend send failed at the network layer: ${(cause as Error).message ?? String(cause)}`,
124
+ { context: { provider: 'resend', retryable: true }, cause },
125
+ )
126
+ }
127
+
128
+ if (response.ok) return
129
+
130
+ let providerError: unknown
131
+ try {
132
+ providerError = await response.json()
133
+ } catch {
134
+ providerError = await response.text().catch(() => undefined)
135
+ }
136
+
137
+ throw new MailTransportError(
138
+ `Resend rejected the send (HTTP ${response.status} ${response.statusText}).`,
139
+ {
140
+ context: {
141
+ provider: 'resend',
142
+ status: response.status,
143
+ retryable: isRetryableStatus(response.status),
144
+ providerError,
145
+ },
146
+ },
147
+ )
148
+ }
149
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `SendGridTransport` — sends mail via the SendGrid v3 API.
3
+ *
4
+ * POST {endpoint}/v3/mail/send
5
+ * Authorization: Bearer {apiKey}
6
+ * Content-Type: application/json
7
+ *
8
+ * {
9
+ * personalizations: [{ to, cc?, bcc?, subject?, headers? }],
10
+ * from, reply_to?,
11
+ * subject,
12
+ * content: [{ type: 'text/plain', value }, { type: 'text/html', value }],
13
+ * attachments?: [{ content (base64), filename, type, disposition }],
14
+ * headers?,
15
+ * }
16
+ *
17
+ * SendGrid wants structured recipients (`{ "email": "...", "name": "..." }`)
18
+ * and a multi-part `content` array. The transport normalises both.
19
+ *
20
+ * SendGrid returns `202 Accepted` on success (no body). Any other
21
+ * status throws `MailTransportError` with `provider: 'sendgrid'` and
22
+ * the parsed error body when present.
23
+ *
24
+ * @see https://docs.sendgrid.com/api-reference/mail-send/mail-send
25
+ */
26
+
27
+ import type { MailAddress, Message } from '../message.ts'
28
+ import type { Transport } from '../transport.ts'
29
+ import { MailTransportError } from '../transport_error.ts'
30
+ import {
31
+ attachmentToBase64,
32
+ isRetryableStatus,
33
+ mapRecipients,
34
+ toStructured,
35
+ } from './internal/normalize.ts'
36
+
37
+ export interface SendGridTransportOptions {
38
+ /** SendGrid API key (`SG.…`). Read from env / config, never hard-coded. */
39
+ apiKey: string
40
+ /**
41
+ * Base URL of the SendGrid API. Defaults to `https://api.sendgrid.com`.
42
+ * Override for regional / mocked endpoints.
43
+ */
44
+ endpoint?: string
45
+ /** Custom `fetch` for tests. */
46
+ fetch?: typeof fetch
47
+ }
48
+
49
+ interface SendGridContent {
50
+ type: 'text/plain' | 'text/html'
51
+ value: string
52
+ }
53
+
54
+ interface SendGridPersonalization {
55
+ to: MailAddress[]
56
+ cc?: MailAddress[]
57
+ bcc?: MailAddress[]
58
+ }
59
+
60
+ interface SendGridAttachment {
61
+ content: string
62
+ filename: string
63
+ type?: string
64
+ disposition: 'attachment'
65
+ }
66
+
67
+ interface SendGridRequestBody {
68
+ personalizations: SendGridPersonalization[]
69
+ from: MailAddress
70
+ reply_to?: MailAddress
71
+ subject: string
72
+ content: SendGridContent[]
73
+ attachments?: SendGridAttachment[]
74
+ headers?: Record<string, string>
75
+ }
76
+
77
+ export class SendGridTransport implements Transport {
78
+ private readonly apiKey: string
79
+ private readonly endpoint: string
80
+ private readonly fetchFn: typeof fetch
81
+
82
+ constructor(opts: SendGridTransportOptions) {
83
+ this.apiKey = opts.apiKey
84
+ this.endpoint = (opts.endpoint ?? 'https://api.sendgrid.com').replace(/\/$/, '')
85
+ this.fetchFn = opts.fetch ?? fetch
86
+ }
87
+
88
+ async send(message: Message): Promise<void> {
89
+ if (message.from === undefined) {
90
+ throw new MailTransportError('SendGrid requires `from` — none on the message or default.', {
91
+ context: { provider: 'sendgrid', retryable: false },
92
+ })
93
+ }
94
+
95
+ // SendGrid requires at least one content entry. The order matters —
96
+ // text/plain before text/html, per the v3 docs.
97
+ const content: SendGridContent[] = []
98
+ if (message.text !== undefined) content.push({ type: 'text/plain', value: message.text })
99
+ if (message.html !== undefined) content.push({ type: 'text/html', value: message.html })
100
+ if (content.length === 0) {
101
+ throw new MailTransportError(
102
+ 'SendGrid: Message must include at least one of `html` or `text`.',
103
+ { context: { provider: 'sendgrid', retryable: false } },
104
+ )
105
+ }
106
+
107
+ const personalization: SendGridPersonalization = {
108
+ to: mapRecipients(message.to, toStructured),
109
+ }
110
+ if (message.cc !== undefined) personalization.cc = mapRecipients(message.cc, toStructured)
111
+ if (message.bcc !== undefined) personalization.bcc = mapRecipients(message.bcc, toStructured)
112
+
113
+ const body: SendGridRequestBody = {
114
+ personalizations: [personalization],
115
+ from: toStructured(message.from),
116
+ subject: message.subject,
117
+ content,
118
+ }
119
+ if (message.replyTo !== undefined) {
120
+ // SendGrid v3 single reply_to. If the caller passes a list, take
121
+ // the first — the rest are best-effort dropped (multi reply-to
122
+ // goes through `reply_to_list`, a newer field; not modelled here
123
+ // until a real user needs it).
124
+ const list = mapRecipients(message.replyTo, toStructured)
125
+ if (list[0] !== undefined) body.reply_to = list[0]
126
+ }
127
+ if (message.headers !== undefined) body.headers = message.headers
128
+ if (message.attachments !== undefined && message.attachments.length > 0) {
129
+ body.attachments = message.attachments.map((a) => {
130
+ const out: SendGridAttachment = {
131
+ content: attachmentToBase64(a),
132
+ filename: a.filename,
133
+ disposition: 'attachment',
134
+ }
135
+ if (a.contentType !== undefined) out.type = a.contentType
136
+ return out
137
+ })
138
+ }
139
+
140
+ let response: Response
141
+ try {
142
+ response = await this.fetchFn(`${this.endpoint}/v3/mail/send`, {
143
+ method: 'POST',
144
+ headers: {
145
+ authorization: `Bearer ${this.apiKey}`,
146
+ 'content-type': 'application/json',
147
+ },
148
+ body: JSON.stringify(body),
149
+ })
150
+ } catch (cause) {
151
+ throw new MailTransportError(
152
+ `SendGrid send failed at the network layer: ${(cause as Error).message ?? String(cause)}`,
153
+ { context: { provider: 'sendgrid', retryable: true }, cause },
154
+ )
155
+ }
156
+
157
+ // SendGrid returns 202 Accepted on success. Anything else is a failure.
158
+ if (response.ok) return
159
+
160
+ let providerError: unknown
161
+ try {
162
+ providerError = await response.json()
163
+ } catch {
164
+ providerError = await response.text().catch(() => undefined)
165
+ }
166
+
167
+ throw new MailTransportError(
168
+ `SendGrid rejected the send (HTTP ${response.status} ${response.statusText}).`,
169
+ {
170
+ context: {
171
+ provider: 'sendgrid',
172
+ status: response.status,
173
+ retryable: isRetryableStatus(response.status),
174
+ providerError,
175
+ },
176
+ },
177
+ )
178
+ }
179
+ }