@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,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
|
+
}
|