@fabrika/email 0.4.8 → 0.5.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,133 +1,126 @@
1
1
  import { z } from 'zod'
2
- import { EmailDeliveryError, type EmailDeliveryResult, type EmailMessage, type EmailSender } from './email.js'
3
- import { isValidEmailFrom, validateEmailMessage } from './validation.js'
2
+ import { type EmailProvider, type EmailSenderIdentity, parseProviderVariables } from './config.js'
3
+ import {
4
+ EmailDeliveryError,
5
+ type EmailDeliveryResult,
6
+ type EmailFetch,
7
+ type EmailMessage,
8
+ type EmailSender,
9
+ type EmailSenderCapabilities,
10
+ } from './email.js'
11
+ import { parseTimeout, postToProvider, type ProviderResponse } from './http.js'
12
+ import { isValidEmailSenderIdentity, isVisibleAscii, validateEmailMessage } from './validation.js'
4
13
 
5
14
  const RESEND_EMAILS_URL = 'https://api.resend.com/emails'
15
+ const LABEL = 'Resend'
6
16
  const resendSuccessSchema = z.object({ id: z.string().min(1) })
7
17
  const resendErrorSchema = z.object({ name: z.string() })
8
-
9
- export type EmailFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
18
+ const QUOTA_ERRORS: ReadonlySet<string> = new Set(['daily_quota_exceeded', 'monthly_quota_exceeded'])
19
+ /** RFC 5322 specials a display name may still hold after validation; any of them makes the name a quoted string. */
20
+ const DISPLAY_NAME_SPECIALS = /[()[\]:;@,.]/
10
21
 
11
22
  export interface ResendEmailSenderOptions {
12
23
  readonly apiKey: string
13
- readonly from: string
24
+ readonly from: EmailSenderIdentity
14
25
  readonly fetch?: EmailFetch
15
26
  readonly timeoutMs?: number
16
27
  }
17
28
 
