mail-time 1.4.0 → 4.0.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,633 +1,704 @@
1
- import JoSk from 'josk';
2
- import merge from 'deepmerge';
1
+ import { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } from 'josk';
2
+ import { randomUUID } from 'node:crypto';
3
3
 
4
- const noop = () => {};
5
- const _debug = (isDebug, ...args) => {
6
- if (isDebug) {
7
- console.info.call(console, '[DEBUG] [mail-time]', `${new Date}`, ...args);
4
+ import { MongoQueue } from './adapters/mongo.js';
5
+ import { RedisQueue } from './adapters/redis.js';
6
+ import { PostgresQueue } from './adapters/postgres.js';
7
+ import { mailTimePreset, presets, presetNames } from './presets.js';
8
+ import { debug, logError, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
9
+
10
+ const noop = () => {};
11
+ const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
12
+
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 };
8
16
  }
9
- };
10
- const _logError = (...args) => {
11
- console.error.call(console, '[ERROR] [mail-time]', `${new Date}`, ...args);
17
+ return updateObj;
12
18
  };
13
19
 
14
- const mongoErrorHandler = (error) => {
15
- if (error) {
16
- _logError('[mongoErrorHandler]:', error);
17
- console.trace();
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 };
18
23
  }
24
+ return void 0;
19
25
  };
20
26
 
21
- /**
22
- * Ensure (create) index on MongoDB collection, catch and log exception if thrown
23
- * @function ensureIndex
24
- * @param {Collection} collection - Mongo's driver Collection instance
25
- * @param {object} keys - Field and value pairs where the field is the index key and the value describes the type of index for that field
26
- * @param {object} opts - Set of options that controls the creation of the index
27
- * @returns {void 0}
28
- */
29
- const ensureIndex = async (collection, keys, opts) => {
30
- try {
31
- await collection.createIndex(keys, opts);
32
- } catch (e) {
33
- if (e.code === 85) {
34
- let indexName;
35
- const indexes = await collection.indexes();
36
- for (const index of indexes) {
37
- let drop = true;
38
- for (const indexKey of Object.keys(keys)) {
39
- if (typeof index.key[indexKey] === 'undefined') {
40
- drop = false;
41
- break;
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();
42
49
  }
43
- }
50
+ tryStart();
51
+ });
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
+ };
44
80
 
45
- for (const indexKey of Object.keys(index.key)) {
46
- if (typeof keys[indexKey] === 'undefined') {
47
- drop = false;
48
- break;
49
- }
50
- }
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
+ };
51
91
 
52
- if (drop) {
53
- indexName = index.name;
54
- 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());
55
99
  }
56
100
  }
101
+ }
102
+ }
103
+ return set;
104
+ };
57
105
 
58
- if (indexName) {
59
- await collection.dropIndex(indexName);
60
- await collection.createIndex(keys, opts);
61
- }
62
- } else {
63
- _logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection._name}" collection`, { keys, opts, details: e });
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);
111
+ }
112
+ }
113
+ return set;
114
+ };
115
+
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;
64
124
  }
125
+ const err = rejectedErrors[i];
126
+ map.set(addr, err ? `${err.message || err}` : 'Recipient rejected by transport');
65
127
  }
128
+ return map;
66
129
  };
67
130
 
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>';
132
+
68
133
  /**
69
- * Check if entities of various types are equal
70
- * including edge cases like unordered Objects and Array
71
- * @function equals
72
- * @param {mix} a
73
- * @param {mix} b
74
- * @returns {boolean}
134
+ * @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
75
135
  */
76
- const equals = (a, b) => {
77
- let i;
78
- if (a === b) {
79
- return true;
80
- }
81
136
 
82
- if (!a || !b) {
83
- return false;
84
- }
137
+ /**
138
+ * @typedef {import('./presets.js').MailTimePresetConfig} MailTimePresetConfig
139
+ */
85
140
 
86
- if (!(typeof a === 'object' && typeof b === 'object')) {
87
- return false;
88
- }
141
+ /**
142
+ * @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
143
+ */
89
144
 
90
- if (a instanceof Date && b instanceof Date) {
91
- return a.valueOf() === b.valueOf();
92
- }
145
+ /**
146
+ * @typedef {{ [key: string]: any, query?: (queryText: string, values?: unknown[]) => Promise<{ rows?: unknown[], rowCount?: number | null }> }} MailTimeStorageClient
147
+ */
93
148
 
94
- if (a instanceof Array) {
95
- if (!(b instanceof Array)) {
96
- return false;
97
- }
149
+ /**
150
+ * @typedef {{ [key: string]: any }} MailTimeMongoDb
151
+ */
98
152
 
99
- if (a.length !== b.length) {
100
- return false;
101
- }
153
+ /**
154
+ * @typedef {{ [key: string]: any }} MailTimeTransport
155
+ */
102
156
 
103
- const _a = a.slice();
104
- const _b = b.slice();
105
- let j;
106
- for (i = _a.length - 1; i >= 0; i--) {
107
- let result = false;
108
- for (j = _b.length - 1; j >= 0; j--) {
109
- if (equals(_a[i], _b[j])) {
110
- result = true;
111
- _a.splice(i, 1);
112
- _b.splice(j, 1);
113
- break;
114
- }
115
- }
157
+ /**
158
+ * @typedef {{ [key: string]: any, type?: 'mongo' | 'redis' | 'postgres', client?: MailTimeStorageClient, db?: MailTimeMongoDb, prefix?: string, resetOnInit?: boolean, useHashTags?: boolean }} MailTimeJoSkAdapterOptions
159
+ */
116
160
 
117
- if (!result) {
118
- return false;
119
- }
120
- }
121
- return true;
122
- }
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
+ */
123
164
 
124
- i = 0;
125
- if (typeof a === 'object' && typeof b === 'object') {
126
- const akeys = Object.keys(a);
127
- const bkeys = Object.keys(b);
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
+ */
128
168
 
129
- if (akeys.length !== bkeys.length) {
130
- return false;
131
- }
169
+ /**
170
+ * @typedef {{ limit?: number, sendingTimeout?: number }} MailTimeIterateOptions
171
+ */
132
172
 
133
- for (i = akeys.length - 1; i >= 0; i--) {
134
- if (!hasOwnProperty.call(b, akeys[i])) {
135
- return false;
136
- }
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
+ */
137
176
 
138
- if (!equals(a[akeys[i]], b[akeys[i]])) {
139
- return false;
140
- }
141
- }
177
+ /**
178
+ * @typedef {{ address: string, error: string }} MailTimeRejectedRecipient
179
+ */
142
180
 
143
- return true;
144
- }
145
- return false;
146
- };
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
+ */
147
184
 
148
- 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>';
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
+ */
149
192
 
150
193
  /** Class of MailTime */
