@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/src/resend.ts CHANGED
@@ -1,80 +1,65 @@
1
1
  import { z } from 'zod'
2
- import { type EmailProvider, type EmailSenderIdentity, parseProviderVariables } from './config.js'
2
+ import { type EmailProvider, parseProviderVariables } from './config.js'
3
3
  import { base64Of } from './content.js'
4
+ import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities, oneClickUnsubscribeHeaders } from './email.js'
4
5
  import {
5
- EmailDeliveryError,
6
- type EmailDeliveryResult,
7
- type EmailFetch,
8
- type EmailMessage,
9
- type EmailSender,
10
- type EmailSenderCapabilities,
11
- oneClickUnsubscribeHeaders,
12
- } from './email.js'
13
- import { parseTimeout, postToProvider, type ProviderResponse } from './http.js'
14
- import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailAttachments, validateEmailMessage } from './validation.js'
6
+ credentialVariable,
7
+ formatMailbox,
8
+ type HttpEmailRequest,
9
+ HttpEmailSender,
10
+ type HttpEmailSenderOptions,
11
+ isCredential,
12
+ } from './http-sender.js'
13
+ import type { ProviderResponse } from './http.js'
15
14
 
16
15
  const RESEND_EMAILS_URL = 'https://api.resend.com/emails'
17
16
  const LABEL = 'Resend'
18
- const resendSuccessSchema = z.object({ id: z.string().min(1) })
17
+ const BROADCAST_TAG = { name: 'category', value: 'broadcast' }
18
+ const resendSuccessSchema = z.object({ id: z.string() })
19
19
  const resendErrorSchema = z.object({ name: z.string() })
20
20
  const QUOTA_ERRORS: ReadonlySet<string> = new Set(['daily_quota_exceeded', 'monthly_quota_exceeded'])
21
- /** RFC 5322 specials a display name may still hold after validation; any of them makes the name a quoted string. */
22
- const DISPLAY_NAME_SPECIALS = /[()[\]:;@,.]/
23
21
  /** Resend refuses a message over 40 MB once its attachments are base64; 25 MiB of bytes leaves room for the rest. */
24
22
  const RESEND_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
25
23
 
