@fabrika/email 0.8.1 → 0.9.0-cloudflare.1

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,138 @@
1
+ // What every HTTP provider adapter shares, so an adapter holds only its provider's mapping: the request it sends for a
2
+ // message, the message id of an accepted answer, and the answers it tells apart beyond the HTTP status. The base
3
+ // validates the message against the adapter's capabilities before any I/O and classifies the rest.
4
+
5
+ import { z } from 'zod'
6
+ import type { EmailSenderIdentity } from './config.js'
7
+ import {
8
+ EmailDeliveryError,
9
+ type EmailDeliveryResult,
10
+ type EmailFetch,
11
+ type EmailMessage,
12
+ type EmailSender,
13
+ type EmailSenderCapabilities,
14
+ } from './email.js'
15
+ import { parseTimeout, postToProvider, type ProviderResponse } from './http.js'
16
+ import {
17
+ isValidEmailSenderIdentity,
18
+ isVisibleAscii,
19
+ validateEmailAttachments,
20
+ validateEmailBodies,
21
+ validateEmailBroadcast,
22
+ validateEmailMessage,
23
+ } from './validation.js'
24
+
25
+ /** The options every HTTP adapter takes beside its credentials. */
26
+ export interface HttpEmailSenderOptions {
27
+ readonly from: EmailSenderIdentity
28
+ readonly fetch?: EmailFetch
29
+ readonly timeoutMs?: number
30
+ }
31
+
32
+ export interface HttpEmailRequest {
33
+ readonly url: string
34
+ readonly headers: Readonly<Record<string, string>>
35
+ readonly body: unknown
36
+ }
37
+
38
+ export abstract class HttpEmailSender implements EmailSender {
39
+ abstract readonly capabilities: EmailSenderCapabilities
40
+ protected readonly from: EmailSenderIdentity
41
+ readonly #label: string
42
+ readonly #provider: string
43
+ readonly #fetch: EmailFetch
44
+ readonly #timeoutMs: number
45
+
46
+ /** `label` names the provider in every message, `provider` in every result. An adapter checks its credentials first. */
47
+ protected constructor(names: { readonly label: string; readonly provider: string }, options: HttpEmailSenderOptions) {
48
+ if (!isValidEmailSenderIdentity(options.from)) {
49
+ throw new EmailDeliveryError(`${names.label} sender is invalid`, { code: 'invalid_configuration' })
50
+ }
51
+ this.from = options.from
52
+ this.#label = names.label
53
+ this.#provider = names.provider
54
+ this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
55
+ this.#timeoutMs = parseTimeout(names.label, options.timeoutMs)
56
+ }
57
+
58
+ /** The request for a message that passed validation. */
59
+ protected abstract request(message: EmailMessage): HttpEmailRequest | Promise<HttpEmailRequest>
60
+
61
+ /** The message id of a 2xx answer to `message`; undefined when the answer is not a valid success. May throw a refusal. */
62
+ protected abstract messageId(response: ProviderResponse, message: EmailMessage): string | undefined
63
+
64
+ /** A failed answer to the message the provider tells apart beyond its status; undefined leaves it to `refusalByStatus`. */
65
+ protected refusal(_response: ProviderResponse, _message: EmailMessage): EmailDeliveryError | undefined {
66
+ return undefined
67
+ }
68
+
69
+ async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
70
+ validateEmailMessage(message)
71
+ if (message.unsubscribeUrl !== undefined && this.capabilities.listUnsubscribe !== true) {
72
+ throw new EmailDeliveryError(`${this.#label} cannot write the List-Unsubscribe headers`, { code: 'invalid_message' })
73
+ }
74
+ validateEmailAttachments(message, this.capabilities, this.#label)
75
+ validateEmailBodies(message, this.capabilities, this.#label)
76
+ validateEmailBroadcast(message, this.capabilities, this.#label)
77
+
78
+ const request = await this.request(message)
79
+ const response = await postToProvider({ label: this.#label, fetch: this.#fetch, ...request, signal, timeoutMs: this.#timeoutMs })
80
+ if (!response.ok) throw this.refusal(response, message) ?? refusalByStatus(this.#label, response.status)
81
+
82
+ const messageId = this.messageId(response, message)
83
+ if (messageId === undefined || messageId === '') {
84
+ throw new EmailDeliveryError(`${this.#label} returned an invalid success response`, {
85
+ code: 'malformed_response',
86
+ httpStatus: response.status,
87
+ })
88
+ }
89
+ return { status: 'accepted', provider: this.#provider, messageId }
90
+ }
91
+ }
92
+
93
+ /** The classification of a failed answer by its status alone, for every answer an adapter does not tell apart. */
94
+ export function refusalByStatus(label: string, status: number): EmailDeliveryError {
95
+ if (status === 401 || status === 403) {
96
+ return new EmailDeliveryError(`${label} refused the credentials (${status})`, { code: 'provider_unauthorized', httpStatus: status })
97
+ }
98
+ if (status === 429) return new EmailDeliveryError(`${label} rate limit exceeded`, { code: 'rate_limited', httpStatus: status })
99
+ if (status >= 500) return new EmailDeliveryError(`${label} is unavailable`, { code: 'provider_unavailable', httpStatus: status })
100
+ return new EmailDeliveryError(`${label} rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
101
+ }
102
+
103
+ /** RFC 5322 specials a display name may still hold after validation; any of them makes the name a quoted string. */
104
+ const DISPLAY_NAME_SPECIALS = /[()[\]:;@,.]/
105
+
106
+ /** `Name <address>` for an API that takes the sender as one header value; validation keeps the name quotable. */
107
+ export function formatMailbox(address: string, name: string | undefined): string {
108
+ if (name === undefined) return address
109
+ return DISPLAY_NAME_SPECIALS.test(name) ? `"${name}" <${address}>` : `${name} <${address}>`
110
+ }
111
+
112
+ /** An API key or token: non-empty visible ASCII, so it fits a header unchanged. */
113
+ export function isCredential(value: string): boolean {
114
+ return value.length > 0 && isVisibleAscii(value)
115
+ }
116
+
117
+ /** The name of a provider's stream or route: 1..64 letters, digits, `_` and `-`. */
118
+ export function isChannelName(value: string): boolean {
119
+ return /^[A-Za-z0-9_-]{1,64}$/.test(value)
120
+ }
121
+
122
+ /** A provider variable that holds a credential. */
123
+ export const credentialVariable = z.string().refine(isCredential)
124
+
125
+ /** An optional provider variable: empty is absent, and a present value must pass `valid`. */
126
+ export function optionalVariable(valid: (value: string) => boolean) {
127
+ return z.string().optional().transform((value) => (value === '' ? undefined : value)).refine((value) => value === undefined || valid(value))
128
+ }
129
+
130
+ /**
131
+ * The message's key as a provider that takes at most `maxLength` characters sends it. A longer key is sent as its
132
+ * SHA-256, which keeps one key one key: a retry sends the same digest, another message another.
133
+ */
134
+ export async function providerIdempotencyKey(key: string, maxLength: number): Promise<string> {
135
+ if (key.length <= maxLength) return key
136
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key)))
137
+ return `sha256:${Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')}`
138
+ }
package/src/index.ts CHANGED
@@ -36,5 +36,7 @@ export {
36
36
  MAX_EMAIL_SUBJECT_LENGTH,
37
37
  MAX_UNSUBSCRIBE_URL_LENGTH,
38
38
  validateEmailAttachments,
39
+ validateEmailBodies,
40
+ validateEmailBroadcast,
39
41
  validateEmailMessage,
40
42
  } from './validation.js'
@@ -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,40 @@
1
- import type { EmailProviderRegistry } from './config.js'
1
+ import { ahaSendEmailProvider } from './ahasend.js'
2
+ import { cloudflareEmailProvider } from './cloudflare.js'
3
+ import type { EmailProviderRegistry, EmailProviderVariable } from './config.js'
4
+ import { lettermintEmailProvider } from './lettermint.js'
5
+ import { postmarkEmailProvider } from './postmark.js'
2
6
  import { resendEmailProvider } from './resend.js'
3
7
  import { smartEmailingEmailProvider } from './smartemailing.js'
4
8
 
5
- /** The providers `FABRIKA_EMAIL_PROVIDER` can name in a stock installation. Memory and Directory are composition inputs. */
9
+ /**
10
+ * The providers `FABRIKA_EMAIL_PROVIDER` can name in a stock installation, sorted by name: the order every help text
11
+ * and refusal lists them in. Memory and Directory are composition inputs.
12
+ */
6
13
  export const builtinEmailProviders = {
14
+ ahasend: ahaSendEmailProvider,
15
+ cloudflare: cloudflareEmailProvider,
16
+ lettermint: lettermintEmailProvider,
17
+ postmark: postmarkEmailProvider,
7
18
  resend: resendEmailProvider,
8
19
  smartemailing: smartEmailingEmailProvider,
9
20
  } satisfies EmailProviderRegistry
21
+
22
+ /** Every built-in provider's own variables, in registry order: what the installers write and the workflow passes on. */
23
+ export const builtinEmailProviderVariables: readonly EmailProviderVariable[] = Object.values(builtinEmailProviders).flatMap((provider) =>
24
+ provider.variables
25
+ )
26
+
27
+ /**
28
+ * The first framework release whose registry holds each built-in provider. An application on an older release builds
29
+ * its sender from its own registry, so it cannot be handed one it lacks; Control refuses that deploy. `null`: every
30
+ * release that sends through the installation (`mailer: 'installation'`, from 0.5.0) holds it. A new provider names
31
+ * the release after the newest one published without it.
32
+ */
33
+ export const builtinEmailProviderSince = {
34
+ ahasend: '0.8.2',
35
+ cloudflare: '0.9.0',
36
+ lettermint: '0.8.2',
37
+ postmark: '0.8.2',
38
+ resend: null,
39
+ smartemailing: null,
40
+ } as const satisfies Record<keyof typeof builtinEmailProviders, string | null>