@nixxie-cms/email 1.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,41 @@
1
+ {
2
+ "name": "@nixxie-cms/email",
3
+ "version": "1.0.1",
4
+ "license": "MIT",
5
+ "main": "dist/nixxie-cms-email.cjs.js",
6
+ "module": "dist/nixxie-cms-email.esm.js",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/nixxie-cms-email.cjs.js",
10
+ "module": "./dist/nixxie-cms-email.esm.js",
11
+ "default": "./dist/nixxie-cms-email.cjs.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "dependencies": {
16
+ "@babel/runtime": "^7.24.7",
17
+ "nodemailer": "^6.9.14"
18
+ },
19
+ "devDependencies": {
20
+ "@types/nodemailer": "^6.4.15",
21
+ "handlebars": "^4.7.8",
22
+ "pug": "^3.0.3",
23
+ "@nixxie-cms/core": "^1.0.1"
24
+ },
25
+ "peerDependencies": {
26
+ "@nixxie-cms/core": "^1.0.1"
27
+ },
28
+ "optionalDependencies": {
29
+ "handlebars": "^4.7.8",
30
+ "pug": "^3.0.3"
31
+ },
32
+ "preconstruct": {
33
+ "entrypoints": [
34
+ "index.ts"
35
+ ]
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/nixxiecms/nixxie/tree/main/packages/email"
40
+ }
41
+ }
@@ -0,0 +1,263 @@
1
+ import type {
2
+ NixxieEmailQueueOptions,
3
+ NixxieEmailRecipient,
4
+ NixxieEmailSendOptions,
5
+ NixxieEmailSentInfo,
6
+ NixxieEmailService,
7
+ } from '@nixxie-cms/core'
8
+ import type {
9
+ EmailConfig,
10
+ EmailQueueStats,
11
+ EmailTransporter,
12
+ QueueJob,
13
+ TransportMessage,
14
+ } from './types'
15
+ import { mkdir, writeFile } from 'node:fs/promises'
16
+ import { join } from 'node:path'
17
+ import { EmailQueue } from './queue/EmailQueue'
18
+ import { TemplateManager } from './templates/TemplateManager'
19
+ import { createTransport } from './transports'
20
+
21
+ function normalizeRecipients(
22
+ recipients: NixxieEmailRecipient | NixxieEmailRecipient[]
23
+ ): string[] {
24
+ const arr = Array.isArray(recipients) ? recipients : [recipients]
25
+ return arr.map(r => (typeof r === 'string' ? r : r.name ? `"${r.name}" <${r.address}>` : r.address))
26
+ }
27
+
28
+ function extractAddress(recipient: NixxieEmailRecipient): string {
29
+ if (typeof recipient === 'string') {
30
+ const match = recipient.match(/<(.+)>/)
31
+ return match ? match[1] : recipient
32
+ }
33
+ return recipient.address
34
+ }
35
+
36
+ export class EmailService implements NixxieEmailService {
37
+ private transport: EmailTransporter
38
+ private templateManager: TemplateManager | undefined
39
+ private emailQueue: EmailQueue | undefined
40
+ private suppressionList = new Set<string>()
41
+ private config: EmailConfig
42
+
43
+ constructor(config: EmailConfig) {
44
+ this.config = config
45
+
46
+ // In development mode, log emails to the console instead of delivering them for real.
47
+ this.transport = this.isDevMode()
48
+ ? createTransport({ type: 'console' })
49
+ : createTransport(config.transport)
50
+
51
+ if (config.templates) {
52
+ this.templateManager = new TemplateManager(config.templates)
53
+ }
54
+
55
+ if (config.queue?.enabled !== false) {
56
+ this.emailQueue = new EmailQueue(config.queue ?? {}, async (job: QueueJob) => {
57
+ await this.sendImmediate(job.options)
58
+ })
59
+ this.emailQueue.start()
60
+ }
61
+
62
+ if (config.suppressionList?.length) {
63
+ for (const addr of config.suppressionList) {
64
+ this.suppressionList.add(addr.toLowerCase())
65
+ }
66
+ }
67
+ }
68
+
69
+ async send(options: NixxieEmailSendOptions): Promise<NixxieEmailSentInfo> {
70
+ return this.sendImmediate(options)
71
+ }
72
+
73
+ async bulk(
74
+ recipients: NixxieEmailRecipient[],
75
+ options: Omit<NixxieEmailSendOptions, 'to'>
76
+ ): Promise<NixxieEmailSentInfo[]> {
77
+ const results: NixxieEmailSentInfo[] = []
78
+
79
+ for (const recipient of recipients) {
80
+ try {
81
+ const info = await this.sendImmediate({ ...options, to: recipient })
82
+ results.push(info)
83
+ } catch (err) {
84
+ try {
85
+ await this.config.onFailed?.(
86
+ err instanceof Error ? err : new Error(String(err)),
87
+ { ...options, to: recipient }
88
+ )
89
+ } catch {
90
+ // a failing onFailed callback must not abort the rest of the bulk send
91
+ }
92
+ }
93
+ }
94
+
95
+ return results
96
+ }
97
+
98
+ /**
99
+ * Whether development mode is active. When a `development` config block is present, this defaults
100
+ * to `process.env.NODE_ENV !== 'production'` unless `development.enabled` is set explicitly.
101
+ * In dev mode emails are logged (and optionally written to `previewPath`) instead of being sent.
102
+ */
103
+ private isDevMode(): boolean {
104
+ const dev = this.config.development
105
+ if (!dev) return false
106
+ return dev.enabled ?? process.env.NODE_ENV !== 'production'
107
+ }
108
+
109
+ private async writePreview(
110
+ dir: string,
111
+ template: string | undefined,
112
+ html: string
113
+ ): Promise<void> {
114
+ try {
115
+ await mkdir(dir, { recursive: true })
116
+ const safeName = (template ?? 'email').replace(/[^a-zA-Z0-9._-]/g, '_')
117
+ await writeFile(join(dir, `${safeName}-${Date.now()}.html`), html, 'utf-8')
118
+ } catch {
119
+ // Preview writing is best-effort and must never break sending.
120
+ }
121
+ }
122
+
123
+ queue(options: NixxieEmailQueueOptions): string {
124
+ if (!this.emailQueue) {
125
+ throw new Error('Email queue is not enabled. Set queue.enabled = true in email config.')
126
+ }
127
+ const job = this.emailQueue.enqueue({ options, sendAt: options.sendAt, priority: options.priority ?? 'normal' })
128
+ return job.id
129
+ }
130
+
131
+ addToSuppressionList(address: string): void {
132
+ this.suppressionList.add(address.toLowerCase())
133
+ }
134
+
135
+ removeFromSuppressionList(address: string): void {
136
+ this.suppressionList.delete(address.toLowerCase())
137
+ }
138
+
139
+ isSuppressed(address: string): boolean {
140
+ return this.suppressionList.has(address.toLowerCase())
141
+ }
142
+
143
+ getSuppressionList(): string[] {
144
+ return Array.from(this.suppressionList)
145
+ }
146
+
147
+ getQueueStats(): EmailQueueStats {
148
+ if (!this.emailQueue) {
149
+ return { pending: 0, processing: 0, failed: 0, completed: 0 }
150
+ }
151
+ return this.emailQueue.getStats()
152
+ }
153
+
154
+ async flushQueue(): Promise<void> {
155
+ if (this.emailQueue) {
156
+ await this.emailQueue.flush()
157
+ }
158
+ }
159
+
160
+ async close(): Promise<void> {
161
+ this.emailQueue?.stop()
162
+ await this.transport.close()
163
+ }
164
+
165
+ private async sendImmediate(options: NixxieEmailSendOptions): Promise<NixxieEmailSentInfo> {
166
+ const toAddresses = normalizeRecipients(
167
+ Array.isArray(options.to) ? options.to : [options.to]
168
+ )
169
+
170
+ // Apply dev override
171
+ let effectiveTo = toAddresses
172
+ if (this.config.development?.enabled && this.config.development.toOverride) {
173
+ effectiveTo = normalizeRecipients(
174
+ Array.isArray(this.config.development.toOverride)
175
+ ? this.config.development.toOverride
176
+ : [this.config.development.toOverride]
177
+ )
178
+ }
179
+
180
+ // Suppression check
181
+ if (!options.skipSuppressionCheck) {
182
+ const suppressed = effectiveTo.filter(addr => {
183
+ const raw = extractAddress(addr)
184
+ return this.suppressionList.has(raw.toLowerCase())
185
+ })
186
+ if (suppressed.length) {
187
+ throw new Error(`Email suppressed for: ${suppressed.join(', ')}`)
188
+ }
189
+ }
190
+
191
+ let { html, text, subject } = options
192
+
193
+ // Render template
194
+ if (options.template && this.templateManager) {
195
+ const rendered = await this.templateManager.render(options.template, options.data ?? {})
196
+ html = html ?? rendered.html
197
+ text = text ?? rendered.text
198
+ subject = subject ?? rendered.subject
199
+ }
200
+
201
+ if (!subject) {
202
+ throw new Error('Email subject is required')
203
+ }
204
+
205
+ const from = options.from ?? this.config.from
206
+ if (!from) {
207
+ throw new Error('Email "from" address is required. Set it in config.email.from or pass it in the send options.')
208
+ }
209
+
210
+ const ccAddresses = options.cc
211
+ ? normalizeRecipients(Array.isArray(options.cc) ? options.cc : [options.cc])
212
+ : undefined
213
+ const bccAddresses = options.bcc
214
+ ? normalizeRecipients(Array.isArray(options.bcc) ? options.bcc : [options.bcc])
215
+ : undefined
216
+ const replyToAddresses = options.replyTo
217
+ ? normalizeRecipients(Array.isArray(options.replyTo) ? options.replyTo : [options.replyTo])
218
+ : this.config.replyTo
219
+ ? normalizeRecipients(this.config.replyTo)
220
+ : undefined
221
+
222
+ const message: TransportMessage = {
223
+ from:
224
+ typeof from === 'string'
225
+ ? from
226
+ : from.name
227
+ ? `"${from.name}" <${from.address}>`
228
+ : from.address,
229
+ to: effectiveTo,
230
+ cc: ccAddresses,
231
+ bcc: bccAddresses,
232
+ replyTo: replyToAddresses,
233
+ subject,
234
+ html,
235
+ text,
236
+ attachments: options.attachments,
237
+ headers: { ...this.config.headers, ...options.headers },
238
+ }
239
+
240
+ // In dev mode, optionally write the rendered HTML to disk for browser preview.
241
+ if (this.isDevMode() && this.config.development?.previewPath && html) {
242
+ await this.writePreview(this.config.development.previewPath, options.template, html)
243
+ }
244
+
245
+ const result = await this.transport.send(message)
246
+
247
+ const info: NixxieEmailSentInfo = {
248
+ messageId: result.messageId,
249
+ accepted: result.accepted,
250
+ rejected: result.rejected,
251
+ response: result.response,
252
+ timestamp: new Date(),
253
+ }
254
+
255
+ try {
256
+ await this.config.onSent?.(info, options)
257
+ } catch {
258
+ // a failing onSent callback must not fail an already-sent email
259
+ }
260
+
261
+ return info
262
+ }
263
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { EmailConfig } from './types'
2
+ import { EmailService } from './EmailService'
3
+
4
+ export function createEmail(config: EmailConfig): EmailService {
5
+ return new EmailService(config)
6
+ }
7
+
8
+ export { EmailService }
9
+
10
+ export type {
11
+ EmailAttachment,
12
+ EmailConfig,
13
+ EmailQueueConfig,
14
+ EmailTemplatesConfig,
15
+ EmailTrackingConfig,
16
+ EmailTransportConfig,
17
+ MailgunTransportConfig,
18
+ ResendTransportConfig,
19
+ SendEmailOptions,
20
+ SesTransportConfig,
21
+ SmtpTransportConfig,
22
+ ConsoleTransportConfig,
23
+ SendGridTransportConfig,
24
+ QueueJob,
25
+ } from './types'
26
+
27
+ export type {
28
+ NixxieEmailRecipient,
29
+ NixxieEmailAttachment,
30
+ NixxieEmailSendOptions,
31
+ NixxieEmailQueueOptions,
32
+ NixxieEmailSentInfo,
33
+ NixxieEmailQueueStats,
34
+ NixxieEmailService,
35
+ } from '@nixxie-cms/core'
@@ -0,0 +1,134 @@
1
+ import type { EmailQueueConfig, QueueJob } from '../types'
2
+
3
+ type JobHandler = (job: QueueJob) => Promise<void>
4
+
5
+ // Higher number = processed first
6
+ const PRIORITY_RANK: Record<QueueJob['priority'], number> = {
7
+ high: 2,
8
+ normal: 1,
9
+ low: 0,
10
+ }
11
+
12
+ export class EmailQueue {
13
+ private jobs: QueueJob[] = []
14
+ private processing = new Set<string>()
15
+ private config: Required<EmailQueueConfig>
16
+ private handler: JobHandler
17
+ private timer: NodeJS.Timeout | null = null
18
+ private running = false
19
+ private completedCount = 0
20
+
21
+ constructor(config: EmailQueueConfig, handler: JobHandler) {
22
+ this.config = {
23
+ enabled: config.enabled ?? true,
24
+ concurrency: config.concurrency ?? 5,
25
+ retries: config.retries ?? 3,
26
+ retryDelay: config.retryDelay ?? 5000,
27
+ maxAge: config.maxAge ?? 86400000,
28
+ }
29
+ this.handler = handler
30
+ }
31
+
32
+ enqueue(job: Omit<QueueJob, 'id' | 'status' | 'attempts' | 'createdAt'>): QueueJob {
33
+ const queued: QueueJob = {
34
+ ...job,
35
+ id: `job-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
36
+ status: 'pending',
37
+ attempts: 0,
38
+ createdAt: new Date(),
39
+ }
40
+ this.jobs.push(queued)
41
+ this.scheduleFlush()
42
+ return queued
43
+ }
44
+
45
+ start() {
46
+ this.running = true
47
+ this.scheduleFlush()
48
+ }
49
+
50
+ stop() {
51
+ this.running = false
52
+ if (this.timer) {
53
+ clearTimeout(this.timer)
54
+ this.timer = null
55
+ }
56
+ }
57
+
58
+ getStats() {
59
+ const now = Date.now()
60
+ return {
61
+ pending: this.jobs.filter(j => j.status === 'pending' && (!j.sendAt || j.sendAt.getTime() <= now)).length,
62
+ processing: this.processing.size,
63
+ failed: this.jobs.filter(j => j.status === 'failed').length,
64
+ completed: this.completedCount,
65
+ }
66
+ }
67
+
68
+ async flush(): Promise<void> {
69
+ const now = Date.now()
70
+
71
+ // Expire old jobs. maxAge <= 0 means "never discard".
72
+ this.jobs = this.jobs.filter(j => {
73
+ if (j.status === 'completed') return false
74
+ if (this.config.maxAge > 0 && now - j.createdAt.getTime() > this.config.maxAge) return false
75
+ return true
76
+ })
77
+
78
+ const available = this.jobs
79
+ .filter(j => {
80
+ if (j.status !== 'pending') return false
81
+ if (this.processing.has(j.id)) return false
82
+ if (j.sendAt && j.sendAt.getTime() > now) return false
83
+ return true
84
+ })
85
+ // Highest priority first, then oldest first (FIFO within a priority)
86
+ .sort((a, b) => {
87
+ const byPriority = PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority]
88
+ if (byPriority !== 0) return byPriority
89
+ return a.createdAt.getTime() - b.createdAt.getTime()
90
+ })
91
+
92
+ const slots = this.config.concurrency - this.processing.size
93
+ const toProcess = available.slice(0, slots)
94
+
95
+ await Promise.all(toProcess.map(job => this.processJob(job)))
96
+ }
97
+
98
+ private async processJob(job: QueueJob): Promise<void> {
99
+ this.processing.add(job.id)
100
+ job.status = 'processing'
101
+ job.attempts++
102
+
103
+ try {
104
+ await this.handler(job)
105
+ job.status = 'completed'
106
+ this.completedCount++
107
+ } catch (err) {
108
+ job.lastError = err instanceof Error ? err.message : String(err)
109
+
110
+ // `retries` is the number of retries after the initial attempt, so total attempts =
111
+ // 1 + retries. `attempts` is pre-incremented, hence `<=`.
112
+ if (job.attempts <= this.config.retries) {
113
+ job.status = 'pending'
114
+ // Exponential backoff
115
+ job.sendAt = new Date(Date.now() + this.config.retryDelay * Math.pow(2, job.attempts - 1))
116
+ } else {
117
+ job.status = 'failed'
118
+ }
119
+ } finally {
120
+ this.processing.delete(job.id)
121
+ }
122
+ }
123
+
124
+ private scheduleFlush() {
125
+ if (!this.running || this.timer) return
126
+ this.timer = setTimeout(async () => {
127
+ this.timer = null
128
+ await this.flush()
129
+ if (this.jobs.some(j => j.status === 'pending')) {
130
+ this.scheduleFlush()
131
+ }
132
+ }, 100)
133
+ }
134
+ }
@@ -0,0 +1 @@
1
+ export { EmailQueue } from './EmailQueue'
@@ -0,0 +1,152 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import type Handlebars from 'handlebars'
4
+ import type { EmailTemplatesConfig } from '../types'
5
+
6
+ type HandlebarsInstance = typeof Handlebars
7
+
8
+ let handlebars: HandlebarsInstance | undefined
9
+
10
+ function getHandlebars(): HandlebarsInstance {
11
+ if (!handlebars) {
12
+ try {
13
+ handlebars = require('handlebars') as HandlebarsInstance
14
+ } catch {
15
+ throw new Error(
16
+ 'Handlebars is not installed. Run: npm install handlebars'
17
+ )
18
+ }
19
+ }
20
+ return handlebars
21
+ }
22
+
23
+ export class HandlebarsEngine {
24
+ private hbs: HandlebarsInstance
25
+ private cache = new Map<string, HandlebarsTemplateDelegate>()
26
+ private config: EmailTemplatesConfig
27
+
28
+ constructor(config: EmailTemplatesConfig) {
29
+ this.hbs = getHandlebars().create()
30
+ this.config = config
31
+ this.registerHelpers()
32
+ this.registerPartials()
33
+ }
34
+
35
+ private registerHelpers() {
36
+ // Handlebars engine options are a flat shape: `{ noEscape?, helpers? }` (see EmailTemplatesConfig).
37
+ const helpers = this.config.engineOptions?.helpers as
38
+ | Record<string, unknown>
39
+ | undefined
40
+ if (!helpers) return
41
+ for (const [name, fn] of Object.entries(helpers)) {
42
+ this.hbs.registerHelper(name, fn as Handlebars.HelperDelegate)
43
+ }
44
+ }
45
+
46
+ private compileOptions() {
47
+ return this.config.engineOptions?.noEscape ? { noEscape: true } : undefined
48
+ }
49
+
50
+ private registerPartials() {
51
+ // `partials` is a boolean flag (default: enabled). Load from `<dir>/partials` when on.
52
+ if (this.config.partials === false || !this.config.dir) return
53
+ const partialsDir = path.join(this.config.dir, 'partials')
54
+
55
+ if (!fs.existsSync(partialsDir)) return
56
+
57
+ const files = fs.readdirSync(partialsDir)
58
+ for (const file of files) {
59
+ if (!/\.(hbs|handlebars)$/.test(file)) continue
60
+ const name = path.basename(file, path.extname(file))
61
+ const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8')
62
+ this.hbs.registerPartial(name, content)
63
+ }
64
+ }
65
+
66
+ async render(templateName: string, data: Record<string, unknown>): Promise<string> {
67
+ const compiled = this.getCompiled(templateName)
68
+ const layout = this.config.layouts
69
+ ? this.resolveLayout(templateName)
70
+ : null
71
+
72
+ let body = compiled(data)
73
+
74
+ if (layout) {
75
+ const layoutTemplate = this.getCompiledLayout(layout)
76
+ body = layoutTemplate({ ...data, body })
77
+ }
78
+
79
+ return body
80
+ }
81
+
82
+ private getCompiled(templateName: string): HandlebarsTemplateDelegate {
83
+ const cacheKey = `template:${templateName}`
84
+ if (this.config.cache !== false && this.cache.has(cacheKey)) {
85
+ return this.cache.get(cacheKey)!
86
+ }
87
+
88
+ const filePath = this.resolveTemplatePath(templateName)
89
+ const source = fs.readFileSync(filePath, 'utf-8')
90
+ const compiled = this.hbs.compile(source, this.compileOptions())
91
+
92
+ if (this.config.cache !== false) {
93
+ this.cache.set(cacheKey, compiled)
94
+ }
95
+
96
+ return compiled
97
+ }
98
+
99
+ private getCompiledLayout(layoutPath: string): HandlebarsTemplateDelegate {
100
+ const cacheKey = `layout:${layoutPath}`
101
+ if (this.config.cache !== false && this.cache.has(cacheKey)) {
102
+ return this.cache.get(cacheKey)!
103
+ }
104
+
105
+ const source = fs.readFileSync(layoutPath, 'utf-8')
106
+ const compiled = this.hbs.compile(source, this.compileOptions())
107
+
108
+ if (this.config.cache !== false) {
109
+ this.cache.set(cacheKey, compiled)
110
+ }
111
+
112
+ return compiled
113
+ }
114
+
115
+ private resolveTemplatePath(templateName: string): string {
116
+ if (!this.config.dir) {
117
+ throw new Error('templates.dir must be set to use template rendering')
118
+ }
119
+ const candidates = [
120
+ path.join(this.config.dir, `${templateName}.hbs`),
121
+ path.join(this.config.dir, `${templateName}.handlebars`),
122
+ path.join(this.config.dir, templateName, 'html.hbs'),
123
+ path.join(this.config.dir, templateName, 'html.handlebars'),
124
+ ]
125
+ for (const candidate of candidates) {
126
+ if (fs.existsSync(candidate)) return candidate
127
+ }
128
+ throw new Error(`Handlebars template not found: ${templateName} (searched in ${this.config.dir})`)
129
+ }
130
+
131
+ private resolveLayout(templateName: string): string | null {
132
+ if (!this.config.layouts) return null
133
+
134
+ // Per-template layout override: templates/<name>/layout.hbs
135
+ if (this.config.dir) {
136
+ const perTemplate = path.join(this.config.dir, templateName, 'layout.hbs')
137
+ if (fs.existsSync(perTemplate)) return perTemplate
138
+ }
139
+
140
+ // Default layout: templates/layouts/default.hbs
141
+ const defaultLayout = path.join(this.config.dir, 'layouts', 'default.hbs')
142
+ if (fs.existsSync(defaultLayout)) return defaultLayout
143
+
144
+ return null
145
+ }
146
+
147
+ clearCache() {
148
+ this.cache.clear()
149
+ }
150
+ }
151
+
152
+ type HandlebarsTemplateDelegate = (context: unknown, options?: Handlebars.RuntimeOptions) => string