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/LICENSE +1 -1
- package/README.md +325 -609
- package/adapters/mongo.d.ts +118 -0
- package/adapters/mongo.js +199 -78
- package/adapters/postgres.d.ts +100 -0
- package/adapters/postgres.js +520 -0
- package/adapters/redis.d.ts +105 -0
- package/adapters/redis.js +342 -79
- package/helpers.js +209 -3
- package/index.cjs +2219 -446
- package/index.d.cts +300 -0
- package/index.d.ts +300 -0
- package/index.js +817 -305
- package/package.json +66 -17
- package/presets.d.ts +87 -0
- package/presets.js +193 -0
- package/adapters/blank-example.js +0 -230
package/index.js
CHANGED
|
@@ -1,151 +1,233 @@
|
|
|
1
|
-
import { JoSk, RedisAdapter, MongoAdapter } from 'josk';
|
|
2
|
-
import
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
125
|
+
const err = rejectedErrors[i];
|
|
126
|
+
map.set(addr, err ? `${err.message || err}` : 'Recipient rejected by transport');
|
|
87
127
|
}
|
|
88
|
-
return
|
|
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 {
|
|
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'
|
|
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 =
|
|
138
|
-
this.
|
|
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 = (
|
|
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 :
|
|
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 =
|
|
163
|
-
this.onSent = opts.onSent
|
|
164
|
-
this.onError = opts.onError
|
|
165
|
-
|
|
166
|
-
this.revolvingInterval = opts.revolvingInterval
|
|
167
|
-
|
|
168
|
-
this.
|
|
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.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
})();
|
|
274
|
+
this.queue.mailTimeInstance = this;
|
|
185
275
|
|
|
186
|
-
|
|
187
|
-
this.concatSubject = (
|
|
188
|
-
|
|
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.
|
|
199
|
-
this.
|
|
200
|
-
this.
|
|
201
|
-
this.
|
|
202
|
-
this.
|
|
203
|
-
this.
|
|
204
|
-
this.
|
|
205
|
-
this.
|
|
206
|
-
this.
|
|
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 (
|
|
211
|
-
throw new Error('[mail-time] {transports} is required for {type: "server"} and must be
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 :
|
|
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
|
-
|
|
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<
|
|
271
|
-
* @throws {
|
|
397
|
+
* @returns {Promise<MailTimePingResult>}
|
|
398
|
+
* @throws {Error}
|
|
272
399
|
*/
|
|
273
400
|
async ping() {
|
|
274
|
-
this.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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.
|
|
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
|
|
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.
|
|
482
|
+
this.__debug('[sendMail]', opts);
|
|
307
483
|
if (!opts.html && !opts.text) {
|
|
308
|
-
throw new Error('`html` nor `text` field is
|
|
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
|
-
|
|
325
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
mailOptions: pendingMailOptions
|
|
520
|
+
const appended = await this.queue.update(task, {
|
|
521
|
+
appendMailOption: mailOptions,
|
|
359
522
|
});
|
|
360
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
405
|
-
* @param
|
|
406
|
-
* @param
|
|
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.
|
|
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
|
|
417
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
|
490
|
-
* @param
|
|
491
|
-
* @param
|
|
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
|
|
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
|
-
|
|
501
|
-
|
|
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
|
|
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
|
-
|
|
510
|
-
|
|
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
|
|
522
|
-
* @param
|
|
523
|
-
* @returns {
|
|
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
|
-
|
|
533
|
-
|
|
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 &&
|
|
537
|
-
compiledOpts =
|
|
728
|
+
if (isPlainObject(transport.options) && isPlainObject(transport.options.mailOptions)) {
|
|
729
|
+
compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
|
|
538
730
|
}
|
|
539
731
|
|
|
540
|
-
compiledOpts
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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 <
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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 =
|
|
747
|
+
compiledOpts.html = rendered;
|
|
552
748
|
}
|
|
553
|
-
delete
|
|
749
|
+
delete mailOption.html;
|
|
554
750
|
}
|
|
555
751
|
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
|
|
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 =
|
|
757
|
+
compiledOpts.text = rendered;
|
|
561
758
|
}
|
|
562
|
-
delete
|
|
759
|
+
delete mailOption.text;
|
|
563
760
|
}
|
|
564
761
|
|
|
565
|
-
compiledOpts =
|
|
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 (
|
|
573
|
-
|
|
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
|
|
588
|
-
* @param
|
|
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.
|
|
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
|
|
600
|
-
|
|
601
|
-
|
|
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 (!
|
|
605
|
-
|
|
915
|
+
if (!isClaimed) {
|
|
916
|
+
this.__debug('[private send] [queue.update] Stale claim, skipping', task.uuid);
|
|
606
917
|
return;
|
|
607
918
|
}
|
|
608
919
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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.
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
|
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.
|
|
665
|
-
|
|
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 };
|