mail-time 5.0.0 → 5.2.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 +50 -14
- package/adapters/mongo.d.ts +1 -0
- package/adapters/mongo.js +34 -16
- package/adapters/postgres.d.ts +1 -0
- package/adapters/postgres.js +45 -24
- package/adapters/redis.d.ts +24 -2
- package/adapters/redis.js +442 -32
- package/index.cjs +1093 -87
- package/index.d.cts +136 -14
- package/index.d.ts +136 -14
- package/index.js +270 -15
- package/package.json +13 -4
- package/recipient-policy-lease.js +90 -0
- package/recipient-policy.js +166 -0
- package/recipient-rejections.js +62 -0
- package/scripts/migrate-redis-queue-to-cluster.mjs +112 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { hasOwnProp, isPlainObject } from './helpers.js';
|
|
2
|
+
|
|
3
|
+
const policyError = (message) => new Error(`[mail-time] [recipientPolicies] ${message}`);
|
|
4
|
+
const SIMPLE_MAILBOX = /^[^\s<>,;:"()\\\[\]@]+@[^\s<>,;:"()\\\[\]@]+$/u;
|
|
5
|
+
|
|
6
|
+
const normalizePolicyAddress = (value) => {
|
|
7
|
+
const input = typeof value === 'string' ? value : (!Array.isArray(value) && value?.address);
|
|
8
|
+
if (typeof input !== 'string' || /[\r\n]/u.test(input)) {
|
|
9
|
+
throw policyError('recipient requires a simple address without line breaks');
|
|
10
|
+
}
|
|
11
|
+
let address = input.trim();
|
|
12
|
+
if (address.includes('<') || address.includes('>')) {
|
|
13
|
+
const match = address.match(/^[^<>,;:"\r\n]*<([^<>]+)>$/u);
|
|
14
|
+
if (!match) throw policyError('use a simple explicit envelope.to');
|
|
15
|
+
address = match[1].trim();
|
|
16
|
+
}
|
|
17
|
+
if (!SIMPLE_MAILBOX.test(address)) throw policyError('use a simple explicit envelope.to');
|
|
18
|
+
return address.toLowerCase();
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const preparePolicyEnvelope = (compiled, previousResults = []) => {
|
|
22
|
+
const recipients = new Map();
|
|
23
|
+
const explicit = compiled.envelope && hasOwnProp(compiled.envelope, 'to');
|
|
24
|
+
const add = (value, source, authoritative) => {
|
|
25
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
26
|
+
let address;
|
|
27
|
+
try { address = normalizePolicyAddress(entry); }
|
|
28
|
+
catch (error) { if (authoritative) throw error; else continue; }
|
|
29
|
+
if (!recipients.has(address)) {
|
|
30
|
+
if (!authoritative) continue;
|
|
31
|
+
recipients.set(address, { address, sources: [] });
|
|
32
|
+
}
|
|
33
|
+
const sources = recipients.get(address).sources;
|
|
34
|
+
if (!sources.includes(source)) sources.push(source);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
if (explicit) add(compiled.envelope.to, 'envelope', true);
|
|
38
|
+
for (const source of ['to', 'cc', 'bcc']) {
|
|
39
|
+
if (hasOwnProp(compiled, source) && compiled[source] !== void 0) add(compiled[source], source, !explicit);
|
|
40
|
+
}
|
|
41
|
+
if (!recipients.size) throw policyError('envelope must contain at least one recipient');
|
|
42
|
+
if (previousResults.length && (previousResults.length !== recipients.size || previousResults.some((r) => !recipients.has(r.address)))) {
|
|
43
|
+
throw policyError('envelope recipient set changed between attempts');
|
|
44
|
+
}
|
|
45
|
+
const envelope = { to: [...recipients.keys()] };
|
|
46
|
+
if (compiled.envelope && hasOwnProp(compiled.envelope, 'from')) {
|
|
47
|
+
envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from);
|
|
48
|
+
} else {
|
|
49
|
+
const from = compiled.from || compiled.sender || compiled.replyTo;
|
|
50
|
+
if (from) envelope.from = normalizePolicyAddress(from);
|
|
51
|
+
}
|
|
52
|
+
return { envelope, recipients: [...recipients.values()] };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const validateRecipientPolicies = (value, queue) => {
|
|
56
|
+
if (value === void 0) return null;
|
|
57
|
+
if (!Array.isArray(value) || !value.length) throw policyError('recipientPolicies must be a nonempty array');
|
|
58
|
+
if (queue.supportsRecipientPolicies !== true) throw policyError('queue must declare recipient policy support');
|
|
59
|
+
const names = new Set();
|
|
60
|
+
return value.map((provider) => {
|
|
61
|
+
if (!isPlainObject(provider)) throw policyError('each provider must be a plain object');
|
|
62
|
+
const name = typeof provider.name === 'string' ? provider.name.trim() : '';
|
|
63
|
+
if (!name || name.length > 128 || names.has(name)) throw policyError('provider names must be unique and contain 1-128 characters');
|
|
64
|
+
names.add(name);
|
|
65
|
+
const failureMode = provider.failureMode === void 0 ? 'retry' : provider.failureMode;
|
|
66
|
+
if (failureMode !== 'retry' && failureMode !== 'continue') throw policyError('failureMode must be retry or continue');
|
|
67
|
+
const normalized = { name, failureMode };
|
|
68
|
+
let count = 0;
|
|
69
|
+
for (const hook of ['beforeSend', 'classifyRejections', 'observeAttempt']) {
|
|
70
|
+
if (hasOwnProp(provider, hook)) {
|
|
71
|
+
if (typeof provider[hook] !== 'function') throw policyError(`${hook} must be a function`);
|
|
72
|
+
normalized[hook] = provider[hook].bind(provider);
|
|
73
|
+
count++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!count) throw policyError('provider requires at least one supported hook');
|
|
77
|
+
return normalized;
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const validatePolicyResult = (raw, hook, context) => {
|
|
82
|
+
if (raw === void 0) return [];
|
|
83
|
+
if (!isPlainObject(raw)) throw policyError('hook must return a result object or nothing');
|
|
84
|
+
if (raw.decisions === void 0) return [];
|
|
85
|
+
if (!Array.isArray(raw.decisions)) throw policyError('decisions must be an array');
|
|
86
|
+
const batch = new Set(context.recipients.map((r) => r.address));
|
|
87
|
+
const attributable = new Set((context.rejections || []).map((r) => r.address).filter(Boolean));
|
|
88
|
+
const decisions = new Map();
|
|
89
|
+
for (const item of raw.decisions) {
|
|
90
|
+
if (!isPlainObject(item)) throw policyError('decision must be an object');
|
|
91
|
+
const address = normalizePolicyAddress(item.address);
|
|
92
|
+
if (!batch.has(address)) throw policyError('decision address is outside the input batch');
|
|
93
|
+
const allowed = hook === 'beforeSend' ? item.status === 'suppressed' : (item.status === 'retry' || item.status === 'rejected');
|
|
94
|
+
if (!allowed) throw policyError('invalid decision status for this phase');
|
|
95
|
+
if (item.status === 'rejected' && !attributable.has(address)) throw policyError('permanent rejection requires an attributable record');
|
|
96
|
+
if (typeof item.reason !== 'string' || !item.reason.trim() || item.reason.length > 512) throw policyError('reason must contain 1-512 characters');
|
|
97
|
+
const previous = decisions.get(address);
|
|
98
|
+
if (previous && (previous.status !== item.status || previous.reason !== item.reason)) throw policyError('conflicting duplicate decisions');
|
|
99
|
+
decisions.set(address, { address, status: item.status, reason: item.reason });
|
|
100
|
+
}
|
|
101
|
+
return [...decisions.values()];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const evaluatePolicyPhase = async (providers, hook, context, report) => {
|
|
105
|
+
const decisions = [];
|
|
106
|
+
let retryFailure = false;
|
|
107
|
+
for (const provider of providers) {
|
|
108
|
+
if (typeof provider[hook] !== 'function') continue;
|
|
109
|
+
try {
|
|
110
|
+
const validated = validatePolicyResult(await provider[hook](context), hook, context);
|
|
111
|
+
for (const decision of validated) decisions.push({ ...decision, provider: provider.name });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
report(error, provider.name, hook);
|
|
114
|
+
if (provider.failureMode !== 'continue') retryFailure = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { retryFailure, decisions };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const mergePolicyResults = (previous, recipients, evaluated, details) => {
|
|
121
|
+
const results = new Map(previous.map((r) => [r.address, r]));
|
|
122
|
+
const accepted = new Set(details.accepted || []);
|
|
123
|
+
const decisions = new Map();
|
|
124
|
+
for (const decision of evaluated.decisions) {
|
|
125
|
+
if (!decisions.has(decision.address)) decisions.set(decision.address, []);
|
|
126
|
+
decisions.get(decision.address).push(decision);
|
|
127
|
+
}
|
|
128
|
+
const diagnostics = new Map();
|
|
129
|
+
for (const record of details.rejections || []) {
|
|
130
|
+
if (record.address && !diagnostics.has(record.address)) diagnostics.set(record.address, record);
|
|
131
|
+
}
|
|
132
|
+
for (const recipient of recipients) {
|
|
133
|
+
const prior = results.get(recipient.address);
|
|
134
|
+
if (prior && prior.status !== 'error') continue;
|
|
135
|
+
const candidates = decisions.get(recipient.address) || [];
|
|
136
|
+
const terminal = details.phase === 'beforeSend' ? 'suppressed' : 'rejected';
|
|
137
|
+
let status = 'error';
|
|
138
|
+
if (accepted.has(recipient.address)) status = 'sent';
|
|
139
|
+
else if (!evaluated.retryFailure && candidates.some((d) => d.status === terminal)) status = terminal;
|
|
140
|
+
const reasons = [];
|
|
141
|
+
if (!evaluated.retryFailure && status !== 'sent') {
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
if (candidate.status === (status === 'error' ? 'retry' : status)) reasons.push({ provider: candidate.provider, reason: candidate.reason });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const result = { address: recipient.address, status, sources: [...recipient.sources], reasons, attempt: details.attempt, transportIndex: details.transportIndex };
|
|
147
|
+
if (typeof details.transportName === 'string') result.transportName = details.transportName.slice(0, 128);
|
|
148
|
+
const record = diagnostics.get(recipient.address);
|
|
149
|
+
if (record) {
|
|
150
|
+
for (const key of ['command', 'response', 'message']) {
|
|
151
|
+
if (typeof record[key] === 'string') result[key] = record[key].slice(0, 2048);
|
|
152
|
+
}
|
|
153
|
+
if (Number.isFinite(record.responseCode)) result.responseCode = record.responseCode;
|
|
154
|
+
}
|
|
155
|
+
results.set(recipient.address, result);
|
|
156
|
+
}
|
|
157
|
+
return [...results.values()];
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const summarizePolicyTask = (task, isSettled) => {
|
|
161
|
+
const recipients = { sent: [], error: [], suppressed: [], rejected: [] };
|
|
162
|
+
for (const result of task.recipientResults || []) recipients[result.status].push(result);
|
|
163
|
+
return { uuid: task.uuid, tries: task.tries, isSettled, recipients };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export { policyError, normalizePolicyAddress, preparePolicyEnvelope, validateRecipientPolicies, evaluatePolicyPhase, mergePolicyResults, summarizePolicyTask };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { hasOwnProp } from './helpers.js';
|
|
2
|
+
import { normalizePolicyAddress } from './recipient-policy.js';
|
|
3
|
+
|
|
4
|
+
const diagnosticFields = (record) => {
|
|
5
|
+
const result = {};
|
|
6
|
+
for (const key of ['command', 'response', 'message']) {
|
|
7
|
+
if (typeof record?.[key] === 'string') result[key] = record[key].slice(0, 2048);
|
|
8
|
+
}
|
|
9
|
+
if (Number.isFinite(record?.responseCode)) result.responseCode = record.responseCode;
|
|
10
|
+
return result;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const normalizeRejections = (error, info, transport) => {
|
|
14
|
+
const records = [];
|
|
15
|
+
const ancestors = new WeakSet();
|
|
16
|
+
const addressOf = (value) => {
|
|
17
|
+
try { return normalizePolicyAddress(value); }
|
|
18
|
+
catch { return null; }
|
|
19
|
+
};
|
|
20
|
+
const append = (node, address) => {
|
|
21
|
+
const record = { address, ...diagnosticFields(node), transportIndex: transport.index };
|
|
22
|
+
if (typeof transport.name === 'string') record.transportName = transport.name.slice(0, 128);
|
|
23
|
+
if (typeof node === 'string') record.message = node.slice(0, 2048);
|
|
24
|
+
records.push(record);
|
|
25
|
+
};
|
|
26
|
+
const visit = (node, positional = null, root = false, errorRoot = false) => {
|
|
27
|
+
if (node === null || node === void 0) {
|
|
28
|
+
if (!root) append(node, positional);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (typeof node !== 'object') {
|
|
32
|
+
append(node, positional);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (ancestors.has(node)) return;
|
|
36
|
+
ancestors.add(node);
|
|
37
|
+
const start = records.length;
|
|
38
|
+
const rejected = Array.isArray(node.rejected) ? node.rejected.map(addressOf) : [];
|
|
39
|
+
for (const key of ['errors', 'rejectedErrors']) {
|
|
40
|
+
if (Array.isArray(node[key])) {
|
|
41
|
+
for (let i = 0; i < node[key].length; i++) visit(node[key][i], rejected[i] || null);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (records.length === start) {
|
|
45
|
+
let address = positional;
|
|
46
|
+
for (const key of ['recipient', 'address', 'to']) {
|
|
47
|
+
if (hasOwnProp(node, key)) { address = addressOf(node[key]); break; }
|
|
48
|
+
}
|
|
49
|
+
if (!root || errorRoot || address || Object.keys(diagnosticFields(node)).length) append(node, address);
|
|
50
|
+
}
|
|
51
|
+
const attributed = new Set(records.slice(start).map((r) => r.address));
|
|
52
|
+
for (const address of rejected) {
|
|
53
|
+
if (address && !attributed.has(address)) append({ message: 'Recipient rejected by transport' }, address);
|
|
54
|
+
}
|
|
55
|
+
ancestors.delete(node);
|
|
56
|
+
};
|
|
57
|
+
visit(error, null, true, true);
|
|
58
|
+
visit(info, null, true);
|
|
59
|
+
return records;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export { normalizeRejections, diagnosticFields };
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
const disconnect = async (client) => {
|
|
30
|
+
if (typeof client.close === 'function') await client.close();
|
|
31
|
+
else await client.quit();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const scan = async function * (client, pattern) {
|
|
35
|
+
for await (const batch of client.scanIterator({ TYPE: 'string', MATCH: pattern, COUNT: 9999 })) {
|
|
36
|
+
for (const key of (Array.isArray(batch) ? batch : [batch])) {
|
|
37
|
+
yield key;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const nextEligibleAt = (task) => {
|
|
43
|
+
if (task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (task.isSending === true) {
|
|
47
|
+
return (typeof task.sendingAt === 'number' ? task.sendingAt : 0) + sendingTimeout;
|
|
48
|
+
}
|
|
49
|
+
return +task.sendAt;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await target.connect();
|
|
54
|
+
const trackedConcatKeys = await target.zRange(concatKeysKey, 0, -1);
|
|
55
|
+
for (const key of trackedConcatKeys) {
|
|
56
|
+
if (await target.pTTL(key) === -2) {
|
|
57
|
+
await target.zRem(concatKeysKey, key);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const targetHasData = (await target.hLen(lettersKey)) > 0
|
|
61
|
+
|| (await target.zCard(scheduleKey)) > 0
|
|
62
|
+
|| (await target.zCard(concatKeysKey)) > 0;
|
|
63
|
+
if (targetHasData && !overwrite) {
|
|
64
|
+
throw new Error(`Target queue "${taggedName}" is not empty. Pass --overwrite only after verifying its contents can be replaced.`);
|
|
65
|
+
}
|
|
66
|
+
if (targetHasData) {
|
|
67
|
+
const concatKeys = await target.zRange(concatKeysKey, 0, -1);
|
|
68
|
+
if (concatKeys.length) {
|
|
69
|
+
await target.del(concatKeys);
|
|
70
|
+
}
|
|
71
|
+
await target.del([lettersKey, scheduleKey, concatKeysKey]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let migrated = 0;
|
|
75
|
+
for await (const key of scan(source, `${legacyName}:letter:*`)) {
|
|
76
|
+
const payload = await source.get(key);
|
|
77
|
+
if (!payload) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const task = JSON.parse(payload);
|
|
81
|
+
if (!task || typeof task.uuid !== 'string') {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
await target.hSet(lettersKey, task.uuid, payload);
|
|
85
|
+
const eligibleAt = nextEligibleAt(task);
|
|
86
|
+
if (eligibleAt !== null && Number.isFinite(eligibleAt)) {
|
|
87
|
+
await target.zAdd(scheduleKey, { score: eligibleAt, value: task.uuid });
|
|
88
|
+
}
|
|
89
|
+
migrated++;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for await (const key of scan(source, `${legacyName}:concatletter:*`)) {
|
|
93
|
+
const uuid = await source.get(key);
|
|
94
|
+
if (!uuid) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const ttl = await source.pTTL(key);
|
|
98
|
+
const to = key.slice(`${legacyName}:concatletter:`.length);
|
|
99
|
+
const targetKey = `${taggedName}:concatletter:${to}`;
|
|
100
|
+
if (ttl > 0) {
|
|
101
|
+
await target.set(targetKey, uuid, { PX: ttl });
|
|
102
|
+
await target.zAdd(concatKeysKey, { score: Date.now() + ttl, value: targetKey });
|
|
103
|
+
} else if (ttl === -1) {
|
|
104
|
+
await target.set(targetKey, uuid);
|
|
105
|
+
await target.zAdd(concatKeysKey, { score: Number.MAX_SAFE_INTEGER, value: targetKey });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.info(`Migrated ${migrated} RedisQueue tasks from "${legacyName}" to "${taggedName}".`);
|
|
110
|
+
} finally {
|
|
111
|
+
await Promise.all([disconnect(source), disconnect(target)]);
|
|
112
|
+
}
|