26
- export interface ResendEmailSenderOptions {
24
+ export interface ResendEmailSenderOptions extends HttpEmailSenderOptions {
27
25
  readonly apiKey: string
28
- readonly from: EmailSenderIdentity
29
- readonly fetch?: EmailFetch
30
- readonly timeoutMs?: number
31
26
  }
32
27
 
33
- export class ResendEmailSender implements EmailSender {
28
+ export class ResendEmailSender extends HttpEmailSender {
34
29
  readonly capabilities: EmailSenderCapabilities = {
35
30
  idempotent: true,
36
31
  confirmation: 'sync',
37
32
  maxBatch: 1,
38
33
  listUnsubscribe: true,
39
34
  maxAttachmentBytes: RESEND_MAX_ATTACHMENT_BYTES,
35
+ // Resend takes one-to-one marketing mail through this API, with consent and an unsubscribe; it has no channel apart.
36
+ broadcast: true,
40
37
  }
41
38
  readonly #apiKey: string
42
- readonly #from: EmailSenderIdentity
43
- readonly #fetch: EmailFetch
44
- readonly #timeoutMs: number
45
39
 
46
40
  constructor(options: ResendEmailSenderOptions) {
47
- if (!validApiKey(options.apiKey)) {
41
+ if (!isCredential(options.apiKey)) {
48
42
  throw new EmailDeliveryError('Resend API key is invalid', { code: 'invalid_configuration' })
49
43
  }
50
- if (!isValidEmailSenderIdentity(options.from)) {
51
- throw new EmailDeliveryError('Resend sender is invalid', { code: 'invalid_configuration' })
52
- }
53
-
44
+ super({ label: LABEL, provider: 'resend' }, options)
54
45
  this.#apiKey = options.apiKey
55
- this.#from = options.from
56
- this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
57
- this.#timeoutMs = parseTimeout(LABEL, options.timeoutMs)
58
46
  }
59
47
 
60
- async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
61
- validateEmailMessage(message)
62
- validateEmailAttachments(message, this.capabilities, LABEL)
48
+ protected request(message: EmailMessage): HttpEmailRequest {
63
49
  const attachments = message.attachments ?? []
64
-
65
- const response = await postToProvider({
66
- label: LABEL,
67
- fetch: this.#fetch,
50
+ return {
68
51
  url: RESEND_EMAILS_URL,
69
52
  headers: { Authorization: `Bearer ${this.#apiKey}`, 'Idempotency-Key': message.idempotencyKey },
70
53
  body: {
71
- from: formatMailbox(this.#from.address, message.fromName ?? this.#from.name),
54
+ from: formatMailbox(this.from.address, message.fromName ?? this.from.name),
72
55
  to: message.to,
73
56
  subject: message.subject,
74
57
  text: message.text,
75
58
  ...(message.html === undefined ? {} : { html: message.html }),
76
59
  ...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
77
60
  ...(message.unsubscribeUrl === undefined ? {} : { headers: oneClickUnsubscribeHeaders(message.unsubscribeUrl) }),
61
+ // Tagged, so the account's logs and webhooks tell broadcast mail from transactional.
62
+ ...(message.broadcast === true ? { tags: [BROADCAST_TAG] } : {}),
78
63
  ...(attachments.length === 0
79
64
  ? {}
80
65
  : {
@@ -85,22 +70,37 @@ export class ResendEmailSender implements EmailSender {
85
70
  })),
86
71
  }),
87
72
  },
88
- signal,
89
- timeoutMs: this.#timeoutMs,
90
- })
73
+ }
74
+ }
91
75
 
92
- if (!response.ok) throw responseError(response)
76
+ protected messageId(response: ProviderResponse): string | undefined {
77
+ return resendSuccessSchema.safeParse(response.body).data?.id
78
+ }
93
79
 
94
- const parsed = resendSuccessSchema.safeParse(response.body)
95
- if (!parsed.success) {
96
- throw new EmailDeliveryError('Resend returned an invalid success response', { code: 'malformed_response', httpStatus: response.status })
80
+ protected override refusal(response: ProviderResponse): EmailDeliveryError | undefined {
81
+ const status = response.status
82
+ const name = resendErrorSchema.safeParse(response.body).data?.name
83
+ if (status === 401 || status === 403) {
84
+ // A 403 is also an unverified sender domain.
85
+ return new EmailDeliveryError(`Resend refused the credentials or the sender (${status})`, { code: 'provider_unauthorized', httpStatus: status })
97
86
  }
98
-
99
- return { status: 'accepted', provider: 'resend', messageId: parsed.data.id }
87
+ if (status === 409 && name === 'concurrent_idempotent_requests') {
88
+ return new EmailDeliveryError('A matching Resend request is still in progress', { code: 'request_in_progress', httpStatus: status })
89
+ }
90
+ if (status === 409 && name === 'invalid_idempotent_request') {
91
+ return new EmailDeliveryError('Resend already accepted a different message under this idempotency key', {
92
+ code: 'idempotency_conflict',
93
+ httpStatus: status,
94
+ })
95
+ }
96
+ if (status === 429 && name !== undefined && QUOTA_ERRORS.has(name)) {
97
+ return new EmailDeliveryError('Resend sending quota exceeded', { code: 'quota_exceeded', httpStatus: status })
98
+ }
99
+ return undefined
100
100
  }
101
101
  }
102
102
 
103
- const resendVariablesSchema = z.object({ FABRIKA_EMAIL_RESEND_API_KEY: z.string().refine(validApiKey) })
103
+ const resendVariablesSchema = z.object({ FABRIKA_EMAIL_RESEND_API_KEY: credentialVariable })
104
104
 
105
105
  export const resendEmailProvider: EmailProvider = {
106
106
  variables: [{ name: 'FABRIKA_EMAIL_RESEND_API_KEY', secret: true, required: true }],
@@ -109,40 +109,3 @@ export const resendEmailProvider: EmailProvider = {
109
109
  return new ResendEmailSender({ apiKey: variables.FABRIKA_EMAIL_RESEND_API_KEY, from, fetch })
110
110
  },
111
111
  }
112
-
113
- function responseError(response: ProviderResponse): EmailDeliveryError {
114
- const status = response.status
115
- const parsed = resendErrorSchema.safeParse(response.body)
116
- const name = parsed.success ? parsed.data.name : undefined
117
- if (status === 401 || status === 403) {
118
- return new EmailDeliveryError(`Resend refused the credentials or the sender (${status})`, { code: 'provider_unauthorized', httpStatus: status })
119
- }
120
- if (status === 409 && name === 'concurrent_idempotent_requests') {
121
- return new EmailDeliveryError('A matching Resend request is still in progress', { code: 'request_in_progress', httpStatus: status })
122
- }
123
- if (status === 409 && name === 'invalid_idempotent_request') {
124
- return new EmailDeliveryError('Resend already accepted a different message under this idempotency key', {
125
- code: 'idempotency_conflict',
126
- httpStatus: status,
127
- })
128
- }
129
- if (status === 429 && name !== undefined && QUOTA_ERRORS.has(name)) {
130
- return new EmailDeliveryError('Resend sending quota exceeded', { code: 'quota_exceeded', httpStatus: status })
131
- }
132
- if (status === 429) {
133
- return new EmailDeliveryError('Resend rate limit exceeded', { code: 'rate_limited', httpStatus: status })
134
- }
135
- if (status >= 500) {
136
- return new EmailDeliveryError('Resend is unavailable', { code: 'provider_unavailable', httpStatus: status })
137
- }
138
- return new EmailDeliveryError(`Resend rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
139
- }
140
-
141
- function formatMailbox(address: string, name: string | undefined): string {
142
- if (name === undefined) return address
143
- return DISPLAY_NAME_SPECIALS.test(name) ? `"${name}" <${address}>` : `${name} <${address}>`
144
- }
145
-
146
- function validApiKey(value: string): boolean {
147
- return value.length > 0 && isVisibleAscii(value)
148
- }
@@ -2,83 +2,66 @@
2
2
  // verified only against a stubbed fetch. It has no idempotency key, and a 201 only queues the request.
3
3
 
4
4
  import { z } from 'zod'
5
- import { type EmailProvider, type EmailSenderIdentity, parseProviderVariables } from './config.js'
5
+ import { type EmailProvider, parseProviderVariables } from './config.js'
6
+ import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js'
6
7
  import {
7
- EmailDeliveryError,
8
- type EmailDeliveryResult,
9
- type EmailFetch,
10
- type EmailMessage,
11
- type EmailSender,
12
- type EmailSenderCapabilities,
13
- } from './email.js'
14
- import { parseTimeout, postToProvider } from './http.js'
15
- import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailAttachments, validateEmailMessage } from './validation.js'
8
+ credentialVariable,
9
+ type HttpEmailRequest,
10
+ HttpEmailSender,
11
+ type HttpEmailSenderOptions,
12
+ isCredential,
13
+ optionalVariable,
14
+ } from './http-sender.js'
15
+ import type { ProviderResponse } from './http.js'
16
16
 
17
17
  const SMARTEMAILING_SEND_URL = 'https://app.smartemailing.cz/api/v3/send/transactional-emails-bulk'
18
- const LABEL = 'SmartEmailing'
19
18
  const DEFAULT_TAG = 'fabrika'
20
19
  const TAG_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
21
- const smartEmailingSuccessSchema = z.object({ data: z.tuple([z.object({ id: z.string().min(1) })]) })
20
+ const smartEmailingSuccessSchema = z.object({ data: z.tuple([z.object({ id: z.string() })]) })
22
21
 
23
- export interface SmartEmailingEmailSenderOptions {
22
+ export interface SmartEmailingEmailSenderOptions extends HttpEmailSenderOptions {
24
23
  /** The account's user name or e-mail: the first half of the Basic credential. */
25
24
  readonly username: string
26
25
  readonly apiKey: string
27
- readonly from: EmailSenderIdentity
28
26
  /** Groups the messages in SmartEmailing's statistics. */
29
27
  readonly tag?: string
30
- readonly fetch?: EmailFetch
31
- readonly timeoutMs?: number
32
28
  }
33
29
 
34
- export class SmartEmailingEmailSender implements EmailSender {
35
- /** The API takes up to five tasks a request, but `send` carries one. */
30
+ /** Its error body's `message` is free text that may quote the request, so a refusal is classified by status alone. */
31
+ export class SmartEmailingEmailSender extends HttpEmailSender {
32
+ /**
33
+ * The API takes up to five tasks a request, but `send` carries one. Its API documents no message headers, so a
34
+ * one-click unsubscribe is refused rather than sent without one.
35
+ */
36
36
  readonly capabilities: EmailSenderCapabilities = { idempotent: false, confirmation: 'queued', maxBatch: 1 }
37
37
  readonly #authorization: string
38
- readonly #from: EmailSenderIdentity
39
38
  readonly #tag: string
40
- readonly #fetch: EmailFetch
41
- readonly #timeoutMs: number
42
39
 
43
40
  constructor(options: SmartEmailingEmailSenderOptions) {
44
41
  if (!validUsername(options.username)) {
45
42
  throw new EmailDeliveryError('SmartEmailing username is invalid', { code: 'invalid_configuration' })
46
43
  }
47
- if (!validApiKey(options.apiKey)) {
44
+ if (!isCredential(options.apiKey)) {
48
45
  throw new EmailDeliveryError('SmartEmailing API key is invalid', { code: 'invalid_configuration' })
49
46
  }
50
- if (!isValidEmailSenderIdentity(options.from)) {
51
- throw new EmailDeliveryError('SmartEmailing sender is invalid', { code: 'invalid_configuration' })
52
- }
47
+ super({ label: 'SmartEmailing', provider: 'smartemailing' }, options)
53
48
  const tag = options.tag ?? DEFAULT_TAG
54
49
  if (!TAG_PATTERN.test(tag)) {
55
50
  throw new EmailDeliveryError('SmartEmailing tag is invalid', { code: 'invalid_configuration' })
56
51
  }
57
52
 
58
53
  this.#authorization = `Basic ${btoa(`${options.username}:${options.apiKey}`)}`
59
- this.#from = options.from
60
54
  this.#tag = tag
61
- this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
62
- this.#timeoutMs = parseTimeout(LABEL, options.timeoutMs)
63
55
  }
64
56
 
65
- async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
66
- validateEmailMessage(message)
67
- // Its API documents no message headers, so a one-click unsubscribe is refused rather than sent without one.
68
- if (message.unsubscribeUrl !== undefined) {
69
- throw new EmailDeliveryError('SmartEmailing cannot write the List-Unsubscribe headers', { code: 'invalid_message' })
70
- }
71
- validateEmailAttachments(message, this.capabilities, LABEL)
72
-
73
- const senderName = message.fromName ?? this.#from.name
74
- const response = await postToProvider({
75
- label: LABEL,
76
- fetch: this.#fetch,
57
+ protected request(message: EmailMessage): HttpEmailRequest {
58
+ const senderName = message.fromName ?? this.from.name
59
+ return {
77
60
  url: SMARTEMAILING_SEND_URL,
78
61
  headers: { Authorization: this.#authorization },
79
62
  body: {
80
63
  sender_credentials: {
81
- from: this.#from.address,
64
+ from: this.from.address,
82
65
  ...(senderName === undefined ? {} : { sender_name: senderName }),
83
66
  ...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
84
67
  },
@@ -91,28 +74,18 @@ export class SmartEmailingEmailSender implements EmailSender {
91
74
  },
92
75
  tasks: [{ recipient: { emailaddress: message.to } }],
93
76
  },
94
- signal,
95
- timeoutMs: this.#timeoutMs,
96
- })
97
-
98
- // The error body's `message` is free text that may quote the request, so only the status is classified.
99
- if (!response.ok) throw responseError(response.status)
100
-
101
- const parsed = smartEmailingSuccessSchema.safeParse(response.body)
102
- if (!parsed.success) {
103
- throw new EmailDeliveryError('SmartEmailing returned an invalid success response', { code: 'malformed_response', httpStatus: response.status })
104
77
  }
78
+ }
105
79
 
106
- return { status: 'accepted', provider: 'smartemailing', messageId: parsed.data.data[0].id }
80
+ protected messageId(response: ProviderResponse): string | undefined {
81
+ return smartEmailingSuccessSchema.safeParse(response.body).data?.data[0].id
107
82
  }
108
83
  }
109
84
 
110
85
  const smartEmailingVariablesSchema = z.object({
111
86
  FABRIKA_EMAIL_SMARTEMAILING_USERNAME: z.string().refine(validUsername),
112
- FABRIKA_EMAIL_SMARTEMAILING_API_KEY: z.string().refine(validApiKey),
113
- FABRIKA_EMAIL_SMARTEMAILING_TAG: z.string().optional().transform((value) => (value === '' ? undefined : value)).refine((value) =>
114
- value === undefined || TAG_PATTERN.test(value)
115
- ),
87
+ FABRIKA_EMAIL_SMARTEMAILING_API_KEY: credentialVariable,
88
+ FABRIKA_EMAIL_SMARTEMAILING_TAG: optionalVariable((value) => TAG_PATTERN.test(value)),
116
89
  })
117
90
 
118
91
  export const smartEmailingEmailProvider: EmailProvider = {
@@ -133,23 +106,6 @@ export const smartEmailingEmailProvider: EmailProvider = {
133
106
  },
134
107
  }
135
108
 
136
- function responseError(status: number): EmailDeliveryError {
137
- if (status === 401 || status === 403) {
138
- return new EmailDeliveryError(`SmartEmailing refused the credentials (${status})`, { code: 'provider_unauthorized', httpStatus: status })
139
- }
140
- if (status === 429) {
141
- return new EmailDeliveryError('SmartEmailing rate limit exceeded', { code: 'rate_limited', httpStatus: status })
142
- }
143
- if (status >= 500) {
144
- return new EmailDeliveryError('SmartEmailing is unavailable', { code: 'provider_unavailable', httpStatus: status })
145
- }
146
- return new EmailDeliveryError(`SmartEmailing rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
147
- }
148
-
149
109
  function validUsername(value: string): boolean {
150
- return validApiKey(value) && !value.includes(':')
151
- }
152
-
153
- function validApiKey(value: string): boolean {
154
- return value.length > 0 && isVisibleAscii(value)
110
+ return isCredential(value) && !value.includes(':')
155
111
  }
package/src/validation.ts CHANGED
@@ -64,6 +64,7 @@ export function validateEmailMessage(message: EmailMessage): void {
64
64
  if (message.replyTo !== undefined && !isValidEmailAddress(message.replyTo)) throw invalidMessage('Email reply-to address is invalid')
65
65
  if (message.fromName !== undefined && !isValidEmailDisplayName(message.fromName)) throw invalidMessage('Email sender name is invalid')
66
66
  if (message.unsubscribeUrl !== undefined && !isUnsubscribeUrl(message.unsubscribeUrl)) throw invalidMessage('Email unsubscribe URL is invalid')
67
+ if (message.broadcast === true && message.unsubscribeUrl === undefined) throw invalidMessage('A broadcast email needs an unsubscribe URL')
67
68
  const attachments = message.attachments ?? []
68
69
  if (attachments.length > MAX_EMAIL_ATTACHMENTS) throw invalidMessage(`Email has more than ${MAX_EMAIL_ATTACHMENTS} attachments`)
69
70
  for (const attachment of attachments) {
@@ -73,15 +74,42 @@ export function validateEmailMessage(message: EmailMessage): void {
73
74
  }
74
75
 
75
76
  /**
76
- * Refuses a message whose attachments `capabilities` does not carry: any attachment where the transport sends none, or
77
- * more bytes together than its `maxAttachmentBytes`. `label` names the transport in the message.
77
+ * Refuses a message whose attachments `capabilities` does not carry: any attachment where the transport sends none,
78
+ * more bytes together than its `maxAttachmentBytes`, or a file whose extension it refuses. `label` names the transport
79
+ * in the message.
78
80
  */
79
81
  export function validateEmailAttachments(message: EmailMessage, capabilities: EmailSenderCapabilities, label: string): void {
80
- if ((message.attachments ?? []).length === 0) return
82
+ const attachments = message.attachments ?? []
83
+ if (attachments.length === 0) return
81
84
  const limit = capabilities.maxAttachmentBytes
82
85
  if (limit === undefined) throw invalidMessage(`${label} sends no attachments`)
83
86
  const bytes = attachmentBytes(message)
84
87
  if (bytes > limit) throw invalidMessage(`Email attachments have ${bytes} bytes, more than the ${limit} ${label} sends`)
88
+ const refused = capabilities.refusedAttachmentExtensions ?? []
89
+ if (refused.length > 0 && attachments.some((attachment) => refused.includes(extensionOf(attachment.filename)))) {
90
+ throw invalidMessage(`${label} sends no attachment of this file type`)
91
+ }
92
+ }
93
+
94
+ /** Refuses a `broadcast` message where the transport sends none. */
95
+ export function validateEmailBroadcast(message: EmailMessage, capabilities: EmailSenderCapabilities, label: string): void {
96
+ if (message.broadcast === true && capabilities.broadcast !== true) throw invalidMessage(`${label} sends no broadcast email`)
97
+ }
98
+
99
+ /** Refuses a text or HTML body shorter than the transport's `minBodyLength`, counted in characters once trimmed. */
100
+ export function validateEmailBodies(message: EmailMessage, capabilities: EmailSenderCapabilities, label: string): void {
101
+ const min = capabilities.minBodyLength
102
+ if (min === undefined) return
103
+ const short = (body: string): boolean => [...body.trim()].length < min
104
+ if (short(message.text) || (message.html !== undefined && short(message.html))) {
105
+ throw invalidMessage(`${label} sends no body shorter than ${min} characters`)
106
+ }
107
+ }
108
+
109
+ /** The extension after the last dot, lowercase; empty without one. */
110
+ function extensionOf(filename: string): string {
111
+ const dot = filename.lastIndexOf('.')
112
+ return dot < 0 ? '' : filename.slice(dot + 1).toLowerCase()
85
113
  }
86
114
 
87
115
  /** The most files one message carries. */
@@ -0,0 +1,26 @@
1
+ import { type EmailProvider } from './config.js';
2
+ import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js';
3
+ import { type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions } from './http-sender.js';
4
+ import type { ProviderResponse } from './http.js';
5
+ export interface AhaSendEmailSenderOptions extends HttpEmailSenderOptions {
6
+ /** An API key v2, scoped to `messages:send:{domain}` at least. */
7
+ readonly apiKey: string;
8
+ /** The account the key belongs to: the path of every request. */
9
+ readonly accountId: string;
10
+ /**
11
+ * The address a `broadcast` message is sent from, on a domain the account has configured as a Marketing domain:
12
+ * AhaSend's terms allow marketing mail through the API from such a domain only. Without it, a broadcast is refused.
13
+ */
14
+ readonly broadcastFrom?: string;
15
+ }
16
+ /** A refusal's body is one free-text `message`, so only the status is read. */
17
+ export declare class AhaSendEmailSender extends HttpEmailSender {
18
+ #private;
19
+ /** `broadcast` only with `broadcastFrom`. */
20
+ readonly capabilities: EmailSenderCapabilities;
21
+ constructor(options: AhaSendEmailSenderOptions);
22
+ protected request(message: EmailMessage): Promise<HttpEmailRequest>;
23
+ protected messageId(response: ProviderResponse): string | undefined;
24
+ protected refusal(response: ProviderResponse): EmailDeliveryError | undefined;
25
+ }
26
+ export declare const ahaSendEmailProvider: EmailProvider;
@@ -0,0 +1,20 @@
1
+ import { type EmailProvider } from './config.js';
2
+ import { EmailDeliveryError, type EmailMessage, type EmailSenderCapabilities } from './email.js';
3
+ import { type HttpEmailRequest, HttpEmailSender, type HttpEmailSenderOptions } from './http-sender.js';
4
+ import type { ProviderResponse } from './http.js';
5
+ export interface CloudflareEmailSenderOptions extends HttpEmailSenderOptions {
6
+ /** An account API token with the Email Sending Write permission and nothing else. */
7
+ readonly apiToken: string;
8
+ /** The account the token sends for: the path of every request. */
9
+ readonly accountId: string;
10
+ }
11
+ /** A refusal's body carries numeric codes and machine-readable messages; only the status is read. */
12
+ export declare class CloudflareEmailSender extends HttpEmailSender {
13
+ #private;
14
+ readonly capabilities: EmailSenderCapabilities;
15
+ constructor(options: CloudflareEmailSenderOptions);
16
+ protected request(message: EmailMessage): HttpEmailRequest;
17
+ protected messageId(response: ProviderResponse, message: EmailMessage): string | undefined;
18
+ protected refusal(response: ProviderResponse): EmailDeliveryError | undefined;
19
+ }
20
+ export declare const cloudflareEmailProvider: EmailProvider;
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import { type EmailFetch, type EmailSender } from './email.js';
3
+ export type EmailEnvironment = Readonly<Record<string, string | undefined>>;
4
+ export interface EmailSenderIdentity {
5
+ readonly address: string;
6
+ readonly name?: string;
7
+ }
8
+ export interface EmailProviderVariable {
9
+ readonly name: string;
10
+ readonly secret: boolean;
11
+ readonly required: boolean;
12
+ }
13
+ export interface EmailProvider {
14
+ /** Provider-specific variables, beside FABRIKA_EMAIL_PROVIDER/FROM/FROM_NAME. */
15
+ readonly variables: readonly EmailProviderVariable[];
16
+ /** Parses its own variables; throws `invalid_configuration` naming the variable, never its value. */
17
+ create(input: {
18
+ readonly from: EmailSenderIdentity;
19
+ readonly env: EmailEnvironment;
20
+ readonly fetch?: EmailFetch;
21
+ }): EmailSender;
22
+ }
23
+ export type EmailProviderRegistry = Readonly<Record<string, EmailProvider>>;
24
+ export type EmailTransportConfig = {
25
+ readonly kind: 'none';
26
+ } | {
27
+ readonly kind: 'provider';
28
+ readonly provider: string;
29
+ readonly from: EmailSenderIdentity;
30
+ readonly variables: readonly EmailProviderVariable[];
31
+ };
32
+ /** The variable that selects the provider. */
33
+ export declare const EMAIL_PROVIDER_VARIABLE = "FABRIKA_EMAIL_PROVIDER";
34
+ /** `FABRIKA_EMAIL_PROVIDER=none`: the installation states it has no transport, which an absent variable does not. */
35
+ export declare const NO_EMAIL_PROVIDER = "none";
36
+ /** Throws one `invalid_configuration` listing every missing or invalid variable name. */
37
+ export declare function readEmailTransportConfig(env: EmailEnvironment, registry: EmailProviderRegistry): EmailTransportConfig;
38
+ /** null for `none` or an absent provider. */
39
+ export declare function emailSenderFromEnv(env: EmailEnvironment, registry: EmailProviderRegistry, options?: {
40
+ readonly fetch?: EmailFetch;
41
+ }): EmailSender | null;
42
+ /** The non-empty string `FABRIKA_EMAIL_*` entries of any binding object (Worker bindings, process.env). */
43
+ export declare function pickEmailEnvironment(source: object): EmailEnvironment;
44
+ /**
45
+ * A provider's own variables through its schema. A failure is one `invalid_configuration` naming each offending
46
+ * variable and never its value, in the form `readEmailTransportConfig` merges into its own refusal.
47
+ */
48
+ export declare function parseProviderVariables<Schema extends z.ZodTypeAny>(schema: Schema, env: EmailEnvironment): z.output<Schema>;
@@ -0,0 +1,59 @@
1
+ import { type EmailAttachmentContent } from './content.js';
2
+ import { type EmailFetch, type EmailMessage, type EmailSender } from './email.js';
3
+ /** What the provider received, or what a local sender stored, in the message's own terms. */
4
+ export interface WireMessage {
5
+ readonly to: string;
6
+ readonly subject: string;
7
+ readonly text: string;
8
+ readonly html?: string;
9
+ readonly replyTo?: string;
10
+ readonly fromName?: string;
11
+ readonly idempotencyKey?: string;
12
+ /** The URL of the `List-Unsubscribe` header, when `List-Unsubscribe-Post` says one click. */
13
+ readonly unsubscribeUrl?: string;
14
+ /** Each attachment with its bytes as base64. */
15
+ readonly attachments?: readonly EmailAttachmentContent[];
16
+ /** Whether the provider got it as broadcast mail, on its channel for them where it has one. */
17
+ readonly broadcast?: boolean;
18
+ }
19
+ /** A stored or sent message in wire terms: what a local harness answers for an `EmailMessage` it kept. */
20
+ export declare function wireMessageOf(message: EmailMessage): WireMessage;
21
+ /** How the provider answers. `inProgress` and `idempotencyConflict` are required when the sender is idempotent. */
22
+ export interface ProviderAnswers {
23
+ accepted(request: Request): Response;
24
+ malformedSuccess(): Response;
25
+ unauthorized(): Response;
26
+ rejected(): Response;
27
+ rateLimited(): Response;
28
+ unavailable(): Response;
29
+ quotaExceeded?(): Response;
30
+ inProgress?(): Response;
31
+ idempotencyConflict?(): Response;
32
+ }
33
+ export interface EmailSenderHttpHarness {
34
+ readonly kind: 'http';
35
+ /** Credentials the sender holds; none may appear in an error or a log line. */
36
+ readonly secrets: readonly string[];
37
+ create(fetch: EmailFetch, options: {
38
+ readonly timeoutMs: number;
39
+ }): EmailSender;
40
+ readRequest(request: Request): Promise<WireMessage>;
41
+ readonly answers: ProviderAnswers;
42
+ }
43
+ /** A local sender's I/O cannot be held open from outside, so the abort-during-I/O cases run for HTTP senders only. */
44
+ export interface EmailSenderLocalHarness {
45
+ readonly kind: 'local';
46
+ /** Called before each case. */
47
+ create(): Promise<EmailSender>;
48
+ /** What the sender created by the last `create` stored, oldest first. */
49
+ stored(): Promise<readonly WireMessage[]>;
50
+ /** Called after each case. */
51
+ cleanup?(): Promise<void>;
52
+ }
53
+ export type EmailSenderConformanceHarness = EmailSenderHttpHarness | EmailSenderLocalHarness;
54
+ /**
55
+ * Registers the conformance cases for one sender. HTTP harnesses also get the provider answer table, the thrown and
56
+ * silent fetch, and an abort or a timeout while the request or the answer body is in flight; a local harness gets
57
+ * only the already-aborted case, because nothing can hold its I/O open.
58
+ */
59
+ export declare function describeEmailSenderConformance(name: string, harness: EmailSenderConformanceHarness): void;
@@ -0,0 +1,23 @@
1
+ import type { EmailMessage } from './email.js';
2
+ /** An attachment as a local sender keeps and compares it: the bytes as base64. */
3
+ export interface EmailAttachmentContent {
4
+ readonly filename: string;
5
+ readonly contentType: string;
6
+ readonly content: string;
7
+ }
8
+ /** What a local sender compares when one idempotency key arrives twice: everything the recipient would see. */
9
+ export interface EmailContent {
10
+ readonly to: string;
11
+ readonly subject: string;
12
+ readonly text: string;
13
+ readonly html?: string;
14
+ readonly replyTo?: string;
15
+ readonly fromName?: string;
16
+ readonly unsubscribeUrl?: string;
17
+ readonly broadcast?: true;
18
+ readonly attachments?: readonly EmailAttachmentContent[];
19
+ }
20
+ export declare function sameEmailContent(left: EmailContent, right: EmailContent): boolean;
21
+ export declare function emailContentOf(message: EmailMessage): EmailContent;
22
+ /** `bytes` as standard base64, on every runtime: `btoa` over chunks, so a large file never overflows the call stack. */
23
+ export declare function base64Of(bytes: Uint8Array): string;