@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/email",
3
- "version": "0.8.1",
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
+ }
@@ -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 {