@fabrika/email 0.3.3 → 0.4.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.3.3",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,11 @@
24
24
  "bun": "./src/resend.ts",
25
25
  "types": "./src/resend.ts",
26
26
  "default": "./src/resend.ts"
27
+ },
28
+ "./directory": {
29
+ "bun": "./src/directory.ts",
30
+ "types": "./src/directory.ts",
31
+ "default": "./src/directory.ts"
27
32
  }
28
33
  },
29
34
  "scripts": {
@@ -0,0 +1,78 @@
1
+ // A sender that keeps each message as a JSON file in a directory and delivers nothing: a local composition's mail
2
+ // capture, which its own tests read back with `readCapturedEmails`. Bun and Node only. The files hold the bodies, and
3
+ // with them every one-time link, so the directory belongs to the composition's disposable local state.
4
+
5
+ import { createHash, randomUUID } from 'node:crypto'
6
+ import { link, readdir, readFile, unlink, writeFile } from 'node:fs/promises'
7
+ import { isAbsolute, join } from 'node:path'
8
+ import { z } from 'zod'
9
+ import { EmailDeliveryError, type EmailDeliveryResult, type EmailMessage, type EmailSender } from './email.js'
10
+ import { validateEmailMessage } from './validation.js'
11
+
12
+ const capturedEmailSchema = z.object({
13
+ messageId: z.string().min(1),
14
+ to: z.string(),
15
+ subject: z.string(),
16
+ text: z.string(),
17
+ html: z.string().optional(),
18
+ capturedAt: z.number().int(),
19
+ })
20
+
21
+ export type CapturedEmail = z.infer<typeof capturedEmailSchema>
22
+
23
+ export interface DirectoryEmailSenderOptions {
24
+ /** An existing directory, as an absolute path. */
25
+ readonly directory: string
26
+ readonly now?: () => number
27
+ }
28
+
29
+ export class DirectoryEmailSender implements EmailSender {
30
+ readonly #directory: string
31
+ readonly #now: () => number
32
+
33
+ constructor(options: DirectoryEmailSenderOptions) {
34
+ if (!isAbsolute(options.directory)) {
35
+ throw new EmailDeliveryError('The mail capture directory must be an absolute path', { code: 'invalid_configuration', retryable: false })
36
+ }
37
+ this.#directory = options.directory
38
+ this.#now = options.now ?? Date.now
39
+ }
40
+
41
+ /** One file per idempotency key: a repeated send keeps the first message and answers the same id, as a provider does. */
42
+ async send(message: EmailMessage): Promise<EmailDeliveryResult> {
43
+ validateEmailMessage(message)
44
+ const messageId = createHash('sha256').update(message.idempotencyKey).digest('hex')
45
+ const captured: CapturedEmail = {
46
+ messageId,
47
+ to: message.to,
48
+ subject: message.subject,
49
+ text: message.text,
50
+ ...(message.html === undefined ? {} : { html: message.html }),
51
+ capturedAt: this.#now(),
52
+ }
53
+ // Written aside and linked in, so a reader never sees a partial file and an existing one is never replaced.
54
+ const pending = join(this.#directory, `.${messageId}.${randomUUID()}.tmp`)
55
+ try {
56
+ await writeFile(pending, JSON.stringify(captured), { flag: 'wx' })
57
+ await link(pending, join(this.#directory, `${messageId}.json`)).catch((error: unknown) => {
58
+ if (!isAlreadyCaptured(error)) throw error
59
+ })
60
+ } catch {
61
+ throw new EmailDeliveryError('The mail capture directory could not be written', { code: 'provider_unavailable', retryable: true })
62
+ } finally {
63
+ await unlink(pending).catch(() => undefined)
64
+ }
65
+ return { status: 'accepted', provider: 'directory', messageId }
66
+ }
67
+ }
68
+
69
+ /** Every message a `DirectoryEmailSender` captured in `directory`, oldest first. */
70
+ export async function readCapturedEmails(directory: string): Promise<CapturedEmail[]> {
71
+ const names = (await readdir(directory)).filter((name) => name.endsWith('.json') && !name.startsWith('.'))
72
+ const emails = await Promise.all(names.map(async (name) => capturedEmailSchema.parse(JSON.parse(await readFile(join(directory, name), 'utf8')))))
73
+ return emails.sort((left, right) => left.capturedAt - right.capturedAt || left.messageId.localeCompare(right.messageId))
74
+ }
75
+
76
+ function isAlreadyCaptured(error: unknown): boolean {
77
+ return error instanceof Error && 'code' in error && error.code === 'EEXIST'
78
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { EmailDeliveryError } from './email.js'
2
2
  export type { EmailDeliveryErrorCode, EmailDeliveryErrorOptions, EmailDeliveryResult, EmailMessage, EmailSender } from './email.js'
3
- export { isValidEmailAddress, isValidEmailFrom, validateEmailMessage } from './validation.js'
3
+ export { isValidEmailAddress, isValidEmailFrom, MAX_EMAIL_SUBJECT_LENGTH, validateEmailMessage } from './validation.js'
package/src/validation.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { EmailDeliveryError, type EmailMessage } from './email.js'
2
2
 
3
+ /** The longest subject a message may have, in UTF-16 code units: RFC 5322's line limit. */
4
+ export const MAX_EMAIL_SUBJECT_LENGTH = 998
5
+
3
6
  const DOT_ATOM_CHARACTER = /^[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+$/
4
7
  const DOMAIN_LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/
5
8
 
@@ -32,7 +35,7 @@ export function isValidEmailFrom(value: string): boolean {
32
35
 
33
36
  export function validateEmailMessage(message: EmailMessage): void {
34
37
  if (!isValidEmailAddress(message.to)) throw invalidMessage('Email recipient is invalid')
35
- if (message.subject.length === 0 || message.subject.length > 998 || containsControlCharacter(message.subject)) {
38
+ if (message.subject.length === 0 || message.subject.length > MAX_EMAIL_SUBJECT_LENGTH || containsControlCharacter(message.subject)) {
36
39
  throw invalidMessage('Email subject is invalid')
37
40
  }
38
41
  if (message.text.trim().length === 0) throw invalidMessage('Email text is empty')