@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.
- package/package.json +16 -1
- package/src/ahasend.ts +172 -0
- package/src/conformance.ts +54 -0
- package/src/content.ts +3 -1
- package/src/directory.ts +4 -1
- package/src/email.ts +12 -0
- package/src/http-sender.ts +138 -0
- package/src/index.ts +2 -0
- package/src/lettermint.ts +153 -0
- package/src/memory.ts +3 -1
- package/src/platform-wire.ts +6 -0
- package/src/platform.ts +2 -1
- package/src/postmark.ts +136 -0
- package/src/providers.ts +30 -2
- package/src/resend.ts +48 -85
- package/src/smartemailing.ts +30 -74
- package/src/validation.ts +31 -3
|
@@ -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)
|
package/src/platform-wire.ts
CHANGED
|
@@ -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))
|
package/src/postmark.ts
ADDED
|
@@ -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
|
|
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
|
-
/**
|
|
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,
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
type
|
|
8
|
-
|
|
9
|
-
type
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from './
|
|
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
|
|
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
|
|
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 (!
|
|
41
|
+
if (!isCredential(options.apiKey)) {
|
|
48
42
|
throw new EmailDeliveryError('Resend API key is invalid', { code: 'invalid_configuration' })
|
|
49
43
|
}
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
91
75
|
|
|
92
|
-
|
|
76
|
+
protected messageId(response: ProviderResponse): string | undefined {
|
|
77
|
+
return resendSuccessSchema.safeParse(response.body).data?.id
|
|
78
|
+
}
|
|
93
79
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
}
|