@strav/mail 1.0.0-alpha.36 → 1.0.0-alpha.38

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/mail",
3
- "version": "1.0.0-alpha.36",
3
+ "version": "1.0.0-alpha.38",
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.36",
23
- "@strav/queue": "1.0.0-alpha.36"
22
+ "@strav/kernel": "1.0.0-alpha.38",
23
+ "@strav/queue": "1.0.0-alpha.38"
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
- // - Two transports: `ArrayTransport` (in-memory recorder for tests)
8
- // + `LogTransport` (writes to a `Logger` channel — local-dev).
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,
@@ -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(['array', 'log', 'resend', 'sendgrid', 'mailgun', 'alibaba'])
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.append('attachment', attachmentToBlob(a), a.filename)
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<{ filename: string; content: string; content_type?: string }>
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: { filename: string; content: string; content_type?: string } = {
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
  }