mail-time 3.0.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,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 'node: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, 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, 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,51 @@ 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.__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;
169
259
  this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
170
- this.transports = opts.transports || [];
260
+ this.transports = Array.isArray(opts.transports) ? opts.transports : [];
171
261
  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
- }
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
+ }
182
273
 
183
- return false;
184
- })();
274
+ this.queue.mailTimeInstance = this;
185
275
 
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>';
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;
284
+ }
285
+ } else {
286
+ this.concatEmails = false;
287
+ }
288
+ this.concatDelimiter = (typeof opts.concatDelimiter === 'string' && opts.concatDelimiter) ? opts.concatDelimiter : '<hr>';
189
289
 
190
290
  if (typeof opts.concatDelay === 'number') {
191
291
  this.concatDelay = opts.concatDelay;
@@ -195,63 +295,90 @@ class MailTime {
195
295
  this.concatDelay = 60000;
196
296
  }
197
297
 
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}]`);
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}]`);
207
308
 
208
309
  /** SERVER-SPECIFIC CHECKS AND CONFIG */
209
310
  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`');
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`');
212
313
  }
213
314
 
214
- if (typeof opts.josk !== 'object') {
315
+ if (!opts.josk || typeof opts.josk !== 'object') {
215
316
  throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
216
317
  }
217
318
 
218
- if (typeof opts.josk.adapter !== 'object') {
319
+ if (!opts.josk.adapter || typeof opts.josk.adapter !== 'object') {
219
320
  throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
220
321
  }
221
322
 
222
323
  this.josk = { ...opts.josk };
324
+ const buildAdapterOptions = () => {
325
+ const adapterOptions = {
326
+ prefix: `mailTimeQueue${this.prefix}`,
327
+ ...opts.josk.adapter,
328
+ };
223
329
 
224
- if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'mongo') {
330
+ if (typeof opts.josk.resetOnInit === 'boolean' && typeof adapterOptions.resetOnInit !== 'boolean') {
331
+ adapterOptions.resetOnInit = opts.josk.resetOnInit;
332
+ }
333
+
334
+ return adapterOptions;
335
+ };
336
+
337
+ const adapterType = opts.josk.adapter.type;
338
+ if (adapterType === 'mongo') {
225
339
  if (!opts.josk.adapter.db) {
226
340
  throw new Error('[mail-time] {josk.adapter.db} option required for {josk.adapter.type: "mongo"}');
227
341
  }
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') {
342
+ this.josk.adapter = new MongoAdapter(buildAdapterOptions());
343
+ } else if (adapterType === 'redis') {
232
344
  if (!opts.josk.adapter.client) {
233
345
  throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "redis"}');
234
346
  }
235
- this.josk.adapter = new RedisAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
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());
236
353
  }
237
354
 
238
355
  this.josk.minRevolvingDelay = (typeof opts.josk.minRevolvingDelay === 'number') ? opts.josk.minRevolvingDelay : 512;
239
356
  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;
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;
361
+
362
+ if (typeof opts.josk.lockOwnerId === 'string' && opts.josk.lockOwnerId.length > 0) {
363
+ this.josk.lockOwnerId = opts.josk.lockOwnerId;
364
+ }
365
+
366
+ if (typeof opts.josk.onError !== 'function') {
367
+ this.josk.onError = (title, details) => {
368
+ logError(`[scheduler] ${title}`, details);
369
+ };
370
+ }
241
371
 
242
372
  this.scheduler = new JoSk({
243
373
  debug: this.debug,
244
374
  ...this.josk,
245
375
  });
