mail-time 1.4.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,565 @@
1
+ import {
2
+ debug,
3
+ logError,
4
+ isSendClaimUpdate,
5
+ isSendLeaseGuardedUpdate,
6
+ isAppendMailOptionUpdate,
7
+ stripInternalUpdateMeta,
8
+ isSendLeaseRemove,
9
+ } from '../helpers.js';
10
+
11
+ /**
12
+ * @typedef {object} RedisClient
13
+ * @property {(key: string) => Promise<number>} exists
14
+ * @property {(key: string) => Promise<string|null>} get
15
+ * @property {(key: string, value: string, options?: object) => Promise<unknown>} set
16
+ * @property {(key: string|string[]) => Promise<number>} del
17
+ * @property {() => Promise<string>} ping
18
+ * @property {(options: object) => AsyncIterable<string|string[]>} scanIterator
19
+ * @property {(key: string) => Promise<unknown>} [watch]
20
+ * @property {() => Promise<unknown>} [unwatch]
21
+ * @property {() => object} [multi]
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} RedisQueueOption
26
+ * @property {RedisClient} client
27
+ * @property {string} [prefix]
28
+ */
29
+
30
+ const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
31
+ const DEFAULT_PREFIX = 'default';
32
+
33
+ const canReleaseLease = (currentTask, updateObj) => {
34
+ return currentTask
35
+ && currentTask.tries === updateObj.leaseTries
36
+ && currentTask.isSending === true
37
+ && (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) === updateObj.leaseSendingAt
38
+ && currentTask.isCancelled !== true
39
+ && currentTask.isFailed !== true;
40
+ };
41
+
42
+ const canClaimTask = (currentTask, task, now, sendingTimeout) => {
43
+ if (!currentTask) {
44
+ return false;
45
+ }
46
+ if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
47
+ return false;
48
+ }
49
+ if (currentTask.tries !== task.tries) {
50
+ return false;
51
+ }
52
+ if (currentTask.isSending === true) {
53
+ const sendingAt = typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0;
54
+ if (sendingAt > now - sendingTimeout) {
55
+ return false;
56
+ }
57
+ }
58
+ return true;
59
+ };
60
+
61
+ const isIterateCandidate = (candidate, now, sendingTimeout, maxTries) => {
62
+ if (!candidate || typeof candidate !== 'object') {
63
+ return false;
64
+ }
65
+ if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true) {
66
+ return false;
67
+ }
68
+ const tries = typeof candidate.tries === 'number' ? candidate.tries : 0;
69
+ if (tries >= maxTries) {
70
+ return false;
71
+ }
72
+ if (candidate.isSending === true) {
73
+ const sendingAt = typeof candidate.sendingAt === 'number' ? candidate.sendingAt : 0;
74
+ if (sendingAt > now - sendingTimeout) {
75
+ return false;
76
+ }
77
+ }
78
+ return true;
79
+ };
80
+
81
+ const parseUuidFromKey = (key, uniqueName) => {
82
+ const prefix = `${uniqueName}:sendat:`;
83
+ if (!key.startsWith(prefix)) {
84
+ return null;
85
+ }
86
+ return key.slice(prefix.length);
87
+ };
88
+
89
+ /** Class representing Redis Queue for MailTime */
90
+ class RedisQueue {
91
+ /**
92
+ * Create a RedisQueue instance
93
+ * @param {RedisQueueOption} opts - configuration object
94
+ */
95
+ constructor (opts) {
96
+ this.name = 'redis-queue';
97
+ if (!opts || typeof opts !== 'object') {
98
+ throw new TypeError('[mail-time] Configuration object must be passed into RedisQueue constructor');
99
+ }
100
+
101
+ if (!opts.client) {
102
+ throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
103
+ }
104
+
105
+ this.client = opts.client;
106
+ if (typeof opts.prefix === 'string') {
107
+ this.__applyPrefix(opts.prefix);
108
+ }
109
+ }
110
+
111
+ /** @internal */
112
+ __applyPrefix(prefix) {
113
+ this.prefix = prefix;
114
+ this.uniqueName = `mailtime:${prefix}`;
115
+ }
116
+
117
+ /** @internal */
118
+ __ensurePrefix() {
119
+ if (typeof this.prefix !== 'string') {
120
+ this.__applyPrefix(this.mailTimeInstance?.prefix || DEFAULT_PREFIX);
121
+ }
122
+ }
123
+
124
+ /** @internal */
125
+ __debug(...args) {
126
+ debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
127
+ }
128
+
129
+ /**
130
+ * @async
131
+ * @memberOf RedisQueue
132
+ * @name ready
133
+ * @description Storage adapter has no async setup
134
+ * @returns {Promise<void 0>}
135
+ */
136
+ async ready() {
137
+ this.__ensurePrefix();
138
+ this.__debug('[ready]');
139
+ return void 0;
140
+ }
141
+
142
+ /**
143
+ * @async
144
+ * @memberOf RedisQueue
145
+ * @name ping
146
+ * @description Check connection to Storage
147
+ * @returns {Promise<object>}
148
+ */
149
+ async ping() {
150
+ this.__debug('[ping]');
151
+ if (!this.mailTimeInstance) {
152
+ return {
153
+ status: 'Service Unavailable',
154
+ code: 503,
155
+ statusCode: 503,
156
+ error: new Error('MailTime instance not yet assigned to {mailTimeInstance} of Queue Adapter context'),
157
+ };
158
+ }
159
+
160
+ try {
161
+ const ping = await this.client.ping();
162
+ if (ping === 'PONG') {
163
+ return {
164
+ status: 'OK',
165
+ code: 200,
166
+ statusCode: 200,
167
+ };
168
+ }
169
+ } catch (pingError) {
170
+ return {
171
+ status: 'Internal Server Error',
172
+ code: 500,
173
+ statusCode: 500,
174
+ error: pingError,
175
+ };
176
+ }
177
+
178
+ return {
179
+ status: 'Service Unavailable',
180
+ code: 503,
181
+ statusCode: 503,
182
+ error: new Error('Service Unavailable'),
183
+ };
184
+ }
185
+
186
+ /**
187
+ * @memberOf RedisQueue
188
+ * @name iterate
189
+ * @description iterate over queued tasks passing each to `mailTimeInstance.___dispatch` (the bounded send pool)
190
+ * @param {{ limit?: number, sendingTimeout?: number }} [opts] - iteration options
191
+ * @returns {Promise<void>}
192
+ */
193
+ async iterate(opts) {
194
+ this.__debug('[iterate]', opts);
195
+ try {
196
+ const now = Date.now();
197
+ const sendingTimeout = (opts && typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0)
198
+ ? opts.sendingTimeout
199
+ : 300000;
200
+ const limit = (opts && typeof opts.limit === 'number' && Number.isFinite(opts.limit) && opts.limit > 0)
201
+ ? Math.floor(opts.limit)
202
+ : 0;
203
+ const maxTries = (this.mailTimeInstance && typeof this.mailTimeInstance.maxTries === 'number')
204
+ ? this.mailTimeInstance.maxTries
205
+ : 60;
206
+ let dispatched = 0;
207
+
208
+ const matchPattern = this.__getKey('*', 'sendat');
209
+ const cursor = this.client.scanIterator({
210
+ TYPE: 'string',
211
+ MATCH: matchPattern,
212
+ COUNT: 9999,
213
+ });
214
+
215
+ outer:
216
+ for await (const cursorValue of cursor) {
217
+ const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
218
+ for (const sendatKey of sendatKeys) {
219
+ const raw = await this.client.get(sendatKey);
220
+ if (raw === null || parseInt(raw, 10) > now) {
221
+ continue;
222
+ }
223
+ const uuid = parseUuidFromKey(sendatKey, this.uniqueName);
224
+ if (!uuid) {
225
+ continue;
226
+ }
227
+ const taskJSON = await this.client.get(this.__getKey(uuid));
228
+ if (!taskJSON) {
229
+ continue;
230
+ }
231
+ const candidate = JSON.parse(taskJSON);
232
+ if (!isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
233
+ continue;
234
+ }
235
+ await this.mailTimeInstance.___dispatch(candidate);
236
+ dispatched++;
237
+ if (limit > 0 && dispatched >= limit) {
238
+ break outer;
239
+ }
240
+ }
241
+ }
242
+ } catch (iterateError) {
243
+ logError('[iterate] [for/await] [iterateError]', iterateError);
244
+ }
245
+ }
246
+
247
+ /**
248
+ * @async
249
+ * @memberOf RedisQueue
250
+ * @name getPendingTo
251
+ * @description get queued task by `to` field (addressee)
252
+ * @param to {string} - email address
253
+ * @param sendAt {number} - timestamp
254
+ * @returns {Promise<object|null>}
255
+ */
256
+ async getPendingTo(to, sendAt) {
257
+ this.__debug('[getPendingTo]', to, sendAt);
258
+ if (typeof to !== 'string' || typeof sendAt !== 'number') {
259
+ return null;
260
+ }
261
+
262
+ const concatKey = this.__getKey(to, 'concatletter');
263
+ const uuid = await this.client.get(concatKey);
264
+ if (!uuid) {
265
+ return null;
266
+ }
267
+
268
+ const letterKey = this.__getKey(uuid, 'letter');
269
+ const taskJSON = await this.client.get(letterKey);
270
+ if (!taskJSON) {
271
+ return null;
272
+ }
273
+
274
+ const task = JSON.parse(taskJSON);
275
+ if (!task || task.isSent === true || task.isCancelled === true || task.isFailed === true || task.sendAt > sendAt || task.isSending === true || task.tries >= this.mailTimeInstance.maxTries) {
276
+ return null;
277
+ }
278
+
279
+ return task;
280
+ }
281
+
282
+ /**
283
+ * @async
284
+ * @memberOf RedisQueue
285
+ * @name push
286
+ * @description push task to the queue/storage
287
+ * @param task {object} - task's object
288
+ * @returns {Promise<void 0>}
289
+ */
290
+ async push(task) {
291
+ this.__debug('[push]', task?.uuid);
292
+ if (!task || typeof task !== 'object') {
293
+ return;
294
+ }
295
+
296
+ if (task.sendAt instanceof Date) {
297
+ task.sendAt = +task.sendAt;
298
+ }
299
+
300
+ const letterKey = this.__getKey(task.uuid, 'letter');
301
+ const sendatKey = this.__getKey(task.uuid, 'sendat');
302
+ const taskJSON = JSON.stringify(task);
303
+
304
+ if (typeof this.client.multi === 'function') {
305
+ const multi = this.client.multi();
306
+ multi.set(letterKey, taskJSON);
307
+ multi.set(sendatKey, `${task.sendAt}`);
308
+ if (task.to) {
309
+ multi.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
310
+ PXAT: task.sendAt - 128,
311
+ });
312
+ }
313
+ await multi.exec();
314
+ return;
315
+ }
316
+
317
+ await this.client.set(letterKey, taskJSON);
318
+ await this.client.set(sendatKey, `${task.sendAt}`);
319
+ if (task.to) {
320
+ await this.client.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
321
+ PXAT: task.sendAt - 128,
322
+ });
323
+ }
324
+ }
325
+
326
+ /**
327
+ * @async
328
+ * @memberOf RedisQueue
329
+ * @name cancel
330
+ * @description cancel scheduled email
331
+ * @param uuid {string} - email's uuid
332
+ * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
333
+ */
334
+ async cancel(uuid) {
335
+ this.__debug('[cancel]', uuid);
336
+ if (typeof uuid !== 'string') {
337
+ return false;
338
+ }
339
+
340
+ await this.client.del(this.__getKey(uuid, 'sendat'));
341
+ const letterKey = this.__getKey(uuid, 'letter');
342
+ const taskJSON = await this.client.get(letterKey);
343
+ if (!taskJSON) {
344
+ return false;
345
+ }
346
+
347
+ const task = JSON.parse(taskJSON);
348
+ if (!task || task.isSent === true || task.isCancelled === true) {
349
+ return false;
350
+ }
351
+
352
+ if (!this.mailTimeInstance.keepHistory) {
353
+ return await this.remove(task);
354
+ }
355
+
356
+ return await this.update(task, {
357
+ isCancelled: true,
358
+ });
359
+ }
360
+
361
+ /**
362
+ * @async
363
+ * @memberOf RedisQueue
364
+ * @name remove
365
+ * @description remove task from queue
366
+ * @param task {object} - task's object
367
+ * @param {{ leaseTries: number, leaseSendingAt: number }} [opts] - lease guard: only remove if this worker still holds the lease (tries + sendingAt match, row not cancelled/failed)
368
+ * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
369
+ */
370
+ async remove(task, opts) {
371
+ this.__debug('[remove]', task?.uuid);
372
+ if (!task || typeof task !== 'object' || typeof task.uuid !== 'string') {
373
+ return false;
374
+ }
375
+
376
+ const letterKey = this.__getKey(task.uuid, 'letter');
377
+ if (isSendLeaseRemove(opts)) {
378
+ if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
379
+ return false;
380
+ }
381
+ try {
382
+ await this.client.watch(letterKey);
383
+ const taskJSON = await this.client.get(letterKey);
384
+ if (!taskJSON) {
385
+ await this.client.unwatch?.();
386
+ return false;
387
+ }
388
+ const currentTask = JSON.parse(taskJSON);
389
+ if (currentTask.tries !== opts.leaseTries
390
+ || currentTask.isSending !== true
391
+ || (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) !== opts.leaseSendingAt
392
+ || currentTask.isCancelled === true
393
+ || currentTask.isFailed === true) {
394
+ await this.client.unwatch?.();
395
+ return false;
396
+ }
397
+ const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
398
+ if (task.to) {
399
+ keysToDelete.push(this.__getKey(task.to, 'concatletter'));
400
+ }
401
+ const multi = this.client.multi();
402
+ for (const key of keysToDelete) {
403
+ multi.del(key);
404
+ }
405
+ const result = await multi.exec();
406
+ return result !== null;
407
+ } catch (opError) {
408
+ logError('[remove] [lease] [opError]', opError);
409
+ return false;
410
+ }
411
+ }
412
+
413
+ const exists = await this.client.exists(letterKey);
414
+ if (!exists) {
415
+ return false;
416
+ }
417
+
418
+ const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
419
+ if (task.to) {
420
+ keysToDelete.push(this.__getKey(task.to, 'concatletter'));
421
+ }
422
+ await this.client.del(keysToDelete);
423
+ return true;
424
+ }
425
+
426
+ /**
427
+ * @async
428
+ * @memberOf RedisQueue
429
+ * @name update
430
+ * @description update task in queue
431
+ * @param task {object} - task's object
432
+ * @param updateObj {object} - fields with new values to update
433
+ * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
434
+ */
435
+ async update(task, updateObj) {
436
+ this.__debug('[update]', task?.uuid);
437
+ if (!task || typeof task !== 'object' || typeof task.uuid !== 'string' || !updateObj || typeof updateObj !== 'object') {
438
+ return false;
439
+ }
440
+
441
+ const letterKey = this.__getKey(task.uuid, 'letter');
442
+ const sendatKey = this.__getKey(task.uuid, 'sendat');
443
+ const isClaim = isSendClaimUpdate(updateObj);
444
+ const isAppend = isAppendMailOptionUpdate(updateObj);
445
+ const isLeaseRelease = isSendLeaseGuardedUpdate(updateObj);
446
+ const now = isClaim && typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
447
+ const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
448
+
449
+ try {
450
+ if (isAppend) {
451
+ if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
452
+ if (!RedisQueue.__atomicAppendWarned) {
453
+ RedisQueue.__atomicAppendWarned = true;
454
+ logError('[update] Redis client without watch()/multi() — concat appendMailOption falls back to non-atomic read-modify-write; concurrent folds into the same row may lose letters');
455
+ }
456
+ const taskJSON = await this.client.get(letterKey);
457
+ if (!taskJSON) {
458
+ return false;
459
+ }
460
+ const currentTask = JSON.parse(taskJSON);
461
+ if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
462
+ return false;
463
+ }
464
+ currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
465
+ await this.client.set(letterKey, JSON.stringify(currentTask));
466
+ return true;
467
+ }
468
+
469
+ await this.client.watch(letterKey);
470
+ const taskJSON = await this.client.get(letterKey);
471
+ if (!taskJSON) {
472
+ await this.client.unwatch?.();
473
+ return false;
474
+ }
475
+ const currentTask = JSON.parse(taskJSON);
476
+ if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
477
+ await this.client.unwatch?.();
478
+ return false;
479
+ }
480
+ currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
481
+ const multi = this.client.multi();
482
+ multi.set(letterKey, JSON.stringify(currentTask));
483
+ const result = await multi.exec();
484
+ return result !== null;
485
+ }
486
+
487
+ if ((isClaim || isLeaseRelease) && (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function')) {
488
+ if (isClaim && !RedisQueue.__atomicClaimWarned) {
489
+ RedisQueue.__atomicClaimWarned = true;
490
+ logError('[update] Redis client must support watch() and multi() for atomic send claims');
491
+ }
492
+ return false;
493
+ }
494
+
495
+ if (isClaim || isLeaseRelease) {
496
+ await this.client.watch(letterKey);
497
+ const taskJSON = await this.client.get(letterKey);
498
+ if (!taskJSON) {
499
+ await this.client.unwatch?.();
500
+ return false;
501
+ }
502
+
503
+ const currentTask = JSON.parse(taskJSON);
504
+ if (isClaim && !canClaimTask(currentTask, task, now, sendingTimeout)) {
505
+ await this.client.unwatch?.();
506
+ return false;
507
+ }
508
+ if (isLeaseRelease && !canReleaseLease(currentTask, updateObj)) {
509
+ await this.client.unwatch?.();
510
+ return false;
511
+ }
512
+
513
+ const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
514
+ const multi = this.client.multi();
515
+ multi.set(letterKey, JSON.stringify(updatedTask));
516
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
517
+ multi.del(sendatKey);
518
+ } else if (updatedTask.sendAt) {
519
+ multi.set(sendatKey, `${+updatedTask.sendAt}`);
520
+ }
521
+
522
+ const result = await multi.exec();
523
+ return result !== null;
524
+ }
525
+
526
+ const taskJSON = await this.client.get(letterKey);
527
+ if (!taskJSON) {
528
+ return false;
529
+ }
530
+
531
+ const currentTask = JSON.parse(taskJSON);
532
+ const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
533
+ await this.client.set(letterKey, JSON.stringify(updatedTask));
534
+
535
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
536
+ await this.client.del(sendatKey);
537
+ } else if (updatedTask.sendAt) {
538
+ await this.client.set(sendatKey, `${+updatedTask.sendAt}`);
539
+ }
540
+ return true;
541
+ } catch (opError) {
542
+ logError('[update] [try/catch] [opError]', opError);
543
+ return false;
544
+ }
545
+ }
546
+
547
+ /**
548
+ * @internal
549
+ * @memberOf RedisQueue
550
+ * @name __getKey
551
+ * @description helper to generate scoped key
552
+ * @param uuid {string} - letter's uuid (or "to" address for `concatletter` keys)
553
+ * @param type {string} - "letter" or "sendat" or "concatletter"
554
+ * @returns {string} returns key used by Redis
555
+ */
556
+ __getKey(uuid, type = 'letter') {
557
+ if (!KEY_TYPES.has(type)) {
558
+ throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
559
+ }
560
+ this.__ensurePrefix();
561
+ return `${this.uniqueName}:${type}:${uuid}`;
562
+ }
563
+ }
564
+
565
+ export { RedisQueue };