151
- export default class MailTime {
194
+ class MailTime {
195
+ /**
196
+ * Create a MailTime instance
197
+ * @param {MailTimeOptions} opts - configuration object
198
+ */
152
199
  constructor (opts) {
153
- if (!opts || typeof opts !== 'object' || opts === null) {
200
+ if (!opts || typeof opts !== 'object') {
154
201
  throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
155
202
  }
156
203
 
157
- if (!opts.db) {
158
- throw new Error('[mail-time] MongoDB database {db} option is required, like returned from `MongoClient.connect`');
204
+ if (!opts.queue || typeof opts.queue !== 'object') {
205
+ throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
159
206
  }
160
207
 
161
- this.callbacks = {};
162
- this.type = (!opts.type || (opts.type !== 'client' && opts.type !== 'server')) ? 'server' : opts.type;
163
- this.debug = (opts.debug !== true) ? false : true;
164
- this.prefix = opts.prefix || '';
165
- this.maxTries = ((opts.maxTries && !isNaN(opts.maxTries)) ? parseInt(opts.maxTries) : 59) + 1;
166
- this.interval = ((opts.interval && !isNaN(opts.interval)) ? parseInt(opts.interval) : 60) * 1000;
167
- this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
168
- this.zombieTime = opts.zombieTime || 32786;
169
- this.keepHistory = opts.keepHistory || false;
208
+ this.queue = opts.queue;
209
+
210
+ for (let i = queueMethods.length - 1; i >= 0; i--) {
211
+ if (typeof this.queue[queueMethods[i]] !== 'function') {
212
+ throw new Error(`[mail-time] {queue} instance is missing {${queueMethods[i]}} method that is required!`);
213
+ }
214
+ }
170
215
 
171
- this.revolvingInterval = opts.revolvingInterval || 1536;
172
- this.minRevolvingDelay = opts.minRevolvingDelay || 512;
173
- this.maxRevolvingDelay = opts.maxRevolvingDelay || 2048;
216
+ this.debug = opts.debug === true;
217
+ this.__debug = (...args) => {
218
+ debug(this.debug, `[${this.prefix || 'default'}]`, ...args);
219
+ };
220
+
221
+ this.type = (opts.type === 'client' || opts.type === 'server') ? opts.type : 'server';
222
+ this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
174
223
 
175
- if (this.interval < 2048 || isNaN(this.interval)) {
176
- this.interval = 3072;
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
+ }
228
+ this.maxTries = opts.retries + 1;
229
+ } else if (typeof opts.maxTries === 'number') {
230
+ this.maxTries = (opts.maxTries < 1) ? 1 : opts.maxTries;
231
+ } else {
232
+ this.maxTries = 60;
177
233
  }
178
234
 
179
- if (this.zombieTime < 8192 || isNaN(this.zombieTime)) {
180
- this.zombieTime = 8192;
235
+ if (typeof opts.retryDelay === 'number') {
236
+ this.retryDelay = opts.retryDelay;
237
+ } else if (typeof opts.interval === 'number') {
238
+ this.retryDelay = opts.interval * 1000;
239
+ } else {
240
+ this.retryDelay = 60000;
181
241
  }
182
242
 
183
- this.failsToNext = (opts.failsToNext && !isNaN(opts.failsToNext)) ? parseInt(opts.failsToNext) : 4;
243
+ this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
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.__readyPromise = null;
254
+ this.__schedulerTimer = null;
255
+ this.__inFlight = new Set();
256
+ this.__pool = createPool(this.concurrency);
257
+
258
+ this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
184
259
  this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
185
- this.transports = opts.transports || [];
260
+ this.transports = Array.isArray(opts.transports) ? opts.transports : [];
186
261
  this.transport = 0;
187
- this.from = (() => {
188
- if (typeof opts.from === 'string') {
189
- return () => {
190
- return opts.from;
191
- };
192
- }
262
+ this.verifyTransports = opts.verifyTransports !== false;
263
+ this.__unhealthyTransports = new Set();
264
+
265
+ if (typeof opts.from === 'string') {
266
+ const fromStr = opts.from;
267
+ this.from = () => fromStr;
268
+ } else if (typeof opts.from === 'function') {
269
+ this.from = opts.from;
270
+ } else {
271
+ this.from = false;
272
+ }
273
+
274
+ this.queue.mailTimeInstance = this;
193
275
 
194
- if (typeof opts.from === 'function') {
195
- return opts.from;
276
+ /** @type {string} */
277
+ this.concatSubject = (typeof opts.concatSubject === 'string' && opts.concatSubject) ? opts.concatSubject : 'Multiple notifications';
278
+ if (opts.concatEmails === true) {
279
+ this.concatEmails = true;
280
+ } else if (isPlainObject(opts.concatEmails)) {
281
+ this.concatEmails = true;
282
+ if (typeof opts.concatEmails.subject === 'string' && opts.concatEmails.subject) {
283
+ this.concatSubject = opts.concatEmails.subject;
196
284
  }
285
+ } else {
286
+ this.concatEmails = false;
287
+ }
288
+ this.concatDelimiter = (typeof opts.concatDelimiter === 'string' && opts.concatDelimiter) ? opts.concatDelimiter : '<hr>';
197
289
 
198
- return false;
199
- })();
200
-
201
- this.concatEmails = (opts.concatEmails !== true) ? false : true;
202
- this.concatSubject = (opts.concatSubject && typeof opts.concatSubject === 'string') ? opts.concatSubject : 'Multiple notifications';
203
- this.concatDelimiter = (opts.concatDelimiter && typeof opts.concatDelimiter === 'string') ? opts.concatDelimiter : '<hr>';
204
- this.concatThrottling = ((opts.concatThrottling && !isNaN(opts.concatThrottling)) ? parseInt(opts.concatThrottling) : 60) * 1000;
205
-
206
- if (this.concatThrottling < 2048) {
207
- this.concatThrottling = 3072;
208
- }
209
-
210
- _debug(this.debug, 'DEBUG ON {debug: true}');
211
- _debug(this.debug, `INITIALIZING [type: ${this.type}]`);
212
- _debug(this.debug, `INITIALIZING [strategy: ${this.strategy}]`);
213
- _debug(this.debug, `INITIALIZING [prefix: ${this.prefix}]`);
214
- _debug(this.debug, `INITIALIZING [maxTries: ${this.maxTries}]`);
215
- _debug(this.debug, `INITIALIZING [failsToNext: ${this.failsToNext}]`);
216
-
217
- if (opts.type === 'server' && (this.transports.constructor !== Array || !this.transports.length)) {
218
- throw new Error('[mail-time] transports is required and must be an Array, like returned from `nodemailer.createTransport`');
219
- }
220
-
221
- this.collection = opts.db.collection(`__mailTimeQueue__${this.prefix}`);
222
- ensureIndex(this.collection, { isSent: 1, to: 1, sendAt: 1 });
223
- ensureIndex(this.collection, { isSent: 1, sendAt: 1, tries: 1 }, { background: true });
224
-
225
- // MongoDB Collection Schema:
226
- // _id
227
- // to {String|[String]}
228
- // tries {Number} - qty of send attempts
229
- // sendAt {Date} - When letter should be sent
230
- // isSent {Boolean} - Email status
231
- // template {String} - Template for this email
232
- // transport {Number} - Last used transport
233
- // concatSubject {String|Boolean} - Email concatenation subject
234
- // ---
235
- // mailOptions {[Object]} - Array of nodeMailer's `mailOptions`
236
- // mailOptions.to {String|Array} - [REQUIRED]
237
- // mailOptions.from {String}
238
- // mailOptions.text {String|Boolean}
239
- // mailOptions.html (String)
240
- // mailOptions.subject {String}
241
- // mailOptions.Other nodeMailer `sendMail` options...
290
+ if (typeof opts.concatDelay === 'number') {
291
+ this.concatDelay = opts.concatDelay;
292
+ } else if (typeof opts.concatThrottling === 'number') {
293
+ this.concatDelay = opts.concatThrottling * 1000;
294
+ } else {
295
+ this.concatDelay = 60000;
296
+ }
242
297
 
298
+ this.__debug('DEBUG ON {debug: true}');
299
+ this.__debug(`INITIALIZING [type: ${this.type}]`);
300
+ this.__debug(`INITIALIZING [strategy: ${this.strategy}]`);
301
+ this.__debug(`INITIALIZING [josk.adapter.type: ${opts?.josk?.adapter?.type || 'custom'}]`);
302
+ this.__debug(`INITIALIZING [prefix: ${this.prefix}]`);
303
+ this.__debug(`INITIALIZING [retries: ${this.maxTries - 1}]`);
304
+ this.__debug(`INITIALIZING [failsToNext: ${this.failsToNext}]`);
305
+ this.__debug(`INITIALIZING [mode: ${this.mode}]`);
306
+ this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
307
+ this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
308
+
309
+ /** SERVER-SPECIFIC CHECKS AND CONFIG */
243
310
  if (this.type === 'server') {
244
- const scheduler = new JoSk({
245
- db: opts.db,
246
- debug: this.debug,
247
- prefix: `mailTimeQueue${this.prefix}`,
248
- resetOnInit: false,
249
- zombieTime: this.zombieTime,
250
- minRevolvingDelay: this.minRevolvingDelay,
251
- maxRevolvingDelay: this.maxRevolvingDelay
252
- });
253
-
254
- scheduler.setInterval(this.___send.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
255
- }
256
- }
311
+ if (!this.transports.length) {
312
+ throw new Error('[mail-time] {transports} is required for {type: "server"} and must be a non-empty Array, like one returned from `nodemailer.createTransport`');
313
+ }
257
314
 
258
- static get Template() {
259
- return DEFAULT_TEMPLATE;
260
- }
315
+ if (!opts.josk || typeof opts.josk !== 'object') {
316
+ throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
317
+ }
261
318
 
262
- static set Template(newVal) {
263
- DEFAULT_TEMPLATE = newVal;
264
- }
319
+ if (!opts.josk.adapter || typeof opts.josk.adapter !== 'object') {
320
+ throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
321
+ }
265
322
 
266
- ___compileMailOpts(transport, task) {
267
- let _mailOpts = {};
323
+ this.josk = { ...opts.josk };
324
+ const buildAdapterOptions = () => {
325
+ const adapterOptions = {
326
+ prefix: `mailTimeQueue${this.prefix}`,
327
+ ...opts.josk.adapter,
328
+ };
268
329
 
269
- if (!transport) {
270
- throw new Error('[mail-time] [sendMail] [___compileMailOpts] transport not available or misconfiguration is in place!');
271
- }
330
+ if (typeof opts.josk.resetOnInit === 'boolean' && typeof adapterOptions.resetOnInit !== 'boolean') {
331
+ adapterOptions.resetOnInit = opts.josk.resetOnInit;
332
+ }
272
333
 
273
- if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) {
274
- _mailOpts = merge(_mailOpts, transport._options.mailOptions);
275
- }
334
+ return adapterOptions;
335
+ };
276
336
 
277
- if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) {
278
- _mailOpts = merge(_mailOpts, transport.options.mailOptions);
279
- }
337
+ const adapterType = opts.josk.adapter.type;
338
+ if (adapterType === 'mongo') {
339
+ if (!opts.josk.adapter.db) {
340
+ throw new Error('[mail-time] {josk.adapter.db} option required for {josk.adapter.type: "mongo"}');
341
+ }
342
+ this.josk.adapter = new MongoAdapter(buildAdapterOptions());
343
+ } else if (adapterType === 'redis') {
344
+ if (!opts.josk.adapter.client) {
345
+ throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "redis"}');
346
+ }
347
+ this.josk.adapter = new RedisAdapter(buildAdapterOptions());
348
+ } else if (adapterType === 'postgres') {
349
+ if (!opts.josk.adapter.client) {
350
+ throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "postgres"}');
351
+ }
352
+ this.josk.adapter = new PostgresAdapter(buildAdapterOptions());
353
+ }
280
354
 
281
- _mailOpts = merge(_mailOpts, {
282
- html: '',
283
- text: '',
284
- subject: ''
285
- });
355
+ this.josk.minRevolvingDelay = (typeof opts.josk.minRevolvingDelay === 'number') ? opts.josk.minRevolvingDelay : 512;
356
+ this.josk.maxRevolvingDelay = (typeof opts.josk.maxRevolvingDelay === 'number') ? opts.josk.maxRevolvingDelay : 2048;
357
+ this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime : 60000;
358
+ this.josk.execute = (opts.josk.execute === 'one' || opts.josk.execute === 'batch') ? opts.josk.execute : 'batch';
359
+ this.josk.concurrency = (typeof opts.josk.concurrency === 'number' && opts.josk.concurrency > 0) ? opts.josk.concurrency : Infinity;
360
+ this.josk.autoClear = opts.josk.autoClear === true;
286
361
 
287
- for (let i = 0; i < task.mailOptions.length; i++) {
288
- if (task.mailOptions[i].html) {
289
- if (task.mailOptions.length > 1) {
290
- _mailOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]);
291
- } else {
292
- _mailOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]);
293
- }
294
- delete task.mailOptions[i].html;
362
+ if (typeof opts.josk.lockOwnerId === 'string' && opts.josk.lockOwnerId.length > 0) {
363
+ this.josk.lockOwnerId = opts.josk.lockOwnerId;
295
364
  }
296
365
 
297
- if (task.mailOptions[i].text) {
298
- if (task.mailOptions.length > 1) {
299
- _mailOpts.text += '\r\n' + this.___render(task.mailOptions[i].text, task.mailOptions[i]);
300
- } else {
301
- _mailOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]);
302
- }
303
- delete task.mailOptions[i].text;
366
+ if (typeof opts.josk.onError !== 'function') {
367
+ this.josk.onError = (title, details) => {
368
+ logError(`[scheduler] ${title}`, details);
369
+ };
304
370
  }