246
376
 
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}`);
377
+ this.__schedulerTimer = this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
254
378
  }
379
+
380
+ this.__readyPromise = this.___ready();
381
+ this.__readyPromise.catch(() => void 0);
255
382
  }
256
383
 
257
384
  static get Template() {
@@ -267,26 +394,78 @@ class MailTime {
267
394
  * @memberOf MailTime
268
395
  * @name ping
269
396
  * @description Check package readiness and connection to Storage
270
- * @returns {Promise<object>}
271
- * @throws {mix}
397
+ * @returns {Promise<MailTimePingResult>}
398
+ * @throws {Error}
272
399
  */
273
400
  async ping() {
274
- this._debug('[ping]');
275
- const schedulerPing = await this.scheduler.ping();
276
- if (schedulerPing.status !== 'OK') {
277
- return schedulerPing;
401
+ this.__debug('[ping]');
402
+ if (this.scheduler) {
403
+ const schedulerPing = await this.scheduler.ping();
404
+ if (schedulerPing.status !== 'OK') {
405
+ return schedulerPing;
406
+ }
278
407
  }
279
408
  return await this.queue.ping();
280
409
  }
281
410
 
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
+ }
422
+
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
+ }
435
+
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
+ }
445
+
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
+ }
458
+ }
459
+
282
460
  /**
283
461
  * @memberOf MailTime
284
462
  * @name send
285
463
  * @description alias of `sendMail`
464
+ * @param {MailTimeMailOptions} opts - email options
286
465
  * @returns {Promise<string>} uuid of the email
287
466
  */
288
467
  async send(opts) {
289
- this._debug('[send]', opts);
468
+ this.__debug('[send]', opts);
290
469
  return await this.sendMail(opts);
291
470
  }
292
471
 
@@ -295,49 +474,34 @@ class MailTime {
295
474
  * @memberOf MailTime
296
475
  * @name sendMail
297
476
  * @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
477
+ * @param {MailTimeMailOptions} opts - email options
302
478
  * @returns {Promise<string>} uuid of the email
303
479
  * @throws {Error}
304
480
  */
305
481
  async sendMail(opts = {}) {
306
- this._debug('[sendMail]', opts);
482
+ this.__debug('[sendMail]', opts);
307
483
  if (!opts.html && !opts.text) {
308
- 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');
309
485
  }
310
486
 
311
487
  let sendAt = opts.sendAt;
312
- if (!sendAt) {
313
- sendAt = Date.now();
314
- }
315
-
316
488
  if (sendAt instanceof Date) {
317
489
  sendAt = +sendAt;
318
490
  }
319
-
320
- if (typeof sendAt !== 'number') {
491
+ if (typeof sendAt !== 'number' || !Number.isFinite(sendAt)) {
321
492
  sendAt = Date.now();
322
493
  }
323
494
 
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
- }
495
+ const template = (typeof opts.template === 'string') ? opts.template : false;
496
+ const concatSubject = (typeof opts.concatSubject === 'string') ? opts.concatSubject : false;
333
497
 
334
498
  const mailOptions = { ...opts };
335
499
  delete mailOptions.sendAt;
336
500
  delete mailOptions.template;
337
501
  delete mailOptions.concatSubject;
338
502
 
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');
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');
341
505
  }
342
506
 
343
507
  if (this.concatEmails) {
@@ -353,11 +517,12 @@ class MailTime {
353
517
  }
354
518
  }
355
519
 
356
- pendingMailOptions.push(opts);
357
- await this.queue.update(task, {
358
- mailOptions: pendingMailOptions
520
+ const appended = await this.queue.update(task, {
521
+ appendMailOption: mailOptions,
359
522
  });
360
- return task.uuid;
523
+ if (appended) {
524
+ return task.uuid;
525
+ }
361
526
  }
362
527
  }
363
528
 
@@ -365,18 +530,20 @@ class MailTime {
365
530
  sendAt,
366
531
  template,
367
532
  concatSubject,
368
- mailOptions: mailOptions,
533
+ mailOptions,
369
534
  });
370
535
  }
371
536
 
372
537
  /**
538
+ * @async
373
539
  * @memberOf MailTime
374
540
  * @name cancel
375
541
  * @description alias of `cancelMail`
542
+ * @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
376
543
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
377
544
  */
378
545
  async cancel(uuid) {
379
- this._debug('[cancel]', uuid);
546
+ this.__debug('[cancel]', uuid);
380
547
  return await this.cancelMail(uuid);
381
548
  }
382
549
 
@@ -385,47 +552,62 @@ class MailTime {
385
552
  * @memberOf MailTime
386
553
  * @name cancelMail
387
554
  * @description remove email from the queue or mark as `isCancelled`
388
- * @param uuid {string|Promise<string>} - uuid returned from `send` or `sendMail`
555
+ * @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
389
556
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
390
557
  */
391
558
  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);
559
+ this.__debug('[cancelMail]', uuid);
560
+ const resolved = (uuid && typeof uuid.then === 'function') ? await uuid : uuid;
561
+ return await this.queue.cancel(resolved);
397
562
  }
398
563
 
399
564
  /**
400
565
  * @async
566
+ * @internal
401
567
  * @memberOf MailTime
402
568
  * @name ___handleError
403
569
  * @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
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
407
573
  * @returns {Promise<void 0>}
408
574
  */
409
575
  async ___handleError(task, error, info) {
410
- this._debug('[private handleError]', { task, error, info });
576
+ this.__debug('[private handleError]', { task, error, info });
411
577
  if (!task) {
412
578
  return;
413
579
  }
414
580
 
415
581
  if (task.tries >= this.maxTries) {
416
- task.isSent = false;
417
- task.isFailed = true;
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
+ });
418
592
 
593
+ let finalized = false;
419
594
  if (!this.keepHistory) {
420
- await this.queue.remove(task);
595
+ finalized = await this.queue.remove(task, leaseRemove);
421
596
  } else {
422
- await this.queue.update(task, {
423
- isSent: task.isSent,
424
- isFailed: task.isFailed,
425
- });
597
+ finalized = await this.queue.update(task, leaseUpdate);
598
+ }
599
+
600
+ if (!finalized) {
601
+ this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
602
+ return;
426
603
  }
427
604
 
428
- this._debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
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);
429
611
  this.onError(error, task, info);
430
612
  return;
431
613
  }
@@ -433,43 +615,50 @@ class MailTime {
433
615
  let transportIndex = task.transport;
434
616
 
435
617
  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
- }
618
+ transportIndex = this.___nextHealthyTransport(transportIndex);
440
619
  }
441
620
 
442
- await this.queue.update(task, {
443
- isSent: false,
621
+ const released = await this.queue.update(task, __withLease(task, {
622
+ isSending: false,
623
+ sendingAt: 0,
444
624
  sendAt: Date.now() + this.retryDelay,
445
625
  transport: transportIndex,
446
- });
626
+ }));
627
+
628
+ if (!released) {
629
+ this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
630
+ return;
631
+ }
447
632
 
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);
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);
449
634
  }
450
635
 
451
636
  /**
452
637
  * @async
638
+ * @internal
453
639
  * @memberOf MailTime
454
640
  * @name ___addToQueue
455
641
  * @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
642
+ * @param {{ sendAt: number, template: string | false, mailOptions: MailTimeMailOptions, concatSubject: string | false }} opts - Email options
461
643
  * @returns {Promise<string>} message uuid
462
644
  */
463
645
  async ___addToQueue(opts) {
464
- this._debug('[private 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
+ }
465
652
  const task = {
466
- uuid: crypto.randomUUID(),
653
+ uuid: randomUUID(),
467
654
  tries: 0,
468
655
  isSent: false,
469
656
  sendAt: opts.sendAt,
470
657
  isFailed: false,
658
+ isSending: false,
659
+ sendingAt: 0,
471
660
  template: opts.template,
472
- transport: this.transport,
661
+ transport: transportIndex,
473
662
  isCancelled: false,
474
663
  mailOptions: [opts.mailOptions],
475
664
  concatSubject: opts.concatSubject,
@@ -484,30 +673,32 @@ class MailTime {
484
673
  }
485
674
 
486
675
  /**
676
+ * @internal
487
677
  * @memberOf MailTime
488
678
  * @name ___render
489
- * @description Render templates
490
- * @param string {string} - Template with Mustache-like placeholders
491
- * @param replacements {object} - Blaze/Mustache-like helpers Object
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
492
682
  * @returns {string}
493
683
  */
494
684
  ___render(_string, replacements) {
495
- let i;
496
685
  let string = _string;
497
- 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);
498
687
  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()]);
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]}`);
502
692
  }
