@fabrika/email 0.0.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 ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@fabrika/email",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/contember/fabrika-platform.git",
8
+ "directory": "packages/email"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/__tests__/**"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "bun": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "default": "./src/index.ts"
22
+ },
23
+ "./resend": {
24
+ "bun": "./src/resend.ts",
25
+ "types": "./src/resend.ts",
26
+ "default": "./src/resend.ts"
27
+ }
28
+ },
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "bun test src/__tests__"
32
+ }
33
+ }
package/src/email.ts ADDED
@@ -0,0 +1,48 @@
1
+ export interface EmailMessage {
2
+ readonly to: string
3
+ readonly subject: string
4
+ readonly text: string
5
+ readonly html?: string
6
+ readonly idempotencyKey: string
7
+ }
8
+
9
+ export interface EmailDeliveryResult {
10
+ readonly status: 'accepted'
11
+ readonly provider: string
12
+ readonly messageId: string
13
+ }
14
+
15
+ export interface EmailSender {
16
+ send(message: EmailMessage): Promise<EmailDeliveryResult>
17
+ }
18
+
19
+ export type EmailDeliveryErrorCode =
20
+ | 'invalid_configuration'
21
+ | 'invalid_message'
22
+ | 'network_error'
23
+ | 'request_timeout'
24
+ | 'rate_limited'
25
+ | 'request_in_progress'
26
+ | 'provider_rejected'
27
+ | 'provider_unavailable'
28
+ | 'malformed_response'
29
+
30
+ export interface EmailDeliveryErrorOptions {
31
+ readonly code: EmailDeliveryErrorCode
32
+ readonly retryable: boolean
33
+ readonly httpStatus?: number
34
+ }
35
+
36
+ export class EmailDeliveryError extends Error {
37
+ readonly code: EmailDeliveryErrorCode
38
+ readonly retryable: boolean
39
+ readonly httpStatus: number | undefined
40
+
41
+ constructor(message: string, options: EmailDeliveryErrorOptions) {
42
+ super(message)
43
+ this.name = 'EmailDeliveryError'
44
+ this.code = options.code
45
+ this.retryable = options.retryable
46
+ this.httpStatus = options.httpStatus
47
+ }
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { EmailDeliveryError } from './email.js'
2
+ export type { EmailDeliveryErrorCode, EmailDeliveryErrorOptions, EmailDeliveryResult, EmailMessage, EmailSender } from './email.js'
3
+ export { isValidEmailAddress, isValidEmailFrom, validateEmailMessage } from './validation.js'
package/src/resend.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { EmailDeliveryError, type EmailDeliveryResult, type EmailMessage, type EmailSender } from './email.js'
2
+ import { isValidEmailFrom, validateEmailMessage } from './validation.js'
3
+
4
+ const RESEND_EMAILS_URL = 'https://api.resend.com/emails'
5
+
6
+ export type EmailFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
7
+
8
+ export interface ResendEmailSenderOptions {
9
+ readonly apiKey: string
10
+ readonly from: string
11
+ readonly fetch?: EmailFetch
12
+ readonly timeoutMs?: number
13
+ }
14
+
15
+ export class ResendEmailSender implements EmailSender {
16
+ readonly #apiKey: string
17
+ readonly #from: string
18
+ readonly #fetch: EmailFetch
19
+ readonly #timeoutMs: number
20
+
21
+ constructor(options: ResendEmailSenderOptions) {
22
+ if (!validApiKey(options.apiKey)) {
23
+ throw new EmailDeliveryError('Resend API key is invalid', { code: 'invalid_configuration', retryable: false })
24
+ }
25
+ if (!isValidEmailFrom(options.from)) {
26
+ throw new EmailDeliveryError('Resend sender is invalid', { code: 'invalid_configuration', retryable: false })
27
+ }
28
+ const timeoutMs = options.timeoutMs ?? 10_000
29
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 120_000) {
30
+ throw new EmailDeliveryError('Resend timeout is invalid', { code: 'invalid_configuration', retryable: false })
31
+ }
32
+
33
+ this.#apiKey = options.apiKey
34
+ this.#from = options.from
35
+ this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init))
36
+ this.#timeoutMs = timeoutMs
37
+ }
38
+
39
+ async send(message: EmailMessage): Promise<EmailDeliveryResult> {
40
+ validateEmailMessage(message)
41
+
42
+ let response: Response
43
+ const signal = AbortSignal.timeout(this.#timeoutMs)
44
+ try {
45
+ response = await this.#fetch(RESEND_EMAILS_URL, {
46
+ method: 'POST',
47
+ headers: {
48
+ Authorization: `Bearer ${this.#apiKey}`,
49
+ 'Content-Type': 'application/json',
50
+ 'Idempotency-Key': message.idempotencyKey,
51
+ },
52
+ signal,
53
+ body: JSON.stringify({
54
+ from: this.#from,
55
+ to: message.to,
56
+ subject: message.subject,
57
+ text: message.text,
58
+ ...(message.html === undefined ? {} : { html: message.html }),
59
+ }),
60
+ })
61
+ } catch {
62
+ if (signal.aborted) {
63
+ throw new EmailDeliveryError('Resend request timed out', { code: 'request_timeout', retryable: true })
64
+ }
65
+ throw new EmailDeliveryError('Resend request failed', { code: 'network_error', retryable: true })
66
+ }
67
+
68
+ if (!response.ok) throw await responseError(response)
69
+
70
+ const messageId = await readMessageId(response)
71
+ if (messageId === undefined) {
72
+ throw new EmailDeliveryError('Resend returned an invalid success response', {
73
+ code: 'malformed_response',
74
+ retryable: true,
75
+ httpStatus: response.status,
76
+ })
77
+ }
78
+
79
+ return { status: 'accepted', provider: 'resend', messageId }
80
+ }
81
+ }
82
+
83
+ async function responseError(response: Response): Promise<EmailDeliveryError> {
84
+ const providerError = await readProviderError(response)
85
+ if (response.status === 429) {
86
+ return new EmailDeliveryError('Resend rate limit exceeded', { code: 'rate_limited', retryable: true, httpStatus: response.status })
87
+ }
88
+ if (response.status >= 500) {
89
+ return new EmailDeliveryError('Resend is unavailable', { code: 'provider_unavailable', retryable: true, httpStatus: response.status })
90
+ }
91
+ if (response.status === 409 && providerError === 'concurrent_idempotent_requests') {
92
+ return new EmailDeliveryError('A matching Resend request is still in progress', {
93
+ code: 'request_in_progress',
94
+ retryable: true,
95
+ httpStatus: response.status,
96
+ })
97
+ }
98
+ return new EmailDeliveryError(`Resend rejected the request (${response.status})`, {
99
+ code: 'provider_rejected',
100
+ retryable: false,
101
+ httpStatus: response.status,
102
+ })
103
+ }
104
+
105
+ async function readMessageId(response: Response): Promise<string | undefined> {
106
+ const body = await readJson(response)
107
+ if (!isRecord(body)) return undefined
108
+ const id: unknown = Reflect.get(body, 'id')
109
+ return typeof id === 'string' && id.length > 0 ? id : undefined
110
+ }
111
+
112
+ async function readProviderError(response: Response): Promise<string | undefined> {
113
+ const body = await readJson(response)
114
+ if (!isRecord(body)) return undefined
115
+ const name: unknown = Reflect.get(body, 'name')
116
+ return typeof name === 'string' ? name : undefined
117
+ }
118
+
119
+ async function readJson(response: Response): Promise<unknown> {
120
+ try {
121
+ return await response.json()
122
+ } catch {
123
+ return undefined
124
+ }
125
+ }
126
+
127
+ function isRecord(value: unknown): value is Record<string, unknown> {
128
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
129
+ }
130
+
131
+ function validApiKey(value: string): boolean {
132
+ if (value.length === 0 || value.trim() !== value) return false
133
+ for (const character of value) {
134
+ const code = character.charCodeAt(0)
135
+ if (code < 33 || code > 126) return false
136
+ }
137
+ return true
138
+ }
@@ -0,0 +1,62 @@
1
+ import { EmailDeliveryError, type EmailMessage } from './email.js'
2
+
3
+ const DOT_ATOM_CHARACTER = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+$/
4
+ const DOMAIN_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/
5
+
6
+ export function isValidEmailAddress(value: string): boolean {
7
+ if (value.length === 0 || value.length > 254 || value.trim() !== value || containsControlCharacter(value)) return false
8
+
9
+ const separator = value.indexOf('@')
10
+ if (separator <= 0 || separator !== value.lastIndexOf('@')) return false
11
+
12
+ const local = value.slice(0, separator)
13
+ const domain = value.slice(separator + 1)
14
+ if (local.length > 64 || domain.length === 0 || domain.length > 253 || !domain.includes('.')) return false
15
+ if (!local.split('.').every((part) => part.length > 0 && DOT_ATOM_CHARACTER.test(part))) return false
16
+
17
+ return domain.split('.').every((label) => label.length <= 63 && DOMAIN_LABEL.test(label))
18
+ }
19
+
20
+ export function isValidEmailFrom(value: string): boolean {
21
+ if (isValidEmailAddress(value)) return true
22
+ if (value.length === 0 || value.length > 384 || value.trim() !== value || containsControlCharacter(value) || !value.endsWith('>')) return false
23
+
24
+ const openingBracket = value.lastIndexOf('<')
25
+ if (openingBracket <= 0 || value.indexOf('<') !== openingBracket || value.indexOf('>') !== value.length - 1) return false
26
+
27
+ const displayName = value.slice(0, openingBracket).trim()
28
+ const address = value.slice(openingBracket + 1, -1)
29
+ return displayName.length > 0 && displayName.length <= 128 && !displayName.includes('<') && !displayName.includes('>')
30
+ && isValidEmailAddress(address)
31
+ }
32
+
33
+ export function validateEmailMessage(message: EmailMessage): void {
34
+ if (!isValidEmailAddress(message.to)) throw invalidMessage('Email recipient is invalid')
35
+ if (message.subject.length === 0 || message.subject.length > 998 || containsControlCharacter(message.subject)) {
36
+ throw invalidMessage('Email subject is invalid')
37
+ }
38
+ if (message.text.trim().length === 0) throw invalidMessage('Email text is empty')
39
+ if (message.idempotencyKey.length === 0 || message.idempotencyKey.length > 256 || !isVisibleAscii(message.idempotencyKey)) {
40
+ throw invalidMessage('Email idempotency key is invalid')
41
+ }
42
+ }
43
+
44
+ function invalidMessage(message: string): EmailDeliveryError {
45
+ return new EmailDeliveryError(message, { code: 'invalid_message', retryable: false })
46
+ }
47
+
48
+ function containsControlCharacter(value: string): boolean {
49
+ for (const character of value) {
50
+ const code = character.charCodeAt(0)
51
+ if (code < 32 || code === 127) return true
52
+ }
53
+ return false
54
+ }
55
+
56
+ function isVisibleAscii(value: string): boolean {
57
+ for (const character of value) {
58
+ const code = character.charCodeAt(0)
59
+ if (code < 33 || code > 126) return false
60
+ }
61
+ return true
62
+ }