mail-time 3.0.0 → 4.1.0

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/index.js CHANGED
@@ -1,151 +1,233 @@
1
- import { JoSk, RedisAdapter, MongoAdapter } from 'josk';
2
- import merge from 'deepmerge';
1
+ import { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } from 'josk';
2
+ import { randomUUID } from 'crypto';
3
3
 
4
- import crypto from 'crypto';
5
4
  import { MongoQueue } from './adapters/mongo.js';
6
5
  import { RedisQueue } from './adapters/redis.js';
7
- import { debug, logError } from './helpers.js';
6
+ import { PostgresQueue } from './adapters/postgres.js';
7
+ import { mailTimePreset, presets, presetNames } from './presets.js';
8
+ import { debug, logError, hasOwnProp, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
8
9
 
9
10
  const noop = () => {};
11
+ const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
10
12
 
11
- /**
12
- * Check if entities of various types are equal
13
- * including edge cases like unordered Objects and Array
14
- * @function equals
15
- * @param {mix} a
16
- * @param {mix} b
17
- * @returns {boolean}
18
- */
19
- const equals = (a, b) => {
20
- let i;
21
- if (a === b) {
22
- return true;
23
- }
24
-
25
- if (!a || !b) {
26
- return false;
27
- }
28
-
29
- if (!(typeof a === 'object' && typeof b === 'object')) {
30
- return false;
13
+ const __withLease = (task, updateObj) => {
14
+ if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
15
+ return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
31
16
  }
17
+ return updateObj;
18
+ };
32
19
 
33
- if (a instanceof Date && b instanceof Date) {
34
- return a.valueOf() === b.valueOf();
20
+ const __leaseRemoveOpts = (task) => {
21
+ if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
22
+ return { leaseTries: task.tries, leaseSendingAt: task.sendingAt };
35
23
  }
24
+ return void 0;
25
+ };
36
26
 
37
- if (a instanceof Array) {
38
- if (!(b instanceof Array)) {
39
- return false;
27
+ const createPool = (concurrency) => {
28
+ const limit = Math.max(1, concurrency | 0);
29
+ const queue = [];
30
+ let active = 0;
31
+ let drainResolvers = [];
32
+
33
+ const tryStart = () => {
34
+ while (active < limit && queue.length > 0) {
35
+ const job = queue.shift();
36
+ active++;
37
+ job.resolveSlot();
38
+ Promise.resolve()
39
+ .then(job.fn)
40
+ .catch((poolError) => {
41
+ logError('[pool] unhandled send error', poolError);
42
+ })
43
+ .finally(() => {
44
+ active--;
45
+ if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
46
+ const resolvers = drainResolvers;
47
+ drainResolvers = [];
48
+ for (const r of resolvers) r();
49
+ }
50
+ tryStart();
51
+ });
40
52
  }
53
+ };
54
+
55
+ return {
56
+ /**
57
+ * Queue `fn` for execution under the concurrency limit.
58
+ * The returned Promise resolves as soon as a slot is acquired and `fn` has started.
59
+ * It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
60
+ * @param {() => Promise<void>} fn
61
+ * @returns {Promise<void>}
62
+ */
63
+ dispatch(fn) {
64
+ return new Promise((resolveSlot) => {
65
+ queue.push({ fn, resolveSlot });
66
+ tryStart();
67
+ });
68
+ },
69
+ drain() {
70
+ if (active === 0 && queue.length === 0) {
71
+ return Promise.resolve();
72
+ }
73
+ return new Promise((resolve) => drainResolvers.push(resolve));
74
+ },
75
+ get size() {
76
+ return active + queue.length;
77
+ },
78
+ };
79
+ };
41
80
 
42
- if (a.length !== b.length) {
43
- return false;
44
- }
81
+ const mailOptionRecipients = (mailOption) => {
82
+ if (!mailOption) {
83
+ return [];
84
+ }
85
+ return [
86
+ ...toAddressList(mailOption.to),
87
+ ...toAddressList(mailOption.cc),
88
+ ...toAddressList(mailOption.bcc),
89
+ ];
90
+ };
45
91
 
46
- const _a = a.slice();
47
- const _b = b.slice();
48
- let j;
49
- for (i = _a.length - 1; i >= 0; i--) {
50
- let result = false;
51
- for (j = _b.length - 1; j >= 0; j--) {
52
- if (equals(_a[i], _b[j])) {
53
- result = true;
54
- _a.splice(i, 1);
55
- _b.splice(j, 1);
56
- break;
92
+ const collectAcceptedSet = (task) => {
93
+ const set = new Set();
94
+ for (const mo of (task?.mailOptions || [])) {
95
+ if (Array.isArray(mo.accepted)) {
96
+ for (const addr of mo.accepted) {
97
+ if (typeof addr === 'string') {
98
+ set.add(addr.toLowerCase());
57
99
  }
58
100
  }
59
-
60
- if (!result) {
61
- return false;
62
- }
63
101
  }
64
- return true;
65
102
  }
103
+ return set;
104
+ };
66
105
 
67
- i = 0;
68
- if (typeof a === 'object' && typeof b === 'object') {
69
- const akeys = Object.keys(a);
70
- const bkeys = Object.keys(b);
71
-
72
- if (akeys.length !== bkeys.length) {
73
- return false;
106
+ const collectAllRecipients = (task) => {
107
+ const set = new Set();
108
+ for (const mo of (task?.mailOptions || [])) {
109
+ for (const addr of mailOptionRecipients(mo)) {
110
+ set.add(addr);
74
111
  }
112
+ }
113
+ return set;
114
+ };
75
115
 
76
- for (i = akeys.length - 1; i >= 0; i--) {
77
- if (!hasOwnProperty.call(b, akeys[i])) {
78
- return false;
79
- }
80
-
81
- if (!equals(a[akeys[i]], b[akeys[i]])) {
82
- return false;
83
- }
116
+ const buildRejectionErrorMap = (info) => {
117
+ const map = new Map();
118
+ const rejected = Array.isArray(info?.rejected) ? info.rejected : [];
119
+ const rejectedErrors = Array.isArray(info?.rejectedErrors) ? info.rejectedErrors : [];
120
+ for (let i = 0; i < rejected.length; i++) {
121
+ const addr = extractEmail(rejected[i]);
122
+ if (!addr) {
123
+ continue;
84
124
  }
85
-
86
- return true;
125
+ const err = rejectedErrors[i];
126
+ map.set(addr, err ? `${err.message || err}` : 'Recipient rejected by transport');
87
127
  }
88
- return false;
128
+ return map;
89
129
  };
90
130
 
91
131
  let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml><meta content="text/html; charset=utf-8"http-equiv=Content-Type><meta content="width=device-width,initial-scale=1"name=viewport><title>{{subject}}</title><style>body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;font-family:Tiempos,Georgia,Times,serif;font-weight:400;width:100%;height:100%;background:#fff;font-size:15px;color:#000;line-height:1.5}a{text-decoration:underline;border:0;color:#000;outline:0;color:inherit}a:hover{text-decoration:none}a[href^=sms],a[href^=tel]{text-decoration:none;color:#000;cursor:default}a img{border:none;text-decoration:none}td{font-family:Tiempos,Georgia,Times,serif;font-weight:400}hr{height:1px;border:none;width:100%;margin:0;margin-top:25px;margin-bottom:25px;background-color:#ECECEC}h1,h2,h3,h4,h5,h6{font-family:HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;line-height:normal;margin-top:35px;margin-bottom:4px;margin-left:0;margin-right:0}h1{margin:23px 15px;font-size:25px}h2{margin-top:15px;font-size:21px}h3{font-weight:400;font-size:19px;border-bottom:1px solid #ECECEC}h4{font-weight:400;font-size:18px}h5{font-weight:400;font-size:17px}h6{font-weight:600;font-size:16px}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{text-decoration:none}pre{font-family:Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;display:block;font-size:13px;padding:9.5px;margin:0 0 10px;line-height:1.42;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px;text-align:left!important;max-width:100%;white-space:pre-wrap;width:auto;overflow:auto}code{font-size:13px;font-family:font-family: Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;border:1px solid rgba(0,0,0,.223);border-radius:2px;padding:1px 2px;word-break:break-all;word-wrap:break-word}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border:none;border-radius:0;word-break:break-all;word-wrap:break-word}td{text-align:center}table{border-collapse:collapse!important}.force-full-width{width:100%!important}</style><style media=screen>@media screen{h1,h2,h3,h4,h5,h6{font-family:\'Helvetica Neue\',Arial,sans-serif!important}td{font-family:Tiempos,Georgia,Times,serif!important}code,pre{font-family:Consolas,Menlo,Monaco,\'Lucida Console\',\'Liberation Mono\',\'DejaVu Sans Mono\',\'Bitstream Vera Sans Mono\',\'Courier New\',monospace,sans-serif!important}}</style><style media="only screen and (max-width:480px)">@media only screen and (max-width:480px){table[class=w320]{width:100%!important}}</style><body bgcolor=#FFFFFF class=body style=padding:0;margin:0;display:block;background:#fff;-webkit-text-size-adjust:none><table cellpadding=0 cellspacing=0 width=100% align=center><tr><td align=center valign=top bgcolor=#FFFFFF width=100%><center><table cellpadding=0 cellspacing=0 width=600 style="margin:0 auto"class=w320><tr><td align=center valign=top><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto;border-bottom:1px solid #ddd"bgcolor=#ECECEC><tr><td><h1>{{{subject}}}</h1></table><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"bgcolor=#F2F2F2><tr><td><center><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"><tr><td align=left style="text-align:left;padding:30px 25px">{{{html}}}</table></center></table></table></center></table>';
92
132
 
133
+ /**
134
+ * @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
135
+ */
136
+
137
+ /**
138
+ * @typedef {import('./presets.js').MailTimePresetConfig} MailTimePresetConfig
139
+ */
140
+
141
+ /**
142
+ * @typedef {{ status: string, code: number, statusCode: number, paused?: boolean, error?: unknown }} MailTimePingResult
143
+ */
144
+
145
+ /**
146
+ * @typedef {{ [key: string]: any, query?: (queryText: string, values?: unknown[]) => Promise<{ rows?: unknown[], rowCount?: number | null }> }} MailTimeStorageClient
147
+ */
148
+
149
+ /**
150
+ * @typedef {{ [key: string]: any }} MailTimeMongoDb
151
+ */
152
+
153
+ /**
154
+ * @typedef {{ [key: string]: any }} MailTimeTransport
155
+ */
156
+
157
+ /**
158
+ * @typedef {{ [key: string]: any, type?: 'mongo' | 'redis' | 'postgres', client?: MailTimeStorageClient, db?: MailTimeMongoDb, prefix?: string, resetOnInit?: boolean, useHashTags?: boolean }} MailTimeJoSkAdapterOptions
159
+ */
160
+
161
+ /**
162
+ * @typedef {{ [key: string]: any, adapter: MailTimeJoSkAdapterOptions | object, debug?: boolean, autoClear?: boolean, zombieTime?: number, minRevolvingDelay?: number, maxRevolvingDelay?: number, execute?: 'batch' | 'one', concurrency?: number, lockOwnerId?: string, resetOnInit?: boolean, onError?: (title: string, details: object) => void, onExecuted?: (uid: string, details: object) => void }} MailTimeJoSkOptions
163
+ */
164
+
165
+ /**
166
+ * @typedef {{ uuid: string, to?: string | string[], tries: number, sendAt: number, isSent: boolean, isCancelled: boolean, isFailed: boolean, isSending?: boolean, sendingAt?: number, template?: string | false, transport: number, concatSubject?: string | false, mailOptions: MailTimeMailOptions[] }} MailTimeTask
167
+ */
168
+
169
+ /**
170
+ * @typedef {{ limit?: number, sendingTimeout?: number }} MailTimeIterateOptions
171
+ */
172
+
173
+ /**
174
+ * @typedef {{ ping: () => Promise<MailTimePingResult>, iterate: (opts?: MailTimeIterateOptions) => Promise<void> | void, getPendingTo: (to: string, sendAt: number) => Promise<MailTimeTask | object | null>, push: (email: MailTimeTask) => Promise<void> | void, cancel: (uuid: string) => Promise<boolean>, remove: (email: MailTimeTask | object, opts?: { leaseTries: number, leaseSendingAt: number }) => Promise<boolean>, update: (email: MailTimeTask | object, updateObj: object) => Promise<boolean>, ready?: () => Promise<void> }} CustomQueue
175
+ */
176
+
177
+ /**
178
+ * @typedef {{ address: string, error: string }} MailTimeRejectedRecipient
179
+ */
180
+
181
+ /**
182
+ * @typedef {{ [key: string]: any, to: string | string[], sendAt?: Date | number, template?: string, concatSubject?: string, text?: string | false, html?: string | false, subject?: string, accepted?: string[], rejected?: MailTimeRejectedRecipient[] }} MailTimeMailOptions
183
+ */
184
+
185
+ /**
186
+ * @typedef {{ subject?: string }} MailTimeConcatEmailsOptions
187
+ */
188
+
189
+ /**
190
+ * @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, retries?: number, maxTries?: number, retryDelay?: number, interval?: number, keepHistory?: boolean, concatEmails?: boolean | MailTimeConcatEmailsOptions, concatSubject?: string, concatDelimiter?: string, concatDelay?: number, concatThrottling?: number, revolvingInterval?: number, mode?: 'one' | 'batch', concurrency?: number, sendingTimeout?: number, verifyTransports?: boolean, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, onError?: (error: unknown, email: MailTimeTask | null, details?: object) => void, onSent?: (email: MailTimeTask, details?: object) => void }} MailTimeOptions
191
+ */
192
+
93
193
  /** Class of MailTime */
94
194
  class MailTime {
95
195
  /**
96
196
  * Create a MailTime instance
97
- * @param {object} opts - configuration object
98
- * @param {RedisQueue|MongoQueue|CustomQueue} opts.queue - Queue Storage Driver instance
99
- * @param {string} [opts.type] - "server" or "client" type of MailTime instance
100
- * @param {function} [opts.from] - A function returning *String* for `from` header, format: `"MyApp" <user@example.com>`
101
- * @param {[object]} [opts.transports] - An array of `nodemailer`'s transports, returned from `nodemailer.createTransport({})`; Required for `{type: 'server'}`
102
- * @param {string} [opts.strategy] - `backup` or `balancer`
103
- * @param {number} [opts.failsToNext] - After how many failed "send attempts" switch to the next transport? Applied only for `backup` strategy, default - `4`
104
- * @param {number} [opts.retries] - How many times resend failed emails
105
- * @param {number} [opts.retryDelay] - Interval in milliseconds between send re-tries
106
- * @param {boolean} [opts.keepHistory] - Keep queue task as it is in the database
107
- * @param {boolean} [opts.concatEmails] - Concatenate email by `to` field, default - `false`
108
- * @param {string} [opts.concatSubject] - Email subject used in concatenated email, default - `Multiple notifications`
109
- * @param {string} [opts.concatDelimiter] - HTML or plain string delimiter used between concatenated email, default - `<hr>`
110
- * @param {number} [opts.concatDelay] - HTML or plain string delimiter used between concatenated email, default - `<hr>`
111
- * @param {number} [opts.revolvingInterval] - Interval in *milliseconds* in between queue checks, default - `256`
112
- * @param {object|RedisAdapter|MongoAdapter|CustomAdapter} [opts.josk.adapter] - Interval in milliseconds between send re-tries
113
- * @param {string} [opts.josk.adapter.type] - One of `mongo` or `redis`
114
- * @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application; By default prefix inherited from MailTime instance
197
+ * @param {MailTimeOptions} opts - configuration object
115
198
  */
116
199
  constructor (opts) {
117
- if (!opts || typeof opts !== 'object' || opts === null) {
200
+ if (!opts || typeof opts !== 'object') {
118
201
  throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
119
202
  }
120
203
 
121
204
  if (!opts.queue || typeof opts.queue !== 'object') {
122
- throw new Error('[mail-time] {queue} option is required', {
123
- description: 'MailTime requires MongoQueue, RedisQueue, or CustomQueue to connect to an intermediate database'
124
- });
205
+ throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
125
206
  }
126
207
 
127
208
  this.queue = opts.queue;
128
- this.queue.mailTimeInstance = this;
129
- const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
130
209
 
131
210
  for (let i = queueMethods.length - 1; i >= 0; i--) {
132
211
  if (typeof this.queue[queueMethods[i]] !== 'function') {
133
- throw new Error(`{queue} instance is missing {${queueMethods[i]}} method that is required!`);
212
+ throw new Error(`[mail-time] {queue} instance is missing {${queueMethods[i]}} method that is required!`);
134
213
  }
135
214
  }
136
215
 
137
- this.debug = (opts.debug !== true) ? false : true;
138
- this._debug = (...args) => {
139
- debug(this.debug, ...args);
216
+ this.debug = opts.debug === true;
217
+ this.__debug = (...args) => {
218
+ debug(this.debug, `[${this.prefix || 'default'}]`, ...args);
140
219
  };
141
220
 
142
- this.type = (typeof opts.type !== 'string' || (opts.type !== 'client' && opts.type !== 'server')) ? 'server' : opts.type;
221
+ this.type = (opts.type === 'client' || opts.type === 'server') ? opts.type : 'server';
143
222
  this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
144
223
 
145
224
  if (typeof opts.retries === 'number') {
225
+ if (opts.retries < 0) {
226
+ throw new Error('[mail-time] {retries} must be a non-negative number');
227
+ }
146
228
  this.maxTries = opts.retries + 1;
147
229
  } else if (typeof opts.maxTries === 'number') {
148
- this.maxTries = (opts.maxTries < 1) ? 1 : 0;
230
+ this.maxTries = (opts.maxTries < 1) ? 1 : opts.maxTries;
149
231
  } else {
150
232
  this.maxTries = 60;
151
233
  }
@@ -159,33 +241,53 @@ class MailTime {
159
241
  }
160
242
 
161
243
  this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
162
- this.keepHistory = (typeof opts.keepHistory === 'boolean') ? opts.keepHistory : false;
163
- this.onSent = opts.onSent || noop;
164
- this.onError = opts.onError || noop;
165
-
166
- this.revolvingInterval = opts.revolvingInterval || 1536;
167
-
168
- this.failsToNext = (typeof opts.failsToNext === 'number') ? opts.failsToNext : 4;
244
+ this.keepHistory = opts.keepHistory === true;
245
+ this.onSent = (typeof opts.onSent === 'function') ? opts.onSent.bind(this) : noop;
246
+ this.onError = (typeof opts.onError === 'function') ? opts.onError.bind(this) : noop;
247
+
248
+ this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
249
+ this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
250
+ this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
251
+ this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
252
+ this.__isDestroyed = false;
253
+ this.__abortInFlight = false;
254
+ this.__isPaused = false;
255
+ this.__readyPromise = null;
256
+ this.__schedulerTimer = null;
257
+ this.__inFlight = new Set();
258
+ this.__pool = createPool(this.concurrency);
259
+
260
+ this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
169
261
  this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
170
- this.transports = opts.transports || [];
262
+ this.transports = Array.isArray(opts.transports) ? opts.transports : [];
171
263
  this.transport = 0;
172
- this.from = (() => {
173
- if (typeof opts.from === 'string') {
174
- return () => {
175
- return opts.from;
176
- };
177
- }
178
-
179
- if (typeof opts.from === 'function') {
180
- return opts.from;
181
- }
264
+ this.verifyTransports = opts.verifyTransports !== false;
265
+ this.__unhealthyTransports = new Set();
266
+
267
+ if (typeof opts.from === 'string') {
268
+ const fromStr = opts.from;
269
+ this.from = () => fromStr;
270
+ } else if (typeof opts.from === 'function') {
271
+ this.from = opts.from;
272
+ } else {
273
+ this.from = false;
274
+ }
182
275
 
183
- return false;
184
- })();
276
+ this.queue.mailTimeInstance = this;
185
277
 
186
- this.concatEmails = (opts.concatEmails !== true) ? false : true;
187
- this.concatSubject = (opts.concatSubject && typeof opts.concatSubject === 'string') ? opts.concatSubject : 'Multiple notifications';
188
- this.concatDelimiter = (opts.concatDelimiter && typeof opts.concatDelimiter === 'string') ? opts.concatDelimiter : '<hr>';
278
+ /** @type {string} */
279
+ this.concatSubject = (typeof opts.concatSubject === 'string' && opts.concatSubject) ? opts.concatSubject : 'Multiple notifications';
280
+ if (opts.concatEmails === true) {
281
+ this.concatEmails = true;
282
+ } else if (isPlainObject(opts.concatEmails)) {
283
+ this.concatEmails = true;
284
+ if (typeof opts.concatEmails.subject === 'string' && opts.concatEmails.subject) {
285
+ this.concatSubject = opts.concatEmails.subject;
286
+ }
287
+ } else {
288
+ this.concatEmails = false;
289
+ }
290
+ this.concatDelimiter = (typeof opts.concatDelimiter === 'string' && opts.concatDelimiter) ? opts.concatDelimiter : '<hr>';
189
291
 
190
292
  if (typeof opts.concatDelay === 'number') {
191
293
  this.concatDelay = opts.concatDelay;
@@ -195,63 +297,90 @@ class MailTime {
195
297
  this.concatDelay = 60000;
196
298
  }
197
299
 
198
- this._debug('DEBUG ON {debug: true}');
199
- this._debug(`INITIALIZING [type: ${this.type}]`);
200
- this._debug(`INITIALIZING [strategy: ${this.strategy}]`);
201
- this._debug(`INITIALIZING [josk.adapter: ${opts?.josk?.adapter}]`);
202
- this._debug(`INITIALIZING [prefix: ${this.prefix}]`);
203
- this._debug(`INITIALIZING [retries: ${this.retries}]`);
204
- this._debug(`INITIALIZING [failsToNext: ${this.failsToNext}]`);
205
- this._debug(`INITIALIZING [onError: ${this.onError}]`);
206
- this._debug(`INITIALIZING [onSent: ${this.onSent}]`);
300
+ this.__debug('DEBUG ON {debug: true}');
301
+ this.__debug(`INITIALIZING [type: ${this.type}]`);
302
+ this.__debug(`INITIALIZING [strategy: ${this.strategy}]`);
303
+ this.__debug(`INITIALIZING [josk.adapter.type: ${opts?.josk?.adapter?.type || 'custom'}]`);
304
+ this.__debug(`INITIALIZING [prefix: ${this.prefix}]`);
305
+ this.__debug(`INITIALIZING [retries: ${this.maxTries - 1}]`);
306
+ this.__debug(`INITIALIZING [failsToNext: ${this.failsToNext}]`);
307
+ this.__debug(`INITIALIZING [mode: ${this.mode}]`);
308
+ this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
309
+ this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
207
310
 
208
311
  /** SERVER-SPECIFIC CHECKS AND CONFIG */
209
312
  if (this.type === 'server') {
210
- if (this.transports.constructor !== Array || !this.transports.length) {
211
- throw new Error('[mail-time] {transports} is required for {type: "server"} and must be an Array, like returned from `nodemailer.createTransport`');
313
+ if (!this.transports.length) {
314
+ throw new Error('[mail-time] {transports} is required for {type: "server"} and must be a non-empty Array, like one returned from `nodemailer.createTransport`');
212
315
  }
213
316
 
214
- if (typeof opts.josk !== 'object') {
317
+ if (!opts.josk || typeof opts.josk !== 'object') {
215
318
  throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
216
319
  }
217
320
 
218
- if (typeof opts.josk.adapter !== 'object') {
321
+ if (!opts.josk.adapter || typeof opts.josk.adapter !== 'object') {
219
322
  throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
220
323
  }
221
324
 
222
325
  this.josk = { ...opts.josk };
326
+ const buildAdapterOptions = () => {
327
+ const adapterOptions = {
328
+ prefix: `mailTimeQueue${this.prefix}`,
329
+ ...opts.josk.adapter,
330
+ };
331
+
332
+ if (typeof opts.josk.resetOnInit === 'boolean' && typeof adapterOptions.resetOnInit !== 'boolean') {
333
+ adapterOptions.resetOnInit = opts.josk.resetOnInit;
334
+ }
223
335
 
224
- if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'mongo') {
336
+ return adapterOptions;
337
+ };
338
+
339
+ const adapterType = opts.josk.adapter.type;
340
+ if (adapterType === 'mongo') {
225
341
  if (!opts.josk.adapter.db) {
226
342
  throw new Error('[mail-time] {josk.adapter.db} option required for {josk.adapter.type: "mongo"}');
227
343
  }
228
- this.josk.adapter = new MongoAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
229
- }
230
-
231
- if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'redis') {
344
+ this.josk.adapter = new MongoAdapter(buildAdapterOptions());
345
+ } else if (adapterType === 'redis') {
232
346
  if (!opts.josk.adapter.client) {
233
347
  throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "redis"}');
234
348
  }
235
- this.josk.adapter = new RedisAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
349
+ this.josk.adapter = new RedisAdapter(buildAdapterOptions());
350
+ } else if (adapterType === 'postgres') {
351
+ if (!opts.josk.adapter.client) {
352
+ throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "postgres"}');
353
+ }
354
+ this.josk.adapter = new PostgresAdapter(buildAdapterOptions());
236
355
  }
237
356
 
238
357
  this.josk.minRevolvingDelay = (typeof opts.josk.minRevolvingDelay === 'number') ? opts.josk.minRevolvingDelay : 512;
239
358
  this.josk.maxRevolvingDelay = (typeof opts.josk.maxRevolvingDelay === 'number') ? opts.josk.maxRevolvingDelay : 2048;
240
- this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime : 32786;
359
+ this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime : 60000;
360
+ this.josk.execute = (opts.josk.execute === 'one' || opts.josk.execute === 'batch') ? opts.josk.execute : 'batch';
361
+ this.josk.concurrency = (typeof opts.josk.concurrency === 'number' && opts.josk.concurrency > 0) ? opts.josk.concurrency : Infinity;
362
+ this.josk.autoClear = opts.josk.autoClear === true;
363
+
364
+ if (typeof opts.josk.lockOwnerId === 'string' && opts.josk.lockOwnerId.length > 0) {
365
+ this.josk.lockOwnerId = opts.josk.lockOwnerId;
366
+ }
367
+
368
+ if (typeof opts.josk.onError !== 'function') {
369
+ this.josk.onError = (title, details) => {
370
+ logError(`[scheduler] ${title}`, details);
371
+ };
372
+ }
241
373
 
242
374
  this.scheduler = new JoSk({
243
375
  debug: this.debug,
244
376
  ...this.josk,
245
377
  });
246
378
 
247
- process.nextTick(async () => {
248
- if ((await this.scheduler.ping()).status !== 'OK') {
249
- throw new Error('[mail-time] [MailTime#ping] can not connect to storage, make sure it is available and properly configured');
250
- }
251
- });
252
-
253
- this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
379
+ this.__schedulerTimer = this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
254
380
  }
381
+
382
+ this.__readyPromise = this.___ready();
383
+ this.__readyPromise.catch(() => void 0);
255
384
  }
256
385
 
257
386
  static get Template() {
@@ -267,26 +396,134 @@ class MailTime {
267
396
  * @memberOf MailTime
268
397
  * @name ping
269
398
  * @description Check package readiness and connection to Storage
270
- * @returns {Promise<object>}
271
- * @throws {mix}
399
+ * @returns {Promise<MailTimePingResult>}
400
+ * @throws {Error}
272
401
  */
273
402
  async ping() {
274
- this._debug('[ping]');
275
- const schedulerPing = await this.scheduler.ping();
276
- if (schedulerPing.status !== 'OK') {
277
- return schedulerPing;
403
+ this.__debug('[ping]');
404
+ if (this.scheduler) {
405
+ const schedulerPing = await this.scheduler.ping();
406
+ if (schedulerPing.status !== 'OK') {
407
+ return { ...schedulerPing, paused: this.__isPaused };
408
+ }
278
409
  }
279
- return await this.queue.ping();
410
+ const queuePing = await this.queue.ping();
411
+ return { ...queuePing, paused: this.__isPaused };
412
+ }
413
+
414
+ /**
415
+ * @async
416
+ * @memberOf MailTime
417
+ * @name ready
418
+ * @description Wait until queue and scheduler storage are ready
419
+ * @returns {Promise<MailTime>}
420
+ */
421
+ async ready() {
422
+ this.__debug('[ready]');
423
+ return await this.__readyPromise;
424
+ }
425
+
426
+ /**
427
+ * @memberOf MailTime
428
+ * @name destroy
429
+ * @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, any in-flight SMTP attempts are neutralized on completion — they perform no storage writes, no `onSent`/`onError` callbacks, and no logging once `destroy()` returns; their claimed rows are recovered by stale-lock timeout (`sendingTimeout`). Pass `{ drain: true }` to instead let in-flight attempts run to completion (returns a Promise that resolves once they settle).
430
+ * @param {{ drain?: boolean }} [opts]
431
+ * @returns {boolean | Promise<boolean>}
432
+ */
433
+ destroy(opts) {
434
+ this.__debug('[destroy]', opts);
435
+ if (this.__isDestroyed) {
436
+ return false;
437
+ }
438
+
439
+ this.__isDestroyed = true;
440
+ this.__isPaused = false;
441
+ if (this.scheduler && typeof this.scheduler.destroy === 'function') {
442
+ this.scheduler.destroy();
443
+ }
444
+ if (opts && opts.drain === true && this.__pool) {
445
+ return this.__pool.drain().then(() => true);
446
+ }
447
+ // No graceful drain requested: neutralize in-flight SMTP completions so a
448
+ // destroyed instance performs no storage writes, no `onSent`/`onError`
449
+ // callbacks, and no logging after teardown. Each claimed row keeps its
450
+ // `isSending` lock, which a peer or a future instance recovers once
451
+ // `sendingTimeout` elapses — identical to a crash mid-send. Use
452
+ // `destroy({ drain: true })` (or `await drain()` before `destroy()`) when
453
+ // in-flight sends must run to completion.
454
+ this.__abortInFlight = true;
455
+ return true;
456
+ }
457
+
458
+ /**
459
+ * @async
460
+ * @memberOf MailTime
461
+ * @name drain
462
+ * @description Wait for all in-flight email send attempts to settle
463
+ * @returns {Promise<void>}
464
+ */
465
+ async drain() {
466
+ this.__debug('[drain]');
467
+ if (this.__pool) {
468
+ await this.__pool.drain();
469
+ }
470
+ }
471
+
472
+ /**
473
+ * @memberOf MailTime
474
+ * @name pause
475
+ * @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
476
+ * @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
477
+ */
478
+ pause() {
479
+ this.__debug('[pause]');
480
+ if (this.__isDestroyed || !this.scheduler) {
481
+ return false;
482
+ }
483
+ const paused = this.scheduler.pause();
484
+ if (paused) {
485
+ this.__isPaused = true;
486
+ }
487
+ return paused;
488
+ }
489
+
490
+ /**
491
+ * @memberOf MailTime
492
+ * @name resume
493
+ * @description Resume competing for the queue-drain lease after `pause()`; triggers an immediate scan. No-op on `client` instances, after `destroy()`, or when not paused.
494
+ * @returns {boolean} `true` if newly resumed; `false` if not paused, a client instance, or destroyed
495
+ */
496
+ resume() {
497
+ this.__debug('[resume]');
498
+ if (this.__isDestroyed || !this.scheduler) {
499
+ return false;
500
+ }
501
+ const resumed = this.scheduler.resume();
502
+ if (resumed) {
503
+ this.__isPaused = false;
504
+ }
505
+ return resumed;
506
+ }
507
+
508
+ /**
509
+ * @memberOf MailTime
510
+ * @name isPaused
511
+ * @description Whether this instance is currently paused from draining the queue. Always `false` on `client` instances.
512
+ * @returns {boolean}
513
+ */
514
+ get isPaused() {
515
+ return this.__isPaused;
280
516
  }
281
517
 
282
518
  /**
283
519
  * @memberOf MailTime
284
520
  * @name send
285
521
  * @description alias of `sendMail`
522
+ * @param {MailTimeMailOptions} opts - email options
286
523
  * @returns {Promise<string>} uuid of the email
287
524
  */
288
525
  async send(opts) {
289
- this._debug('[send]', opts);
526
+ this.__debug('[send]', opts);
290
527
  return await this.sendMail(opts);
291
528
  }
292
529
 
@@ -295,49 +532,34 @@ class MailTime {
295
532
  * @memberOf MailTime
296
533
  * @name sendMail
297
534
  * @description add email to the queue or append to existing letter if {concatEmails: true}
298
- * @param opts {object} - email options
299
- * @param opts.sendAt {Date|number} - When email should be sent
300
- * @param opts.template {string} - Email-specific template
301
- * @param opts[key] {mix} - Other NodeMailer's options
535
+ * @param {MailTimeMailOptions} opts - email options
302
536
  * @returns {Promise<string>} uuid of the email
303
537
  * @throws {Error}
304
538
  */
305
539
  async sendMail(opts = {}) {
306
- this._debug('[sendMail]', opts);
540
+ this.__debug('[sendMail]', opts);
307
541
  if (!opts.html && !opts.text) {
308
- throw new Error('`html` nor `text` field is presented, at least one of those fields is required');
542
+ throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
309
543
  }
310
544
 
311
545
  let sendAt = opts.sendAt;
312
- if (!sendAt) {
313
- sendAt = Date.now();
314
- }
315
-
316
546
  if (sendAt instanceof Date) {
317
547
  sendAt = +sendAt;
318
548
  }
319
-
320
- if (typeof sendAt !== 'number') {
549
+ if (typeof sendAt !== 'number' || !Number.isFinite(sendAt)) {
321
550
  sendAt = Date.now();
322
551
  }
323
552
 
324
- let template = opts.template;
325
- if (typeof template !== 'string') {
326
- template = false;
327
- }
328
-
329
- let concatSubject = opts.concatSubject;
330
- if (typeof concatSubject !== 'string') {
331
- concatSubject = false;
332
- }
553
+ const template = (typeof opts.template === 'string') ? opts.template : false;
554
+ const concatSubject = (typeof opts.concatSubject === 'string') ? opts.concatSubject : false;
333
555
 
334
556
  const mailOptions = { ...opts };
335
557
  delete mailOptions.sendAt;
336
558
  delete mailOptions.template;
337
559
  delete mailOptions.concatSubject;
338
560
 
339
- if (typeof mailOptions.to !== 'string' && (!(mailOptions.to instanceof Array) || !mailOptions.to.length)) {
340
- throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array');
561
+ if (typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
562
+ throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
341
563
  }
342
564
 
343
565
  if (this.concatEmails) {
@@ -353,11 +575,12 @@ class MailTime {
353
575
  }
354
576
  }
355
577
 
356
- pendingMailOptions.push(opts);
357
- await this.queue.update(task, {
358
- mailOptions: pendingMailOptions
578
+ const appended = await this.queue.update(task, {
579
+ appendMailOption: mailOptions,
359
580
  });
360
- return task.uuid;
581
+ if (appended) {
582
+ return task.uuid;
583
+ }
361
584
  }
362
585
  }
363
586
 
@@ -365,18 +588,20 @@ class MailTime {
365
588
  sendAt,
366
589
  template,
367
590
  concatSubject,
368
- mailOptions: mailOptions,
591
+ mailOptions,
369
592
  });
370
593
  }
371
594
 
372
595
  /**
596
+ * @async
373
597
  * @memberOf MailTime
374
598
  * @name cancel
375
599
  * @description alias of `cancelMail`
600
+ * @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
376
601
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
377
602
  */
378
603
  async cancel(uuid) {
379
- this._debug('[cancel]', uuid);
604
+ this.__debug('[cancel]', uuid);
380
605
  return await this.cancelMail(uuid);
381
606
  }
382
607
 
@@ -385,47 +610,66 @@ class MailTime {
385
610
  * @memberOf MailTime
386
611
  * @name cancelMail
387
612
  * @description remove email from the queue or mark as `isCancelled`
388
- * @param uuid {string|Promise<string>} - uuid returned from `send` or `sendMail`
613
+ * @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
389
614
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
390
615
  */
391
616
  async cancelMail(uuid) {
392
- this._debug('[cancelMail]', uuid);
393
- if (typeof uuid === 'object' && uuid instanceof Promise) {
394
- return await this.queue.cancel(await uuid);
395
- }
396
- return await this.queue.cancel(uuid);
617
+ this.__debug('[cancelMail]', uuid);
618
+ const resolved = (uuid && typeof uuid.then === 'function') ? await uuid : uuid;
619
+ return await this.queue.cancel(resolved);
397
620
  }
398
621
 
399
622
  /**
400
623
  * @async
624
+ * @internal
401
625
  * @memberOf MailTime
402
626
  * @name ___handleError
403
627
  * @description Handle runtime errors and pass to `onError` callback
404
- * @param task {object} - Email task record form Storage
405
- * @param error {mix} - Error String/Object/Error
406
- * @param info {object} - Info object returned from nodemailer
628
+ * @param {MailTimeTask} task - Email task record from Storage
629
+ * @param {unknown} error - Error String/Object/Error
630
+ * @param {object} info - Info object returned from nodemailer
407
631
  * @returns {Promise<void 0>}
408
632
  */
409
633
  async ___handleError(task, error, info) {
410
- this._debug('[private handleError]', { task, error, info });
634
+ this.__debug('[private handleError]', { task, error, info });
635
+ if (this.__abortInFlight) {
636
+ this.__debug('[private handleError] instance destroyed; skipping onError', task?.uuid);
637
+ return;
638
+ }
411
639
  if (!task) {
412
640
  return;
413
641
  }
414
642
 
415
643
  if (task.tries >= this.maxTries) {
416
- task.isSent = false;
417
- task.isFailed = true;
644
+ this.___finalizeRejected(task, info);
645
+
646
+ const leaseRemove = __leaseRemoveOpts(task);
647
+ const leaseUpdate = __withLease(task, {
648
+ isSent: false,
649
+ isFailed: true,
650
+ isSending: false,
651
+ sendingAt: 0,
652
+ mailOptions: task.mailOptions,
653
+ });
418
654
 
655
+ let finalized = false;
419
656
  if (!this.keepHistory) {
420
- await this.queue.remove(task);
657
+ finalized = await this.queue.remove(task, leaseRemove);
421
658
  } else {
422
- await this.queue.update(task, {
423
- isSent: task.isSent,
424
- isFailed: task.isFailed,
425
- });
659
+ finalized = await this.queue.update(task, leaseUpdate);
660
+ }
661
+
662
+ if (!finalized) {
663
+ this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
664
+ return;
426
665
  }
427
666
 
428
- this._debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
667
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
668
+ task.isSent = false;
669
+ task.isFailed = true;
670
+ task.isSending = false;
671
+
672
+ this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
429
673
  this.onError(error, task, info);
430
674
  return;
431
675
  }
@@ -433,43 +677,50 @@ class MailTime {
433
677
  let transportIndex = task.transport;
434
678
 
435
679
  if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
436
- ++transportIndex;
437
- if (transportIndex > this.transports.length - 1) {
438
- transportIndex = 0;
439
- }
680
+ transportIndex = this.___nextHealthyTransport(transportIndex);
440
681
  }
441
682
 
442
- await this.queue.update(task, {
443
- isSent: false,
683
+ const released = await this.queue.update(task, __withLease(task, {
684
+ isSending: false,
685
+ sendingAt: 0,
444
686
  sendAt: Date.now() + this.retryDelay,
445
687
  transport: transportIndex,
446
- });
688
+ }));
689
+
690
+ if (!released) {
691
+ this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
692
+ return;
693
+ }
447
694
 
448
- this._debug(`[private handleError] Next re-send attempt at ${new Date(Date.now() + this.retryDelay)}: #${task.tries}/${this.maxTries}, transport #${transportIndex} to: `, task.mailOptions[0].to, error);
695
+ this.__debug(`[private handleError] Next re-send attempt at ${new Date(Date.now() + this.retryDelay)}: #${task.tries}/${this.maxTries}, transport #${transportIndex} to: `, task.mailOptions[0].to, error);
449
696
  }
450
697
 
451
698
  /**
452
699
  * @async
700
+ * @internal
453
701
  * @memberOf MailTime
454
702
  * @name ___addToQueue
455
703
  * @description Prepare task's object and push to the queue
456
- * @param opts {object} - Email options with next properties:
457
- * @param opts.sendAt {number} - When email should be sent
458
- * @param opts.template {string} - Email-specific template
459
- * @param opts.mailOptions {object} - MailOptions according to NodeMailer lib
460
- * @param opts.concatSubject {string} - Email subject used when sending concatenated email
704
+ * @param {{ sendAt: number, template: string | false, mailOptions: MailTimeMailOptions, concatSubject: string | false }} opts - Email options
461
705
  * @returns {Promise<string>} message uuid
462
706
  */
463
707
  async ___addToQueue(opts) {
464
- this._debug('[private addToQueue]', opts);
708
+ this.__debug('[private addToQueue]', opts);
709
+ let transportIndex = this.transport;
710
+ if (this.strategy === 'balancer' && this.transports.length > 0) {
711
+ transportIndex = this.___nextHealthyTransport(this.transport);
712
+ this.transport = transportIndex;
713
+ }
465
714
  const task = {
466
- uuid: crypto.randomUUID(),
715
+ uuid: randomUUID(),
467
716
  tries: 0,
468
717
  isSent: false,
469
718
  sendAt: opts.sendAt,
470
719
  isFailed: false,
720
+ isSending: false,
721
+ sendingAt: 0,
471
722
  template: opts.template,
472
- transport: this.transport,
723
+ transport: transportIndex,
473
724
  isCancelled: false,
474
725
  mailOptions: [opts.mailOptions],
475
726
  concatSubject: opts.concatSubject,
@@ -484,30 +735,32 @@ class MailTime {
484
735
  }
485
736
 
486
737
  /**
738
+ * @internal
487
739
  * @memberOf MailTime
488
740
  * @name ___render
489
- * @description Render templates
490
- * @param string {string} - Template with Mustache-like placeholders
491
- * @param replacements {object} - Blaze/Mustache-like helpers Object
741
+ * @description Render Mustache-like placeholders
742
+ * @param {string} _string - Template with Mustache-like placeholders
743
+ * @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
492
744
  * @returns {string}
493
745
  */
494
746
  ___render(_string, replacements) {
495
- let i;
496
747
  let string = _string;
497
- const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-\_]+)\s?\}{3}/g);
748
+ const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
498
749
  if (matchHTML) {
499
- for (i = 0; i < matchHTML.length; i++) {
500
- if (replacements[matchHTML[i].replace('{{{', '').replace('}}}', '').trim()]) {
501
- string = string.replace(matchHTML[i], replacements[matchHTML[i].replace('{{{', '').replace('}}}', '').trim()]);
750
+ for (let i = 0; i < matchHTML.length; i++) {
751
+ const key = matchHTML[i].slice(3, -3).trim();
752
+ if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
753
+ string = string.replace(matchHTML[i], `${replacements[key]}`);
502
754
  }
503
755
  }
504
756
  }
505
757
 
506
- const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9\-\_]+)\s?\}{2}/g);
758
+ const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9\-_]+)\s?\}{2}/g);
507
759
  if (matchStr) {
508
- for (i = 0; i < matchStr.length; i++) {
509
- if (replacements[matchStr[i].replace('{{', '').replace('}}', '').trim()]) {
510
- string = string.replace(matchStr[i], replacements[matchStr[i].replace('{{', '').replace('}}', '').trim()].replace(/<(?:.|\n)*?>/gm, ''));
760
+ for (let i = 0; i < matchStr.length; i++) {
761
+ const key = matchStr[i].slice(2, -2).trim();
762
+ if (hasOwnProp(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
763
+ string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
511
764
  }
512
765
  }
513
766
  }
@@ -515,155 +768,485 @@ class MailTime {
515
768
  }
516
769
 
517
770
  /**
771
+ * @internal
518
772
  * @memberOf MailTime
519
773
  * @name ___compileMailOpts
520
774
  * @description Run various checks, compile options, and render template
521
- * @param transport {object} - Current transport
522
- * @param info {object} - Info object returned from NodeMailer's `sendMail` method
523
- * @returns {void 0}
775
+ * @param {MailTimeTransport} transport - Current transport
776
+ * @param {MailTimeTask} task - Email task record from Storage
777
+ * @returns {MailTimeMailOptions}
524
778
  */
525
779
  ___compileMailOpts(transport, task) {
526
- let compiledOpts = {};
527
-
528
780
  if (!transport) {
529
781
  throw new Error('[mail-time] [sendMail] [___compileMailOpts] {transport} is not available or misconfigured!');
530
782
  }
531
783
 
532
- if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) {
533
- compiledOpts = merge(compiledOpts, transport._options.mailOptions);
784
+ let compiledOpts = {};
785
+
786
+ if (isPlainObject(transport._options) && isPlainObject(transport._options.mailOptions)) {
787
+ compiledOpts = deepMerge(compiledOpts, transport._options.mailOptions);
534
788
  }
535
789
 
536
- if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) {
537
- compiledOpts = merge(compiledOpts, transport.options.mailOptions);
790
+ if (isPlainObject(transport.options) && isPlainObject(transport.options.mailOptions)) {
791
+ compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
538
792
  }
539
793
 
540
- compiledOpts = merge(compiledOpts, {
541
- html: '',
542
- text: '',
543
- subject: ''
544
- });
794
+ compiledOpts.html ??= '';
795
+ compiledOpts.text ??= '';
796
+ compiledOpts.subject ??= '';
797
+
798
+ const mailOptionsList = task.mailOptions || [];
799
+ const isMulti = mailOptionsList.length > 1;
545
800
 
546
- for (let i = 0; i < task.mailOptions.length; i++) {
547
- if (task.mailOptions[i].html) {
548
- if (task.mailOptions.length > 1) {
549
- compiledOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]);
801
+ for (let i = 0; i < mailOptionsList.length; i++) {
802
+ const mailOption = { ...mailOptionsList[i] };
803
+
804
+ if (mailOption.html) {
805
+ const rendered = this.___render(mailOption.html, mailOption);
806
+ if (isMulti) {
807
+ compiledOpts.html += this.___render(this.concatDelimiter, mailOption) + rendered;
550
808
  } else {
551
- compiledOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]);
809
+ compiledOpts.html = rendered;
552
810
  }
553
- delete task.mailOptions[i].html;
811
+ delete mailOption.html;
554
812
  }
555
813
 
556
- if (task.mailOptions[i].text) {
557
- if (task.mailOptions.length > 1) {
558
- compiledOpts.text += '\r\n' + this.___render(task.mailOptions[i].text, task.mailOptions[i]);
814
+ if (mailOption.text) {
815
+ const rendered = this.___render(mailOption.text, mailOption);
816
+ if (isMulti) {
817
+ compiledOpts.text += '\r\n' + rendered;
559
818
  } else {
560
- compiledOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]);
819
+ compiledOpts.text = rendered;
561
820
  }
562
- delete task.mailOptions[i].text;
821
+ delete mailOption.text;
563
822
  }
564
823
 
565
- compiledOpts = merge(compiledOpts, task.mailOptions[i]);
824
+ compiledOpts = deepMerge(compiledOpts, mailOption);
566
825
  }
567
826
 
568
827
  if (compiledOpts.html && (task.template || this.template)) {
569
828
  compiledOpts.html = this.___render((task.template || this.template), compiledOpts);
570
829
  }
571
830
 
572
- if (task.mailOptions.length > 1) {
573
- compiledOpts.subject = task.concatSubject || this.concatSubject || compiledOpts.subject;
831
+ if (isMulti) {
832
+ const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
833
+ compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
574
834
  }
575
835
 
576
836
  if (!compiledOpts.from && this.from) {
577
837
  compiledOpts.from = this.from(transport);
578
838
  }
579
839
 
840
+ const acceptedSet = collectAcceptedSet(task);
841
+ if (acceptedSet.size > 0) {
842
+ compiledOpts.to = filterAddressField(compiledOpts.to, acceptedSet);
843
+ compiledOpts.cc = filterAddressField(compiledOpts.cc, acceptedSet);
844
+ compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
845
+ }
846
+
580
847
  return compiledOpts;
581
848
  }
582
849
 
850
+ /**
851
+ * @internal
852
+ * @memberOf MailTime
853
+ * @name ___trackAcceptedRecipients
854
+ * @description Append newly-accepted addresses to each mailOption's `accepted` list
855
+ * @param {MailTimeTask} task - Email task record from Storage
856
+ * @param {string[]} acceptedAddrs - Lowercased addresses confirmed by transport
857
+ * @returns {void}
858
+ */
859
+ ___trackAcceptedRecipients(task, acceptedAddrs) {
860
+ if (!Array.isArray(task?.mailOptions) || acceptedAddrs.length === 0) {
861
+ return;
862
+ }
863
+ const acceptedSet = new Set(acceptedAddrs);
864
+ for (const mo of task.mailOptions) {
865
+ if (!Array.isArray(mo.accepted)) {
866
+ mo.accepted = [];
867
+ }
868
+ const moRecipients = new Set(mailOptionRecipients(mo));
869
+ if (moRecipients.size === 0) {
870
+ continue;
871
+ }
872
+ const alreadyAccepted = new Set(mo.accepted.map((a) => typeof a === 'string' ? a.toLowerCase() : a));
873
+ for (const addr of acceptedSet) {
874
+ if (moRecipients.has(addr) && !alreadyAccepted.has(addr)) {
875
+ mo.accepted.push(addr);
876
+ alreadyAccepted.add(addr);
877
+ }
878
+ }
879
+ }
880
+ }
881
+
882
+ /**
883
+ * @internal
884
+ * @memberOf MailTime
885
+ * @name ___finalizeRejected
886
+ * @description Populate each mailOption's `rejected` list with addresses that never delivered
887
+ * @param {MailTimeTask} task - Email task record from Storage
888
+ * @param {object} info - Info object from the last nodemailer attempt
889
+ * @returns {void}
890
+ */
891
+ ___finalizeRejected(task, info) {
892
+ if (!Array.isArray(task?.mailOptions)) {
893
+ return;
894
+ }
895
+ const errorMap = buildRejectionErrorMap(info);
896
+ for (const mo of task.mailOptions) {
897
+ const moAccepted = new Set((mo.accepted || []).map((a) => typeof a === 'string' ? a.toLowerCase() : a));
898
+ const seen = new Set();
899
+ const rejected = [];
900
+ for (const addr of mailOptionRecipients(mo)) {
901
+ if (moAccepted.has(addr) || seen.has(addr)) {
902
+ continue;
903
+ }
904
+ seen.add(addr);
905
+ rejected.push({
906
+ address: addr,
907
+ error: errorMap.get(addr) || 'Recipient rejected by transport',
908
+ });
909
+ }
910
+ mo.rejected = rejected;
911
+ }
912
+ }
913
+
583
914
  /**
584
915
  * @async
916
+ * @internal
917
+ * @memberOf MailTime
918
+ * @name ___dispatch
919
+ * @description Queue full-lifecycle send for `task` under the bounded send pool. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
920
+ * @param {MailTimeTask} task - email's task object from Storage
921
+ * @returns {Promise<void 0>}
922
+ */
923
+ async ___dispatch(task) {
924
+ if (this.__isDestroyed) {
925
+ return;
926
+ }
927
+ if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
928
+ return;
929
+ }
930
+ if (this.__inFlight.has(task.uuid)) {
931
+ this.__debug('[private dispatch] already in-flight on this instance, skipping', task.uuid);
932
+ return;
933
+ }
934
+ this.__inFlight.add(task.uuid);
935
+ await this.__pool.dispatch(async () => {
936
+ try {
937
+ if (this.__isDestroyed) {
938
+ return;
939
+ }
940
+ await this.___send(task);
941
+ } finally {
942
+ this.__inFlight.delete(task.uuid);
943
+ }
944
+ });
945
+ }
946
+
947
+ /**
948
+ * @async
949
+ * @internal
585
950
  * @memberOf MailTime
586
951
  * @name ___send
587
- * @description send email using nodemailer's transport
588
- * @param task {object} - email's task object from Storage
952
+ * @description Full send lifecycle for a single task: atomic claim (`isSending=true, sendingAt=now, tries=tries+1`), SMTP roundtrip, completion. Returns when the lifecycle ends.
953
+ * @param {MailTimeTask} task - email's task object from Storage
589
954
  * @returns {Promise<void 0>}
590
955
  */
591
956
  async ___send(task) {
592
- this._debug('[private send]', task);
957
+ this.__debug('[private send]', task);
593
958
  try {
594
- if (task.isSent === true || task.isFailed === true || task.isCancelled === true) {
959
+ if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
595
960
  return;
596
961
  }
597
962
 
598
- task.tries++;
599
- const isUpdated = await this.queue.update(task, {
600
- isSent: true,
601
- tries: task.tries
602
- });
963
+ const tries = task.tries + 1;
964
+ const sendingAt = Date.now();
965
+ let isClaimed = false;
966
+ try {
967
+ isClaimed = await this.queue.update(task, {
968
+ isSending: true,
969
+ sendingAt,
970
+ tries,
971
+ });
972
+ } catch (claimError) {
973
+ logError('[private send] [claim] storage error during atomic claim', claimError);
974
+ return;
975
+ }
603
976
 
604
- if (!isUpdated) {
605
- logError('[private send] [queue.update] Not updated!');
977
+ if (!isClaimed) {
978
+ this.__debug('[private send] [queue.update] Stale claim, skipping', task.uuid);
606
979
  return;
607
980
  }
608
981
 
609
- let transport;
610
- let transportIndex;
611
- if (this.strategy === 'balancer') {
612
- this.transport = this.transport + 1;
613
- if (this.transport >= this.transports.length) {
614
- this.transport = 0;
615
- }
616
- transportIndex = this.transport;
617
- transport = this.transports[transportIndex];
618
- } else {
619
- transportIndex = task.transport;
620
- transport = this.transports[transportIndex];
982
+ task.tries = tries;
983
+ task.isSending = true;
984
+ task.sendingAt = sendingAt;
985
+
986
+ let transportIndex = task.transport;
987
+ if (!this.___isHealthyTransport(transportIndex)) {
988
+ transportIndex = this.___nextHealthyTransport(transportIndex);
989
+ task.transport = transportIndex;
621
990
  }
991
+ const transport = this.transports[transportIndex];
622
992
 
623
993
  const compiledOpts = this.___compileMailOpts(transport, task);
624
994
 
625
995
  await new Promise((resolve) => {
626
996
  transport.sendMail(compiledOpts, async (error, info) => {
627
- this._debug('[private send] [sending]', { error, info });
628
- if (error) {
629
- await this.___handleError(task, error, info);
997
+ if (this.__abortInFlight) {
998
+ this.__debug('[private send] instance destroyed mid-send; leaving claim for stale-lock recovery', task.uuid);
630
999
  resolve();
631
1000
  return;
632
1001
  }
633
-
634
- if (info.accepted && !info.accepted.length) {
635
- await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
1002
+ this.__debug('[private send] [sending]', { error, info });
1003
+ try {
1004
+ if (error) {
1005
+ await this.___handleError(task, error, info);
1006
+ return;
1007
+ }
1008
+
1009
+ const acceptedAddrs = Array.isArray(info?.accepted)
1010
+ ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
1011
+ : [];
1012
+
1013
+ if (acceptedAddrs.length === 0) {
1014
+ await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
1015
+ return;
1016
+ }
1017
+
1018
+ this.___trackAcceptedRecipients(task, acceptedAddrs);
1019
+
1020
+ const allRecipients = collectAllRecipients(task);
1021
+ const allAccepted = collectAcceptedSet(task);
1022
+ let isFullyDelivered = true;
1023
+ for (const addr of allRecipients) {
1024
+ if (!allAccepted.has(addr)) {
1025
+ isFullyDelivered = false;
1026
+ break;
1027
+ }
1028
+ }
1029
+
1030
+ if (isFullyDelivered) {
1031
+ this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
1032
+
1033
+ const leaseRemove = __leaseRemoveOpts(task);
1034
+ const leaseUpdate = __withLease(task, {
1035
+ isSent: true,
1036
+ isSending: false,
1037
+ sendingAt: 0,
1038
+ mailOptions: task.mailOptions,
1039
+ });
1040
+
1041
+ let completed = false;
1042
+ if (!this.keepHistory) {
1043
+ completed = await this.queue.remove(task, leaseRemove);
1044
+ } else {
1045
+ completed = await this.queue.update(task, leaseUpdate);
1046
+ }
1047
+
1048
+ if (!completed) {
1049
+ this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
1050
+ return;
1051
+ }
1052
+
1053
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
1054
+ task.isSent = true;
1055
+ task.isSending = false;
1056
+ task.sendingAt = 0;
1057
+ this.onSent(task, info);
1058
+ return;
1059
+ }
1060
+
1061
+ if (task.tries >= this.maxTries) {
1062
+ this.___finalizeRejected(task, info);
1063
+
1064
+ const leaseRemove = __leaseRemoveOpts(task);
1065
+ const leaseUpdate = __withLease(task, {
1066
+ isSent: false,
1067
+ isFailed: true,
1068
+ isSending: false,
1069
+ sendingAt: 0,
1070
+ mailOptions: task.mailOptions,
1071
+ });
1072
+
1073
+ let finalized = false;
1074
+ if (!this.keepHistory) {
1075
+ finalized = await this.queue.remove(task, leaseRemove);
1076
+ } else {
1077
+ finalized = await this.queue.update(task, leaseUpdate);
1078
+ }
1079
+
1080
+ if (!finalized) {
1081
+ this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
1082
+ return;
1083
+ }
1084
+
1085
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
1086
+ task.isSent = false;
1087
+ task.isFailed = true;
1088
+ task.isSending = false;
1089
+
1090
+ const rejectedAddrs = [];
1091
+ for (const mo of task.mailOptions) {
1092
+ for (const r of (mo.rejected || [])) {
1093
+ rejectedAddrs.push(r.address);
1094
+ }
1095
+ }
1096
+ const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
1097
+ this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
1098
+ this.onError(partialError, task, info);
1099
+ return;
1100
+ }
1101
+
1102
+ const nextSendAt = Date.now() + this.retryDelay;
1103
+ const released = await this.queue.update(task, __withLease(task, {
1104
+ isSending: false,
1105
+ sendingAt: 0,
1106
+ sendAt: nextSendAt,
1107
+ mailOptions: task.mailOptions,
1108
+ }));
1109
+ if (!released) {
1110
+ this.__debug('[private send] lease lost before partial retry release', task.uuid);
1111
+ return;
1112
+ }
1113
+ this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
1114
+ } finally {
636
1115
  resolve();
637
- return;
638
1116
  }
639
-
640
- this._debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
641
-
642
- if (!this.keepHistory) {
643
- await this.queue.remove(task);
644
- }
645
-
646
- task.isSent = true;
647
- this.onSent(task, info);
648
- resolve();
649
1117
  });
650
1118
  });
651
1119
  } catch (e) {
1120
+ if (this.__abortInFlight) {
1121
+ this.__debug('[private send] instance destroyed mid-send; suppressing runtime exception', e);
1122
+ return;
1123
+ }
652
1124
  logError('Exception during runtime:', e);
653
- this.___handleError(task, e, {});
1125
+ await this.___handleError(task, e, {});
654
1126
  }
655
1127
  }
656
1128
 
657
1129
  /**
1130
+ * @internal
658
1131
  * @memberOf MailTime
659
1132
  * @name ___iterate
660
- * @description Iterate over queued tasks
661
- * @returns {Promise}
1133
+ * @description JoSk handler — claim and dispatch due tasks. Returns as soon as the scan finishes so the JoSk lease is released; in-flight SMTP work continues in the background pool.
1134
+ * @returns {Promise<void>|void}
662
1135
  */
663
1136
  async ___iterate() {
664
- this._debug('[private iterate]');
665
- return await this.queue.iterate();
1137
+ this.__debug('[private iterate]');
1138
+ if (this.__isDestroyed || this.__isPaused) {
1139
+ return;
1140
+ }
1141
+ const limit = this.mode === 'one' ? 1 : Infinity;
1142
+ return await this.queue.iterate({
1143
+ limit,
1144
+ sendingTimeout: this.sendingTimeout,
1145
+ });
1146
+ }
1147
+
1148
+ /**
1149
+ * @async
1150
+ * @internal
1151
+ * @memberOf MailTime
1152
+ * @name ___ready
1153
+ * @description Internal storage readiness gate
1154
+ * @returns {Promise<MailTime>}
1155
+ */
1156
+ async ___ready() {
1157
+ if (typeof this.queue.ready === 'function') {
1158
+ await this.queue.ready();
1159
+ }
1160
+
1161
+ if (this.__schedulerTimer) {
1162
+ await this.__schedulerTimer;
1163
+ }
1164
+
1165
+ const pingResult = await this.ping();
1166
+ if (pingResult.status !== 'OK') {
1167
+ throw new Error('[mail-time] [MailTime#ready] can not connect to storage, make sure it is available and properly configured', { cause: pingResult.error });
1168
+ }
1169
+
1170
+ if (this.type === 'server' && this.verifyTransports && this.transports.length > 0) {
1171
+ await this.___verifyTransports();
1172
+ }
1173
+
1174
+ return this;
1175
+ }
1176
+
1177
+ /**
1178
+ * @async
1179
+ * @internal
1180
+ * @memberOf MailTime
1181
+ * @name ___verifyTransports
1182
+ * @description Probe each transport's `verify()` once at startup. Failing transports are marked unusable and skipped during rotation; the failure is surfaced through `onError(error, null, { transportIndex, phase: 'verify' })`. Throws if every transport fails — there is nothing left that could deliver.
1183
+ * @returns {Promise<void>}
1184
+ */
1185
+ async ___verifyTransports() {
1186
+ this.__debug('[private verifyTransports]');
1187
+ const results = await Promise.all(this.transports.map(async (transport, index) => {
1188
+ if (!transport || typeof transport.verify !== 'function') {
1189
+ return { index, ok: true };
1190
+ }
1191
+ try {
1192
+ await Promise.resolve(transport.verify());
1193
+ return { index, ok: true };
1194
+ } catch (error) {
1195
+ return { index, ok: false, error };
1196
+ }
1197
+ }));
1198
+
1199
+ for (const r of results) {
1200
+ if (r.ok) {
1201
+ continue;
1202
+ }
1203
+ this.__unhealthyTransports.add(r.index);
1204
+ logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
1205
+ this.onError(r.error, null, { transportIndex: r.index, phase: 'verify' });
1206
+ }
1207
+
1208
+ if (this.__unhealthyTransports.size === this.transports.length) {
1209
+ throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
1210
+ }
1211
+
1212
+ if (this.__unhealthyTransports.has(this.transport)) {
1213
+ this.transport = this.___nextHealthyTransport(this.transport);
1214
+ }
1215
+ }
1216
+
1217
+ /**
1218
+ * @internal
1219
+ * @memberOf MailTime
1220
+ * @name ___isHealthyTransport
1221
+ * @description Return true when the transport at `index` has not been marked unusable by verification.
1222
+ * @param {number} index
1223
+ * @returns {boolean}
1224
+ */
1225
+ ___isHealthyTransport(index) {
1226
+ return !this.__unhealthyTransports.has(index);
1227
+ }
1228
+
1229
+ /**
1230
+ * @internal
1231
+ * @memberOf MailTime
1232
+ * @name ___nextHealthyTransport
1233
+ * @description Advance at least one position from `fromIdx` and return the next healthy transport index (wrapping). Falls back to `fromIdx` if no healthy transport exists.
1234
+ * @param {number} fromIdx
1235
+ * @returns {number}
1236
+ */
1237
+ ___nextHealthyTransport(fromIdx) {
1238
+ if (this.transports.length === 0) {
1239
+ return fromIdx;
1240
+ }
1241
+ let next = fromIdx;
1242
+ for (let i = 0; i < this.transports.length; i++) {
1243
+ next = (next + 1) % this.transports.length;
1244
+ if (this.___isHealthyTransport(next)) {
1245
+ return next;
1246
+ }
1247
+ }
1248
+ return fromIdx;
666
1249
  }
667
1250
  }
668
1251
 
669
- export { MailTime, MongoQueue, RedisQueue };
1252
+ export { MailTime, MongoQueue, RedisQueue, PostgresQueue, mailTimePreset, presets, presetNames };