18
29
  export class ResendEmailSender implements EmailSender {
30
+ readonly capabilities: EmailSenderCapabilities = { idempotent: true, confirmation: 'sync', maxBatch: 1 }
19
31
  readonly #apiKey: string
20
- readonly #from: string
32
+ readonly #from: EmailSenderIdentity
21
33
  readonly #fetch: EmailFetch
22
34
  readonly #timeoutMs: number
23
35
 
24
36
  constructor(options: ResendEmailSenderOptions) {
25
37
  if (!validApiKey(options.apiKey)) {
26
- throw new EmailDeliveryError('Resend API key is invalid', { code: 'invalid_configuration', retryable: false })
27
- }
28
- if (!isValidEmailFrom(options.from)) {
29
- throw new EmailDeliveryError('Resend sender is invalid', { code: 'invalid_configuration', retryable: false })
38
+ throw new EmailDeliveryError('Resend API key is invalid', { code: 'invalid_configuration' })
30
39
  }
31
- const timeoutMs = options.timeoutMs ?? 10_000
32
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 120_000) {
33
- throw new EmailDeliveryError('Resend timeout is invalid', { code: 'invalid_configuration', retryable: false })
40
+ if (!isValidEmailSenderIdentity(options.from)) {
41
+ throw new EmailDeliveryError('Resend sender is invalid', { code: 'invalid_configuration' })
34
42
  }
35
43
 
36
44
  this.#apiKey = options.apiKey
37
45
  this.#from = options.from
38
46
  this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
39
- this.#timeoutMs = timeoutMs
47
+ this.#timeoutMs = parseTimeout(LABEL, options.timeoutMs)
40
48
  }
41
49
 
42
- async send(message: EmailMessage): Promise<EmailDeliveryResult> {
50
+ async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
43
51
  validateEmailMessage(message)
44
52
 
45
- let response: Response
46
- const signal = AbortSignal.timeout(this.#timeoutMs)
47
- try {
48
- response = await this.#fetch(RESEND_EMAILS_URL, {
49
- method: 'POST',
50
- headers: {
51
- Authorization: `Bearer ${this.#apiKey}`,
52
- 'Content-Type': 'application/json',
53
- 'Idempotency-Key': message.idempotencyKey,
54
- },
55
- signal,
56
- body: JSON.stringify({
57
- from: this.#from,
58
- to: message.to,
59
- subject: message.subject,
60
- text: message.text,
61
- ...(message.html === undefined ? {} : { html: message.html }),
62
- }),
63
- })
64
- } catch {
65
- if (signal.aborted) {
66
- throw new EmailDeliveryError('Resend request timed out', { code: 'request_timeout', retryable: true })
67
- }
68
- throw new EmailDeliveryError('Resend request failed', { code: 'network_error', retryable: true })
69
- }
53
+ const response = await postToProvider({
54
+ label: LABEL,
55
+ fetch: this.#fetch,
56
+ url: RESEND_EMAILS_URL,
57
+ headers: { Authorization: `Bearer ${this.#apiKey}`, 'Idempotency-Key': message.idempotencyKey },
58
+ body: {
59
+ from: formatMailbox(this.#from.address, message.fromName ?? this.#from.name),
60
+ to: message.to,
61
+ subject: message.subject,
62
+ text: message.text,
63
+ ...(message.html === undefined ? {} : { html: message.html }),
64
+ ...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
65
+ },
66
+ signal,
67
+ timeoutMs: this.#timeoutMs,
68
+ })
70
69
 
71
- if (!response.ok) throw await responseError(response)
70
+ if (!response.ok) throw responseError(response)
72
71
 
73
- const messageId = await readMessageId(response)
74
- if (messageId === undefined) {
75
- throw new EmailDeliveryError('Resend returned an invalid success response', {
76
- code: 'malformed_response',
77
- retryable: true,
78
- httpStatus: response.status,
79
- })
72
+ const parsed = resendSuccessSchema.safeParse(response.body)
73
+ if (!parsed.success) {
74
+ throw new EmailDeliveryError('Resend returned an invalid success response', { code: 'malformed_response', httpStatus: response.status })
80
75
  }
81
76
 
82
- return { status: 'accepted', provider: 'resend', messageId }
77
+ return { status: 'accepted', provider: 'resend', messageId: parsed.data.id }
83
78
  }
84
79
  }
85
80
 
86
- async function responseError(response: Response): Promise<EmailDeliveryError> {
87
- const providerError = await readProviderError(response)
88
- if (response.status === 429) {
89
- return new EmailDeliveryError('Resend rate limit exceeded', { code: 'rate_limited', retryable: true, httpStatus: response.status })
81
+ const resendVariablesSchema = z.object({ FABRIKA_EMAIL_RESEND_API_KEY: z.string().refine(validApiKey) })
82
+
83
+ export const resendEmailProvider: EmailProvider = {
84
+ variables: [{ name: 'FABRIKA_EMAIL_RESEND_API_KEY', secret: true, required: true }],
85
+ create({ from, env, fetch }) {
86
+ const variables = parseProviderVariables(resendVariablesSchema, env)
87
+ return new ResendEmailSender({ apiKey: variables.FABRIKA_EMAIL_RESEND_API_KEY, from, fetch })
88
+ },
89
+ }
90
+
91
+ function responseError(response: ProviderResponse): EmailDeliveryError {
92
+ const status = response.status
93
+ const parsed = resendErrorSchema.safeParse(response.body)
94
+ const name = parsed.success ? parsed.data.name : undefined
95
+ if (status === 401 || status === 403) {
96
+ return new EmailDeliveryError(`Resend refused the credentials or the sender (${status})`, { code: 'provider_unauthorized', httpStatus: status })
90
97
  }
91
- if (response.status >= 500) {
92
- return new EmailDeliveryError('Resend is unavailable', { code: 'provider_unavailable', retryable: true, httpStatus: response.status })
98
+ if (status === 409 && name === 'concurrent_idempotent_requests') {
99
+ return new EmailDeliveryError('A matching Resend request is still in progress', { code: 'request_in_progress', httpStatus: status })
93
100
  }
94
- if (response.status === 409 && providerError === 'concurrent_idempotent_requests') {
95
- return new EmailDeliveryError('A matching Resend request is still in progress', {
96
- code: 'request_in_progress',
97
- retryable: true,
98
- httpStatus: response.status,
101
+ if (status === 409 && name === 'invalid_idempotent_request') {
102
+ return new EmailDeliveryError('Resend already accepted a different message under this idempotency key', {
103
+ code: 'idempotency_conflict',
104
+ httpStatus: status,
99
105
  })
100
106
  }
101
- return new EmailDeliveryError(`Resend rejected the request (${response.status})`, {
102
- code: 'provider_rejected',
103
- retryable: false,
104
- httpStatus: response.status,
105
- })
106
- }
107
-
108
- async function readMessageId(response: Response): Promise<string | undefined> {
109
- const parsed = resendSuccessSchema.safeParse(await readJson(response))
110
- return parsed.success ? parsed.data.id : undefined
111
- }
112
-
113
- async function readProviderError(response: Response): Promise<string | undefined> {
114
- const parsed = resendErrorSchema.safeParse(await readJson(response))
115
- return parsed.success ? parsed.data.name : undefined
107
+ if (status === 429 && name !== undefined && QUOTA_ERRORS.has(name)) {
108
+ return new EmailDeliveryError('Resend sending quota exceeded', { code: 'quota_exceeded', httpStatus: status })
109
+ }
110
+ if (status === 429) {
111
+ return new EmailDeliveryError('Resend rate limit exceeded', { code: 'rate_limited', httpStatus: status })
112
+ }
113
+ if (status >= 500) {
114
+ return new EmailDeliveryError('Resend is unavailable', { code: 'provider_unavailable', httpStatus: status })
115
+ }
116
+ return new EmailDeliveryError(`Resend rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
116
117
  }
117
118
 
118
- async function readJson(response: Response): Promise<unknown> {
119
- try {
120
- return await response.json()
121
- } catch {
122
- return undefined
123
- }
119
+ function formatMailbox(address: string, name: string | undefined): string {
120
+ if (name === undefined) return address
121
+ return DISPLAY_NAME_SPECIALS.test(name) ? `"${name}" <${address}>` : `${name} <${address}>`
124
122
  }
125
123
 
126
124
  function validApiKey(value: string): boolean {
127
- if (value.length === 0 || value.trim() !== value) return false
128
- for (const character of value) {
129
- const code = character.charCodeAt(0)
130
- if (code < 33 || code > 126) return false
131
- }
132
- return true
125
+ return value.length > 0 && isVisibleAscii(value)
133
126
  }
@@ -0,0 +1,150 @@
1
+ // SmartEmailing's transactional API v3 (https://app.smartemailing.cz/docs/api/v3/), built from its OpenAPI document and
2
+ // verified only against a stubbed fetch. It has no idempotency key, and a 201 only queues the request.
3
+
4
+ import { z } from 'zod'
5
+ import { type EmailProvider, type EmailSenderIdentity, parseProviderVariables } from './config.js'
6
+ 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, validateEmailMessage } from './validation.js'
16
+
17
+ const SMARTEMAILING_SEND_URL = 'https://app.smartemailing.cz/api/v3/send/transactional-emails-bulk'
18
+ const LABEL = 'SmartEmailing'
19
+ const DEFAULT_TAG = 'fabrika'
20
+ const TAG_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
21
+ const smartEmailingSuccessSchema = z.object({ data: z.tuple([z.object({ id: z.string().min(1) })]) })
22
+
23
+ export interface SmartEmailingEmailSenderOptions {
24
+ /** The account's user name or e-mail: the first half of the Basic credential. */
25
+ readonly username: string
26
+ readonly apiKey: string
27
+ readonly from: EmailSenderIdentity
28
+ /** Groups the messages in SmartEmailing's statistics. */
29
+ readonly tag?: string
30
+ readonly fetch?: EmailFetch
31
+ readonly timeoutMs?: number
32
+ }
33
+
34
+ export class SmartEmailingEmailSender implements EmailSender {
35
+ /** The API takes up to five tasks a request, but `send` carries one. */
36
+ readonly capabilities: EmailSenderCapabilities = { idempotent: false, confirmation: 'queued', maxBatch: 1 }
37
+ readonly #authorization: string
38
+ readonly #from: EmailSenderIdentity
39
+ readonly #tag: string
40
+ readonly #fetch: EmailFetch
41
+ readonly #timeoutMs: number
42
+
43
+ constructor(options: SmartEmailingEmailSenderOptions) {
44
+ if (!validUsername(options.username)) {
45
+ throw new EmailDeliveryError('SmartEmailing username is invalid', { code: 'invalid_configuration' })
46
+ }
47
+ if (!validApiKey(options.apiKey)) {
48
+ throw new EmailDeliveryError('SmartEmailing API key is invalid', { code: 'invalid_configuration' })
49
+ }
50
+ if (!isValidEmailSenderIdentity(options.from)) {
51
+ throw new EmailDeliveryError('SmartEmailing sender is invalid', { code: 'invalid_configuration' })
52
+ }
53
+ const tag = options.tag ?? DEFAULT_TAG
54
+ if (!TAG_PATTERN.test(tag)) {
55
+ throw new EmailDeliveryError('SmartEmailing tag is invalid', { code: 'invalid_configuration' })
56
+ }
57
+
58
+ this.#authorization = `Basic ${btoa(`${options.username}:${options.apiKey}`)}`
59
+ this.#from = options.from
60
+ this.#tag = tag
61
+ this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
62
+ this.#timeoutMs = parseTimeout(LABEL, options.timeoutMs)
63
+ }
64
+
65
+ async send(message: EmailMessage, signal: AbortSignal): Promise<EmailDeliveryResult> {
66
+ validateEmailMessage(message)
67
+
68
+ const senderName = message.fromName ?? this.#from.name
69
+ const response = await postToProvider({
70
+ label: LABEL,
71
+ fetch: this.#fetch,
72
+ url: SMARTEMAILING_SEND_URL,
73
+ headers: { Authorization: this.#authorization },
74
+ body: {
75
+ sender_credentials: {
76
+ from: this.#from.address,
77
+ ...(senderName === undefined ? {} : { sender_name: senderName }),
78
+ ...(message.replyTo === undefined ? {} : { reply_to: message.replyTo }),
79
+ },
80
+ tag: this.#tag,
81
+ // Unverified: the API documents no rule for a message without `html_body`.
82
+ message_contents: {
83
+ subject: message.subject,
84
+ text_body: message.text,
85
+ ...(message.html === undefined ? {} : { html_body: message.html }),
86
+ },
87
+ tasks: [{ recipient: { emailaddress: message.to } }],
88
+ },
89
+ signal,
90
+ timeoutMs: this.#timeoutMs,
91
+ })
92
+
93
+ // The error body's `message` is free text that may quote the request, so only the status is classified.
94
+ if (!response.ok) throw responseError(response.status)
95
+
96
+ const parsed = smartEmailingSuccessSchema.safeParse(response.body)
97
+ if (!parsed.success) {
98
+ throw new EmailDeliveryError('SmartEmailing returned an invalid success response', { code: 'malformed_response', httpStatus: response.status })
99
+ }
100
+
101
+ return { status: 'accepted', provider: 'smartemailing', messageId: parsed.data.data[0].id }
102
+ }
103
+ }
104
+
105
+ const smartEmailingVariablesSchema = z.object({
106
+ FABRIKA_EMAIL_SMARTEMAILING_USERNAME: z.string().refine(validUsername),
107
+ FABRIKA_EMAIL_SMARTEMAILING_API_KEY: z.string().refine(validApiKey),
108
+ FABRIKA_EMAIL_SMARTEMAILING_TAG: z.string().optional().transform((value) => (value === '' ? undefined : value)).refine((value) =>
109
+ value === undefined || TAG_PATTERN.test(value)
110
+ ),
111
+ })
112
+
113
+ export const smartEmailingEmailProvider: EmailProvider = {
114
+ variables: [
115
+ { name: 'FABRIKA_EMAIL_SMARTEMAILING_USERNAME', secret: true, required: true },
116
+ { name: 'FABRIKA_EMAIL_SMARTEMAILING_API_KEY', secret: true, required: true },
117
+ { name: 'FABRIKA_EMAIL_SMARTEMAILING_TAG', secret: false, required: false },
118
+ ],
119
+ create({ from, env, fetch }) {
120
+ const variables = parseProviderVariables(smartEmailingVariablesSchema, env)
121
+ return new SmartEmailingEmailSender({
122
+ username: variables.FABRIKA_EMAIL_SMARTEMAILING_USERNAME,
123
+ apiKey: variables.FABRIKA_EMAIL_SMARTEMAILING_API_KEY,
124
+ from,
125
+ tag: variables.FABRIKA_EMAIL_SMARTEMAILING_TAG,
126
+ fetch,
127
+ })
128
+ },
129
+ }
130
+
131
+ function responseError(status: number): EmailDeliveryError {
132
+ if (status === 401 || status === 403) {
133
+ return new EmailDeliveryError(`SmartEmailing refused the credentials (${status})`, { code: 'provider_unauthorized', httpStatus: status })
134
+ }
135
+ if (status === 429) {
136
+ return new EmailDeliveryError('SmartEmailing rate limit exceeded', { code: 'rate_limited', httpStatus: status })
137
+ }
138
+ if (status >= 500) {
139
+ return new EmailDeliveryError('SmartEmailing is unavailable', { code: 'provider_unavailable', httpStatus: status })
140
+ }
141
+ return new EmailDeliveryError(`SmartEmailing rejected the request (${status})`, { code: 'provider_rejected', httpStatus: status })
142
+ }
143
+
144
+ function validUsername(value: string): boolean {
145
+ return validApiKey(value) && !value.includes(':')
146
+ }
147
+
148
+ function validApiKey(value: string): boolean {
149
+ return value.length > 0 && isVisibleAscii(value)
150
+ }
package/src/validation.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { EmailSenderIdentity } from './config.js'
1
2
  import { EmailDeliveryError, type EmailMessage } from './email.js'
2
3
 
3
4
  /** The longest subject a message may have, in UTF-16 code units: RFC 5322's line limit. */
@@ -20,17 +21,35 @@ export function isValidEmailAddress(value: string): boolean {
20
21
  return domain.split('.').every((label) => label.length <= 63 && DOMAIN_LABEL.test(label))
21
22
  }
22
23
 
24
+ /** A mailbox address, or the legacy `Name <address>` form. */
23
25
  export function isValidEmailFrom(value: string): boolean {
24
- if (isValidEmailAddress(value)) return true
25
- if (value.length === 0 || value.length > 384 || value.trim() !== value || containsControlCharacter(value) || !value.endsWith('>')) return false
26
+ return parseEmailFrom(value) !== null
27
+ }
28
+
29
+ /**
30
+ * Splits a bare address or the legacy `Name <address>` form. A name in one pair of double quotes loses them, since a
31
+ * transport quotes the name itself when it has to.
32
+ */
33
+ export function parseEmailFrom(value: string): EmailSenderIdentity | null {
34
+ if (isValidEmailAddress(value)) return { address: value }
35
+ if (value.length === 0 || value.length > 384 || value.trim() !== value || containsControlCharacter(value) || !value.endsWith('>')) return null
26
36
 
27
37
  const openingBracket = value.lastIndexOf('<')
28
- if (openingBracket <= 0 || value.indexOf('<') !== openingBracket || value.indexOf('>') !== value.length - 1) return false
38
+ if (openingBracket <= 0 || value.indexOf('<') !== openingBracket || value.indexOf('>') !== value.length - 1) return null
29
39
 
30
- const displayName = value.slice(0, openingBracket).trim()
40
+ const displayName = unquoted(value.slice(0, openingBracket).trim())
31
41
  const address = value.slice(openingBracket + 1, -1)
32
- return displayName.length > 0 && displayName.length <= 128 && !displayName.includes('<') && !displayName.includes('>')
33
- && isValidEmailAddress(address)
42
+ if (!isValidEmailDisplayName(displayName) || !isValidEmailAddress(address)) return null
43
+ return { address, name: displayName }
44
+ }
45
+
46
+ /** 1..128 characters, no control characters and none of `<>"\`, so a transport can always quote it. */
47
+ export function isValidEmailDisplayName(value: string): boolean {
48
+ return value.length > 0 && value.length <= 128 && !containsControlCharacter(value) && !/[<>"\\]/.test(value)
49
+ }
50
+
51
+ export function isValidEmailSenderIdentity(identity: EmailSenderIdentity): boolean {
52
+ return isValidEmailAddress(identity.address) && (identity.name === undefined || isValidEmailDisplayName(identity.name))
34
53
  }
35
54
 
36
55
  export function validateEmailMessage(message: EmailMessage): void {
@@ -42,10 +61,12 @@ export function validateEmailMessage(message: EmailMessage): void {
42
61
  if (message.idempotencyKey.length === 0 || message.idempotencyKey.length > 256 || !isVisibleAscii(message.idempotencyKey)) {
43
62
  throw invalidMessage('Email idempotency key is invalid')
44
63
  }
64
+ if (message.replyTo !== undefined && !isValidEmailAddress(message.replyTo)) throw invalidMessage('Email reply-to address is invalid')
65
+ if (message.fromName !== undefined && !isValidEmailDisplayName(message.fromName)) throw invalidMessage('Email sender name is invalid')
45
66
  }
46
67
 
47
68
  function invalidMessage(message: string): EmailDeliveryError {
48
- return new EmailDeliveryError(message, { code: 'invalid_message', retryable: false })
69
+ return new EmailDeliveryError(message, { code: 'invalid_message' })
49
70
  }
50
71
 
51
72
  function containsControlCharacter(value: string): boolean {
@@ -56,7 +77,11 @@ function containsControlCharacter(value: string): boolean {
56
77
  return false
57
78
  }
58
79
 
59
- function isVisibleAscii(value: string): boolean {
80
+ function unquoted(value: string): string {
81
+ return value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value
82
+ }
83
+
84
+ export function isVisibleAscii(value: string): boolean {
60
85
  for (const character of value) {
61
86
  const code = character.charCodeAt(0)
62
87
  if (code < 33 || code > 126) return false