@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.
@@ -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. */