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