@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
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
import _defineProperty from '@babel/runtime/helpers/esm/defineProperty';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import nodemailer from 'nodemailer';
|
|
7
|
+
|
|
8
|
+
// Higher number = processed first
|
|
9
|
+
const PRIORITY_RANK = {
|
|
10
|
+
high: 2,
|
|
11
|
+
normal: 1,
|
|
12
|
+
low: 0
|
|
13
|
+
};
|
|
14
|
+
class EmailQueue {
|
|
15
|
+
constructor(config, handler) {
|
|
16
|
+
var _config$enabled, _config$concurrency, _config$retries, _config$retryDelay, _config$maxAge;
|
|
17
|
+
_defineProperty(this, "jobs", []);
|
|
18
|
+
_defineProperty(this, "processing", new Set());
|
|
19
|
+
_defineProperty(this, "timer", null);
|
|
20
|
+
_defineProperty(this, "running", false);
|
|
21
|
+
_defineProperty(this, "completedCount", 0);
|
|
22
|
+
this.config = {
|
|
23
|
+
enabled: (_config$enabled = config.enabled) !== null && _config$enabled !== void 0 ? _config$enabled : true,
|
|
24
|
+
concurrency: (_config$concurrency = config.concurrency) !== null && _config$concurrency !== void 0 ? _config$concurrency : 5,
|
|
25
|
+
retries: (_config$retries = config.retries) !== null && _config$retries !== void 0 ? _config$retries : 3,
|
|
26
|
+
retryDelay: (_config$retryDelay = config.retryDelay) !== null && _config$retryDelay !== void 0 ? _config$retryDelay : 5000,
|
|
27
|
+
maxAge: (_config$maxAge = config.maxAge) !== null && _config$maxAge !== void 0 ? _config$maxAge : 86400000
|
|
28
|
+
};
|
|
29
|
+
this.handler = handler;
|
|
30
|
+
}
|
|
31
|
+
enqueue(job) {
|
|
32
|
+
const queued = {
|
|
33
|
+
...job,
|
|
34
|
+
id: `job-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
35
|
+
status: 'pending',
|
|
36
|
+
attempts: 0,
|
|
37
|
+
createdAt: new Date()
|
|
38
|
+
};
|
|
39
|
+
this.jobs.push(queued);
|
|
40
|
+
this.scheduleFlush();
|
|
41
|
+
return queued;
|
|
42
|
+
}
|
|
43
|
+
start() {
|
|
44
|
+
this.running = true;
|
|
45
|
+
this.scheduleFlush();
|
|
46
|
+
}
|
|
47
|
+
stop() {
|
|
48
|
+
this.running = false;
|
|
49
|
+
if (this.timer) {
|
|
50
|
+
clearTimeout(this.timer);
|
|
51
|
+
this.timer = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
getStats() {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
return {
|
|
57
|
+
pending: this.jobs.filter(j => j.status === 'pending' && (!j.sendAt || j.sendAt.getTime() <= now)).length,
|
|
58
|
+
processing: this.processing.size,
|
|
59
|
+
failed: this.jobs.filter(j => j.status === 'failed').length,
|
|
60
|
+
completed: this.completedCount
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async flush() {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
|
|
66
|
+
// Expire old jobs. maxAge <= 0 means "never discard".
|
|
67
|
+
this.jobs = this.jobs.filter(j => {
|
|
68
|
+
if (j.status === 'completed') return false;
|
|
69
|
+
if (this.config.maxAge > 0 && now - j.createdAt.getTime() > this.config.maxAge) return false;
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
const available = this.jobs.filter(j => {
|
|
73
|
+
if (j.status !== 'pending') return false;
|
|
74
|
+
if (this.processing.has(j.id)) return false;
|
|
75
|
+
if (j.sendAt && j.sendAt.getTime() > now) return false;
|
|
76
|
+
return true;
|
|
77
|
+
})
|
|
78
|
+
// Highest priority first, then oldest first (FIFO within a priority)
|
|
79
|
+
.sort((a, b) => {
|
|
80
|
+
const byPriority = PRIORITY_RANK[b.priority] - PRIORITY_RANK[a.priority];
|
|
81
|
+
if (byPriority !== 0) return byPriority;
|
|
82
|
+
return a.createdAt.getTime() - b.createdAt.getTime();
|
|
83
|
+
});
|
|
84
|
+
const slots = this.config.concurrency - this.processing.size;
|
|
85
|
+
const toProcess = available.slice(0, slots);
|
|
86
|
+
await Promise.all(toProcess.map(job => this.processJob(job)));
|
|
87
|
+
}
|
|
88
|
+
async processJob(job) {
|
|
89
|
+
this.processing.add(job.id);
|
|
90
|
+
job.status = 'processing';
|
|
91
|
+
job.attempts++;
|
|
92
|
+
try {
|
|
93
|
+
await this.handler(job);
|
|
94
|
+
job.status = 'completed';
|
|
95
|
+
this.completedCount++;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
job.lastError = err instanceof Error ? err.message : String(err);
|
|
98
|
+
|
|
99
|
+
// `retries` is the number of retries after the initial attempt, so total attempts =
|
|
100
|
+
// 1 + retries. `attempts` is pre-incremented, hence `<=`.
|
|
101
|
+
if (job.attempts <= this.config.retries) {
|
|
102
|
+
job.status = 'pending';
|
|
103
|
+
// Exponential backoff
|
|
104
|
+
job.sendAt = new Date(Date.now() + this.config.retryDelay * Math.pow(2, job.attempts - 1));
|
|
105
|
+
} else {
|
|
106
|
+
job.status = 'failed';
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
this.processing.delete(job.id);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
scheduleFlush() {
|
|
113
|
+
if (!this.running || this.timer) return;
|
|
114
|
+
this.timer = setTimeout(async () => {
|
|
115
|
+
this.timer = null;
|
|
116
|
+
await this.flush();
|
|
117
|
+
if (this.jobs.some(j => j.status === 'pending')) {
|
|
118
|
+
this.scheduleFlush();
|
|
119
|
+
}
|
|
120
|
+
}, 100);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let handlebars;
|
|
125
|
+
function getHandlebars() {
|
|
126
|
+
if (!handlebars) {
|
|
127
|
+
try {
|
|
128
|
+
handlebars = require('handlebars');
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Error('Handlebars is not installed. Run: npm install handlebars');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return handlebars;
|
|
134
|
+
}
|
|
135
|
+
class HandlebarsEngine {
|
|
136
|
+
constructor(config) {
|
|
137
|
+
_defineProperty(this, "cache", new Map());
|
|
138
|
+
this.hbs = getHandlebars().create();
|
|
139
|
+
this.config = config;
|
|
140
|
+
this.registerHelpers();
|
|
141
|
+
this.registerPartials();
|
|
142
|
+
}
|
|
143
|
+
registerHelpers() {
|
|
144
|
+
var _this$config$engineOp;
|
|
145
|
+
// Handlebars engine options are a flat shape: `{ noEscape?, helpers? }` (see EmailTemplatesConfig).
|
|
146
|
+
const helpers = (_this$config$engineOp = this.config.engineOptions) === null || _this$config$engineOp === void 0 ? void 0 : _this$config$engineOp.helpers;
|
|
147
|
+
if (!helpers) return;
|
|
148
|
+
for (const [name, fn] of Object.entries(helpers)) {
|
|
149
|
+
this.hbs.registerHelper(name, fn);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
compileOptions() {
|
|
153
|
+
var _this$config$engineOp2;
|
|
154
|
+
return (_this$config$engineOp2 = this.config.engineOptions) !== null && _this$config$engineOp2 !== void 0 && _this$config$engineOp2.noEscape ? {
|
|
155
|
+
noEscape: true
|
|
156
|
+
} : undefined;
|
|
157
|
+
}
|
|
158
|
+
registerPartials() {
|
|
159
|
+
// `partials` is a boolean flag (default: enabled). Load from `<dir>/partials` when on.
|
|
160
|
+
if (this.config.partials === false || !this.config.dir) return;
|
|
161
|
+
const partialsDir = path.join(this.config.dir, 'partials');
|
|
162
|
+
if (!fs.existsSync(partialsDir)) return;
|
|
163
|
+
const files = fs.readdirSync(partialsDir);
|
|
164
|
+
for (const file of files) {
|
|
165
|
+
if (!/\.(hbs|handlebars)$/.test(file)) continue;
|
|
166
|
+
const name = path.basename(file, path.extname(file));
|
|
167
|
+
const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8');
|
|
168
|
+
this.hbs.registerPartial(name, content);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async render(templateName, data) {
|
|
172
|
+
const compiled = this.getCompiled(templateName);
|
|
173
|
+
const layout = this.config.layouts ? this.resolveLayout(templateName) : null;
|
|
174
|
+
let body = compiled(data);
|
|
175
|
+
if (layout) {
|
|
176
|
+
const layoutTemplate = this.getCompiledLayout(layout);
|
|
177
|
+
body = layoutTemplate({
|
|
178
|
+
...data,
|
|
179
|
+
body
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return body;
|
|
183
|
+
}
|
|
184
|
+
getCompiled(templateName) {
|
|
185
|
+
const cacheKey = `template:${templateName}`;
|
|
186
|
+
if (this.config.cache !== false && this.cache.has(cacheKey)) {
|
|
187
|
+
return this.cache.get(cacheKey);
|
|
188
|
+
}
|
|
189
|
+
const filePath = this.resolveTemplatePath(templateName);
|
|
190
|
+
const source = fs.readFileSync(filePath, 'utf-8');
|
|
191
|
+
const compiled = this.hbs.compile(source, this.compileOptions());
|
|
192
|
+
if (this.config.cache !== false) {
|
|
193
|
+
this.cache.set(cacheKey, compiled);
|
|
194
|
+
}
|
|
195
|
+
return compiled;
|
|
196
|
+
}
|
|
197
|
+
getCompiledLayout(layoutPath) {
|
|
198
|
+
const cacheKey = `layout:${layoutPath}`;
|
|
199
|
+
if (this.config.cache !== false && this.cache.has(cacheKey)) {
|
|
200
|
+
return this.cache.get(cacheKey);
|
|
201
|
+
}
|
|
202
|
+
const source = fs.readFileSync(layoutPath, 'utf-8');
|
|
203
|
+
const compiled = this.hbs.compile(source, this.compileOptions());
|
|
204
|
+
if (this.config.cache !== false) {
|
|
205
|
+
this.cache.set(cacheKey, compiled);
|
|
206
|
+
}
|
|
207
|
+
return compiled;
|
|
208
|
+
}
|
|
209
|
+
resolveTemplatePath(templateName) {
|
|
210
|
+
if (!this.config.dir) {
|
|
211
|
+
throw new Error('templates.dir must be set to use template rendering');
|
|
212
|
+
}
|
|
213
|
+
const candidates = [path.join(this.config.dir, `${templateName}.hbs`), path.join(this.config.dir, `${templateName}.handlebars`), path.join(this.config.dir, templateName, 'html.hbs'), path.join(this.config.dir, templateName, 'html.handlebars')];
|
|
214
|
+
for (const candidate of candidates) {
|
|
215
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
216
|
+
}
|
|
217
|
+
throw new Error(`Handlebars template not found: ${templateName} (searched in ${this.config.dir})`);
|
|
218
|
+
}
|
|
219
|
+
resolveLayout(templateName) {
|
|
220
|
+
if (!this.config.layouts) return null;
|
|
221
|
+
|
|
222
|
+
// Per-template layout override: templates/<name>/layout.hbs
|
|
223
|
+
if (this.config.dir) {
|
|
224
|
+
const perTemplate = path.join(this.config.dir, templateName, 'layout.hbs');
|
|
225
|
+
if (fs.existsSync(perTemplate)) return perTemplate;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Default layout: templates/layouts/default.hbs
|
|
229
|
+
const defaultLayout = path.join(this.config.dir, 'layouts', 'default.hbs');
|
|
230
|
+
if (fs.existsSync(defaultLayout)) return defaultLayout;
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
clearCache() {
|
|
234
|
+
this.cache.clear();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let pugModule;
|
|
239
|
+
function getPug() {
|
|
240
|
+
if (!pugModule) {
|
|
241
|
+
try {
|
|
242
|
+
pugModule = require('pug');
|
|
243
|
+
} catch {
|
|
244
|
+
throw new Error('Pug is not installed. Run: npm install pug');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return pugModule;
|
|
248
|
+
}
|
|
249
|
+
class PugEngine {
|
|
250
|
+
constructor(config) {
|
|
251
|
+
_defineProperty(this, "cache", new Map());
|
|
252
|
+
this.config = config;
|
|
253
|
+
}
|
|
254
|
+
async render(templateName, data) {
|
|
255
|
+
const p = getPug();
|
|
256
|
+
const compiled = this.getCompiled(templateName, p);
|
|
257
|
+
const layout = this.config.layouts ? this.resolveLayout(templateName) : null;
|
|
258
|
+
const renderData = {
|
|
259
|
+
...data
|
|
260
|
+
};
|
|
261
|
+
if (layout) {
|
|
262
|
+
renderData['layout'] = layout;
|
|
263
|
+
}
|
|
264
|
+
return compiled(renderData);
|
|
265
|
+
}
|
|
266
|
+
getCompiled(templateName, p) {
|
|
267
|
+
var _this$config$engineOp, _this$config$engineOp2;
|
|
268
|
+
const cacheKey = `template:${templateName}`;
|
|
269
|
+
if (this.config.cache !== false && this.cache.has(cacheKey)) {
|
|
270
|
+
return this.cache.get(cacheKey);
|
|
271
|
+
}
|
|
272
|
+
const filePath = this.resolveTemplatePath(templateName);
|
|
273
|
+
const engineOptions = (_this$config$engineOp = (_this$config$engineOp2 = this.config.engineOptions) === null || _this$config$engineOp2 === void 0 ? void 0 : _this$config$engineOp2.pug) !== null && _this$config$engineOp !== void 0 ? _this$config$engineOp : {};
|
|
274
|
+
const compiled = p.compileFile(filePath, {
|
|
275
|
+
...engineOptions,
|
|
276
|
+
filename: filePath,
|
|
277
|
+
cache: false
|
|
278
|
+
});
|
|
279
|
+
if (this.config.cache !== false) {
|
|
280
|
+
this.cache.set(cacheKey, compiled);
|
|
281
|
+
}
|
|
282
|
+
return compiled;
|
|
283
|
+
}
|
|
284
|
+
resolveTemplatePath(templateName) {
|
|
285
|
+
if (!this.config.dir) {
|
|
286
|
+
throw new Error('templates.dir must be set to use template rendering');
|
|
287
|
+
}
|
|
288
|
+
const candidates = [path.join(this.config.dir, `${templateName}.pug`), path.join(this.config.dir, `${templateName}.jade`), path.join(this.config.dir, templateName, 'html.pug'), path.join(this.config.dir, templateName, 'index.pug')];
|
|
289
|
+
for (const candidate of candidates) {
|
|
290
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
291
|
+
}
|
|
292
|
+
throw new Error(`Pug template not found: ${templateName} (searched in ${this.config.dir})`);
|
|
293
|
+
}
|
|
294
|
+
resolveLayout(templateName) {
|
|
295
|
+
if (!this.config.layouts) return null;
|
|
296
|
+
if (this.config.dir) {
|
|
297
|
+
const perTemplate = path.join(this.config.dir, templateName, 'layout.pug');
|
|
298
|
+
if (fs.existsSync(perTemplate)) return perTemplate;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Default layout: templates/layouts/default.pug
|
|
302
|
+
const defaultLayout = path.join(this.config.dir, 'layouts', 'default.pug');
|
|
303
|
+
if (fs.existsSync(defaultLayout)) return defaultLayout;
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
clearCache() {
|
|
307
|
+
this.cache.clear();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
class TemplateManager {
|
|
312
|
+
constructor(config) {
|
|
313
|
+
var _config$engine;
|
|
314
|
+
this.config = config;
|
|
315
|
+
const engine = (_config$engine = config.engine) !== null && _config$engine !== void 0 ? _config$engine : 'handlebars';
|
|
316
|
+
if (engine === 'handlebars' || engine === 'auto') {
|
|
317
|
+
this.handlebars = new HandlebarsEngine(config);
|
|
318
|
+
}
|
|
319
|
+
if (engine === 'pug' || engine === 'auto') {
|
|
320
|
+
this.pug = new PugEngine(config);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async render(templateName, data) {
|
|
324
|
+
const engine = this.detectEngine(templateName);
|
|
325
|
+
switch (engine) {
|
|
326
|
+
case 'handlebars':
|
|
327
|
+
return this.renderWithHandlebars(templateName, data);
|
|
328
|
+
case 'pug':
|
|
329
|
+
return this.renderWithPug(templateName, data);
|
|
330
|
+
default:
|
|
331
|
+
throw new Error(`No template engine configured for template: ${templateName}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
detectEngine(templateName) {
|
|
335
|
+
var _this$config$engine;
|
|
336
|
+
const configuredEngine = (_this$config$engine = this.config.engine) !== null && _this$config$engine !== void 0 ? _this$config$engine : 'handlebars';
|
|
337
|
+
if (configuredEngine === 'auto' || configuredEngine === 'handlebars') {
|
|
338
|
+
if (this.config.dir) {
|
|
339
|
+
const hbsCandidates = [path.join(this.config.dir, `${templateName}.hbs`), path.join(this.config.dir, `${templateName}.handlebars`), path.join(this.config.dir, templateName, 'html.hbs'), path.join(this.config.dir, templateName, 'html.handlebars')];
|
|
340
|
+
if (hbsCandidates.some(p => fs.existsSync(p))) return 'handlebars';
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (configuredEngine === 'auto' || configuredEngine === 'pug') {
|
|
344
|
+
if (this.config.dir) {
|
|
345
|
+
const pugCandidates = [path.join(this.config.dir, `${templateName}.pug`), path.join(this.config.dir, `${templateName}.jade`), path.join(this.config.dir, templateName, 'html.pug'), path.join(this.config.dir, templateName, 'index.pug')];
|
|
346
|
+
if (pugCandidates.some(p => fs.existsSync(p))) return 'pug';
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (configuredEngine !== 'auto') return configuredEngine;
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
async renderWithHandlebars(templateName, data) {
|
|
353
|
+
if (!this.handlebars) {
|
|
354
|
+
this.handlebars = new HandlebarsEngine(this.config);
|
|
355
|
+
}
|
|
356
|
+
const html = await this.handlebars.render(templateName, data);
|
|
357
|
+
const text = await this.renderTextVersion(templateName, data, 'handlebars');
|
|
358
|
+
const subject = await this.renderSubject(templateName, data, 'handlebars');
|
|
359
|
+
return {
|
|
360
|
+
html,
|
|
361
|
+
text,
|
|
362
|
+
subject
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
async renderWithPug(templateName, data) {
|
|
366
|
+
if (!this.pug) {
|
|
367
|
+
this.pug = new PugEngine(this.config);
|
|
368
|
+
}
|
|
369
|
+
const html = await this.pug.render(templateName, data);
|
|
370
|
+
const text = await this.renderTextVersion(templateName, data, 'pug');
|
|
371
|
+
const subject = await this.renderSubject(templateName, data, 'pug');
|
|
372
|
+
return {
|
|
373
|
+
html,
|
|
374
|
+
text,
|
|
375
|
+
subject
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
async renderTextVersion(templateName, data, engine) {
|
|
379
|
+
if (!this.config.dir) return undefined;
|
|
380
|
+
const textPaths = engine === 'handlebars' ? [path.join(this.config.dir, templateName, 'text.hbs'), path.join(this.config.dir, templateName, 'text.handlebars'), path.join(this.config.dir, `${templateName}.text.hbs`)] : [path.join(this.config.dir, templateName, 'text.pug'), path.join(this.config.dir, `${templateName}.text.pug`)];
|
|
381
|
+
const textFile = textPaths.find(p => fs.existsSync(p));
|
|
382
|
+
if (!textFile) return undefined;
|
|
383
|
+
|
|
384
|
+
// Render a plain-text variant using the same engine but without layout
|
|
385
|
+
const textConfig = {
|
|
386
|
+
...this.config,
|
|
387
|
+
layouts: undefined
|
|
388
|
+
};
|
|
389
|
+
try {
|
|
390
|
+
if (engine === 'handlebars') {
|
|
391
|
+
const eng = new HandlebarsEngine(textConfig);
|
|
392
|
+
return await eng.render(path.relative(this.config.dir, textFile).replace(/\\/g, '/').replace(/\.(hbs|handlebars)$/, ''), data);
|
|
393
|
+
} else {
|
|
394
|
+
const eng = new PugEngine(textConfig);
|
|
395
|
+
return await eng.render(path.relative(this.config.dir, textFile).replace(/\\/g, '/').replace(/\.(pug|jade)$/, ''), data);
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
// Fall back to reading the file as plain template
|
|
399
|
+
return fs.readFileSync(textFile, 'utf-8');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async renderSubject(templateName, data, engine) {
|
|
403
|
+
if (!this.config.dir) return undefined;
|
|
404
|
+
const subjectPaths = engine === 'handlebars' ? [path.join(this.config.dir, templateName, 'subject.hbs'), path.join(this.config.dir, templateName, 'subject.handlebars')] : [path.join(this.config.dir, templateName, 'subject.pug')];
|
|
405
|
+
const subjectFile = subjectPaths.find(p => fs.existsSync(p));
|
|
406
|
+
if (!subjectFile) return undefined;
|
|
407
|
+
const subjectConfig = {
|
|
408
|
+
...this.config,
|
|
409
|
+
layouts: undefined
|
|
410
|
+
};
|
|
411
|
+
try {
|
|
412
|
+
if (engine === 'handlebars') {
|
|
413
|
+
const eng = new HandlebarsEngine(subjectConfig);
|
|
414
|
+
const relative = path.relative(this.config.dir, subjectFile).replace(/\\/g, '/').replace(/\.(hbs|handlebars)$/, '');
|
|
415
|
+
return (await eng.render(relative, data)).trim();
|
|
416
|
+
} else {
|
|
417
|
+
const eng = new PugEngine(subjectConfig);
|
|
418
|
+
const relative = path.relative(this.config.dir, subjectFile).replace(/\\/g, '/').replace(/\.(pug|jade)$/, '');
|
|
419
|
+
return (await eng.render(relative, data)).trim();
|
|
420
|
+
}
|
|
421
|
+
} catch {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
clearCache() {
|
|
426
|
+
var _this$handlebars, _this$pug;
|
|
427
|
+
(_this$handlebars = this.handlebars) === null || _this$handlebars === void 0 || _this$handlebars.clearCache();
|
|
428
|
+
(_this$pug = this.pug) === null || _this$pug === void 0 || _this$pug.clearCache();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let messageCounter = 0;
|
|
433
|
+
class ConsoleTransport {
|
|
434
|
+
async send(message) {
|
|
435
|
+
var _message$cc, _message$bcc, _message$replyTo, _message$attachments;
|
|
436
|
+
const id = `console-${Date.now()}-${++messageCounter}`;
|
|
437
|
+
const separator = '─'.repeat(60);
|
|
438
|
+
console.log(`\n${separator}`);
|
|
439
|
+
console.log('📧 [Nixxie Email — Development Mode]');
|
|
440
|
+
console.log(separator);
|
|
441
|
+
console.log(` From: ${message.from}`);
|
|
442
|
+
console.log(` To: ${message.to.join(', ')}`);
|
|
443
|
+
if ((_message$cc = message.cc) !== null && _message$cc !== void 0 && _message$cc.length) console.log(` CC: ${message.cc.join(', ')}`);
|
|
444
|
+
if ((_message$bcc = message.bcc) !== null && _message$bcc !== void 0 && _message$bcc.length) console.log(` BCC: ${message.bcc.join(', ')}`);
|
|
445
|
+
if ((_message$replyTo = message.replyTo) !== null && _message$replyTo !== void 0 && _message$replyTo.length) console.log(` Reply-To: ${message.replyTo.join(', ')}`);
|
|
446
|
+
console.log(` Subject: ${message.subject}`);
|
|
447
|
+
console.log(` ID: ${id}`);
|
|
448
|
+
if ((_message$attachments = message.attachments) !== null && _message$attachments !== void 0 && _message$attachments.length) {
|
|
449
|
+
console.log(` Attachments:`);
|
|
450
|
+
for (const a of message.attachments) {
|
|
451
|
+
var _ref, _a$filename;
|
|
452
|
+
console.log(` • ${(_ref = (_a$filename = a.filename) !== null && _a$filename !== void 0 ? _a$filename : a.path) !== null && _ref !== void 0 ? _ref : 'unnamed'}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (message.text) {
|
|
456
|
+
console.log(`\n Text body:\n ${message.text.slice(0, 300).replace(/\n/g, '\n ')}`);
|
|
457
|
+
}
|
|
458
|
+
if (message.html) {
|
|
459
|
+
const truncated = message.html.replace(/<[^>]+>/g, '').trim().slice(0, 200);
|
|
460
|
+
console.log(`\n HTML preview:\n ${truncated}…`);
|
|
461
|
+
}
|
|
462
|
+
console.log(`${separator}\n`);
|
|
463
|
+
return {
|
|
464
|
+
messageId: id,
|
|
465
|
+
accepted: message.to,
|
|
466
|
+
rejected: [],
|
|
467
|
+
response: 'logged'
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
async verify() {
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
async close() {
|
|
474
|
+
// nothing to close
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
class MailgunTransport {
|
|
479
|
+
constructor(config) {
|
|
480
|
+
const host = config.region === 'eu' ? 'smtp.eu.mailgun.org' : 'smtp.mailgun.org';
|
|
481
|
+
this.transporter = nodemailer.createTransport({
|
|
482
|
+
host,
|
|
483
|
+
port: 587,
|
|
484
|
+
secure: false,
|
|
485
|
+
auth: {
|
|
486
|
+
user: `postmaster@${config.domain}`,
|
|
487
|
+
pass: config.apiKey
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
async send(message) {
|
|
492
|
+
var _message$cc, _message$bcc, _message$replyTo, _ref, _ref2;
|
|
493
|
+
const info = await this.transporter.sendMail({
|
|
494
|
+
from: message.from,
|
|
495
|
+
to: message.to.join(', '),
|
|
496
|
+
cc: (_message$cc = message.cc) === null || _message$cc === void 0 ? void 0 : _message$cc.join(', '),
|
|
497
|
+
bcc: (_message$bcc = message.bcc) === null || _message$bcc === void 0 ? void 0 : _message$bcc.join(', '),
|
|
498
|
+
replyTo: (_message$replyTo = message.replyTo) === null || _message$replyTo === void 0 ? void 0 : _message$replyTo.join(', '),
|
|
499
|
+
subject: message.subject,
|
|
500
|
+
html: message.html,
|
|
501
|
+
text: message.text,
|
|
502
|
+
attachments: message.attachments,
|
|
503
|
+
headers: message.headers
|
|
504
|
+
});
|
|
505
|
+
return {
|
|
506
|
+
messageId: info.messageId,
|
|
507
|
+
accepted: (_ref = info.accepted) !== null && _ref !== void 0 ? _ref : message.to,
|
|
508
|
+
rejected: (_ref2 = info.rejected) !== null && _ref2 !== void 0 ? _ref2 : [],
|
|
509
|
+
response: info.response
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
async verify() {
|
|
513
|
+
try {
|
|
514
|
+
await this.transporter.verify();
|
|
515
|
+
return true;
|
|
516
|
+
} catch {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async close() {
|
|
521
|
+
this.transporter.close();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
class ResendTransport {
|
|
526
|
+
constructor(config) {
|
|
527
|
+
this.apiKey = config.apiKey;
|
|
528
|
+
}
|
|
529
|
+
async send(message) {
|
|
530
|
+
var _message$attachments;
|
|
531
|
+
const body = {
|
|
532
|
+
from: message.from,
|
|
533
|
+
to: message.to,
|
|
534
|
+
cc: message.cc,
|
|
535
|
+
bcc: message.bcc,
|
|
536
|
+
reply_to: message.replyTo,
|
|
537
|
+
subject: message.subject,
|
|
538
|
+
html: message.html,
|
|
539
|
+
text: message.text,
|
|
540
|
+
headers: message.headers,
|
|
541
|
+
attachments: (_message$attachments = message.attachments) === null || _message$attachments === void 0 ? void 0 : _message$attachments.map(a => ({
|
|
542
|
+
filename: a.filename,
|
|
543
|
+
// Resend expects attachment `content` as base64; encode raw string/Buffer content.
|
|
544
|
+
content: a.content == null ? undefined : Buffer.isBuffer(a.content) ? a.content.toString('base64') : Buffer.from(a.content, 'utf-8').toString('base64'),
|
|
545
|
+
path: a.path
|
|
546
|
+
}))
|
|
547
|
+
};
|
|
548
|
+
const response = await fetch(`${this.baseUrl}/emails`, {
|
|
549
|
+
method: 'POST',
|
|
550
|
+
headers: {
|
|
551
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
552
|
+
'Content-Type': 'application/json'
|
|
553
|
+
},
|
|
554
|
+
body: JSON.stringify(body)
|
|
555
|
+
});
|
|
556
|
+
const json = await response.json();
|
|
557
|
+
if (!response.ok) {
|
|
558
|
+
const err = json;
|
|
559
|
+
throw new Error(`Resend API error ${response.status}: ${err.message}`);
|
|
560
|
+
}
|
|
561
|
+
const data = json;
|
|
562
|
+
return {
|
|
563
|
+
messageId: data.id,
|
|
564
|
+
accepted: message.to,
|
|
565
|
+
rejected: [],
|
|
566
|
+
response: `${response.status} ${response.statusText}`
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
async verify() {
|
|
570
|
+
try {
|
|
571
|
+
const response = await fetch(`${this.baseUrl}/domains`, {
|
|
572
|
+
headers: {
|
|
573
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
return response.ok;
|
|
577
|
+
} catch {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async close() {
|
|
582
|
+
// No persistent connections to close
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
class SendGridTransport {
|
|
587
|
+
constructor(config) {
|
|
588
|
+
// SendGrid supports nodemailer via their SMTP relay
|
|
589
|
+
this.transporter = nodemailer.createTransport({
|
|
590
|
+
host: 'smtp.sendgrid.net',
|
|
591
|
+
port: 587,
|
|
592
|
+
secure: false,
|
|
593
|
+
auth: {
|
|
594
|
+
user: 'apikey',
|
|
595
|
+
pass: config.apiKey
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
async send(message) {
|
|
600
|
+
var _message$cc, _message$bcc, _message$replyTo, _ref, _ref2;
|
|
601
|
+
const info = await this.transporter.sendMail({
|
|
602
|
+
from: message.from,
|
|
603
|
+
to: message.to.join(', '),
|
|
604
|
+
cc: (_message$cc = message.cc) === null || _message$cc === void 0 ? void 0 : _message$cc.join(', '),
|
|
605
|
+
bcc: (_message$bcc = message.bcc) === null || _message$bcc === void 0 ? void 0 : _message$bcc.join(', '),
|
|
606
|
+
replyTo: (_message$replyTo = message.replyTo) === null || _message$replyTo === void 0 ? void 0 : _message$replyTo.join(', '),
|
|
607
|
+
subject: message.subject,
|
|
608
|
+
html: message.html,
|
|
609
|
+
text: message.text,
|
|
610
|
+
attachments: message.attachments,
|
|
611
|
+
headers: message.headers
|
|
612
|
+
});
|
|
613
|
+
return {
|
|
614
|
+
messageId: info.messageId,
|
|
615
|
+
accepted: (_ref = info.accepted) !== null && _ref !== void 0 ? _ref : message.to,
|
|
616
|
+
rejected: (_ref2 = info.rejected) !== null && _ref2 !== void 0 ? _ref2 : [],
|
|
617
|
+
response: info.response
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
async verify() {
|
|
621
|
+
try {
|
|
622
|
+
await this.transporter.verify();
|
|
623
|
+
return true;
|
|
624
|
+
} catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
async close() {
|
|
629
|
+
this.transporter.close();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
class SesTransport {
|
|
634
|
+
constructor(config) {
|
|
635
|
+
// Uses SES SMTP interface — credentials are SES SMTP credentials (different from IAM keys)
|
|
636
|
+
// See: https://docs.aws.amazon.com/ses/latest/dg/smtp-credentials.html
|
|
637
|
+
this.transporter = nodemailer.createTransport({
|
|
638
|
+
host: `email-smtp.${config.region}.amazonaws.com`,
|
|
639
|
+
port: 587,
|
|
640
|
+
secure: false,
|
|
641
|
+
auth: config.credentials ? {
|
|
642
|
+
user: config.credentials.accessKeyId,
|
|
643
|
+
pass: config.credentials.secretAccessKey
|
|
644
|
+
} : undefined,
|
|
645
|
+
tls: {
|
|
646
|
+
ciphers: 'SSLv3'
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
async send(message) {
|
|
651
|
+
var _message$cc, _message$bcc, _message$replyTo, _ref, _ref2;
|
|
652
|
+
const info = await this.transporter.sendMail({
|
|
653
|
+
from: message.from,
|
|
654
|
+
to: message.to.join(', '),
|
|
655
|
+
cc: (_message$cc = message.cc) === null || _message$cc === void 0 ? void 0 : _message$cc.join(', '),
|
|
656
|
+
bcc: (_message$bcc = message.bcc) === null || _message$bcc === void 0 ? void 0 : _message$bcc.join(', '),
|
|
657
|
+
replyTo: (_message$replyTo = message.replyTo) === null || _message$replyTo === void 0 ? void 0 : _message$replyTo.join(', '),
|
|
658
|
+
subject: message.subject,
|
|
659
|
+
html: message.html,
|
|
660
|
+
text: message.text,
|
|
661
|
+
attachments: message.attachments,
|
|
662
|
+
headers: message.headers
|
|
663
|
+
});
|
|
664
|
+
return {
|
|
665
|
+
messageId: info.messageId,
|
|
666
|
+
accepted: (_ref = info.accepted) !== null && _ref !== void 0 ? _ref : message.to,
|
|
667
|
+
rejected: (_ref2 = info.rejected) !== null && _ref2 !== void 0 ? _ref2 : [],
|
|
668
|
+
response: info.response
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
async verify() {
|
|
672
|
+
try {
|
|
673
|
+
await this.transporter.verify();
|
|
674
|
+
return true;
|
|
675
|
+
} catch {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async close() {
|
|
680
|
+
this.transporter.close();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
class SmtpTransport {
|
|
685
|
+
constructor(config) {
|
|
686
|
+
var _config$port, _config$secure, _config$connectionTim, _config$greetingTimeo, _config$socketTimeout, _config$pool, _config$maxConnection, _config$maxMessages;
|
|
687
|
+
this.transporter = nodemailer.createTransport({
|
|
688
|
+
host: config.host,
|
|
689
|
+
port: (_config$port = config.port) !== null && _config$port !== void 0 ? _config$port : 587,
|
|
690
|
+
secure: (_config$secure = config.secure) !== null && _config$secure !== void 0 ? _config$secure : config.port === 465,
|
|
691
|
+
auth: config.auth,
|
|
692
|
+
tls: config.tls,
|
|
693
|
+
connectionTimeout: (_config$connectionTim = config.connectionTimeout) !== null && _config$connectionTim !== void 0 ? _config$connectionTim : 5000,
|
|
694
|
+
greetingTimeout: (_config$greetingTimeo = config.greetingTimeout) !== null && _config$greetingTimeo !== void 0 ? _config$greetingTimeo : 5000,
|
|
695
|
+
socketTimeout: (_config$socketTimeout = config.socketTimeout) !== null && _config$socketTimeout !== void 0 ? _config$socketTimeout : 30000,
|
|
696
|
+
pool: (_config$pool = config.pool) !== null && _config$pool !== void 0 ? _config$pool : false,
|
|
697
|
+
maxConnections: (_config$maxConnection = config.maxConnections) !== null && _config$maxConnection !== void 0 ? _config$maxConnection : 5,
|
|
698
|
+
maxMessages: (_config$maxMessages = config.maxMessages) !== null && _config$maxMessages !== void 0 ? _config$maxMessages : 100
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
async send(message) {
|
|
702
|
+
var _message$cc, _message$bcc, _message$replyTo, _message$attachments, _ref, _ref2;
|
|
703
|
+
const info = await this.transporter.sendMail({
|
|
704
|
+
from: message.from,
|
|
705
|
+
to: message.to.join(', '),
|
|
706
|
+
cc: (_message$cc = message.cc) === null || _message$cc === void 0 ? void 0 : _message$cc.join(', '),
|
|
707
|
+
bcc: (_message$bcc = message.bcc) === null || _message$bcc === void 0 ? void 0 : _message$bcc.join(', '),
|
|
708
|
+
replyTo: (_message$replyTo = message.replyTo) === null || _message$replyTo === void 0 ? void 0 : _message$replyTo.join(', '),
|
|
709
|
+
subject: message.subject,
|
|
710
|
+
html: message.html,
|
|
711
|
+
text: message.text,
|
|
712
|
+
attachments: (_message$attachments = message.attachments) === null || _message$attachments === void 0 ? void 0 : _message$attachments.map(a => ({
|
|
713
|
+
filename: a.filename,
|
|
714
|
+
content: a.content,
|
|
715
|
+
path: a.path,
|
|
716
|
+
href: a.href,
|
|
717
|
+
contentType: a.contentType,
|
|
718
|
+
encoding: a.encoding,
|
|
719
|
+
contentDisposition: a.contentDisposition,
|
|
720
|
+
cid: a.cid
|
|
721
|
+
})),
|
|
722
|
+
headers: message.headers
|
|
723
|
+
});
|
|
724
|
+
return {
|
|
725
|
+
messageId: info.messageId,
|
|
726
|
+
accepted: (_ref = info.accepted) !== null && _ref !== void 0 ? _ref : [],
|
|
727
|
+
rejected: (_ref2 = info.rejected) !== null && _ref2 !== void 0 ? _ref2 : [],
|
|
728
|
+
response: info.response
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
async verify() {
|
|
732
|
+
try {
|
|
733
|
+
await this.transporter.verify();
|
|
734
|
+
return true;
|
|
735
|
+
} catch {
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async close() {
|
|
740
|
+
this.transporter.close();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function createTransport(config) {
|
|
745
|
+
switch (config.type) {
|
|
746
|
+
case 'smtp':
|
|
747
|
+
return new SmtpTransport(config);
|
|
748
|
+
case 'sendgrid':
|
|
749
|
+
return new SendGridTransport(config);
|
|
750
|
+
case 'mailgun':
|
|
751
|
+
return new MailgunTransport(config);
|
|
752
|
+
case 'ses':
|
|
753
|
+
return new SesTransport(config);
|
|
754
|
+
case 'resend':
|
|
755
|
+
return new ResendTransport(config);
|
|
756
|
+
case 'console':
|
|
757
|
+
return new ConsoleTransport();
|
|
758
|
+
default:
|
|
759
|
+
{
|
|
760
|
+
const exhaustiveCheck = config;
|
|
761
|
+
throw new Error(`Unknown email transport type: ${exhaustiveCheck.type}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function normalizeRecipients(recipients) {
|
|
767
|
+
const arr = Array.isArray(recipients) ? recipients : [recipients];
|
|
768
|
+
return arr.map(r => typeof r === 'string' ? r : r.name ? `"${r.name}" <${r.address}>` : r.address);
|
|
769
|
+
}
|
|
770
|
+
function extractAddress(recipient) {
|
|
771
|
+
if (typeof recipient === 'string') {
|
|
772
|
+
const match = recipient.match(/<(.+)>/);
|
|
773
|
+
return match ? match[1] : recipient;
|
|
774
|
+
}
|
|
775
|
+
return recipient.address;
|
|
776
|
+
}
|
|
777
|
+
class EmailService {
|
|
778
|
+
constructor(config) {
|
|
779
|
+
var _config$queue, _config$suppressionLi;
|
|
780
|
+
_defineProperty(this, "suppressionList", new Set());
|
|
781
|
+
this.config = config;
|
|
782
|
+
|
|
783
|
+
// In development mode, log emails to the console instead of delivering them for real.
|
|
784
|
+
this.transport = this.isDevMode() ? createTransport({
|
|
785
|
+
type: 'console'
|
|
786
|
+
}) : createTransport(config.transport);
|
|
787
|
+
if (config.templates) {
|
|
788
|
+
this.templateManager = new TemplateManager(config.templates);
|
|
789
|
+
}
|
|
790
|
+
if (((_config$queue = config.queue) === null || _config$queue === void 0 ? void 0 : _config$queue.enabled) !== false) {
|
|
791
|
+
var _config$queue2;
|
|
792
|
+
this.emailQueue = new EmailQueue((_config$queue2 = config.queue) !== null && _config$queue2 !== void 0 ? _config$queue2 : {}, async job => {
|
|
793
|
+
await this.sendImmediate(job.options);
|
|
794
|
+
});
|
|
795
|
+
this.emailQueue.start();
|
|
796
|
+
}
|
|
797
|
+
if ((_config$suppressionLi = config.suppressionList) !== null && _config$suppressionLi !== void 0 && _config$suppressionLi.length) {
|
|
798
|
+
for (const addr of config.suppressionList) {
|
|
799
|
+
this.suppressionList.add(addr.toLowerCase());
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
async send(options) {
|
|
804
|
+
return this.sendImmediate(options);
|
|
805
|
+
}
|
|
806
|
+
async bulk(recipients, options) {
|
|
807
|
+
const results = [];
|
|
808
|
+
for (const recipient of recipients) {
|
|
809
|
+
try {
|
|
810
|
+
const info = await this.sendImmediate({
|
|
811
|
+
...options,
|
|
812
|
+
to: recipient
|
|
813
|
+
});
|
|
814
|
+
results.push(info);
|
|
815
|
+
} catch (err) {
|
|
816
|
+
try {
|
|
817
|
+
var _this$config$onFailed, _this$config;
|
|
818
|
+
await ((_this$config$onFailed = (_this$config = this.config).onFailed) === null || _this$config$onFailed === void 0 ? void 0 : _this$config$onFailed.call(_this$config, err instanceof Error ? err : new Error(String(err)), {
|
|
819
|
+
...options,
|
|
820
|
+
to: recipient
|
|
821
|
+
}));
|
|
822
|
+
} catch {
|
|
823
|
+
// a failing onFailed callback must not abort the rest of the bulk send
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return results;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Whether development mode is active. When a `development` config block is present, this defaults
|
|
832
|
+
* to `process.env.NODE_ENV !== 'production'` unless `development.enabled` is set explicitly.
|
|
833
|
+
* In dev mode emails are logged (and optionally written to `previewPath`) instead of being sent.
|
|
834
|
+
*/
|
|
835
|
+
isDevMode() {
|
|
836
|
+
var _dev$enabled;
|
|
837
|
+
const dev = this.config.development;
|
|
838
|
+
if (!dev) return false;
|
|
839
|
+
return (_dev$enabled = dev.enabled) !== null && _dev$enabled !== void 0 ? _dev$enabled : process.env.NODE_ENV !== 'production';
|
|
840
|
+
}
|
|
841
|
+
async writePreview(dir, template, html) {
|
|
842
|
+
try {
|
|
843
|
+
await mkdir(dir, {
|
|
844
|
+
recursive: true
|
|
845
|
+
});
|
|
846
|
+
const safeName = (template !== null && template !== void 0 ? template : 'email').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
847
|
+
await writeFile(join(dir, `${safeName}-${Date.now()}.html`), html, 'utf-8');
|
|
848
|
+
} catch {
|
|
849
|
+
// Preview writing is best-effort and must never break sending.
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
queue(options) {
|
|
853
|
+
var _options$priority;
|
|
854
|
+
if (!this.emailQueue) {
|
|
855
|
+
throw new Error('Email queue is not enabled. Set queue.enabled = true in email config.');
|
|
856
|
+
}
|
|
857
|
+
const job = this.emailQueue.enqueue({
|
|
858
|
+
options,
|
|
859
|
+
sendAt: options.sendAt,
|
|
860
|
+
priority: (_options$priority = options.priority) !== null && _options$priority !== void 0 ? _options$priority : 'normal'
|
|
861
|
+
});
|
|
862
|
+
return job.id;
|
|
863
|
+
}
|
|
864
|
+
addToSuppressionList(address) {
|
|
865
|
+
this.suppressionList.add(address.toLowerCase());
|
|
866
|
+
}
|
|
867
|
+
removeFromSuppressionList(address) {
|
|
868
|
+
this.suppressionList.delete(address.toLowerCase());
|
|
869
|
+
}
|
|
870
|
+
isSuppressed(address) {
|
|
871
|
+
return this.suppressionList.has(address.toLowerCase());
|
|
872
|
+
}
|
|
873
|
+
getSuppressionList() {
|
|
874
|
+
return Array.from(this.suppressionList);
|
|
875
|
+
}
|
|
876
|
+
getQueueStats() {
|
|
877
|
+
if (!this.emailQueue) {
|
|
878
|
+
return {
|
|
879
|
+
pending: 0,
|
|
880
|
+
processing: 0,
|
|
881
|
+
failed: 0,
|
|
882
|
+
completed: 0
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
return this.emailQueue.getStats();
|
|
886
|
+
}
|
|
887
|
+
async flushQueue() {
|
|
888
|
+
if (this.emailQueue) {
|
|
889
|
+
await this.emailQueue.flush();
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
async close() {
|
|
893
|
+
var _this$emailQueue;
|
|
894
|
+
(_this$emailQueue = this.emailQueue) === null || _this$emailQueue === void 0 || _this$emailQueue.stop();
|
|
895
|
+
await this.transport.close();
|
|
896
|
+
}
|
|
897
|
+
async sendImmediate(options) {
|
|
898
|
+
var _this$config$developm, _options$from, _this$config$developm2;
|
|
899
|
+
const toAddresses = normalizeRecipients(Array.isArray(options.to) ? options.to : [options.to]);
|
|
900
|
+
|
|
901
|
+
// Apply dev override
|
|
902
|
+
let effectiveTo = toAddresses;
|
|
903
|
+
if ((_this$config$developm = this.config.development) !== null && _this$config$developm !== void 0 && _this$config$developm.enabled && this.config.development.toOverride) {
|
|
904
|
+
effectiveTo = normalizeRecipients(Array.isArray(this.config.development.toOverride) ? this.config.development.toOverride : [this.config.development.toOverride]);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Suppression check
|
|
908
|
+
if (!options.skipSuppressionCheck) {
|
|
909
|
+
const suppressed = effectiveTo.filter(addr => {
|
|
910
|
+
const raw = extractAddress(addr);
|
|
911
|
+
return this.suppressionList.has(raw.toLowerCase());
|
|
912
|
+
});
|
|
913
|
+
if (suppressed.length) {
|
|
914
|
+
throw new Error(`Email suppressed for: ${suppressed.join(', ')}`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
let {
|
|
918
|
+
html,
|
|
919
|
+
text,
|
|
920
|
+
subject
|
|
921
|
+
} = options;
|
|
922
|
+
|
|
923
|
+
// Render template
|
|
924
|
+
if (options.template && this.templateManager) {
|
|
925
|
+
var _options$data;
|
|
926
|
+
const rendered = await this.templateManager.render(options.template, (_options$data = options.data) !== null && _options$data !== void 0 ? _options$data : {});
|
|
927
|
+
html = html !== null && html !== void 0 ? html : rendered.html;
|
|
928
|
+
text = text !== null && text !== void 0 ? text : rendered.text;
|
|
929
|
+
subject = subject !== null && subject !== void 0 ? subject : rendered.subject;
|
|
930
|
+
}
|
|
931
|
+
if (!subject) {
|
|
932
|
+
throw new Error('Email subject is required');
|
|
933
|
+
}
|
|
934
|
+
const from = (_options$from = options.from) !== null && _options$from !== void 0 ? _options$from : this.config.from;
|
|
935
|
+
if (!from) {
|
|
936
|
+
throw new Error('Email "from" address is required. Set it in config.email.from or pass it in the send options.');
|
|
937
|
+
}
|
|
938
|
+
const ccAddresses = options.cc ? normalizeRecipients(Array.isArray(options.cc) ? options.cc : [options.cc]) : undefined;
|
|
939
|
+
const bccAddresses = options.bcc ? normalizeRecipients(Array.isArray(options.bcc) ? options.bcc : [options.bcc]) : undefined;
|
|
940
|
+
const replyToAddresses = options.replyTo ? normalizeRecipients(Array.isArray(options.replyTo) ? options.replyTo : [options.replyTo]) : this.config.replyTo ? normalizeRecipients(this.config.replyTo) : undefined;
|
|
941
|
+
const message = {
|
|
942
|
+
from: typeof from === 'string' ? from : from.name ? `"${from.name}" <${from.address}>` : from.address,
|
|
943
|
+
to: effectiveTo,
|
|
944
|
+
cc: ccAddresses,
|
|
945
|
+
bcc: bccAddresses,
|
|
946
|
+
replyTo: replyToAddresses,
|
|
947
|
+
subject,
|
|
948
|
+
html,
|
|
949
|
+
text,
|
|
950
|
+
attachments: options.attachments,
|
|
951
|
+
headers: {
|
|
952
|
+
...this.config.headers,
|
|
953
|
+
...options.headers
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
// In dev mode, optionally write the rendered HTML to disk for browser preview.
|
|
958
|
+
if (this.isDevMode() && (_this$config$developm2 = this.config.development) !== null && _this$config$developm2 !== void 0 && _this$config$developm2.previewPath && html) {
|
|
959
|
+
await this.writePreview(this.config.development.previewPath, options.template, html);
|
|
960
|
+
}
|
|
961
|
+
const result = await this.transport.send(message);
|
|
962
|
+
const info = {
|
|
963
|
+
messageId: result.messageId,
|
|
964
|
+
accepted: result.accepted,
|
|
965
|
+
rejected: result.rejected,
|
|
966
|
+
response: result.response,
|
|
967
|
+
timestamp: new Date()
|
|
968
|
+
};
|
|
969
|
+
try {
|
|
970
|
+
var _this$config$onSent, _this$config2;
|
|
971
|
+
await ((_this$config$onSent = (_this$config2 = this.config).onSent) === null || _this$config$onSent === void 0 ? void 0 : _this$config$onSent.call(_this$config2, info, options));
|
|
972
|
+
} catch {
|
|
973
|
+
// a failing onSent callback must not fail an already-sent email
|
|
974
|
+
}
|
|
975
|
+
return info;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function createEmail(config) {
|
|
980
|
+
return new EmailService(config);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
export { EmailService, createEmail };
|