@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/LICENSE +23 -0
- package/dist/declarations/src/EmailService.d.ts +29 -0
- package/dist/declarations/src/EmailService.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +7 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +193 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/nixxie-cms-email.cjs.d.ts +2 -0
- package/dist/nixxie-cms-email.cjs.js +994 -0
- package/dist/nixxie-cms-email.esm.js +983 -0
- package/package.json +41 -0
- package/src/EmailService.ts +263 -0
- package/src/index.ts +35 -0
- package/src/queue/EmailQueue.ts +134 -0
- package/src/queue/index.ts +1 -0
- package/src/templates/HandlebarsEngine.ts +152 -0
- package/src/templates/PugEngine.ts +101 -0
- package/src/templates/TemplateManager.ts +180 -0
- package/src/templates/index.ts +4 -0
- package/src/transports/ConsoleTransport.ts +50 -0
- package/src/transports/MailgunTransport.ts +58 -0
- package/src/transports/ResendTransport.ts +83 -0
- package/src/transports/SendGridTransport.ts +54 -0
- package/src/transports/SesTransport.ts +58 -0
- package/src/transports/SmtpTransport.ts +66 -0
- package/src/transports/index.ts +30 -0
- package/src/types.ts +279 -0
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
|