@strav/mail 1.0.0-alpha.36 → 1.0.0-alpha.39
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/package.json +3 -3
- package/src/index.ts +8 -2
- package/src/mail_manager.ts +36 -1
- package/src/message.ts +16 -0
- package/src/transports/mailgun_transport.ts +10 -1
- package/src/transports/postmark_transport.ts +188 -0
- package/src/transports/resend_transport.ts +18 -2
- package/src/transports/sendgrid_transport.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/mail",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.39",
|
|
4
4
|
"description": "Strav signal layer — mail (core + array/log transports + Mailable + queue-dispatch); notifications + SSE + broadcast follow in later slices",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@strav/kernel": "1.0.0-alpha.
|
|
23
|
-
"@strav/queue": "1.0.0-alpha.
|
|
22
|
+
"@strav/kernel": "1.0.0-alpha.39",
|
|
23
|
+
"@strav/queue": "1.0.0-alpha.39"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"@types/bun": ">=1.3.14"
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// - The `Message` shape + `MailRecipient` / `MailAddress` /
|
|
5
5
|
// `MessageAttachment`.
|
|
6
6
|
// - The `Transport` driver contract.
|
|
7
|
-
// -
|
|
8
|
-
//
|
|
7
|
+
// - Transports: `ArrayTransport` (in-memory recorder for tests),
|
|
8
|
+
// `LogTransport` (writes to a `Logger` channel — local-dev),
|
|
9
|
+
// `ResendTransport`, `SendGridTransport`, `MailgunTransport`,
|
|
10
|
+
// `PostmarkTransport`, `AlibabaDmTransport`.
|
|
9
11
|
// - `MailManager` — multi-transport orchestration with `via(name?)`,
|
|
10
12
|
// default-`from` substitution, lazy/cached transport construction,
|
|
11
13
|
// and a Mailable-aware `send(MailableClass, payload)` overload.
|
|
@@ -49,6 +51,10 @@ export {
|
|
|
49
51
|
export { ArrayTransport } from './transports/array_transport.ts'
|
|
50
52
|
export { LogTransport, type LogTransportOptions } from './transports/log_transport.ts'
|
|
51
53
|
export { MailgunTransport, type MailgunTransportOptions } from './transports/mailgun_transport.ts'
|
|
54
|
+
export {
|
|
55
|
+
PostmarkTransport,
|
|
56
|
+
type PostmarkTransportOptions,
|
|
57
|
+
} from './transports/postmark_transport.ts'
|
|
52
58
|
export { ResendTransport, type ResendTransportOptions } from './transports/resend_transport.ts'
|
|
53
59
|
export {
|
|
54
60
|
SendGridTransport,
|
package/src/mail_manager.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { AlibabaDmTransport } from './transports/alibaba_transport.ts'
|
|
|
37
37
|
import { ArrayTransport } from './transports/array_transport.ts'
|
|
38
38
|
import { LogTransport } from './transports/log_transport.ts'
|
|
39
39
|
import { MailgunTransport } from './transports/mailgun_transport.ts'
|
|
40
|
+
import { PostmarkTransport } from './transports/postmark_transport.ts'
|
|
40
41
|
import { ResendTransport } from './transports/resend_transport.ts'
|
|
41
42
|
import { SendGridTransport } from './transports/sendgrid_transport.ts'
|
|
42
43
|
|
|
@@ -88,6 +89,20 @@ interface MailgunTransportConfig {
|
|
|
88
89
|
endpoint?: string
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
interface PostmarkTransportConfig {
|
|
93
|
+
driver: 'postmark'
|
|
94
|
+
/** Postmark Server Token (per-server). NOT the Account token. */
|
|
95
|
+
serverToken: string
|
|
96
|
+
/** Override the base URL — defaults to `https://api.postmarkapp.com`. */
|
|
97
|
+
endpoint?: string
|
|
98
|
+
/**
|
|
99
|
+
* Optional `MessageStream` ID. When omitted, Postmark routes through
|
|
100
|
+
* the server's default transactional stream. Set this for broadcast
|
|
101
|
+
* or custom-stream slugs.
|
|
102
|
+
*/
|
|
103
|
+
messageStream?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
interface AlibabaDmTransportConfig {
|
|
92
107
|
driver: 'alibaba'
|
|
93
108
|
/** Alibaba Cloud AccessKey ID. */
|
|
@@ -120,6 +135,7 @@ export type MailTransportConfig =
|
|
|
120
135
|
| ResendTransportConfig
|
|
121
136
|
| SendGridTransportConfig
|
|
122
137
|
| MailgunTransportConfig
|
|
138
|
+
| PostmarkTransportConfig
|
|
123
139
|
| AlibabaDmTransportConfig
|
|
124
140
|
|
|
125
141
|
export interface MailConfig {
|
|
@@ -267,6 +283,12 @@ export class MailManager {
|
|
|
267
283
|
domain: cfg.domain,
|
|
268
284
|
endpoint: cfg.endpoint,
|
|
269
285
|
})
|
|
286
|
+
case 'postmark':
|
|
287
|
+
return new PostmarkTransport({
|
|
288
|
+
serverToken: cfg.serverToken,
|
|
289
|
+
endpoint: cfg.endpoint,
|
|
290
|
+
messageStream: cfg.messageStream,
|
|
291
|
+
})
|
|
270
292
|
case 'alibaba':
|
|
271
293
|
return new AlibabaDmTransport({
|
|
272
294
|
accessKeyId: cfg.accessKeyId,
|
|
@@ -285,7 +307,15 @@ export class MailManager {
|
|
|
285
307
|
`Mail: default transport "${config.default}" is not defined in transports.`,
|
|
286
308
|
)
|
|
287
309
|
}
|
|
288
|
-
const knownDrivers = new Set([
|
|
310
|
+
const knownDrivers = new Set([
|
|
311
|
+
'array',
|
|
312
|
+
'log',
|
|
313
|
+
'resend',
|
|
314
|
+
'sendgrid',
|
|
315
|
+
'mailgun',
|
|
316
|
+
'postmark',
|
|
317
|
+
'alibaba',
|
|
318
|
+
])
|
|
289
319
|
for (const [name, cfg] of Object.entries(config.transports)) {
|
|
290
320
|
if (!knownDrivers.has(cfg.driver)) {
|
|
291
321
|
throw new ConfigError(
|
|
@@ -305,6 +335,11 @@ export class MailManager {
|
|
|
305
335
|
`Mail: transport "${name}" (mailgun) requires a non-empty \`domain\`.`,
|
|
306
336
|
)
|
|
307
337
|
}
|
|
338
|
+
if (cfg.driver === 'postmark' && !cfg.serverToken) {
|
|
339
|
+
throw new ConfigError(
|
|
340
|
+
`Mail: transport "${name}" (postmark) requires a non-empty \`serverToken\`.`,
|
|
341
|
+
)
|
|
342
|
+
}
|
|
308
343
|
if (cfg.driver === 'alibaba') {
|
|
309
344
|
if (!cfg.accessKeyId || !cfg.accessKeySecret) {
|
|
310
345
|
throw new ConfigError(
|
package/src/message.ts
CHANGED
|
@@ -40,6 +40,14 @@ export type MailRecipient = string | MailAddress
|
|
|
40
40
|
* `Uint8Array` for binary data or a UTF-8 `string` for text. When
|
|
41
41
|
* passing a string that's actually a base64-encoded payload, set
|
|
42
42
|
* `encoding: 'base64'` so transports decode it before transmission.
|
|
43
|
+
*
|
|
44
|
+
* Inline images: set `disposition: 'inline'` and assign a `cid` (Content-ID)
|
|
45
|
+
* to reference the part from HTML — e.g. `cid: 'logo'` pairs with
|
|
46
|
+
* `<img src="cid:logo">`. Transports map this to their provider's
|
|
47
|
+
* inline-part field (Resend `content_id` + `inline_content`, SendGrid
|
|
48
|
+
* `disposition: "inline"` + `content_id`, Mailgun `inline` part,
|
|
49
|
+
* Postmark `ContentID` with `cid:` prefix). When omitted, the attachment
|
|
50
|
+
* is sent as a regular attachment.
|
|
43
51
|
*/
|
|
44
52
|
export interface MessageAttachment {
|
|
45
53
|
filename: string
|
|
@@ -48,6 +56,14 @@ export interface MessageAttachment {
|
|
|
48
56
|
contentType?: string
|
|
49
57
|
/** How `content` is encoded when it's a string. Defaults to `'utf-8'`. */
|
|
50
58
|
encoding?: 'utf-8' | 'base64'
|
|
59
|
+
/**
|
|
60
|
+
* Content-ID for inline references. Bare token without angle brackets
|
|
61
|
+
* (e.g. `'logo'`, not `'<logo>'`) — transports add provider-specific
|
|
62
|
+
* decoration. Required when `disposition: 'inline'`; ignored otherwise.
|
|
63
|
+
*/
|
|
64
|
+
cid?: string
|
|
65
|
+
/** Defaults to `'attachment'`. Use `'inline'` for images referenced via `cid:` in HTML. */
|
|
66
|
+
disposition?: 'attachment' | 'inline'
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
export interface Message {
|
|
@@ -93,7 +93,16 @@ export class MailgunTransport implements Transport {
|
|
|
93
93
|
|
|
94
94
|
if (message.attachments !== undefined) {
|
|
95
95
|
for (const a of message.attachments) {
|
|
96
|
-
form
|
|
96
|
+
// Mailgun routes inline parts to the `inline` form field instead
|
|
97
|
+
// of `attachment`. The filename becomes the Content-ID — HTML
|
|
98
|
+
// bodies reference it as `<img src="cid:{filename}">` UNLESS an
|
|
99
|
+
// explicit `cid` is supplied, in which case we send the cid as
|
|
100
|
+
// the field filename so it survives as the Content-ID header.
|
|
101
|
+
if (a.disposition === 'inline') {
|
|
102
|
+
form.append('inline', attachmentToBlob(a), a.cid ?? a.filename)
|
|
103
|
+
} else {
|
|
104
|
+
form.append('attachment', attachmentToBlob(a), a.filename)
|
|
105
|
+
}
|
|
97
106
|
}
|
|
98
107
|
}
|
|
99
108
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PostmarkTransport` — sends mail via the Postmark HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* POST {endpoint}/email
|
|
5
|
+
* X-Postmark-Server-Token: {serverToken}
|
|
6
|
+
* Accept: application/json
|
|
7
|
+
* Content-Type: application/json
|
|
8
|
+
*
|
|
9
|
+
* {
|
|
10
|
+
* From, To, Cc?, Bcc?, ReplyTo?,
|
|
11
|
+
* Subject, HtmlBody?, TextBody?,
|
|
12
|
+
* Headers?: [{ Name, Value }],
|
|
13
|
+
* Attachments?: [{ Name, Content (base64), ContentType, ContentID? }],
|
|
14
|
+
* MessageStream?,
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* Postmark uses PascalCase field names, a list-of-`{Name,Value}` headers
|
|
18
|
+
* shape (not a flat map), and recipients as comma-separated RFC 5322
|
|
19
|
+
* strings. Inline images set `ContentID` with a `cid:` prefix — HTML
|
|
20
|
+
* bodies reference them via `<img src="cid:{cid}">`.
|
|
21
|
+
*
|
|
22
|
+
* Streams: Postmark splits transactional vs broadcast into named
|
|
23
|
+
* "message streams". When unset, Postmark routes through the server's
|
|
24
|
+
* default transactional stream. Set `messageStream` on the transport
|
|
25
|
+
* for broadcasts.
|
|
26
|
+
*
|
|
27
|
+
* Failure model: any non-2xx response throws `MailTransportError` with
|
|
28
|
+
* `provider: 'postmark'` and the parsed error body. Postmark returns
|
|
29
|
+
* a numeric `ErrorCode` field in the body — preserved verbatim under
|
|
30
|
+
* `context.providerError` for callers that switch on it.
|
|
31
|
+
*
|
|
32
|
+
* @see https://postmarkapp.com/developer/api/email-api
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { Message } from '../message.ts'
|
|
36
|
+
import type { Transport } from '../transport.ts'
|
|
37
|
+
import { MailTransportError } from '../transport_error.ts'
|
|
38
|
+
import {
|
|
39
|
+
attachmentToBase64,
|
|
40
|
+
isRetryableStatus,
|
|
41
|
+
mapRecipients,
|
|
42
|
+
toRfc5322,
|
|
43
|
+
} from './internal/normalize.ts'
|
|
44
|
+
|
|
45
|
+
export interface PostmarkTransportOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Postmark Server Token (per-server). Pull from env in `config/mail.ts`;
|
|
48
|
+
* never hard-code. NOT the Account token — Account tokens are for the
|
|
49
|
+
* admin API, not for sending.
|
|
50
|
+
*/
|
|
51
|
+
serverToken: string
|
|
52
|
+
/**
|
|
53
|
+
* Base URL of the Postmark API. Defaults to `https://api.postmarkapp.com`.
|
|
54
|
+
* Override for mocked endpoints in tests.
|
|
55
|
+
*/
|
|
56
|
+
endpoint?: string
|
|
57
|
+
/**
|
|
58
|
+
* Optional `MessageStream` ID. When omitted, Postmark routes through
|
|
59
|
+
* the server's default transactional stream. Set this for broadcast
|
|
60
|
+
* streams (e.g. `'broadcast'` or a custom stream slug).
|
|
61
|
+
*/
|
|
62
|
+
messageStream?: string
|
|
63
|
+
/** Custom `fetch` for tests. */
|
|
64
|
+
fetch?: typeof fetch
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface PostmarkAttachment {
|
|
68
|
+
Name: string
|
|
69
|
+
Content: string
|
|
70
|
+
ContentType: string
|
|
71
|
+
ContentID?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface PostmarkHeader {
|
|
75
|
+
Name: string
|
|
76
|
+
Value: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface PostmarkRequestBody {
|
|
80
|
+
From: string
|
|
81
|
+
To: string
|
|
82
|
+
Cc?: string
|
|
83
|
+
Bcc?: string
|
|
84
|
+
ReplyTo?: string
|
|
85
|
+
Subject: string
|
|
86
|
+
HtmlBody?: string
|
|
87
|
+
TextBody?: string
|
|
88
|
+
Headers?: PostmarkHeader[]
|
|
89
|
+
Attachments?: PostmarkAttachment[]
|
|
90
|
+
MessageStream?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class PostmarkTransport implements Transport {
|
|
94
|
+
private readonly serverToken: string
|
|
95
|
+
private readonly endpoint: string
|
|
96
|
+
private readonly messageStream: string | undefined
|
|
97
|
+
private readonly fetchFn: typeof fetch
|
|
98
|
+
|
|
99
|
+
constructor(opts: PostmarkTransportOptions) {
|
|
100
|
+
this.serverToken = opts.serverToken
|
|
101
|
+
this.endpoint = (opts.endpoint ?? 'https://api.postmarkapp.com').replace(/\/$/, '')
|
|
102
|
+
this.messageStream = opts.messageStream
|
|
103
|
+
this.fetchFn = opts.fetch ?? fetch
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async send(message: Message): Promise<void> {
|
|
107
|
+
if (message.from === undefined) {
|
|
108
|
+
throw new MailTransportError('Postmark requires `from` — none on the message or default.', {
|
|
109
|
+
context: { provider: 'postmark', retryable: false },
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const body: PostmarkRequestBody = {
|
|
114
|
+
From: toRfc5322(message.from),
|
|
115
|
+
To: mapRecipients(message.to, toRfc5322).join(', '),
|
|
116
|
+
Subject: message.subject,
|
|
117
|
+
}
|
|
118
|
+
if (message.cc !== undefined) body.Cc = mapRecipients(message.cc, toRfc5322).join(', ')
|
|
119
|
+
if (message.bcc !== undefined) body.Bcc = mapRecipients(message.bcc, toRfc5322).join(', ')
|
|
120
|
+
if (message.replyTo !== undefined) {
|
|
121
|
+
// Postmark accepts multiple Reply-To via a comma-separated string in
|
|
122
|
+
// the single `ReplyTo` field — matches RFC 5322's address-list rules.
|
|
123
|
+
body.ReplyTo = mapRecipients(message.replyTo, toRfc5322).join(', ')
|
|
124
|
+
}
|
|
125
|
+
if (message.html !== undefined) body.HtmlBody = message.html
|
|
126
|
+
if (message.text !== undefined) body.TextBody = message.text
|
|
127
|
+
if (message.headers !== undefined) {
|
|
128
|
+
body.Headers = Object.entries(message.headers).map(([Name, Value]) => ({ Name, Value }))
|
|
129
|
+
}
|
|
130
|
+
if (message.attachments !== undefined && message.attachments.length > 0) {
|
|
131
|
+
body.Attachments = message.attachments.map((a) => {
|
|
132
|
+
const out: PostmarkAttachment = {
|
|
133
|
+
Name: a.filename,
|
|
134
|
+
Content: attachmentToBase64(a),
|
|
135
|
+
// Postmark requires ContentType — fall back to the generic
|
|
136
|
+
// octet-stream when the caller didn't supply one.
|
|
137
|
+
ContentType: a.contentType ?? 'application/octet-stream',
|
|
138
|
+
}
|
|
139
|
+
// Inline parts: Postmark expects `ContentID` with a `cid:` prefix.
|
|
140
|
+
// Sending it as a bare token without the prefix is a common
|
|
141
|
+
// integration mistake — Postmark won't reject it but the inline
|
|
142
|
+
// reference from the HTML body won't resolve.
|
|
143
|
+
if (a.disposition === 'inline' && a.cid !== undefined) out.ContentID = `cid:${a.cid}`
|
|
144
|
+
return out
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
if (this.messageStream !== undefined) body.MessageStream = this.messageStream
|
|
148
|
+
|
|
149
|
+
let response: Response
|
|
150
|
+
try {
|
|
151
|
+
response = await this.fetchFn(`${this.endpoint}/email`, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: {
|
|
154
|
+
accept: 'application/json',
|
|
155
|
+
'content-type': 'application/json',
|
|
156
|
+
'x-postmark-server-token': this.serverToken,
|
|
157
|
+
},
|
|
158
|
+
body: JSON.stringify(body),
|
|
159
|
+
})
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
throw new MailTransportError(
|
|
162
|
+
`Postmark send failed at the network layer: ${(cause as Error).message ?? String(cause)}`,
|
|
163
|
+
{ context: { provider: 'postmark', retryable: true }, cause },
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (response.ok) return
|
|
168
|
+
|
|
169
|
+
let providerError: unknown
|
|
170
|
+
try {
|
|
171
|
+
providerError = await response.json()
|
|
172
|
+
} catch {
|
|
173
|
+
providerError = await response.text().catch(() => undefined)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
throw new MailTransportError(
|
|
177
|
+
`Postmark rejected the send (HTTP ${response.status} ${response.statusText}).`,
|
|
178
|
+
{
|
|
179
|
+
context: {
|
|
180
|
+
provider: 'postmark',
|
|
181
|
+
status: response.status,
|
|
182
|
+
retryable: isRetryableStatus(response.status),
|
|
183
|
+
providerError,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -60,7 +60,12 @@ interface ResendRequestBody {
|
|
|
60
60
|
bcc?: string[]
|
|
61
61
|
reply_to?: string | string[]
|
|
62
62
|
headers?: Record<string, string>
|
|
63
|
-
attachments?: Array<{
|
|
63
|
+
attachments?: Array<{
|
|
64
|
+
filename: string
|
|
65
|
+
content: string
|
|
66
|
+
content_type?: string
|
|
67
|
+
content_id?: string
|
|
68
|
+
}>
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
export class ResendTransport implements Transport {
|
|
@@ -98,11 +103,22 @@ export class ResendTransport implements Transport {
|
|
|
98
103
|
if (message.headers !== undefined) body.headers = message.headers
|
|
99
104
|
if (message.attachments !== undefined && message.attachments.length > 0) {
|
|
100
105
|
body.attachments = message.attachments.map((a) => {
|
|
101
|
-
const out: {
|
|
106
|
+
const out: {
|
|
107
|
+
filename: string
|
|
108
|
+
content: string
|
|
109
|
+
content_type?: string
|
|
110
|
+
content_id?: string
|
|
111
|
+
} = {
|
|
102
112
|
filename: a.filename,
|
|
103
113
|
content: attachmentToBase64(a),
|
|
104
114
|
}
|
|
105
115
|
if (a.contentType !== undefined) out.content_type = a.contentType
|
|
116
|
+
// Resend signals an inline part by populating `content_id` — the
|
|
117
|
+
// HTML body references it via `<img src="cid:{cid}">`. We only
|
|
118
|
+
// emit `content_id` when `disposition: 'inline'` is set, so that
|
|
119
|
+
// a stray `cid` field on a regular attachment doesn't accidentally
|
|
120
|
+
// flip the part to inline.
|
|
121
|
+
if (a.disposition === 'inline' && a.cid !== undefined) out.content_id = a.cid
|
|
106
122
|
return out
|
|
107
123
|
})
|
|
108
124
|
}
|
|
@@ -61,7 +61,8 @@ interface SendGridAttachment {
|
|
|
61
61
|
content: string
|
|
62
62
|
filename: string
|
|
63
63
|
type?: string
|
|
64
|
-
disposition: 'attachment'
|
|
64
|
+
disposition: 'attachment' | 'inline'
|
|
65
|
+
content_id?: string
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
interface SendGridRequestBody {
|
|
@@ -130,9 +131,10 @@ export class SendGridTransport implements Transport {
|
|
|
130
131
|
const out: SendGridAttachment = {
|
|
131
132
|
content: attachmentToBase64(a),
|
|
132
133
|
filename: a.filename,
|
|
133
|
-
disposition: 'attachment',
|
|
134
|
+
disposition: a.disposition ?? 'attachment',
|
|
134
135
|
}
|
|
135
136
|
if (a.contentType !== undefined) out.type = a.contentType
|
|
137
|
+
if (a.disposition === 'inline' && a.cid !== undefined) out.content_id = a.cid
|
|
136
138
|
return out
|
|
137
139
|
})
|
|
138
140
|
}
|