305
371
 
306
- _mailOpts = merge(_mailOpts, task.mailOptions[i]);
307
- }
372
+ this.scheduler = new JoSk({
373
+ debug: this.debug,
374
+ ...this.josk,
375
+ });
308
376
 
309
- if (_mailOpts.html && (task.template || this.template)) {
310
- _mailOpts.html = this.___render((task.template || this.template), _mailOpts);
377
+ this.__schedulerTimer = this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
311
378
  }
312
379
 
313
- if (task.mailOptions.length > 1) {
314
- _mailOpts.subject = task.concatSubject || this.concatSubject || _mailOpts.subject;
315
- }
380
+ this.__readyPromise = this.___ready();
381
+ this.__readyPromise.catch(() => void 0);
382
+ }
316
383
 
317
- if (!_mailOpts.from && this.from) {
318
- _mailOpts.from = this.from(transport);
319
- }
384
+ static get Template() {
385
+ return DEFAULT_TEMPLATE;
386
+ }
320
387
 
321
- return _mailOpts;
388
+ static set Template(newVal) {
389
+ DEFAULT_TEMPLATE = newVal;
322
390
  }
323
391
 
324
392
  /**
393
+ * @async
325
394
  * @memberOf MailTime
326
- * @name ___send
327
- * @param ready {Function} - See JoSk NPM package
328
- * @returns {void}
395
+ * @name ping
396
+ * @description Check package readiness and connection to Storage
397
+ * @returns {Promise<MailTimePingResult>}
398
+ * @throws {Error}
329
399
  */
