mail-time 4.1.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -19
- package/adapters/redis.d.ts +19 -1
- package/adapters/redis.js +337 -8
- package/helpers.js +19 -0
- package/index.cjs +778 -127
- package/index.d.cts +50 -7
- package/index.d.ts +50 -7
- package/index.js +423 -118
- package/package.json +21 -4
- package/presets.js +2 -2
- package/scripts/migrate-redis-queue-to-cluster.mjs +108 -0
package/package.json
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mail-time",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "📮 Email queue extending nodemailer with multi-SMTP transports and horizontally scaled apps support",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
|
7
7
|
"type": "module",
|
|
8
|
+
"files": [
|
|
9
|
+
"adapters/mongo.d.ts",
|
|
10
|
+
"adapters/mongo.js",
|
|
11
|
+
"adapters/postgres.d.ts",
|
|
12
|
+
"adapters/postgres.js",
|
|
13
|
+
"adapters/redis.d.ts",
|
|
14
|
+
"adapters/redis.js",
|
|
15
|
+
"helpers.js",
|
|
16
|
+
"index.cjs",
|
|
17
|
+
"index.d.cts",
|
|
18
|
+
"index.d.ts",
|
|
19
|
+
"index.js",
|
|
20
|
+
"presets.d.ts",
|
|
21
|
+
"presets.js",
|
|
22
|
+
"scripts/migrate-redis-queue-to-cluster.mjs"
|
|
23
|
+
],
|
|
8
24
|
"exports": {
|
|
9
25
|
".": {
|
|
10
26
|
"import": {
|
|
@@ -48,6 +64,7 @@
|
|
|
48
64
|
"test:jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.mjs",
|
|
49
65
|
"test:mocha": "mocha ./test/npm.js",
|
|
50
66
|
"test:mocha:redis": "mocha ./test/npm-redis-redis.js",
|
|
67
|
+
"test:mocha:redis-cluster": "mocha ./test/npm-redis-cluster.js",
|
|
51
68
|
"test:mocha:mongo": "mocha ./test/npm-redis-mongo.js ./test/npm-mongo-redis.js ./test/npm-ha-mongo-redis.js",
|
|
52
69
|
"test:mocha:postgres": "mocha ./test/npm-postgres.js",
|
|
53
70
|
"test:types": "tsc -p ./tsconfig.types.json",
|
|
@@ -125,16 +142,16 @@
|
|
|
125
142
|
}
|
|
126
143
|
},
|
|
127
144
|
"dependencies": {
|
|
128
|
-
"josk": "^6.
|
|
145
|
+
"josk": "^6.3.0"
|
|
129
146
|
},
|
|
130
147
|
"devDependencies": {
|
|
131
148
|
"@jest/globals": "^30.4.1",
|
|
132
149
|
"@types/node": "^25.9.0",
|
|
133
150
|
"chai": "^6.2.2",
|
|
134
151
|
"jest": "^30.4.2",
|
|
135
|
-
"mocha": "^11.
|
|
152
|
+
"mocha": "^11.8.0",
|
|
136
153
|
"mongodb": "^7.2.0",
|
|
137
|
-
"nodemailer": "^
|
|
154
|
+
"nodemailer": "^9.0.5",
|
|
138
155
|
"pg": "^8.21.0",
|
|
139
156
|
"redis": "^5.12.1",
|
|
140
157
|
"rollup": "^4.60.4",
|
package/presets.js
CHANGED
|
@@ -73,7 +73,7 @@ const PRESETS = Object.freeze({
|
|
|
73
73
|
retries: 5,
|
|
74
74
|
retryDelay: 2000,
|
|
75
75
|
revolvingInterval: 1024,
|
|
76
|
-
sendingTimeout:
|
|
76
|
+
sendingTimeout: 120_000,
|
|
77
77
|
mode: 'batch',
|
|
78
78
|
concurrency: 4,
|
|
79
79
|
onError: defaultPresetOnError,
|
|
@@ -126,7 +126,7 @@ const PRESETS = Object.freeze({
|
|
|
126
126
|
retries: 20,
|
|
127
127
|
retryDelay: 5000,
|
|
128
128
|
revolvingInterval: 1024,
|
|
129
|
-
sendingTimeout:
|
|
129
|
+
sendingTimeout: 120_000,
|
|
130
130
|
mode: 'batch',
|
|
131
131
|
concurrency: 2,
|
|
132
132
|
onError: defaultPresetOnError,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createClient, createCluster } from 'redis';
|
|
2
|
+
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
const readArg = (name) => {
|
|
5
|
+
const index = args.indexOf(name);
|
|
6
|
+
return index === -1 ? void 0 : args[index + 1];
|
|
7
|
+
};
|
|
8
|
+
const sourceUrl = readArg('--source');
|
|
9
|
+
const targetUrl = readArg('--target');
|
|
10
|
+
const prefix = readArg('--prefix');
|
|
11
|
+
const sendingTimeout = Number(readArg('--sending-timeout') || 300000);
|
|
12
|
+
const overwrite = args.includes('--overwrite');
|
|
13
|
+
|
|
14
|
+
if (!sourceUrl || !targetUrl || !prefix || !Number.isFinite(sendingTimeout) || sendingTimeout <= 0) {
|
|
15
|
+
throw new Error('Usage: node scripts/migrate-redis-queue-to-cluster.mjs --source <redis-url> --target <cluster-url> --prefix <prefix> [--sending-timeout <ms>] [--overwrite]');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!/^[A-Za-z0-9_\-:.]+$/.test(prefix)) {
|
|
19
|
+
throw new Error(`{prefix} must match /^[A-Za-z0-9_\\-:.]+$/ (received: "${prefix}")`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const legacyName = `mailtime:${prefix}`;
|
|
23
|
+
const taggedName = `mailtime:{${prefix}}`;
|
|
24
|
+
const lettersKey = `${taggedName}:letters`;
|
|
25
|
+
const scheduleKey = `${taggedName}:schedule`;
|
|
26
|
+
const concatKeysKey = `${taggedName}:concatkeys`;
|
|
27
|
+
const source = await createClient({ url: sourceUrl }).connect();
|
|
28
|
+
const target = createCluster({ rootNodes: [{ url: targetUrl }] });
|
|
29
|
+
|
|
30
|
+
const scan = async function * (client, pattern) {
|
|
31
|
+
for await (const batch of client.scanIterator({ TYPE: 'string', MATCH: pattern, COUNT: 9999 })) {
|
|
32
|
+
for (const key of (Array.isArray(batch) ? batch : [batch])) {
|
|
33
|
+
yield key;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const nextEligibleAt = (task) => {
|
|
39
|
+
if (task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (task.isSending === true) {
|
|
43
|
+
return (typeof task.sendingAt === 'number' ? task.sendingAt : 0) + sendingTimeout;
|
|
44
|
+
}
|
|
45
|
+
return +task.sendAt;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await target.connect();
|
|
50
|
+
const trackedConcatKeys = await target.zRange(concatKeysKey, 0, -1);
|
|
51
|
+
for (const key of trackedConcatKeys) {
|
|
52
|
+
if (await target.pTTL(key) === -2) {
|
|
53
|
+
await target.zRem(concatKeysKey, key);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const targetHasData = (await target.hLen(lettersKey)) > 0
|
|
57
|
+
|| (await target.zCard(scheduleKey)) > 0
|
|
58
|
+
|| (await target.zCard(concatKeysKey)) > 0;
|
|
59
|
+
if (targetHasData && !overwrite) {
|
|
60
|
+
throw new Error(`Target queue "${taggedName}" is not empty. Pass --overwrite only after verifying its contents can be replaced.`);
|
|
61
|
+
}
|
|
62
|
+
if (targetHasData) {
|
|
63
|
+
const concatKeys = await target.zRange(concatKeysKey, 0, -1);
|
|
64
|
+
if (concatKeys.length) {
|
|
65
|
+
await target.del(concatKeys);
|
|
66
|
+
}
|
|
67
|
+
await target.del([lettersKey, scheduleKey, concatKeysKey]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let migrated = 0;
|
|
71
|
+
for await (const key of scan(source, `${legacyName}:letter:*`)) {
|
|
72
|
+
const payload = await source.get(key);
|
|
73
|
+
if (!payload) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const task = JSON.parse(payload);
|
|
77
|
+
if (!task || typeof task.uuid !== 'string') {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
await target.hSet(lettersKey, task.uuid, payload);
|
|
81
|
+
const eligibleAt = nextEligibleAt(task);
|
|
82
|
+
if (eligibleAt !== null && Number.isFinite(eligibleAt)) {
|
|
83
|
+
await target.zAdd(scheduleKey, { score: eligibleAt, value: task.uuid });
|
|
84
|
+
}
|
|
85
|
+
migrated++;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for await (const key of scan(source, `${legacyName}:concatletter:*`)) {
|
|
89
|
+
const uuid = await source.get(key);
|
|
90
|
+
if (!uuid) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const ttl = await source.pTTL(key);
|
|
94
|
+
const to = key.slice(`${legacyName}:concatletter:`.length);
|
|
95
|
+
const targetKey = `${taggedName}:concatletter:${to}`;
|
|
96
|
+
if (ttl > 0) {
|
|
97
|
+
await target.set(targetKey, uuid, { PX: ttl });
|
|
98
|
+
await target.zAdd(concatKeysKey, { score: Date.now() + ttl, value: targetKey });
|
|
99
|
+
} else if (ttl === -1) {
|
|
100
|
+
await target.set(targetKey, uuid);
|
|
101
|
+
await target.zAdd(concatKeysKey, { score: Number.MAX_SAFE_INTEGER, value: targetKey });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.info(`Migrated ${migrated} RedisQueue tasks from "${legacyName}" to "${taggedName}".`);
|
|
106
|
+
} finally {
|
|
107
|
+
await Promise.all([source.close(), target.close()]);
|
|
108
|
+
}
|