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