@fabrika/email 0.8.1 → 0.8.2

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,153 @@
1
+ // Lettermint's Sending API (https://lettermint.co/docs/api-reference/sending), built from its OpenAPI document and
2
+ // verified on a live project in October 2026. It keeps an idempotency key for 24 hours, and a 202 means it took the
3
+ // message.
4
+
5
+ import { z } from 'zod'
6
+ import { type EmailProvider, parseProviderVariables } from './config.js'
7
+ import { base64Of } from './content.js'
8
+ import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities, oneClickUnsubscribeHeaders } from './email.js'
9
+ import {
10
+ credentialVariable,
11
+ formatMailbox,
12
+ type HttpEmailRequest,
13
+ HttpEmailSender,
14
+ type HttpEmailSenderOptions,
15
+ isChannelName,
16
+ isCredential,
17
+ optionalVariable,
18
+ providerIdempotencyKey,
19
+ } from './http-sender.js'
20
+ import type { ProviderResponse } from './http.js'
21
+
22
+ const LETTERMINT_SEND_URL = 'https://api.lettermint.co/v1/send'
23
+ /** The slug Lettermint gives a new project's broadcast route. */
24
+ const DEFAULT_BROADCAST_ROUTE = 'broadcast'
25
+ const MAX_IDEMPOTENCY_KEY_LENGTH = 255
26
+ /** `pending` and `scheduled` are documented; the id is what counts, so a new status is no failure. */
27
+ const lettermintSuccessSchema = z.object({ message_id: z.string() })
28
+ /** Lettermint refuses a text or HTML body shorter than this with a 422, counting characters once trimmed. */
29
+ const LETTERMINT_MIN_BODY_LENGTH = 3
30
+ /** A 409 carries only a `message`; the one for a key reused with another body says so in these words. */
31
+ const lettermintErrorSchema = z.object({ message: z.string() })
32
+ const OTHER_PAYLOAD = /payload/i
33
+ /** A structured refusal, whose message names a route the project lacks; read only to say so, never put into the error. */
34
+ const lettermintApiErrorSchema = z.object({ error: z.object({ message: z.string() }) })
35
+ const UNKNOWN_ROUTE = /route/i
36
+ /** Lettermint refuses a request over 25 MB with its attachments base64; 15 MiB of bytes leaves room for the bodies. */
37
+ const LETTERMINT_MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024
38
+
39
+ export interface LettermintEmailSenderOptions extends HttpEmailSenderOptions {
40
+ /** A project API token: it sends through that project's default route. */
41
+ readonly projectToken: string
42
+ /** The slug of the project's broadcast route, which a `broadcast` message goes through; `broadcast` by default. */
43
+ readonly broadcastRoute?: string
44
+ }
45
+
46
+ export class LettermintEmailSender extends HttpEmailSender {
47
+ readonly capabilities: EmailSenderCapabilities = {
48
+ idempotent: true,
49
+ confirmation: 'sync',
50
+ maxBatch: 1,
51
+ listUnsubscribe: true,
52
+ maxAttachmentBytes: LETTERMINT_MAX_ATTACHMENT_BYTES,
53
+ minBodyLength: LETTERMINT_MIN_BODY_LENGTH,
54
+ broadcast: true,
55
+ }
56
+ readonly #projectToken: string
57
+ readonly #broadcastRoute: string
58
+
59
+ constructor(options: LettermintEmailSenderOptions) {
60
+ if (!isCredential(options.projectToken)) {
61
+ throw new EmailDeliveryError('Lettermint project token is invalid', { code: 'invalid_configuration' })
62
+ }
63
+ const broadcastRoute = options.broadcastRoute ?? DEFAULT_BROADCAST_ROUTE
64
+ if (!isChannelName(broadcastRoute)) {
65
+ throw new EmailDeliveryError('Lettermint broadcast route is invalid', { code: 'invalid_configuration' })
66
+ }
67
+ super({ label: 'Lettermint', provider: 'lettermint' }, options)
68
+ this.#projectToken = options.projectToken
69
+ this.#broadcastRoute = broadcastRoute
70
+ }
71
+
72
+ protected async request(message: EmailMessage): Promise<HttpEmailRequest> {
73
+ const attachments = message.attachments ?? []
74
+ return {
75
+ url: LETTERMINT_SEND_URL,
76
+ headers: {
77
+ 'x-lettermint-token': this.#projectToken,
78
+ 'Idempotency-Key': await providerIdempotencyKey(message.idempotencyKey, MAX_IDEMPOTENCY_KEY_LENGTH),
79
+ },
80
+ body: {
81
+ from: formatMailbox(this.from.address, message.fromName ?? this.from.name),
82
+ to: [message.to],
83
+ // Lettermint keeps broadcast mail on infrastructure apart from the transactional route.
84
+ ...(message.broadcast === true ? { route: this.#broadcastRoute } : {}),
85
+ subject: message.subject,
86
+ text: message.text,
87
+ ...(message.html === undefined ? {} : { html: message.html }),
88
+ ...(message.replyTo === undefined ? {} : { reply_to: [message.replyTo] }),
89
+ ...(message.unsubscribeUrl === undefined ? {} : { headers: oneClickUnsubscribeHeaders(message.unsubscribeUrl) }),
90
+ ...(attachments.length === 0
91
+ ? {}
92
+ : {
93
+ attachments: attachments.map((attachment) => ({
94
+ filename: attachment.filename,
95
+ content: base64Of(attachment.content),
96
+ content_type: attachment.contentType,
97
+ })),
98
+ }),
99
+ },
100
+ }
101
+ }
102
+
103
+ protected messageId(response: ProviderResponse): string | undefined {
104
+ return lettermintSuccessSchema.safeParse(response.body).data?.message_id
105
+ }
106
+
107
+ protected override refusal(response: ProviderResponse, message: EmailMessage): EmailDeliveryError | undefined {
108
+ const status = response.status
109
+ // Only a broadcast message names a route; a transactional one takes the project's default, so its 422 is another.
110
+ if (
111
+ status === 422 && message.broadcast === true && UNKNOWN_ROUTE.test(lettermintApiErrorSchema.safeParse(response.body).data?.error.message ?? '')
112
+ ) {
113
+ return new EmailDeliveryError(
114
+ 'Lettermint knows no such route in the project: create its broadcast route, or name it in FABRIKA_EMAIL_LETTERMINT_BROADCAST_ROUTE (422)',
115
+ { code: 'provider_rejected', httpStatus: status },
116
+ )
117
+ }
118
+ if (status !== 409) return undefined
119
+ // The 409 has no code, so its text is read, and only to tell the two apart; it never reaches the error. A wording
120
+ // that no longer names the payload reads as in progress, which a retry cannot turn into a second delivery.
121
+ const text = lettermintErrorSchema.safeParse(response.body).data?.message
122
+ if (text !== undefined && OTHER_PAYLOAD.test(text)) {
123
+ return new EmailDeliveryError('Lettermint already took a different request under this idempotency key', {
124
+ code: 'idempotency_conflict',
125
+ httpStatus: status,
126
+ })
127
+ }
128
+ // Every other 409 is a request under the key still in progress, or one whose outcome is unknown, which Lettermint
129
+ // answers the same way until the key expires. A retry cannot deliver twice, so it is retried.
130
+ return new EmailDeliveryError('A matching Lettermint request is still in progress', { code: 'request_in_progress', httpStatus: status })
131
+ }
132
+ }
133
+
134
+ const lettermintVariablesSchema = z.object({
135
+ FABRIKA_EMAIL_LETTERMINT_PROJECT_TOKEN: credentialVariable,
136
+ FABRIKA_EMAIL_LETTERMINT_BROADCAST_ROUTE: optionalVariable(isChannelName),
137
+ })
138
+
139
+ export const lettermintEmailProvider: EmailProvider = {
140
+ variables: [
141
+ { name: 'FABRIKA_EMAIL_LETTERMINT_PROJECT_TOKEN', secret: true, required: true },
142
+ { name: 'FABRIKA_EMAIL_LETTERMINT_BROADCAST_ROUTE', secret: false, required: false },
143
+ ],
144
+ create({ from, env, fetch }) {
145
+ const variables = parseProviderVariables(lettermintVariablesSchema, env)
146
+ return new LettermintEmailSender({
147
+ projectToken: variables.FABRIKA_EMAIL_LETTERMINT_PROJECT_TOKEN,
148
+ broadcastRoute: variables.FABRIKA_EMAIL_LETTERMINT_BROADCAST_ROUTE,
149
+ from,
150
+ fetch,
151
+ })
152
+ },
153
+ }
package/src/memory.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type EmailSender,
12
12
  type EmailSenderCapabilities,
13
13
  } from './email.js'
14
- import { validateEmailAttachments, validateEmailMessage } from './validation.js'
14
+ import { validateEmailAttachments, validateEmailBroadcast, validateEmailMessage } from './validation.js'
15
15
 
16
16
  export interface MemoryEmail {
17
17
  readonly message: EmailMessage
@@ -82,6 +82,7 @@ export class MemoryEmailSender implements EmailSender {
82
82
  maxBatch: 1,
83
83
  listUnsubscribe: true,
84
84
  maxAttachmentBytes: MEMORY_MAX_ATTACHMENT_BYTES,
85
+ broadcast: true,
85
86
  ...options.capabilities,
86
87
  }
87
88
  if (!Number.isSafeInteger(this.capabilities.maxBatch) || this.capabilities.maxBatch < 1) {
@@ -138,6 +139,7 @@ export class MemoryEmailSender implements EmailSender {
138
139
  throw new EmailDeliveryError('The memory sender writes no List-Unsubscribe header', { code: 'invalid_message' })
139
140
  }
140
141
  validateEmailAttachments(message, this.capabilities, 'The memory sender')
142
+ validateEmailBroadcast(message, this.capabilities, 'The memory sender')
141
143
  if (signal.aborted) throw failure('aborted')
142
144
  const handoff: MutableHandoff = { message, at: this.#now(), taken: false, answer: 'network_error' }
143
145
  this.#handoffs.push(handoff)
@@ -17,6 +17,8 @@ export const PLATFORM_MAIL_CAPABILITIES: EmailSenderCapabilities = {
17
17
  maxBatch: 1,
18
18
  listUnsubscribe: true,
19
19
  maxAttachmentBytes: 10 * 1024 * 1024,
20
+ // Control refuses one at once when its own transport sends none, and dispatches it on the provider's channel.
21
+ broadcast: true,
20
22
  }
21
23
 
22
24
  const LABEL = 'Platform mail'
@@ -38,6 +40,8 @@ interface PlatformMailRequest {
38
40
  readonly replyTo?: string
39
41
  readonly fromName?: string
40
42
  readonly unsubscribeUrl?: string
43
+ /** Present only when true, so a transactional message encodes, and fingerprints, as it did before the field. */
44
+ readonly broadcast?: true
41
45
  readonly attachments?: readonly PlatformMailAttachment[]
42
46
  }
43
47
 
@@ -52,6 +56,7 @@ function platformMailRequestOf(message: EmailMessage): PlatformMailRequest {
52
56
  ...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
53
57
  ...(message.fromName === undefined ? {} : { fromName: message.fromName }),
54
58
  ...(message.unsubscribeUrl === undefined ? {} : { unsubscribeUrl: message.unsubscribeUrl }),
59
+ ...(message.broadcast === true ? { broadcast: true } : {}),
55
60
  ...(attachments.length === 0
56
61
  ? {}
57
62
  : {
@@ -118,6 +123,7 @@ const requestSchema = z.object({
118
123
  replyTo: z.string().optional(),
119
124
  fromName: z.string().optional(),
120
125
  unsubscribeUrl: z.string().optional(),
126
+ broadcast: z.literal(true).optional(),
121
127
  attachments: z.array(attachmentSchema).optional(),
122
128
  }).strict()
123
129
 
package/src/platform.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  } from './email.js'
9
9
  import { parseTimeout, postToProvider } from './http.js'
10
10
  import { encodePlatformMailRequest, parsePlatformMailAnswer, PLATFORM_MAIL_CAPABILITIES, PLATFORM_MAIL_SEND_PATH } from './platform-wire.js'
11
- import { isVisibleAscii, validateEmailAttachments, validateEmailMessage } from './validation.js'
11
+ import { isVisibleAscii, validateEmailAttachments, validateEmailBroadcast, validateEmailMessage } from './validation.js'
12
12
 
13
13
  export {
14
14
  encodePlatformMailRequest,
@@ -68,6 +68,7 @@ export class PlatformEmailSender implements EmailSender {
68
68
  async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
69
69
  validateEmailMessage(message)
70
70
  validateEmailAttachments(message, this.capabilities, LABEL)
71
+ validateEmailBroadcast(message, this.capabilities, LABEL)
71
72
 
72
73
  // postToProvider serializes the body itself; the canonical text survives the round trip unchanged.
73
74
  const body: unknown = JSON.parse(encodePlatformMailRequest(message))
@@ -0,0 +1,136 @@
1
+ // Postmark's Email API (https://postmarkapp.com/developer/api/email-api), built from its documentation and verified on a
2
+ // live server in October 2026. It has no idempotency key; a 200 means Postmark took the message.
3
+
4
+ import { z } from 'zod'
5
+ import { type EmailProvider, parseProviderVariables } from './config.js'
6
+ import { base64Of } from './content.js'
7
+ import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities, oneClickUnsubscribeHeaders } from './email.js'
8
+ import {
9
+ credentialVariable,
10
+ formatMailbox,
11
+ type HttpEmailRequest,
12
+ HttpEmailSender,
13
+ type HttpEmailSenderOptions,
14
+ isChannelName,
15
+ isCredential,
16
+ optionalVariable,
17
+ } from './http-sender.js'
18
+ import type { ProviderResponse } from './http.js'
19
+
20
+ const POSTMARK_EMAIL_URL = 'https://api.postmarkapp.com/email'
21
+ /** The ID Postmark suggests for a server's broadcast stream. */
22
+ const DEFAULT_BROADCAST_STREAM = 'broadcast'
23
+ const postmarkSuccessSchema = z.object({ MessageID: z.string(), ErrorCode: z.literal(0) })
24
+ /** A refusal's `ErrorCode` is a documented number; its `Message` is free text and is never read. */
25
+ const postmarkErrorSchema = z.object({ ErrorCode: z.number() })
26
+ /** Out of credits: sending resumes once the account has them again. */
27
+ const OUT_OF_CREDITS = 405
28
+ /** The sender signature is missing (400) or unconfirmed (401), or the account is pending approval (412, 413). */
29
+ const SENDER_NOT_ALLOWED: ReadonlySet<number> = new Set([400, 401, 412, 413])
30
+ /** Postmark counts the decoded bytes of every attachment together and takes fewer than 10 MiB; the bodies are apart. */
31
+ const POSTMARK_MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 - 1
32
+ /** The extensions Postmark refuses an attachment for (ErrorCode 411), from its documentation. */
33
+ const FORBIDDEN_EXTENSIONS: readonly string[] =
34
+ 'vbs exe bin bat chm com cpl crt hlp hta inf ins isp jse lnk mdb pcd pif reg scr sct shs vbe vba wsf wsh wsl msc msi msp mst'.split(' ')
35
+
36
+ export interface PostmarkEmailSenderOptions extends HttpEmailSenderOptions {
37
+ /** A server token: it sends through that server's default transactional stream. */
38
+ readonly serverToken: string
39
+ /** The ID of the server's broadcast stream, which a `broadcast` message goes through; `broadcast` by default. */
40
+ readonly broadcastStream?: string
41
+ }
42
+
43
+ export class PostmarkEmailSender extends HttpEmailSender {
44
+ readonly capabilities: EmailSenderCapabilities = {
45
+ idempotent: false,
46
+ confirmation: 'sync',
47
+ maxBatch: 1,
48
+ listUnsubscribe: true,
49
+ maxAttachmentBytes: POSTMARK_MAX_ATTACHMENT_BYTES,
50
+ refusedAttachmentExtensions: FORBIDDEN_EXTENSIONS,
51
+ broadcast: true,
52
+ }
53
+ readonly #serverToken: string
54
+ readonly #broadcastStream: string
55
+
56
+ constructor(options: PostmarkEmailSenderOptions) {
57
+ if (!isCredential(options.serverToken)) {
58
+ throw new EmailDeliveryError('Postmark server token is invalid', { code: 'invalid_configuration' })
59
+ }
60
+ const broadcastStream = options.broadcastStream ?? DEFAULT_BROADCAST_STREAM
61
+ if (!isChannelName(broadcastStream)) {
62
+ throw new EmailDeliveryError('Postmark broadcast stream is invalid', { code: 'invalid_configuration' })
63
+ }
64
+ super({ label: 'Postmark', provider: 'postmark' }, options)
65
+ this.#serverToken = options.serverToken
66
+ this.#broadcastStream = broadcastStream
67
+ }
68
+
69
+ protected request(message: EmailMessage): HttpEmailRequest {
70
+ const attachments = message.attachments ?? []
71
+ return {
72
+ url: POSTMARK_EMAIL_URL,
73
+ headers: { 'X-Postmark-Server-Token': this.#serverToken, Accept: 'application/json' },
74
+ body: {
75
+ From: formatMailbox(this.from.address, message.fromName ?? this.from.name),
76
+ To: message.to,
77
+ Subject: message.subject,
78
+ TextBody: message.text,
79
+ // Postmark pauses a server whose transactional stream carries promotional mail.
80
+ ...(message.broadcast === true ? { MessageStream: this.#broadcastStream } : {}),
81
+ ...(message.html === undefined ? {} : { HtmlBody: message.html }),
82
+ ...(message.replyTo === undefined ? {} : { ReplyTo: message.replyTo }),
83
+ ...(message.unsubscribeUrl === undefined
84
+ ? {}
85
+ : { Headers: Object.entries(oneClickUnsubscribeHeaders(message.unsubscribeUrl)).map(([Name, Value]) => ({ Name, Value })) }),
86
+ ...(attachments.length === 0
87
+ ? {}
88
+ : {
89
+ Attachments: attachments.map((attachment) => ({
90
+ Name: attachment.filename,
91
+ Content: base64Of(attachment.content),
92
+ ContentType: attachment.contentType,
93
+ })),
94
+ }),
95
+ },
96
+ }
97
+ }
98
+
99
+ protected messageId(response: ProviderResponse): string | undefined {
100
+ return postmarkSuccessSchema.safeParse(response.body).data?.MessageID
101
+ }
102
+
103
+ protected override refusal(response: ProviderResponse): EmailDeliveryError | undefined {
104
+ const status = response.status
105
+ const code = postmarkErrorSchema.safeParse(response.body).data?.ErrorCode
106
+ if (status !== 422 || code === undefined) return undefined
107
+ if (code === OUT_OF_CREDITS) {
108
+ return new EmailDeliveryError('Postmark account is out of credits', { code: 'quota_exceeded', httpStatus: status })
109
+ }
110
+ if (SENDER_NOT_ALLOWED.has(code)) {
111
+ return new EmailDeliveryError(`Postmark refused the sender or the account (${code})`, { code: 'provider_unauthorized', httpStatus: status })
112
+ }
113
+ return undefined
114
+ }
115
+ }
116
+
117
+ const postmarkVariablesSchema = z.object({
118
+ FABRIKA_EMAIL_POSTMARK_SERVER_TOKEN: credentialVariable,
119
+ FABRIKA_EMAIL_POSTMARK_BROADCAST_STREAM: optionalVariable(isChannelName),
120
+ })
121
+
122
+ export const postmarkEmailProvider: EmailProvider = {
123
+ variables: [
124
+ { name: 'FABRIKA_EMAIL_POSTMARK_SERVER_TOKEN', secret: true, required: true },
125
+ { name: 'FABRIKA_EMAIL_POSTMARK_BROADCAST_STREAM', secret: false, required: false },
126
+ ],
127
+ create({ from, env, fetch }) {
128
+ const variables = parseProviderVariables(postmarkVariablesSchema, env)
129
+ return new PostmarkEmailSender({
130
+ serverToken: variables.FABRIKA_EMAIL_POSTMARK_SERVER_TOKEN,
131
+ broadcastStream: variables.FABRIKA_EMAIL_POSTMARK_BROADCAST_STREAM,
132
+ from,
133
+ fetch,
134
+ })
135
+ },
136
+ }
package/src/providers.ts CHANGED
@@ -1,9 +1,37 @@
1
- import type { EmailProviderRegistry } from './config.js'
1
+ import { ahaSendEmailProvider } from './ahasend.js'
2
+ import type { EmailProviderRegistry, EmailProviderVariable } from './config.js'
3
+ import { lettermintEmailProvider } from './lettermint.js'
4
+ import { postmarkEmailProvider } from './postmark.js'
2
5
  import { resendEmailProvider } from './resend.js'
3
6
  import { smartEmailingEmailProvider } from './smartemailing.js'
4
7
 
5
- /** The providers `FABRIKA_EMAIL_PROVIDER` can name in a stock installation. Memory and Directory are composition inputs. */
8
+ /**
9
+ * The providers `FABRIKA_EMAIL_PROVIDER` can name in a stock installation, sorted by name: the order every help text
10
+ * and refusal lists them in. Memory and Directory are composition inputs.
11
+ */
6
12
  export const builtinEmailProviders = {
13
+ ahasend: ahaSendEmailProvider,
14
+ lettermint: lettermintEmailProvider,
15
+ postmark: postmarkEmailProvider,
7
16
  resend: resendEmailProvider,
8
17
  smartemailing: smartEmailingEmailProvider,
9
18
  } satisfies EmailProviderRegistry
19
+
20
+ /** Every built-in provider's own variables, in registry order: what the installers write and the workflow passes on. */
21
+ export const builtinEmailProviderVariables: readonly EmailProviderVariable[] = Object.values(builtinEmailProviders).flatMap((provider) =>
22
+ provider.variables
23
+ )
24
+
25
+ /**
26
+ * The first framework release whose registry holds each built-in provider. An application on an older release builds
27
+ * its sender from its own registry, so it cannot be handed one it lacks; Control refuses that deploy. `null`: every
28
+ * release that sends through the installation (`mailer: 'installation'`, from 0.5.0) holds it. A new provider names
29
+ * the release after the newest one published without it.
30
+ */
31
+ export const builtinEmailProviderSince = {
32
+ ahasend: '0.8.2',
33
+ lettermint: '0.8.2',
34
+ postmark: '0.8.2',
35
+ resend: null,
36
+ smartemailing: null,
37
+ } as const satisfies Record<keyof typeof builtinEmailProviders, string | null>
package/src/resend.ts CHANGED
@@ -1,80 +1,65 @@
1
1
  import { z } from 'zod'
2
- import { type EmailProvider, type EmailSenderIdentity, parseProviderVariables } from './config.js'
2
+ import { type EmailProvider, parseProviderVariables } from './config.js'
3
3
  import { base64Of } from './content.js'
4
+ import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities, oneClickUnsubscribeHeaders } from './email.js'
4
5
  import {
5
- EmailDeliveryError,
6
- type EmailDeliveryResult,
7
- type EmailFetch,
8
- type EmailMessage,
9
- type EmailSender,
10
- type EmailSenderCapabilities,
11
- oneClickUnsubscribeHeaders,
12
- } from './email.js'
13
- import { parseTimeout, postToProvider, type ProviderResponse } from './http.js'
14
- import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailAttachments, validateEmailMessage } from './validation.js'
6
+ credentialVariable,
7
+ formatMailbox,
8
+ type HttpEmailRequest,
9
+ HttpEmailSender,
10
+ type HttpEmailSenderOptions,
11
+ isCredential,
12
+ } from './http-sender.js'
13
+ import type { ProviderResponse } from './http.js'
15
14
 
16
15
  const RESEND_EMAILS_URL = 'https://api.resend.com/emails'
17
16
  const LABEL = 'Resend'
18
- const resendSuccessSchema = z.object({ id: z.string().min(1) })
17
+ const BROADCAST_TAG = { name: 'category', value: 'broadcast' }
18
+ const resendSuccessSchema = z.object({ id: z.string() })
19
19
  const resendErrorSchema = z.object({ name: z.string() })
20
20
  const QUOTA_ERRORS: ReadonlySet<string> = new Set(['daily_quota_exceeded', 'monthly_quota_exceeded'])
21
- /** RFC 5322 specials a display name may still hold after validation; any of them makes the name a quoted string. */
22
- const DISPLAY_NAME_SPECIALS = /[()[\]:;@,.]/
23
21
  /** Resend refuses a message over 40 MB once its attachments are base64; 25 MiB of bytes leaves room for the rest. */
24
22
  const RESEND_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
25
23
 
26
- export interface ResendEmailSenderOptions {
24
+ export interface ResendEmailSenderOptions extends HttpEmailSenderOptions {
27
25
  readonly apiKey: string
28
- readonly from: EmailSenderIdentity
29
- readonly fetch?: EmailFetch
30
- readonly timeoutMs?: number
31
26
  }
32
27
 
33
- export class ResendEmailSender implements EmailSender {
28
+ export class ResendEmailSender extends HttpEmailSender {
34
29
  readonly capabilities: EmailSenderCapabilities = {
35
30
  idempotent: true,
36
31
  confirmation: 'sync',
37
32
  maxBatch: 1,
38
33
  listUnsubscribe: true,
39
34
  maxAttachmentBytes: RESEND_MAX_ATTACHMENT_BYTES,
35
+ // Resend takes one-to-one marketing mail through this API, with consent and an unsubscribe; it has no channel apart.
36
+ broadcast: true,
40
37
  }
41
38
  readonly #apiKey: string
42
- readonly #from: EmailSenderIdentity
43
- readonly #fetch: EmailFetch
44
- readonly #timeoutMs: number
45
39
 
46
40
  constructor(options: ResendEmailSenderOptions) {
47
- if (!validApiKey(options.apiKey)) {
41
+ if (!isCredential(options.apiKey)) {
48
42
  throw new EmailDeliveryError('Resend API key is invalid', { code: 'invalid_configuration' })
49
43
  }
50
- if (!isValidEmailSenderIdentity(options.from)) {
51
- throw new EmailDeliveryError('Resend sender is invalid', { code: 'invalid_configuration' })
52
- }
53
-
44
+ super({ label: LABEL, provider: 'resend' }, options)
54
45
  this.#apiKey = options.apiKey
55
- this.#from = options.from
56
- this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
57
- this.#timeoutMs = parseTimeout(LABEL, options.timeoutMs)
58
46
  }
59
47
 
60
- async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
61
- validateEmailMessage(message)
62
- validateEmailAttachments(message, this.capabilities, LABEL)
48
+ protected request(message: EmailMessage): HttpEmailRequest {
63
49
  const attachments = message.attachments ?? []
64
-
65
- const response = await postToProvider({
66
- label: LABEL,
67
- fetch: this.#fetch,
50
+ return {
68
51
  url: RESEND_EMAILS_URL,
69
52
  headers: { Authorization: `Bearer ${this.#apiKey}`, 'Idempotency-Key': message.idempotencyKey },
70
53
  body: {
71
- from: formatMailbox(this.#from.address, message.fromName ?? this.#from.name),
54
+ from: formatMailbox(this.from.address, message.fromName ?? this.from.name),
72
55
  to: message.to,
73
56
  subject: message.subject,
74
57
  text: message.text,
75
58
  ...(message.html === undefined ? {} : { html: message.html }),
76
59
  ...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
77
60
  ...(message.unsubscribeUrl === undefined ? {} : { headers: oneClickUnsubscribeHeaders(message.unsubscribeUrl) }),
61
+ // Tagged, so the account's logs and webhooks tell broadcast mail from transactional.
62
+ ...(message.broadcast === true ? { tags: [BROADCAST_TAG] } : {}),
78
63
  ...(attachments.length === 0
79
64
  ? {}
80
65
  : {
@@ -85,22 +70,37 @@ export class ResendEmailSender implements EmailSender {
85
70
  })),
86
71
  }),
87
72
  },
88
- signal,
89
- timeoutMs: this.#timeoutMs,
90
- })
73
+ }
74
+ }
91
75
 
92
- if (!response.ok) throw responseError(response)
76
+ protected messageId(response: ProviderResponse): string | undefined {
77
+ return resendSuccessSchema.safeParse(response.body).data?.id
78
+ }
93
79
 
94
- const parsed = resendSuccessSchema.safeParse(response.body)
95
- if (!parsed.success) {
96
- throw new EmailDeliveryError('Resend returned an invalid success response', { code: 'malformed_response', httpStatus: response.status })
80
+ protected override refusal(response: ProviderResponse): EmailDeliveryError | undefined {
81
+ const status = response.status
82
+ const name = resendErrorSchema.safeParse(response.body).data?.name
83
+ if (status === 401 || status === 403) {
84
+ // A 403 is also an unverified sender domain.
85
+ return new EmailDeliveryError(`Resend refused the credentials or the sender (${status})`, { code: 'provider_unauthorized', httpStatus: status })
97
86
  }
98
-
99
- return { status: 'accepted', provider: 'resend', messageId: parsed.data.id }
87
+ if (status === 409 && name === 'concurrent_idempotent_requests') {
88
+ return new EmailDeliveryError('A matching Resend request is still in progress', { code: 'request_in_progress', httpStatus: status })
89
+ }
90
+ if (status === 409 && name === 'invalid_idempotent_request') {
91
+ return new EmailDeliveryError('Resend already accepted a different message under this idempotency key', {
92
+ code: 'idempotency_conflict',
93
+ httpStatus: status,
94
+ })
95
+ }
96
+ if (status === 429 && name !== undefined && QUOTA_ERRORS.has(name)) {
97
+ return new EmailDeliveryError('Resend sending quota exceeded', { code: 'quota_exceeded', httpStatus: status })
98
+ }
99
+ return undefined
100
100
  }
101
101
  }
102
102
 
103
- const resendVariablesSchema = z.object({ FABRIKA_EMAIL_RESEND_API_KEY: z.string().refine(validApiKey) })
103
+ const resendVariablesSchema = z.object({ FABRIKA_EMAIL_RESEND_API_KEY: credentialVariable })
104
104
 
105
105
  export const resendEmailProvider: EmailProvider = {
106
106
  variables: [{ name: 'FABRIKA_EMAIL_RESEND_API_KEY', secret: true, required: true }],
@@ -109,40 +109,3 @@ export const resendEmailProvider: EmailProvider = {
109
109
  return new ResendEmailSender({ apiKey: variables.FABRIKA_EMAIL_RESEND_API_KEY, from, fetch })
110
110
  },
111
111
  }
112
-
113
- function responseError(response: ProviderResponse): EmailDeliveryError {
114
- const status = response.status
115
- const parsed = resendErrorSchema.safeParse(response.body)
116
- const name = parsed.success ? parsed.data.name : undefined
117
- if (status === 401 || status === 403) {
118
- return new EmailDeliveryError(`Resend refused the credentials or the sender (${status})`, { code: 'provider_unauthorized', httpStatus: status })
119
- }
120
- if (status === 409 && name === 'concurrent_idempotent_requests') {
121
- return new EmailDeliveryError('A matching Resend request is still in progress', { code: 'request_in_progress', httpStatus: status })
122
- }
123
- if (status === 409 && name === 'invalid_idempotent_request') {
124
- return new EmailDeliveryError('Resend already accepted a different message under this idempotency key', {
125
- code: 'idempotency_conflict',
126
- httpStatus: status,
127
- })
128
- }
129
- if (status === 429 && name !== undefined && QUOTA_ERRORS.has(name)) {
130
- return new EmailDeliveryError('Resend sending quota exceeded', { code: 'quota_exceeded', httpStatus: status })
131
- }
132
- if (status === 429) {
133
- return new EmailDeliveryError('Resend rate limit exceeded', { code: 'rate_limited', httpStatus: status })
134
- }
135
- if (status >= 500) {
136
- return new EmailDeliveryError('Resend is unavailable', { code: 'provider_unavailable', httpStatus: status })
137
- }
138
- return new EmailDeliveryError(`Resend rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
139
- }
140
-
141
- function formatMailbox(address: string, name: string | undefined): string {
142
- if (name === undefined) return address
143
- return DISPLAY_NAME_SPECIALS.test(name) ? `"${name}" <${address}>` : `${name} <${address}>`
144
- }
145
-
146
- function validApiKey(value: string): boolean {
147
- return value.length > 0 && isVisibleAscii(value)
148
- }