@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.
- package/package.json +31 -10
- package/src/ahasend.ts +172 -0
- package/src/cloudflare.ts +142 -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 +33 -2
- package/src/resend.ts +48 -85
- package/src/smartemailing.ts +30 -74
- package/src/validation.ts +31 -3
- package/types/src/ahasend.d.ts +26 -0
- package/types/src/cloudflare.d.ts +20 -0
- package/types/src/config.d.ts +48 -0
- package/types/src/conformance.d.ts +59 -0
- package/types/src/content.d.ts +23 -0
- package/types/src/directory.d.ts +84 -0
- package/types/src/email.d.ts +142 -0
- package/types/src/http-sender.d.ts +49 -0
- package/types/src/http.d.ts +20 -0
- package/types/src/index.d.ts +5 -0
- package/types/src/lettermint.d.ts +19 -0
- package/types/src/memory.d.ts +42 -0
- package/types/src/platform-wire.d.ts +63 -0
- package/types/src/platform.d.ts +19 -0
- package/types/src/postmark.d.ts +19 -0
- package/types/src/providers.d.ts +29 -0
- package/types/src/resend.d.ts +16 -0
- package/types/src/smartemailing.d.ts +24 -0
- package/types/src/validation.d.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/email",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-cloudflare.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,47 +12,68 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"src",
|
|
15
|
-
"!src/**/__tests__/**"
|
|
15
|
+
"!src/**/__tests__/**",
|
|
16
|
+
"types"
|
|
16
17
|
],
|
|
17
18
|
"exports": {
|
|
18
19
|
".": {
|
|
19
20
|
"bun": "./src/index.ts",
|
|
20
|
-
"types": "./src/index.ts",
|
|
21
|
+
"types": "./types/src/index.d.ts",
|
|
21
22
|
"default": "./src/index.ts"
|
|
22
23
|
},
|
|
23
24
|
"./resend": {
|
|
24
25
|
"bun": "./src/resend.ts",
|
|
25
|
-
"types": "./src/resend.ts",
|
|
26
|
+
"types": "./types/src/resend.d.ts",
|
|
26
27
|
"default": "./src/resend.ts"
|
|
27
28
|
},
|
|
29
|
+
"./postmark": {
|
|
30
|
+
"bun": "./src/postmark.ts",
|
|
31
|
+
"types": "./types/src/postmark.d.ts",
|
|
32
|
+
"default": "./src/postmark.ts"
|
|
33
|
+
},
|
|
34
|
+
"./ahasend": {
|
|
35
|
+
"bun": "./src/ahasend.ts",
|
|
36
|
+
"types": "./types/src/ahasend.d.ts",
|
|
37
|
+
"default": "./src/ahasend.ts"
|
|
38
|
+
},
|
|
39
|
+
"./cloudflare": {
|
|
40
|
+
"bun": "./src/cloudflare.ts",
|
|
41
|
+
"types": "./types/src/cloudflare.d.ts",
|
|
42
|
+
"default": "./src/cloudflare.ts"
|
|
43
|
+
},
|
|
44
|
+
"./lettermint": {
|
|
45
|
+
"bun": "./src/lettermint.ts",
|
|
46
|
+
"types": "./types/src/lettermint.d.ts",
|
|
47
|
+
"default": "./src/lettermint.ts"
|
|
48
|
+
},
|
|
28
49
|
"./directory": {
|
|
29
50
|
"bun": "./src/directory.ts",
|
|
30
|
-
"types": "./src/directory.ts",
|
|
51
|
+
"types": "./types/src/directory.d.ts",
|
|
31
52
|
"default": "./src/directory.ts"
|
|
32
53
|
},
|
|
33
54
|
"./memory": {
|
|
34
55
|
"bun": "./src/memory.ts",
|
|
35
|
-
"types": "./src/memory.ts",
|
|
56
|
+
"types": "./types/src/memory.d.ts",
|
|
36
57
|
"default": "./src/memory.ts"
|
|
37
58
|
},
|
|
38
59
|
"./smartemailing": {
|
|
39
60
|
"bun": "./src/smartemailing.ts",
|
|
40
|
-
"types": "./src/smartemailing.ts",
|
|
61
|
+
"types": "./types/src/smartemailing.d.ts",
|
|
41
62
|
"default": "./src/smartemailing.ts"
|
|
42
63
|
},
|
|
43
64
|
"./providers": {
|
|
44
65
|
"bun": "./src/providers.ts",
|
|
45
|
-
"types": "./src/providers.ts",
|
|
66
|
+
"types": "./types/src/providers.d.ts",
|
|
46
67
|
"default": "./src/providers.ts"
|
|
47
68
|
},
|
|
48
69
|
"./platform": {
|
|
49
70
|
"bun": "./src/platform.ts",
|
|
50
|
-
"types": "./src/platform.ts",
|
|
71
|
+
"types": "./types/src/platform.d.ts",
|
|
51
72
|
"default": "./src/platform.ts"
|
|
52
73
|
},
|
|
53
74
|
"./conformance": {
|
|
54
75
|
"bun": "./src/conformance.ts",
|
|
55
|
-
"types": "./src/conformance.ts",
|
|
76
|
+
"types": "./types/src/conformance.d.ts",
|
|
56
77
|
"default": "./src/conformance.ts"
|
|
57
78
|
}
|
|
58
79
|
},
|
package/src/ahasend.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// AhaSend's API v2 (https://ahasend.com/docs/api-reference/messages/create-message), built from its documentation and
|
|
2
|
+
// verified on a live account in October 2026. It keeps an idempotency key for 24 hours, and a 202 carries one result
|
|
3
|
+
// per recipient, which can itself be a refusal. It renders a message's content as a template only when the request
|
|
4
|
+
// carries substitutions, which this adapter never sends, so `{{` in a message reaches the recipient as written.
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { type EmailProvider, parseProviderVariables } from './config.js'
|
|
8
|
+
import { base64Of } from './content.js'
|
|
9
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities, oneClickUnsubscribeHeaders } from './email.js'
|
|
10
|
+
import {
|
|
11
|
+
credentialVariable,
|
|
12
|
+
type HttpEmailRequest,
|
|
13
|
+
HttpEmailSender,
|
|
14
|
+
type HttpEmailSenderOptions,
|
|
15
|
+
isCredential,
|
|
16
|
+
optionalVariable,
|
|
17
|
+
providerIdempotencyKey,
|
|
18
|
+
} from './http-sender.js'
|
|
19
|
+
import type { ProviderResponse } from './http.js'
|
|
20
|
+
import { isValidEmailAddress } from './validation.js'
|
|
21
|
+
|
|
22
|
+
const AHASEND_API_URL = 'https://api.ahasend.com/v2/accounts'
|
|
23
|
+
const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/
|
|
24
|
+
/** A refusal carries only a free-text `message`, read here to tell a reused key from another 422 and never put into an error. */
|
|
25
|
+
const ahaSendErrorSchema = z.object({ message: z.string() })
|
|
26
|
+
const KEY_REUSED = /idempotency key/i
|
|
27
|
+
const MAX_IDEMPOTENCY_KEY_LENGTH = 255
|
|
28
|
+
const ahaSendResultSchema = z.object({
|
|
29
|
+
// `queued` and `scheduled` are documented; any status but `error` with an id is taken, so a new one is no failure.
|
|
30
|
+
data: z.tuple([z.object({ status: z.string(), id: z.string().nullable() })]),
|
|
31
|
+
})
|
|
32
|
+
/**
|
|
33
|
+
* AhaSend refuses a message over 20 MiB with its attachments base64 and MIME-wrapped; 12 MiB of bytes leaves room for
|
|
34
|
+
* the bodies, and its body stays under the 30 MB request bound.
|
|
35
|
+
*/
|
|
36
|
+
const AHASEND_MAX_ATTACHMENT_BYTES = 12 * 1024 * 1024
|
|
37
|
+
|
|
38
|
+
export interface AhaSendEmailSenderOptions extends HttpEmailSenderOptions {
|
|
39
|
+
/** An API key v2, scoped to `messages:send:{domain}` at least. */
|
|
40
|
+
readonly apiKey: string
|
|
41
|
+
/** The account the key belongs to: the path of every request. */
|
|
42
|
+
readonly accountId: string
|
|
43
|
+
/**
|
|
44
|
+
* The address a `broadcast` message is sent from, on a domain the account has configured as a Marketing domain:
|
|
45
|
+
* AhaSend's terms allow marketing mail through the API from such a domain only. Without it, a broadcast is refused.
|
|
46
|
+
*/
|
|
47
|
+
readonly broadcastFrom?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A refusal's body is one free-text `message`, so only the status is read. */
|
|
51
|
+
export class AhaSendEmailSender extends HttpEmailSender {
|
|
52
|
+
/** `broadcast` only with `broadcastFrom`. */
|
|
53
|
+
readonly capabilities: EmailSenderCapabilities
|
|
54
|
+
readonly #apiKey: string
|
|
55
|
+
readonly #messagesUrl: string
|
|
56
|
+
readonly #broadcastFrom: string | undefined
|
|
57
|
+
|
|
58
|
+
constructor(options: AhaSendEmailSenderOptions) {
|
|
59
|
+
if (!isCredential(options.apiKey)) {
|
|
60
|
+
throw new EmailDeliveryError('AhaSend API key is invalid', { code: 'invalid_configuration' })
|
|
61
|
+
}
|
|
62
|
+
if (!ACCOUNT_ID_PATTERN.test(options.accountId)) {
|
|
63
|
+
throw new EmailDeliveryError('AhaSend account ID is invalid', { code: 'invalid_configuration' })
|
|
64
|
+
}
|
|
65
|
+
if (options.broadcastFrom !== undefined && !isValidEmailAddress(options.broadcastFrom)) {
|
|
66
|
+
throw new EmailDeliveryError('AhaSend broadcast sender is invalid', { code: 'invalid_configuration' })
|
|
67
|
+
}
|
|
68
|
+
super({ label: 'AhaSend', provider: 'ahasend' }, options)
|
|
69
|
+
this.#apiKey = options.apiKey
|
|
70
|
+
this.#messagesUrl = `${AHASEND_API_URL}/${options.accountId}/messages`
|
|
71
|
+
this.#broadcastFrom = options.broadcastFrom
|
|
72
|
+
this.capabilities = {
|
|
73
|
+
idempotent: true,
|
|
74
|
+
confirmation: 'sync',
|
|
75
|
+
maxBatch: 1,
|
|
76
|
+
listUnsubscribe: true,
|
|
77
|
+
maxAttachmentBytes: AHASEND_MAX_ATTACHMENT_BYTES,
|
|
78
|
+
...(options.broadcastFrom === undefined ? {} : { broadcast: true }),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected async request(message: EmailMessage): Promise<HttpEmailRequest> {
|
|
83
|
+
const senderName = message.fromName ?? this.from.name
|
|
84
|
+
// `send` refused a broadcast message without `broadcastFrom`; never send one from the transactional domain.
|
|
85
|
+
const fromAddress = message.broadcast === true ? this.#broadcastFrom : this.from.address
|
|
86
|
+
if (fromAddress === undefined) throw new EmailDeliveryError('AhaSend sends no broadcast email', { code: 'invalid_message' })
|
|
87
|
+
const attachments = message.attachments ?? []
|
|
88
|
+
return {
|
|
89
|
+
url: this.#messagesUrl,
|
|
90
|
+
headers: {
|
|
91
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
92
|
+
'Idempotency-Key': await providerIdempotencyKey(message.idempotencyKey, MAX_IDEMPOTENCY_KEY_LENGTH),
|
|
93
|
+
},
|
|
94
|
+
body: {
|
|
95
|
+
from: {
|
|
96
|
+
email: fromAddress,
|
|
97
|
+
...(senderName === undefined ? {} : { name: senderName }),
|
|
98
|
+
},
|
|
99
|
+
recipients: [{ email: message.to }],
|
|
100
|
+
subject: message.subject,
|
|
101
|
+
text_content: message.text,
|
|
102
|
+
...(message.html === undefined ? {} : { html_content: message.html }),
|
|
103
|
+
...(message.replyTo === undefined ? {} : { reply_to: { email: message.replyTo } }),
|
|
104
|
+
...(message.unsubscribeUrl === undefined ? {} : { headers: oneClickUnsubscribeHeaders(message.unsubscribeUrl) }),
|
|
105
|
+
...(attachments.length === 0
|
|
106
|
+
? {}
|
|
107
|
+
: {
|
|
108
|
+
attachments: attachments.map((attachment) => ({
|
|
109
|
+
base64: true,
|
|
110
|
+
data: base64Of(attachment.content),
|
|
111
|
+
content_type: attachment.contentType,
|
|
112
|
+
file_name: attachment.filename,
|
|
113
|
+
})),
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
protected messageId(response: ProviderResponse): string | undefined {
|
|
120
|
+
const result = ahaSendResultSchema.safeParse(response.body).data?.data[0]
|
|
121
|
+
if (result === undefined) return undefined
|
|
122
|
+
// The item's `error` is free text about the recipient, so it stays out of the refusal.
|
|
123
|
+
if (result.status === 'error') {
|
|
124
|
+
throw new EmailDeliveryError(`AhaSend refused the recipient (${response.status})`, { code: 'provider_rejected', httpStatus: response.status })
|
|
125
|
+
}
|
|
126
|
+
return result.id ?? undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
protected override refusal(response: ProviderResponse): EmailDeliveryError | undefined {
|
|
130
|
+
const status = response.status
|
|
131
|
+
// Documented for an unknown account; a key of another account answers 401.
|
|
132
|
+
if (status === 404) {
|
|
133
|
+
return new EmailDeliveryError('AhaSend knows no such account (404)', { code: 'provider_unauthorized', httpStatus: status })
|
|
134
|
+
}
|
|
135
|
+
// Live, a concurrent request under the key waited for the first and replayed its answer; 409 is documented.
|
|
136
|
+
if (status === 409) {
|
|
137
|
+
return new EmailDeliveryError('A matching AhaSend request is still in progress', { code: 'request_in_progress', httpStatus: status })
|
|
138
|
+
}
|
|
139
|
+
// A malformed body is a 400; a 422 names the key reused for another request, and any other is a rejection.
|
|
140
|
+
if (status === 422 && KEY_REUSED.test(ahaSendErrorSchema.safeParse(response.body).data?.message ?? '')) {
|
|
141
|
+
return new EmailDeliveryError('AhaSend refused the idempotency key for this request (422)', {
|
|
142
|
+
code: 'idempotency_conflict',
|
|
143
|
+
httpStatus: status,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const ahaSendVariablesSchema = z.object({
|
|
151
|
+
FABRIKA_EMAIL_AHASEND_API_KEY: credentialVariable,
|
|
152
|
+
FABRIKA_EMAIL_AHASEND_ACCOUNT_ID: z.string().regex(ACCOUNT_ID_PATTERN),
|
|
153
|
+
FABRIKA_EMAIL_AHASEND_BROADCAST_FROM: optionalVariable(isValidEmailAddress),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
export const ahaSendEmailProvider: EmailProvider = {
|
|
157
|
+
variables: [
|
|
158
|
+
{ name: 'FABRIKA_EMAIL_AHASEND_API_KEY', secret: true, required: true },
|
|
159
|
+
{ name: 'FABRIKA_EMAIL_AHASEND_ACCOUNT_ID', secret: false, required: true },
|
|
160
|
+
{ name: 'FABRIKA_EMAIL_AHASEND_BROADCAST_FROM', secret: false, required: false },
|
|
161
|
+
],
|
|
162
|
+
create({ from, env, fetch }) {
|
|
163
|
+
const variables = parseProviderVariables(ahaSendVariablesSchema, env)
|
|
164
|
+
return new AhaSendEmailSender({
|
|
165
|
+
apiKey: variables.FABRIKA_EMAIL_AHASEND_API_KEY,
|
|
166
|
+
accountId: variables.FABRIKA_EMAIL_AHASEND_ACCOUNT_ID,
|
|
167
|
+
broadcastFrom: variables.FABRIKA_EMAIL_AHASEND_BROADCAST_FROM,
|
|
168
|
+
from,
|
|
169
|
+
fetch,
|
|
170
|
+
})
|
|
171
|
+
},
|
|
172
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Cloudflare Email Service's Email Sending REST API (https://developers.cloudflare.com/email-service/api/send-emails/rest-api/),
|
|
2
|
+
// built from its documentation in October 2026 and verified only against a stubbed fetch. It has no idempotency key,
|
|
3
|
+
// and Cloudflare takes transactional mail only. The REST API, not the Workers `send_email` binding, so the transport is
|
|
4
|
+
// one set of `FABRIKA_EMAIL_*` on every runtime that sends: a Worker, a Bun process on Zerops, an application.
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { type EmailProvider, parseProviderVariables } from './config.js'
|
|
8
|
+
import { base64Of } from './content.js'
|
|
9
|
+
import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities, oneClickUnsubscribeHeaders } from './email.js'
|
|
10
|
+
import { credentialVariable, type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions, isCredential } from './http-sender.js'
|
|
11
|
+
import type { ProviderResponse } from './http.js'
|
|
12
|
+
|
|
13
|
+
const CLOUDFLARE_ACCOUNTS_URL = 'https://api.cloudflare.com/client/v4/accounts'
|
|
14
|
+
/** A Cloudflare account ID: 32 hexadecimal digits, the path of every request. */
|
|
15
|
+
const ACCOUNT_ID_PATTERN = /^[0-9a-fA-F]{32}$/
|
|
16
|
+
const cloudflareSuccessSchema = z.object({
|
|
17
|
+
success: z.literal(true),
|
|
18
|
+
result: z.object({
|
|
19
|
+
message_id: z.string().optional(),
|
|
20
|
+
delivered: z.array(z.string()).optional(),
|
|
21
|
+
queued: z.array(z.string()).optional(),
|
|
22
|
+
permanent_bounces: z.array(z.string()).optional(),
|
|
23
|
+
suppressed_recipients: z.array(z.string()).optional(),
|
|
24
|
+
}),
|
|
25
|
+
})
|
|
26
|
+
/** Cloudflare bounds a custom header's value at 2,048 bytes; the URL travels inside `<…>`. */
|
|
27
|
+
const MAX_HEADER_VALUE_BYTES = 2048
|
|
28
|
+
/**
|
|
29
|
+
* Cloudflare counts the whole message, attachments base64 and MIME-wrapped, against 5 MiB; 3 MiB of bytes leaves
|
|
30
|
+
* about a mebibyte for the bodies and the headers.
|
|
31
|
+
*/
|
|
32
|
+
const CLOUDFLARE_MAX_ATTACHMENT_BYTES = 3 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
export interface CloudflareEmailSenderOptions extends HttpEmailSenderOptions {
|
|
35
|
+
/** An account API token with the Email Sending Write permission and nothing else. */
|
|
36
|
+
readonly apiToken: string
|
|
37
|
+
/** The account the token sends for: the path of every request. */
|
|
38
|
+
readonly accountId: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A refusal's body carries numeric codes and machine-readable messages; only the status is read. */
|
|
42
|
+
export class CloudflareEmailSender extends HttpEmailSender {
|
|
43
|
+
readonly capabilities: EmailSenderCapabilities = {
|
|
44
|
+
idempotent: false,
|
|
45
|
+
confirmation: 'sync',
|
|
46
|
+
maxBatch: 1,
|
|
47
|
+
listUnsubscribe: true,
|
|
48
|
+
maxAttachmentBytes: CLOUDFLARE_MAX_ATTACHMENT_BYTES,
|
|
49
|
+
// Cloudflare Email Service takes transactional mail only, so it sends no broadcast.
|
|
50
|
+
}
|
|
51
|
+
readonly #apiToken: string
|
|
52
|
+
readonly #sendUrl: string
|
|
53
|
+
|
|
54
|
+
constructor(options: CloudflareEmailSenderOptions) {
|
|
55
|
+
if (!isCredential(options.apiToken)) {
|
|
56
|
+
throw new EmailDeliveryError('Cloudflare API token is invalid', { code: 'invalid_configuration' })
|
|
57
|
+
}
|
|
58
|
+
if (!ACCOUNT_ID_PATTERN.test(options.accountId)) {
|
|
59
|
+
throw new EmailDeliveryError('Cloudflare account ID is invalid', { code: 'invalid_configuration' })
|
|
60
|
+
}
|
|
61
|
+
super({ label: 'Cloudflare', provider: 'cloudflare' }, options)
|
|
62
|
+
this.#apiToken = options.apiToken
|
|
63
|
+
this.#sendUrl = `${CLOUDFLARE_ACCOUNTS_URL}/${options.accountId}/email/sending/send`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
protected request(message: EmailMessage): HttpEmailRequest {
|
|
67
|
+
if (message.unsubscribeUrl !== undefined && new TextEncoder().encode(`<${message.unsubscribeUrl}>`).byteLength > MAX_HEADER_VALUE_BYTES) {
|
|
68
|
+
throw new EmailDeliveryError('Email unsubscribe URL is invalid', { code: 'invalid_message' })
|
|
69
|
+
}
|
|
70
|
+
const senderName = message.fromName ?? this.from.name
|
|
71
|
+
const attachments = message.attachments ?? []
|
|
72
|
+
return {
|
|
73
|
+
url: this.#sendUrl,
|
|
74
|
+
headers: { Authorization: `Bearer ${this.#apiToken}` },
|
|
75
|
+
body: {
|
|
76
|
+
from: { address: this.from.address, ...(senderName === undefined ? {} : { name: senderName }) },
|
|
77
|
+
to: message.to,
|
|
78
|
+
subject: message.subject,
|
|
79
|
+
text: message.text,
|
|
80
|
+
...(message.html === undefined ? {} : { html: message.html }),
|
|
81
|
+
...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
|
|
82
|
+
...(message.unsubscribeUrl === undefined ? {} : { headers: oneClickUnsubscribeHeaders(message.unsubscribeUrl) }),
|
|
83
|
+
...(attachments.length === 0
|
|
84
|
+
? {}
|
|
85
|
+
: {
|
|
86
|
+
attachments: attachments.map((attachment) => ({
|
|
87
|
+
content: base64Of(attachment.content),
|
|
88
|
+
filename: attachment.filename,
|
|
89
|
+
type: attachment.contentType,
|
|
90
|
+
disposition: 'attachment',
|
|
91
|
+
})),
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
protected messageId(response: ProviderResponse, message: EmailMessage): string | undefined {
|
|
98
|
+
const result = cloudflareSuccessSchema.safeParse(response.body).data?.result
|
|
99
|
+
if (result === undefined) return undefined
|
|
100
|
+
const listed = (addresses: readonly string[] | undefined): boolean =>
|
|
101
|
+
(addresses ?? []).some((address) => address.toLowerCase() === message.to.toLowerCase())
|
|
102
|
+
// A 200 groups the recipients by outcome; the one recipient listed as bounced or suppressed got nothing. The
|
|
103
|
+
// lists name the address, so they stay out of the refusal.
|
|
104
|
+
if (listed(result.permanent_bounces) || listed(result.suppressed_recipients)) {
|
|
105
|
+
throw new EmailDeliveryError(`Cloudflare refused the recipient (${response.status})`, { code: 'provider_rejected', httpStatus: response.status })
|
|
106
|
+
}
|
|
107
|
+
if (result.message_id !== undefined && result.message_id !== '') return result.message_id
|
|
108
|
+
// The REST guide documents a 200 with the recipients' status and no `message_id`. A recipient delivered or queued
|
|
109
|
+
// was taken, and the message's own key names that send, the same on every retry of it.
|
|
110
|
+
if (listed(result.delivered) || listed(result.queued)) return `cloudflare:${message.idempotencyKey}`
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
protected override refusal(response: ProviderResponse): EmailDeliveryError | undefined {
|
|
115
|
+
// Documented as a resource not found: under this path, an account the token cannot reach.
|
|
116
|
+
if (response.status === 404) {
|
|
117
|
+
return new EmailDeliveryError('Cloudflare knows no such account (404)', { code: 'provider_unauthorized', httpStatus: response.status })
|
|
118
|
+
}
|
|
119
|
+
return undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cloudflareVariablesSchema = z.object({
|
|
124
|
+
FABRIKA_EMAIL_CLOUDFLARE_API_TOKEN: credentialVariable,
|
|
125
|
+
FABRIKA_EMAIL_CLOUDFLARE_ACCOUNT_ID: z.string().regex(ACCOUNT_ID_PATTERN),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
export const cloudflareEmailProvider: EmailProvider = {
|
|
129
|
+
variables: [
|
|
130
|
+
{ name: 'FABRIKA_EMAIL_CLOUDFLARE_API_TOKEN', secret: true, required: true },
|
|
131
|
+
{ name: 'FABRIKA_EMAIL_CLOUDFLARE_ACCOUNT_ID', secret: false, required: true },
|
|
132
|
+
],
|
|
133
|
+
create({ from, env, fetch }) {
|
|
134
|
+
const variables = parseProviderVariables(cloudflareVariablesSchema, env)
|
|
135
|
+
return new CloudflareEmailSender({
|
|
136
|
+
apiToken: variables.FABRIKA_EMAIL_CLOUDFLARE_API_TOKEN,
|
|
137
|
+
accountId: variables.FABRIKA_EMAIL_CLOUDFLARE_ACCOUNT_ID,
|
|
138
|
+
from,
|
|
139
|
+
fetch,
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
}
|
package/src/conformance.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface WireMessage {
|
|
|
26
26
|
readonly unsubscribeUrl?: string
|
|
27
27
|
/** Each attachment with its bytes as base64. */
|
|
28
28
|
readonly attachments?: readonly EmailAttachmentContent[]
|
|
29
|
+
/** Whether the provider got it as broadcast mail, on its channel for them where it has one. */
|
|
30
|
+
readonly broadcast?: boolean
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** A stored or sent message in wire terms: what a local harness answers for an `EmailMessage` it kept. */
|
|
@@ -99,6 +101,8 @@ const UNSUBSCRIBE_MESSAGE: EmailMessage = {
|
|
|
99
101
|
unsubscribeUrl: 'https://app.example.test/api/mail.unsubscribe?t=conformance-token-6c2e',
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
const BROADCAST_MESSAGE: EmailMessage = { ...UNSUBSCRIBE_MESSAGE, idempotencyKey: 'conformance/broadcast-1', broadcast: true }
|
|
105
|
+
|
|
102
106
|
const ATTACHMENT_MESSAGE: EmailMessage = {
|
|
103
107
|
...MINIMAL_MESSAGE,
|
|
104
108
|
idempotencyKey: 'conformance/attachment-1',
|
|
@@ -134,6 +138,7 @@ const INVALID_MESSAGES: readonly { readonly problem: string; readonly message: E
|
|
|
134
138
|
{ problem: 'a sender name over 128 characters', message: { ...MINIMAL_MESSAGE, fromName: 'x'.repeat(129) } },
|
|
135
139
|
{ problem: 'an unsubscribe URL over http', message: { ...MINIMAL_MESSAGE, unsubscribeUrl: 'http://app.example.test/u' } },
|
|
136
140
|
{ problem: 'an unsubscribe URL with a bracket', message: { ...MINIMAL_MESSAGE, unsubscribeUrl: 'https://app.example.test/u>x' } },
|
|
141
|
+
{ problem: 'a broadcast without an unsubscribe URL', message: { ...MINIMAL_MESSAGE, broadcast: true } },
|
|
137
142
|
{
|
|
138
143
|
problem: 'an attachment name with a path',
|
|
139
144
|
message: { ...MINIMAL_MESSAGE, attachments: [{ filename: '../etc/passwd', contentType: 'text/plain', content: ONE_BYTE }] },
|
|
@@ -231,6 +236,19 @@ function describeHttpSender(harness: EmailSenderHttpHarness): void {
|
|
|
231
236
|
expect(current.requests).toHaveLength(0)
|
|
232
237
|
})
|
|
233
238
|
|
|
239
|
+
test('hands a broadcast message to the provider as one when it says it sends them, and refuses it otherwise', async () => {
|
|
240
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
241
|
+
if (current.sender.capabilities.broadcast === true) {
|
|
242
|
+
await current.sender.send(BROADCAST_MESSAGE, new AbortController().signal)
|
|
243
|
+
const wire = await harness.readRequest(only(current.requests))
|
|
244
|
+
expect(wire).toMatchObject({ ...contentOf(BROADCAST_MESSAGE), unsubscribeUrl: BROADCAST_MESSAGE.unsubscribeUrl })
|
|
245
|
+
expect(wire.broadcast).toBe(true)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
await expectRejection(() => current.sender.send(BROADCAST_MESSAGE, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
249
|
+
expect(current.requests).toHaveLength(0)
|
|
250
|
+
})
|
|
251
|
+
|
|
234
252
|
test('hands the attachments it says it sends to the provider, and refuses them otherwise', async () => {
|
|
235
253
|
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
236
254
|
if (current.sender.capabilities.maxAttachmentBytes !== undefined) {
|
|
@@ -250,6 +268,31 @@ function describeHttpSender(harness: EmailSenderHttpHarness): void {
|
|
|
250
268
|
expect(current.requests).toHaveLength(0)
|
|
251
269
|
})
|
|
252
270
|
|
|
271
|
+
test('refuses an attachment of a file type it says it refuses, without a request', async () => {
|
|
272
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
273
|
+
const extension = current.sender.capabilities.refusedAttachmentExtensions?.[0]
|
|
274
|
+
if (extension === undefined || current.sender.capabilities.maxAttachmentBytes === undefined) return
|
|
275
|
+
const message: EmailMessage = {
|
|
276
|
+
...ATTACHMENT_MESSAGE,
|
|
277
|
+
attachments: [{ filename: `report.${extension.toUpperCase()}`, contentType: 'application/octet-stream', content: ONE_BYTE }],
|
|
278
|
+
}
|
|
279
|
+
await expectRejection(() => current.sender.send(message, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
280
|
+
expect(current.requests).toHaveLength(0)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('refuses a body shorter than it says it sends, counted in characters once trimmed, without a request', async () => {
|
|
284
|
+
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
285
|
+
const min = current.sender.capabilities.minBodyLength
|
|
286
|
+
if (min === undefined) return
|
|
287
|
+
const short = ` ${'😀'.repeat(min - 1)} `
|
|
288
|
+
for (const message of [{ ...MINIMAL_MESSAGE, text: short }, { ...MINIMAL_MESSAGE, html: short }]) {
|
|
289
|
+
await expectRejection(() => current.sender.send(message, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
290
|
+
}
|
|
291
|
+
expect(current.requests).toHaveLength(0)
|
|
292
|
+
await current.sender.send({ ...MINIMAL_MESSAGE, text: '😀'.repeat(min) }, new AbortController().signal)
|
|
293
|
+
expect(current.requests).toHaveLength(1)
|
|
294
|
+
})
|
|
295
|
+
|
|
253
296
|
test.each(INVALID_MESSAGES)('refuses $problem as invalid_message without a request', async ({ message }) => {
|
|
254
297
|
const current = httpCase(async (request) => harness.answers.accepted(request))
|
|
255
298
|
await expectRejection(() => current.sender.send(message, new AbortController().signal), 'invalid_message', forbidden(current))
|
|
@@ -382,6 +425,16 @@ function describeLocalSender(harness: EmailSenderLocalHarness): void {
|
|
|
382
425
|
expect(await harness.stored()).toHaveLength(0)
|
|
383
426
|
})
|
|
384
427
|
|
|
428
|
+
test('stores a broadcast message as one when it says it sends them, and refuses it otherwise', async () => {
|
|
429
|
+
if (current().capabilities.broadcast === true) {
|
|
430
|
+
await current().send(BROADCAST_MESSAGE, new AbortController().signal)
|
|
431
|
+
expect(only(await harness.stored()).broadcast).toBe(true)
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
await expectRejection(() => current().send(BROADCAST_MESSAGE, new AbortController().signal), 'invalid_message', () => MESSAGE_MARKERS)
|
|
435
|
+
expect(await harness.stored()).toHaveLength(0)
|
|
436
|
+
})
|
|
437
|
+
|
|
385
438
|
test('stores the attachments it says it sends, and refuses them otherwise', async () => {
|
|
386
439
|
const limit = current().capabilities.maxAttachmentBytes
|
|
387
440
|
if (limit !== undefined) {
|
|
@@ -448,6 +501,7 @@ function expectOptionalFieldsAbsent(wire: WireMessage): void {
|
|
|
448
501
|
expect(wire.fromName).toBeUndefined()
|
|
449
502
|
expect(wire.unsubscribeUrl).toBeUndefined()
|
|
450
503
|
expect(wire.attachments).toBeUndefined()
|
|
504
|
+
expect(wire.broadcast ?? false).toBe(false)
|
|
451
505
|
}
|
|
452
506
|
|
|
453
507
|
function contentOf(message: EmailMessage): WireMessage {
|
package/src/content.ts
CHANGED
|
@@ -16,13 +16,14 @@ export interface EmailContent {
|
|
|
16
16
|
readonly replyTo?: string
|
|
17
17
|
readonly fromName?: string
|
|
18
18
|
readonly unsubscribeUrl?: string
|
|
19
|
+
readonly broadcast?: true
|
|
19
20
|
readonly attachments?: readonly EmailAttachmentContent[]
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function sameEmailContent(left: EmailContent, right: EmailContent): boolean {
|
|
23
24
|
return left.to === right.to && left.subject === right.subject && left.text === right.text && left.html === right.html
|
|
24
25
|
&& left.replyTo === right.replyTo && left.fromName === right.fromName && left.unsubscribeUrl === right.unsubscribeUrl
|
|
25
|
-
&& sameAttachments(left.attachments ?? [], right.attachments ?? [])
|
|
26
|
+
&& left.broadcast === right.broadcast && sameAttachments(left.attachments ?? [], right.attachments ?? [])
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export function emailContentOf(message: EmailMessage): EmailContent {
|
|
@@ -35,6 +36,7 @@ export function emailContentOf(message: EmailMessage): EmailContent {
|
|
|
35
36
|
...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
|
|
36
37
|
...(message.fromName === undefined ? {} : { fromName: message.fromName }),
|
|
37
38
|
...(message.unsubscribeUrl === undefined ? {} : { unsubscribeUrl: message.unsubscribeUrl }),
|
|
39
|
+
...(message.broadcast === true ? { broadcast: true } : {}),
|
|
38
40
|
...(attachments.length === 0
|
|
39
41
|
? {}
|
|
40
42
|
: {
|
package/src/directory.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { z } from 'zod'
|
|
|
9
9
|
import { type EmailProvider, parseProviderVariables } from './config.js'
|
|
10
10
|
import { emailContentOf, sameEmailContent } from './content.js'
|
|
11
11
|
import { EmailDeliveryError, type EmailDeliveryResult, type EmailMessage, type EmailSender, type EmailSenderCapabilities } from './email.js'
|
|
12
|
-
import { validateEmailAttachments, validateEmailMessage } from './validation.js'
|
|
12
|
+
import { validateEmailAttachments, validateEmailBroadcast, validateEmailMessage } from './validation.js'
|
|
13
13
|
|
|
14
14
|
/** Resend's bound, so a local composition meets the limit a production transport has. */
|
|
15
15
|
const DIRECTORY_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
|
@@ -24,6 +24,7 @@ const capturedEmailSchema = z.object({
|
|
|
24
24
|
replyTo: z.string().optional(),
|
|
25
25
|
fromName: z.string().optional(),
|
|
26
26
|
unsubscribeUrl: z.string().optional(),
|
|
27
|
+
broadcast: z.literal(true).optional(),
|
|
27
28
|
attachments: z.array(z.object({ filename: z.string(), contentType: z.string(), content: z.string() })).readonly().optional(),
|
|
28
29
|
capturedAt: z.number().int(),
|
|
29
30
|
})
|
|
@@ -43,6 +44,7 @@ export class DirectoryEmailSender implements EmailSender {
|
|
|
43
44
|
maxBatch: 1,
|
|
44
45
|
listUnsubscribe: true,
|
|
45
46
|
maxAttachmentBytes: DIRECTORY_MAX_ATTACHMENT_BYTES,
|
|
47
|
+
broadcast: true,
|
|
46
48
|
}
|
|
47
49
|
readonly #directory: string
|
|
48
50
|
readonly #now: () => number
|
|
@@ -59,6 +61,7 @@ export class DirectoryEmailSender implements EmailSender {
|
|
|
59
61
|
async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
|
|
60
62
|
validateEmailMessage(message)
|
|
61
63
|
validateEmailAttachments(message, this.capabilities, 'The mail capture')
|
|
64
|
+
validateEmailBroadcast(message, this.capabilities, 'The mail capture')
|
|
62
65
|
if (signal.aborted) throw new EmailDeliveryError('The mail capture was aborted by the caller', { code: 'aborted' })
|
|
63
66
|
const messageId = createHash('sha256').update(message.idempotencyKey).digest('hex')
|
|
64
67
|
const captured: CapturedEmail = { messageId, idempotencyKey: message.idempotencyKey, ...emailContentOf(message), capturedAt: this.#now() }
|
package/src/email.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface EmailMessage {
|
|
|
23
23
|
* <url>` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`. A transport without `listUnsubscribe` refuses it.
|
|
24
24
|
*/
|
|
25
25
|
readonly unsubscribeUrl?: string
|
|
26
|
+
/**
|
|
27
|
+
* One message of a send to many recipients (a newsletter, an announcement), not a reply to what the recipient did.
|
|
28
|
+
* A provider keeps such mail apart from transactional mail, and the law and the mailbox providers ask it to carry
|
|
29
|
+
* an unsubscribe, so it needs `unsubscribeUrl`. A transport without `broadcast` refuses it.
|
|
30
|
+
*/
|
|
31
|
+
readonly broadcast?: boolean
|
|
26
32
|
/**
|
|
27
33
|
* Files the message carries, at most `MAX_EMAIL_ATTACHMENTS`, together at most the transport's `maxAttachmentBytes`.
|
|
28
34
|
* A transport without `maxAttachmentBytes` refuses a message that has any; an empty list is none.
|
|
@@ -47,6 +53,12 @@ export interface EmailSenderCapabilities {
|
|
|
47
53
|
readonly listUnsubscribe?: boolean
|
|
48
54
|
/** The most attachment bytes one message carries, every file together; absent: it sends no attachments. */
|
|
49
55
|
readonly maxAttachmentBytes?: number
|
|
56
|
+
/** File extensions it refuses an attachment for, lowercase and without the dot; absent: none. */
|
|
57
|
+
readonly refusedAttachmentExtensions?: readonly string[]
|
|
58
|
+
/** The fewest characters a text or HTML body has once trimmed; absent: any body that is not blank. */
|
|
59
|
+
readonly minBodyLength?: number
|
|
60
|
+
/** Whether it sends a `broadcast` message, on its provider's channel for them where it has one; absent is no. */
|
|
61
|
+
readonly broadcast?: boolean
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
export interface EmailSender {
|