503
693
  }
504
694
  }
505
695
 
506
- 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);
507
697
  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, ''));
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, ''));
511
702
  }
512
703
  }
513
704
  }
@@ -515,155 +706,476 @@ class MailTime {
515
706
  }
516
707
 
517
708
  /**
709
+ * @internal
518
710
  * @memberOf MailTime
519
711
  * @name ___compileMailOpts
520
712
  * @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}
713
+ * @param {MailTimeTransport} transport - Current transport
714
+ * @param {MailTimeTask} task - Email task record from Storage
715
+ * @returns {MailTimeMailOptions}
524
716
  */
525
717
  ___compileMailOpts(transport, task) {
526
- let compiledOpts = {};
527
-
528
718
  if (!transport) {
529
719
  throw new Error('[mail-time] [sendMail] [___compileMailOpts] {transport} is not available or misconfigured!');
530
720
  }
531
721
 
532
- if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) {
533
- compiledOpts = merge(compiledOpts, transport._options.mailOptions);
722
+ let compiledOpts = {};
723
+
724
+ if (isPlainObject(transport._options) && isPlainObject(transport._options.mailOptions)) {
725
+ compiledOpts = deepMerge(compiledOpts, transport._options.mailOptions);
534
726
  }
535
727
 
536
- if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) {
537
- compiledOpts = merge(compiledOpts, transport.options.mailOptions);
728
+ if (isPlainObject(transport.options) && isPlainObject(transport.options.mailOptions)) {
729
+ compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
538
730
  }
539
731
 
540
- compiledOpts = merge(compiledOpts, {
541
- html: '',
542
- text: '',
543
- subject: ''
544
- });
732
+ compiledOpts.html ??= '';
733
+ compiledOpts.text ??= '';
734
+ compiledOpts.subject ??= '';
735
+
736
+ const mailOptionsList = task.mailOptions || [];
737
+ const isMulti = mailOptionsList.length > 1;
545
738
 
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]);
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;
550
746
  } else {
551
- compiledOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]);
747
+ compiledOpts.html = rendered;
552
748
  }