330
- ___send(ready) {
331
- const cursor = this.collection.find({
332
- isSent: false,
333
- sendAt: {
334
- $lte: new Date()
335
- },
336
- tries: {
337
- $lt: this.maxTries
338
- }
339
- }, {
340
- projection: {
341
- _id: 1,
342
- tries: 1,
343
- template: 1,
344
- transport: 1,
345
- mailOptions: 1,
346
- concatSubject: 1
400
+ async ping() {
401
+ this.__debug('[ping]');
402
+ if (this.scheduler) {
403
+ const schedulerPing = await this.scheduler.ping();
404
+ if (schedulerPing.status !== 'OK') {
405
+ return schedulerPing;
347
406
  }
348
- });
349
-
350
- cursor.forEach(async (task) => {
351
- try {
352
- await this.collection.updateOne({
353
- _id: task._id
354
- }, {
355
- $set: {
356
- isSent: true
357
- },
358
- $inc: {
359
- tries: 1
360
- }
361
- });
362
-
363
- let transport;
364
- let transportIndex;
365
- if (this.strategy === 'balancer') {
366
- this.transport = this.transport + 1;
367
- if (this.transport >= this.transports.length) {
368
- this.transport = 0;
369
- }
370
- transportIndex = this.transport;
371
- transport = this.transports[transportIndex];
372
- } else {
373
- transportIndex = task.transport;
374
- transport = this.transports[transportIndex];
375
- }
376
-
377
- const _mailOpts = this.___compileMailOpts(transport, task);
378
-
379
- _debug(this.debug, '[sendMail] [sending] To:', _mailOpts.to);
380
- transport.sendMail(_mailOpts, (error, info) => {
381
- if (error) {
382
- this.___handleError(task, error, info);
383
- return;
384
- }
407
+ }
408
+ return await this.queue.ping();
409
+ }
385
410
 
386
- if (info.accepted && !info.accepted.length) {
387
- this.___handleError(task, 'Message not accepted or Greeting never received', info);
388
- return;
389
- }
411
+ /**
412
+ * @async
413
+ * @memberOf MailTime
414
+ * @name ready
415
+ * @description Wait until queue and scheduler storage are ready
416
+ * @returns {Promise<MailTime>}
417
+ */
418
+ async ready() {
419
+ this.__debug('[ready]');
420
+ return await this.__readyPromise;
421
+ }
390
422
 
391
- _debug(this.debug, `email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, _mailOpts.to);
423
+ /**
424
+ * @memberOf MailTime
425
+ * @name destroy
426
+ * @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to also wait for in-flight SMTP attempts (returns a Promise).
427
+ * @param {{ drain?: boolean }} [opts]
428
+ * @returns {boolean | Promise<boolean>}
429
+ */
430
+ destroy(opts) {
431
+ this.__debug('[destroy]', opts);
432
+ if (this.__isDestroyed) {
433
+ return false;
434
+ }
392
435
 
393
- if (!this.keepHistory) {
394
- this.collection.deleteOne({
395
- _id: task._id
396
- }).catch(mongoErrorHandler);
397
- }
436
+ this.__isDestroyed = true;
437
+ if (this.scheduler && typeof this.scheduler.destroy === 'function') {
438
+ this.scheduler.destroy();
439
+ }
440
+ if (opts && opts.drain === true && this.__pool) {
441
+ return this.__pool.drain().then(() => true);
442
+ }
443
+ return true;
444
+ }
398
445
 
399
- this.___triggerCallbacks(void 0, task, info);
400
- return;
401
- });
402
- } catch (e) {
403
- _logError('Exception during runtime:', e);
404
- this.___handleError(task, e, {});
405
- }
406
- }).catch((forEachError) => {
407
- _logError('[___send] [forEach] [forEachError]', forEachError);
408
- }).finally(() => {
409
- ready();
410
- cursor.close();
411
- });
446
+ /**
447
+ * @async
448
+ * @memberOf MailTime
449
+ * @name drain
450
+ * @description Wait for all in-flight email send attempts to settle
451
+ * @returns {Promise<void>}
452
+ */
453
+ async drain() {
454
+ this.__debug('[drain]');
455
+ if (this.__pool) {
456
+ await this.__pool.drain();
457
+ }
412
458
  }
413
459
 
414
460
  /**
415
461
  * @memberOf MailTime
416
462
  * @name send
417
463
  * @description alias of `sendMail`
418
- * @returns {void}
464
+ * @param {MailTimeMailOptions} opts - email options
465
+ * @returns {Promise<string>} uuid of the email
419
466
  */
420
- send(opts, callback) {
421
- this.sendMail(opts, callback);
467
+ async send(opts) {
468
+ this.__debug('[send]', opts);
469
+ return await this.sendMail(opts);
422
470
  }
423
471
 
424
472
  /**
473
+ * @async
425
474
  * @memberOf MailTime
426
475
  * @name sendMail
427
- * @param opts {Object} - Letter options with next properties:
428
- * @param opts.sendAt {Date} - When email should be sent
429
- * @param opts.template {String} - Template string
430
- * @param opts[key] {mix} - Other MailOptions according to NodeMailer lib documentation
431
- * @param callback {Function} - [OPTIONAL] Callback function
432
- * @returns {void}
476
+ * @description add email to the queue or append to existing letter if {concatEmails: true}
477
+ * @param {MailTimeMailOptions} opts - email options
478
+ * @returns {Promise<string>} uuid of the email
479
+ * @throws {Error}
433
480
  */
434
- sendMail(opts = {}, callback = noop) {
435
- _debug(this.debug, '[sendMail] [attempt] To:', opts.to);
481
+ async sendMail(opts = {}) {
482
+ this.__debug('[sendMail]', opts);
436
483
  if (!opts.html && !opts.text) {
437
- throw new Error('`html` nor `text` field is presented, at least one of those fields is required');
484
+ throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
438
485
  }
439
486
 
440
- if (!opts.sendAt || Object.prototype.toString.call(opts.sendAt) !== '[object Date]') {
441
- opts.sendAt = new Date();
487
+ let sendAt = opts.sendAt;
488
+ if (sendAt instanceof Date) {
489
+ sendAt = +sendAt;
442
490
  }
443
-
444
- if (typeof opts.template !== 'string') {
445
- opts.template = false;
491
+ if (typeof sendAt !== 'number' || !Number.isFinite(sendAt)) {
492
+ sendAt = Date.now();
446
493
  }
447
494
 
448
- if (typeof opts.concatSubject !== 'string') {
449
- opts.concatSubject = false;
450
- }
495
+ const template = (typeof opts.template === 'string') ? opts.template : false;
496
+ const concatSubject = (typeof opts.concatSubject === 'string') ? opts.concatSubject : false;
451
497
 
452
- let _sendAt = opts.sendAt;
453
- const _template = opts.template;
454
- const _concatSubject = opts.concatSubject;
455
- delete opts.sendAt;
456
- delete opts.template;
457
- delete opts.concatSubject;
498
+ const mailOptions = { ...opts };
499
+ delete mailOptions.sendAt;
500
+ delete mailOptions.template;
501
+ delete mailOptions.concatSubject;
458
502
 
459
- if (typeof opts.to !== 'string' && (!(opts.to instanceof Array) || !opts.to.length)) {
460
- throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array');
503
+ if (typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
504
+ throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
461
505
  }
462
506
 
463
507
  if (this.concatEmails) {
464
- _sendAt = new Date(+_sendAt + this.concatThrottling);
465
- this.collection.findOne({
466
- to: opts.to,
467
- isSent: false,
468
- sendAt: {
469
- $lte: _sendAt
470
- }
471
- }, {
472
- projection: {
473
- _id: 1,
474
- mailOptions: 1
475
- }
476
- }).then((task) => {
477
- if (task) {
478
- const queue = task.mailOptions || [];
508
+ sendAt = sendAt + this.concatDelay;
509
+ const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
479
510
 
480
- for (let i = 0; i < queue.length; i++) {
481
- if (equals(queue[i], opts)) {
482
- return;
483
- }
484
- }
511
+ if (task) {
512
+ const pendingMailOptions = task.mailOptions || [];
485
513
 
486
- queue.push(opts);
487
- this.collection.updateOne({
488
- _id: task._id
489
- }, {
490
- $set: {
491
- mailOptions: queue
492
- }
493
- }).then(() => {
494
- const _id = task._id.toHexString();
495
- if (!this.callbacks[_id]) {
496
- this.callbacks[_id] = [];
497
- }
498
- this.callbacks[_id].push(callback);
499
- }).catch(mongoErrorHandler);
500
- return;
514
+ for (let i = 0; i < pendingMailOptions.length; i++) {
515
+ if (equals(pendingMailOptions[i], mailOptions)) {
516
+ return task.uuid;
517
+ }
501
518
  }
502
519
 
503
- this.___addToQueue({
504
- sendAt: _sendAt,
505
- template: _template,
506
- mailOptions: opts,
507
- concatSubject: _concatSubject
508
- }, callback);
509
- }).catch(mongoErrorHandler);
510
-
511
- return;
520
+ const appended = await this.queue.update(task, {
521
+ appendMailOption: mailOptions,
522
+ });
523
+ if (appended) {
524
+ return task.uuid;
525
+ }
526
+ }
512
527
  }
513
528
 
514
- this.___addToQueue({
515
- sendAt: _sendAt,
516
- template: _template,
517
- mailOptions: opts,
518
- concatSubject: _concatSubject
519
- }, callback);
529
+ return await this.___addToQueue({
530
+ sendAt,
531
+ template,
532
+ concatSubject,
533
+ mailOptions,
534
+ });
535
+ }
520
536
 
521
- return;
537
+ /**
538
+ * @async
539
+ * @memberOf MailTime
540
+ * @name cancel
541
+ * @description alias of `cancelMail`
542
+ * @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
543
+ * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
544
+ */
545
+ async cancel(uuid) {
546
+ this.__debug('[cancel]', uuid);
547
+ return await this.cancelMail(uuid);
522
548
  }
523
549
 
524
550
  /**
551
+ * @async
552
+ * @memberOf MailTime
553
+ * @name cancelMail
554
+ * @description remove email from the queue or mark as `isCancelled`
555
+ * @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
556
+ * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
557
+ */
558
+ async cancelMail(uuid) {
559
+ this.__debug('[cancelMail]', uuid);
560
+ const resolved = (uuid && typeof uuid.then === 'function') ? await uuid : uuid;
561
+ return await this.queue.cancel(resolved);
562
+ }
563
+
564
+ /**
565
+ * @async
566
+ * @internal
525
567
  * @memberOf MailTime
526
568
  * @name ___handleError
527
- * @param task {Object} - Email task record form Mongo
528
- * @param error {mix} - Error String/Object/Error
529
- * @param info {Object} - Info object returned from nodemailer
530
- * @returns {void}
569
+ * @description Handle runtime errors and pass to `onError` callback
570
+ * @param {MailTimeTask} task - Email task record from Storage
571
+ * @param {unknown} error - Error String/Object/Error
572
+ * @param {object} info - Info object returned from nodemailer
573
+ * @returns {Promise<void 0>}
531
574
  */
532
- ___handleError(task, error, info) {
575
+ async ___handleError(task, error, info) {
576
+ this.__debug('[private handleError]', { task, error, info });
533
577
  if (!task) {
534
578
  return;
535
579
  }
536
580
 
537
581
  if (task.tries >= this.maxTries) {
582
+ this.___finalizeRejected(task, info);
583
+
584
+ const leaseRemove = __leaseRemoveOpts(task);
585
+ const leaseUpdate = __withLease(task, {
586
+ isSent: false,
587
+ isFailed: true,
588
+ isSending: false,
589
+ sendingAt: 0,
590
+ mailOptions: task.mailOptions,
591
+ });
592
+
593
+ let finalized = false;
538
594
  if (!this.keepHistory) {
539
- this.collection.deleteOne({
540
- _id: task._id
541
- }).catch(mongoErrorHandler);
595
+ finalized = await this.queue.remove(task, leaseRemove);
596
+ } else {
597
+ finalized = await this.queue.update(task, leaseUpdate);
542
598
  }
543
599
 
544
- _debug(this.debug, `Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
545
- this.___triggerCallbacks(error, task, info);
600
+ if (!finalized) {
601
+ this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
602
+ return;
603
+ }
604
+
605
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
606
+ task.isSent = false;
607
+ task.isFailed = true;
608
+ task.isSending = false;
609
+
610
+ this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
611
+ this.onError(error, task, info);
546
612
  return;
547
613
  }
548
614
 
549
615
  let transportIndex = task.transport;
550
616
 
551
617
  if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
552
- ++transportIndex;
553
- if (transportIndex > this.transports.length - 1) {
554
- transportIndex = 0;
555
- }
618
+ transportIndex = this.___nextHealthyTransport(transportIndex);
556
619
  }
557
620
 
558
- this.collection.updateOne({
559
- _id: task._id
560
- }, {
561
- $set: {
562
- isSent: false,
563
- sendAt: new Date(Date.now() + this.interval),
564
- transport: transportIndex
565
- }
566
- }).catch(mongoErrorHandler);
621
+ const released = await this.queue.update(task, __withLease(task, {
622
+ isSending: false,
623
+ sendingAt: 0,
624
+ sendAt: Date.now() + this.retryDelay,
625
+ transport: transportIndex,
626
+ }));
627
+
628
+ if (!released) {
629
+ this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
630
+ return;
631
+ }
567
632
 
568
- _debug(this.debug, `Next re-send attempt at ${new Date(Date.now() + this.interval)}: #${task.tries}/${this.maxTries}, transport #${transportIndex} to: `, task.mailOptions[0].to, error);
633
+ 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);
569
634
  }
570
635
 
571
636
  /**
637
+ * @async
638
+ * @internal
572
639
  * @memberOf MailTime
573
640
  * @name ___addToQueue
574
- * @param opts {Object} - Letter options with next properties:
575
- * @param opts.sendAt {Date} - When email should be sent
576
- * @param opts.template {String} - Email template
577
- * @param opts.mailOptions {Object} - MailOptions according to NodeMailer lib
578
- * @param opts.concatSubject {String} - Email subject used when sending multiple concatenated emails
579
- * @param callback {Function} - [OPTIONAL] Callback function
580
- * @returns {void}
641
+ * @description Prepare task's object and push to the queue
642
+ * @param {{ sendAt: number, template: string | false, mailOptions: MailTimeMailOptions, concatSubject: string | false }} opts - Email options
643
+ * @returns {Promise<string>} message uuid
581
644
  */
582
- ___addToQueue(opts, callback) {
583
- _debug(this.debug, '[sendMail] [adding to queue] To:', opts.mailOptions.to);
645
+ async ___addToQueue(opts) {
646
+ this.__debug('[private addToQueue]', opts);
647
+ let transportIndex = this.transport;
648
+ if (this.strategy === 'balancer' && this.transports.length > 0) {
649
+ transportIndex = this.___nextHealthyTransport(this.transport);
650
+ this.transport = transportIndex;
651
+ }
584
652
  const task = {
653
+ uuid: randomUUID(),
585
654
  tries: 0,
586
655
  isSent: false,
587
656
  sendAt: opts.sendAt,
657
+ isFailed: false,
658
+ isSending: false,
659
+ sendingAt: 0,
588
660
  template: opts.template,
589
- transport: this.transport,
661
+ transport: transportIndex,
662
+ isCancelled: false,
590
663
  mailOptions: [opts.mailOptions],
591
- concatSubject: opts.concatSubject
664
+ concatSubject: opts.concatSubject,
592
665
  };
593
666
 
594
667
  if (this.concatEmails) {
595
668
  task.to = opts.mailOptions.to;
596
669
  }
597
670
 
598
- this.collection.insertOne(task).then((r) => {
599
- const _id = r.insertedId.toHexString();
600
- if (!this.callbacks[_id]) {
601
- this.callbacks[_id] = [];
602
- }
603
- this.callbacks[_id].push(callback);
604
- }).catch(mongoErrorHandler);
671
+ await this.queue.push(task);
672
+ return task.uuid;
605
673
  }
606
674
 
607
675
  /**
676
+ * @internal
608
677
  * @memberOf MailTime
609
678
  * @name ___render
610
- * @param string {String} - Template with Spacebars/Blaze/Mustache-like placeholders
611
- * @param replacements {Object} - Blaze/Mustache-like helpers Object
612
- * @returns {String}
679
+ * @description Render Mustache-like placeholders
680
+ * @param {string} _string - Template with Mustache-like placeholders
681
+ * @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
682
+ * @returns {string}
613
683
  */
614
684
  ___render(_string, replacements) {
615
- let i;
616
685
  let string = _string;
617
- const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-\_]+)\s?\}{3}/g);
686
+ const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
618
687
  if (matchHTML) {
619
- for (i = 0; i < matchHTML.length; i++) {
620
- if (replacements[matchHTML[i].replace('{{{', '').replace('}}}', '').trim()]) {
621
- string = string.replace(matchHTML[i], replacements[matchHTML[i].replace('{{{', '').replace('}}}', '').trim()]);
688
+ for (let i = 0; i < matchHTML.length; i++) {
689
+ const key = matchHTML[i].slice(3, -3).trim();
690
+ if (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
691
+ string = string.replace(matchHTML[i], `${replacements[key]}`);
622
692
  }
623
693
  }
624
694
  }
625
695
 
626
- const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9\-\_]+)\s?\}{2}/g);
696
+ const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9\-_]+)\s?\}{2}/g);
627
697
  if (matchStr) {
628
- for (i = 0; i < matchStr.length; i++) {
629
- if (replacements[matchStr[i].replace('{{', '').replace('}}', '').trim()]) {
630
- string = string.replace(matchStr[i], replacements[matchStr[i].replace('{{', '').replace('}}', '').trim()].replace(/<(?:.|\n)*?>/gm, ''));
698
+ for (let i = 0; i < matchStr.length; i++) {
699
+ const key = matchStr[i].slice(2, -2).trim();
700
+ if (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
701
+ string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
631
702
  }
632
703
  }
633
704
  }
@@ -635,20 +706,476 @@ export default class MailTime {
635
706
  }
636
707
 
637
708
  /**
709
+ * @internal
638
710
  * @memberOf MailTime
639
- * @name ___triggerCallbacks
640
- * @param error {mix} - Error thrown during sending email
641
- * @param task {Object} - Task record from mongodb
642
- * @param info {Object} - Info object returned from NodeMailer's `sendMail` method
643
- * @returns {void 0}
711
+ * @name ___compileMailOpts
712
+ * @description Run various checks, compile options, and render template
713
+ * @param {MailTimeTransport} transport - Current transport
714
+ * @param {MailTimeTask} task - Email task record from Storage
715
+ * @returns {MailTimeMailOptions}
644
716
  */
645
- ___triggerCallbacks(error, task, info) {
646
- const _id = task._id.toHexString();
647
- if (this.callbacks[_id] && this.callbacks[_id].length) {
648
- this.callbacks[_id].forEach((cb, i) => {
649
- cb(error, info, task?.mailOptions?.[i]);
717
+ ___compileMailOpts(transport, task) {
718
+ if (!transport) {
719
+ throw new Error('[mail-time] [sendMail] [___compileMailOpts] {transport} is not available or misconfigured!');
720
+ }
721
+
722
+ let compiledOpts = {};
723
+
724
+ if (isPlainObject(transport._options) && isPlainObject(transport._options.mailOptions)) {
725
+ compiledOpts = deepMerge(compiledOpts, transport._options.mailOptions);
726
+ }
727
+
728
+ if (isPlainObject(transport.options) && isPlainObject(transport.options.mailOptions)) {
729
+ compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
730
+ }
731
+
732
+ compiledOpts.html ??= '';
733
+ compiledOpts.text ??= '';
734
+ compiledOpts.subject ??= '';
735
+
736
+ const mailOptionsList = task.mailOptions || [];
737
+ const isMulti = mailOptionsList.length > 1;
738
+
739
+ for (let i = 0; i < mailOptionsList.length; i++) {
740
+ const mailOption = { ...mailOptionsList[i] };
741
+
742
+ if (mailOption.html) {
743
+ const rendered = this.___render(mailOption.html, mailOption);
744
+ if (isMulti) {
745
+ compiledOpts.html += this.___render(this.concatDelimiter, mailOption) + rendered;
746
+ } else {
747
+ compiledOpts.html = rendered;
748
+ }
749
+ delete mailOption.html;
750
+ }
751
+
752
+ if (mailOption.text) {
753
+ const rendered = this.___render(mailOption.text, mailOption);
754
+ if (isMulti) {
755
+ compiledOpts.text += '\r\n' + rendered;
756
+ } else {
757
+ compiledOpts.text = rendered;
758
+ }
759
+ delete mailOption.text;
760
+ }
761
+
762
+ compiledOpts = deepMerge(compiledOpts, mailOption);
763
+ }
764
+
765
+ if (compiledOpts.html && (task.template || this.template)) {
766
+ compiledOpts.html = this.___render((task.template || this.template), compiledOpts);
767
+ }
768
+
769
+ if (isMulti) {
770
+ const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
771
+ compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
772
+ }
773
+
774
+ if (!compiledOpts.from && this.from) {
775
+ compiledOpts.from = this.from(transport);
776
+ }
777
+
778
+ const acceptedSet = collectAcceptedSet(task);
779
+ if (acceptedSet.size > 0) {
780
+ compiledOpts.to = filterAddressField(compiledOpts.to, acceptedSet);
781
+ compiledOpts.cc = filterAddressField(compiledOpts.cc, acceptedSet);
782
+ compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
783
+ }
784
+
785
+ return compiledOpts;
786
+ }
787
+
788
+ /**
789
+ * @internal
790
+ * @memberOf MailTime
791
+ * @name ___trackAcceptedRecipients
792
+ * @description Append newly-accepted addresses to each mailOption's `accepted` list
793
+ * @param {MailTimeTask} task - Email task record from Storage
794
+ * @param {string[]} acceptedAddrs - Lowercased addresses confirmed by transport
795
+ * @returns {void}
796
+ */
797
+ ___trackAcceptedRecipients(task, acceptedAddrs) {
798
+ if (!Array.isArray(task?.mailOptions) || acceptedAddrs.length === 0) {
799
+ return;
800
+ }
801
+ const acceptedSet = new Set(acceptedAddrs);
802
+ for (const mo of task.mailOptions) {
803
+ if (!Array.isArray(mo.accepted)) {
804
+ mo.accepted = [];
805
+ }
806
+ const moRecipients = new Set(mailOptionRecipients(mo));
807
+ if (moRecipients.size === 0) {
808
+ continue;
809
+ }
810
+ const alreadyAccepted = new Set(mo.accepted.map((a) => typeof a === 'string' ? a.toLowerCase() : a));
811
+ for (const addr of acceptedSet) {
812
+ if (moRecipients.has(addr) && !alreadyAccepted.has(addr)) {
813
+ mo.accepted.push(addr);
814
+ alreadyAccepted.add(addr);
815
+ }
816
+ }
817
+ }
818
+ }
819
+
820
+ /**
821
+ * @internal
822
+ * @memberOf MailTime
823
+ * @name ___finalizeRejected
824
+ * @description Populate each mailOption's `rejected` list with addresses that never delivered
825
+ * @param {MailTimeTask} task - Email task record from Storage
826
+ * @param {object} info - Info object from the last nodemailer attempt
827
+ * @returns {void}
828
+ */
829
+ ___finalizeRejected(task, info) {
830
+ if (!Array.isArray(task?.mailOptions)) {
831
+ return;
832
+ }
833
+ const errorMap = buildRejectionErrorMap(info);
834
+ for (const mo of task.mailOptions) {
835
+ const moAccepted = new Set((mo.accepted || []).map((a) => typeof a === 'string' ? a.toLowerCase() : a));
836
+ const seen = new Set();
837
+ const rejected = [];
838
+ for (const addr of mailOptionRecipients(mo)) {
839
+ if (moAccepted.has(addr) || seen.has(addr)) {
840
+ continue;
841
+ }
842
+ seen.add(addr);
843
+ rejected.push({
844
+ address: addr,
845
+ error: errorMap.get(addr) || 'Recipient rejected by transport',
846
+ });
847
+ }
848
+ mo.rejected = rejected;
849
+ }
850
+ }
851
+
852
+ /**
853
+ * @async
854
+ * @internal
855
+ * @memberOf MailTime
856
+ * @name ___dispatch
857
+ * @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.
858
+ * @param {MailTimeTask} task - email's task object from Storage
859
+ * @returns {Promise<void 0>}
860
+ */
861
+ async ___dispatch(task) {
862
+ if (this.__isDestroyed) {
863
+ return;
864
+ }
865
+ if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
866
+ return;
867
+ }
868
+ if (this.__inFlight.has(task.uuid)) {
869
+ this.__debug('[private dispatch] already in-flight on this instance, skipping', task.uuid);
870
+ return;
871
+ }
872
+ this.__inFlight.add(task.uuid);
873
+ await this.__pool.dispatch(async () => {
874
+ try {
875
+ if (this.__isDestroyed) {
876
+ return;
877
+ }
878
+ await this.___send(task);
879
+ } finally {
880
+ this.__inFlight.delete(task.uuid);
881
+ }
882
+ });
883
+ }
884
+
885
+ /**
886
+ * @async
887
+ * @internal
888
+ * @memberOf MailTime
889
+ * @name ___send
890
+ * @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.
891
+ * @param {MailTimeTask} task - email's task object from Storage
892
+ * @returns {Promise<void 0>}
893
+ */
894
+ async ___send(task) {
895
+ this.__debug('[private send]', task);
896
+ try {
897
+ if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
898
+ return;
899
+ }
900
+
901
+ const tries = task.tries + 1;
902
+ const sendingAt = Date.now();
903
+ let isClaimed = false;
904
+ try {
905
+ isClaimed = await this.queue.update(task, {
906
+ isSending: true,
907
+ sendingAt,
908
+ tries,
909
+ });
910
+ } catch (claimError) {
911
+ logError('[private send] [claim] storage error during atomic claim', claimError);
912
+ return;
913
+ }
914
+
915
+ if (!isClaimed) {
916
+ this.__debug('[private send] [queue.update] Stale claim, skipping', task.uuid);
917
+ return;
918
+ }
919
+
920
+ task.tries = tries;
921
+ task.isSending = true;
922
+ task.sendingAt = sendingAt;
923
+
924
+ let transportIndex = task.transport;
925
+ if (!this.___isHealthyTransport(transportIndex)) {
926
+ transportIndex = this.___nextHealthyTransport(transportIndex);
927
+ task.transport = transportIndex;
928
+ }
929
+ const transport = this.transports[transportIndex];
930
+
931
+ const compiledOpts = this.___compileMailOpts(transport, task);
932
+
933
+ await new Promise((resolve) => {
934
+ transport.sendMail(compiledOpts, async (error, info) => {
935
+ this.__debug('[private send] [sending]', { error, info });
936
+ try {
937
+ if (error) {
938
+ await this.___handleError(task, error, info);
939
+ return;
940
+ }
941
+
942
+ const acceptedAddrs = Array.isArray(info?.accepted)
943
+ ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
944
+ : [];
945
+
946
+ if (acceptedAddrs.length === 0) {
947
+ await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
948
+ return;
949
+ }
950
+
951
+ this.___trackAcceptedRecipients(task, acceptedAddrs);
952
+
953
+ const allRecipients = collectAllRecipients(task);
954
+ const allAccepted = collectAcceptedSet(task);
955
+ let isFullyDelivered = true;
956
+ for (const addr of allRecipients) {
957
+ if (!allAccepted.has(addr)) {
958
+ isFullyDelivered = false;
959
+ break;
960
+ }
961
+ }
962
+
963
+ if (isFullyDelivered) {
964
+ this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
965
+
966
+ const leaseRemove = __leaseRemoveOpts(task);
967
+ const leaseUpdate = __withLease(task, {
968
+ isSent: true,
969
+ isSending: false,
970
+ sendingAt: 0,
971
+ mailOptions: task.mailOptions,
972
+ });
973
+
974
+ let completed = false;
975
+ if (!this.keepHistory) {
976
+ completed = await this.queue.remove(task, leaseRemove);
977
+ } else {
978
+ completed = await this.queue.update(task, leaseUpdate);
979
+ }
980
+
981
+ if (!completed) {
982
+ this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
983
+ return;
984
+ }
985
+
986
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
987
+ task.isSent = true;
988
+ task.isSending = false;
989
+ task.sendingAt = 0;
990
+ this.onSent(task, info);
991
+ return;
992
+ }
993
+
994
+ if (task.tries >= this.maxTries) {
995
+ this.___finalizeRejected(task, info);
996
+
997
+ const leaseRemove = __leaseRemoveOpts(task);
998
+ const leaseUpdate = __withLease(task, {
999
+ isSent: false,
1000
+ isFailed: true,
1001
+ isSending: false,
1002
+ sendingAt: 0,
1003
+ mailOptions: task.mailOptions,
1004
+ });
1005
+
1006
+ let finalized = false;
1007
+ if (!this.keepHistory) {
1008
+ finalized = await this.queue.remove(task, leaseRemove);
1009
+ } else {
1010
+ finalized = await this.queue.update(task, leaseUpdate);
1011
+ }
1012
+
1013
+ if (!finalized) {
1014
+ this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
1015
+ return;
1016
+ }
1017
+
1018
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
1019
+ task.isSent = false;
1020
+ task.isFailed = true;
1021
+ task.isSending = false;
1022
+
1023
+ const rejectedAddrs = [];
1024
+ for (const mo of task.mailOptions) {
1025
+ for (const r of (mo.rejected || [])) {
1026
+ rejectedAddrs.push(r.address);
1027
+ }
1028
+ }
1029
+ const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
1030
+ this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
1031
+ this.onError(partialError, task, info);
1032
+ return;
1033
+ }
1034
+
1035
+ const nextSendAt = Date.now() + this.retryDelay;
1036
+ const released = await this.queue.update(task, __withLease(task, {
1037
+ isSending: false,
1038
+ sendingAt: 0,
1039
+ sendAt: nextSendAt,
1040
+ mailOptions: task.mailOptions,
1041
+ }));
1042
+ if (!released) {
1043
+ this.__debug('[private send] lease lost before partial retry release', task.uuid);
1044
+ return;
1045
+ }
1046
+ this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
1047
+ } finally {
1048
+ resolve();
1049
+ }
1050
+ });
650
1051
  });
1052
+ } catch (e) {
1053
+ logError('Exception during runtime:', e);
1054
+ await this.___handleError(task, e, {});
1055
+ }
1056
+ }
1057
+
1058
+ /**
1059
+ * @internal
1060
+ * @memberOf MailTime
1061
+ * @name ___iterate
1062
+ * @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.
1063
+ * @returns {Promise<void>|void}
1064
+ */
1065
+ async ___iterate() {
1066
+ this.__debug('[private iterate]');
1067
+ if (this.__isDestroyed) {
1068
+ return;
1069
+ }
1070
+ const limit = this.mode === 'one' ? 1 : Infinity;
1071
+ return await this.queue.iterate({
1072
+ limit,
1073
+ sendingTimeout: this.sendingTimeout,
1074
+ });
1075
+ }
1076
+
1077
+ /**
1078
+ * @async
1079
+ * @internal
1080
+ * @memberOf MailTime
1081
+ * @name ___ready
1082
+ * @description Internal storage readiness gate
1083
+ * @returns {Promise<MailTime>}
1084
+ */
1085
+ async ___ready() {
1086
+ if (typeof this.queue.ready === 'function') {
1087
+ await this.queue.ready();
1088
+ }
1089
+
1090
+ if (this.__schedulerTimer) {
1091
+ await this.__schedulerTimer;
1092
+ }
1093
+
1094
+ const pingResult = await this.ping();
1095
+ if (pingResult.status !== 'OK') {
1096
+ throw new Error('[mail-time] [MailTime#ready] can not connect to storage, make sure it is available and properly configured', { cause: pingResult.error });
1097
+ }
1098
+
1099
+ if (this.type === 'server' && this.verifyTransports && this.transports.length > 0) {
1100
+ await this.___verifyTransports();
1101
+ }
1102
+
1103
+ return this;
1104
+ }
1105
+
1106
+ /**
1107
+ * @async
1108
+ * @internal
1109
+ * @memberOf MailTime
1110
+ * @name ___verifyTransports
1111
+ * @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.
1112
+ * @returns {Promise<void>}
1113
+ */
1114
+ async ___verifyTransports() {
1115
+ this.__debug('[private verifyTransports]');
1116
+ const results = await Promise.all(this.transports.map(async (transport, index) => {
1117
+ if (!transport || typeof transport.verify !== 'function') {
1118
+ return { index, ok: true };
1119
+ }
1120
+ try {
1121
+ await Promise.resolve(transport.verify());
1122
+ return { index, ok: true };
1123
+ } catch (error) {
1124
+ return { index, ok: false, error };
1125
+ }
1126
+ }));
1127
+
1128
+ for (const r of results) {
1129
+ if (r.ok) {
1130
+ continue;
1131
+ }
1132
+ this.__unhealthyTransports.add(r.index);
1133
+ logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
1134
+ this.onError(r.error, null, { transportIndex: r.index, phase: 'verify' });
1135
+ }
1136
+
1137
+ if (this.__unhealthyTransports.size === this.transports.length) {
1138
+ throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
1139
+ }
1140
+
1141
+ if (this.__unhealthyTransports.has(this.transport)) {
1142
+ this.transport = this.___nextHealthyTransport(this.transport);
1143
+ }
1144
+ }
1145
+
1146
+ /**
1147
+ * @internal
1148
+ * @memberOf MailTime
1149
+ * @name ___isHealthyTransport
1150
+ * @description Return true when the transport at `index` has not been marked unusable by verification.
1151
+ * @param {number} index
1152
+ * @returns {boolean}
1153
+ */
1154
+ ___isHealthyTransport(index) {
1155
+ return !this.__unhealthyTransports.has(index);
1156
+ }
1157
+
1158
+ /**
1159
+ * @internal
1160
+ * @memberOf MailTime
1161
+ * @name ___nextHealthyTransport
1162
+ * @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.
1163
+ * @param {number} fromIdx
1164
+ * @returns {number}
1165
+ */
1166
+ ___nextHealthyTransport(fromIdx) {
1167
+ if (this.transports.length === 0) {
1168
+ return fromIdx;
651
1169
  }
652
- delete this.callbacks[_id];
1170
+ let next = fromIdx;
1171
+ for (let i = 0; i < this.transports.length; i++) {
1172
+ next = (next + 1) % this.transports.length;
1173
+ if (this.___isHealthyTransport(next)) {
1174
+ return next;
1175
+ }
1176
+ }
1177
+ return fromIdx;
653
1178
  }
654
1179
  }
1180
+
1181
+ export { MailTime, MongoQueue, RedisQueue, PostgresQueue, mailTimePreset, presets, presetNames };