553
- delete task.mailOptions[i].html;
749
+ delete mailOption.html;
554
750
  }
555
751
 
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]);
752
+ if (mailOption.text) {
753
+ const rendered = this.___render(mailOption.text, mailOption);
754
+ if (isMulti) {
755
+ compiledOpts.text += '\r\n' + rendered;
559
756
  } else {
560
- compiledOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]);
757
+ compiledOpts.text = rendered;
561
758
  }
562
- delete task.mailOptions[i].text;
759
+ delete mailOption.text;
563
760
  }
564
761
 
565
- compiledOpts = merge(compiledOpts, task.mailOptions[i]);
762
+ compiledOpts = deepMerge(compiledOpts, mailOption);
566
763
  }
567
764
 
568
765
  if (compiledOpts.html && (task.template || this.template)) {
569
766
  compiledOpts.html = this.___render((task.template || this.template), compiledOpts);
570
767
  }
571
768
 
572
- if (task.mailOptions.length > 1) {
573
- compiledOpts.subject = task.concatSubject || this.concatSubject || compiledOpts.subject;
769
+ if (isMulti) {
770
+ const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
771
+ compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
574
772
  }
575
773
 
576
774
  if (!compiledOpts.from && this.from) {
577
775
  compiledOpts.from = this.from(transport);
578
776
  }
579
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
+
580
785
  return compiledOpts;
581
786
  }
582
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
+
583
852
  /**
584
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
585
888
  * @memberOf MailTime
586
889
  * @name ___send
587
- * @description send email using nodemailer's transport
588
- * @param task {object} - email's task object from Storage
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
589
892
  * @returns {Promise<void 0>}
590
893
  */
591
894
  async ___send(task) {
592
- this._debug('[private send]', task);
895
+ this.__debug('[private send]', task);
593
896
  try {
594
- if (task.isSent === true || task.isFailed === true || task.isCancelled === true) {
897
+ if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
595
898
  return;
596
899
  }
597
900
 
598
- task.tries++;
599
- const isUpdated = await this.queue.update(task, {
600
- isSent: true,
601
- tries: task.tries
602
- });
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
+ }
603
914
 
604
- if (!isUpdated) {
605
- logError('[private send] [queue.update] Not updated!');
915
+ if (!isClaimed) {
916
+ this.__debug('[private send] [queue.update] Stale claim, skipping', task.uuid);
606
917
  return;
607
918
  }
608
919
 
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];
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;
621
928
  }
929
+ const transport = this.transports[transportIndex];
622
930
 
623
931
  const compiledOpts = this.___compileMailOpts(transport, task);
624
932
 
625
933
  await new Promise((resolve) => {
626
934
  transport.sendMail(compiledOpts, async (error, info) => {
627
- this._debug('[private send] [sending]', { error, info });
628
- if (error) {
629
- await this.___handleError(task, error, info);
630
- resolve();
631
- return;
632
- }
633
-
634
- if (info.accepted && !info.accepted.length) {
635
- await this.___handleError(task, new Error('Message not accepted or Greeting never received'), 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 {
636
1048
  resolve();
637
- return;
638
1049
  }
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
1050
  });
650
1051
  });
651
1052
  } catch (e) {
652
1053
  logError('Exception during runtime:', e);
653
- this.___handleError(task, e, {});
1054
+ await this.___handleError(task, e, {});
654
1055
  }
655
1056
  }
656
1057
 
657
1058
  /**
1059
+ * @internal
658
1060
  * @memberOf MailTime
659
1061
  * @name ___iterate
660
- * @description Iterate over queued tasks
661
- * @returns {Promise}
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}
662
1064
  */
663
1065
  async ___iterate() {
664
- this._debug('[private iterate]');
665
- return await this.queue.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;
1169
+ }
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;
666
1178
  }
667
1179
  }
668
1180
 
669
- export { MailTime, MongoQueue, RedisQueue };
1181
+ export { MailTime, MongoQueue, RedisQueue, PostgresQueue, mailTimePreset, presets, presetNames };