emailengine-app 2.61.1 → 2.61.3
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/CHANGELOG.md +17 -0
- package/data/google-crawlers.json +1 -1
- package/lib/account/account-state.js +248 -0
- package/lib/account.js +45 -193
- package/lib/api-routes/account-routes.js +1023 -0
- package/lib/api-routes/message-routes.js +1377 -0
- package/lib/consts.js +12 -2
- package/lib/email-client/base-client.js +282 -771
- package/lib/email-client/gmail/gmail-api.js +243 -0
- package/lib/email-client/gmail-client.js +145 -53
- package/lib/email-client/imap/mailbox.js +24 -698
- package/lib/email-client/imap/sync-operations.js +812 -0
- package/lib/email-client/imap-client.js +1 -1
- package/lib/email-client/message-builder.js +566 -0
- package/lib/email-client/notification-handler.js +314 -0
- package/lib/email-client/outlook/graph-api.js +326 -0
- package/lib/email-client/outlook-client.js +159 -113
- package/lib/email-client/smtp-pool-manager.js +196 -0
- package/lib/imapproxy/imap-server.js +3 -12
- package/lib/oauth/gmail.js +4 -4
- package/lib/oauth/mail-ru.js +30 -5
- package/lib/oauth/outlook.js +57 -3
- package/lib/oauth/pubsub/google.js +30 -11
- package/lib/oauth/scope-checker.js +202 -0
- package/lib/oauth2-apps.js +8 -4
- package/lib/redis-operations.js +484 -0
- package/lib/routes-ui.js +283 -2582
- package/lib/tools.js +4 -196
- package/lib/ui-routes/account-routes.js +1931 -0
- package/lib/ui-routes/admin-config-routes.js +1233 -0
- package/lib/ui-routes/admin-entities-routes.js +2367 -0
- package/lib/ui-routes/oauth-routes.js +992 -0
- package/lib/utils/network.js +237 -0
- package/package.json +10 -10
- package/sbom.json +1 -1
- package/static/js/app.js +5 -5
- package/static/licenses.html +79 -19
- package/translations/de.mo +0 -0
- package/translations/de.po +97 -86
- package/translations/en.mo +0 -0
- package/translations/en.po +80 -75
- package/translations/et.mo +0 -0
- package/translations/et.po +96 -86
- package/translations/fr.mo +0 -0
- package/translations/fr.po +97 -86
- package/translations/ja.mo +0 -0
- package/translations/ja.po +96 -86
- package/translations/messages.pot +105 -91
- package/translations/nl.mo +0 -0
- package/translations/nl.po +98 -86
- package/translations/pl.mo +0 -0
- package/translations/pl.po +96 -86
- package/views/account/security.hbs +4 -4
- package/views/accounts/account.hbs +13 -13
- package/views/accounts/register/imap-server.hbs +12 -12
- package/views/config/document-store/pre-processing/index.hbs +4 -2
- package/views/config/oauth/app.hbs +6 -7
- package/views/config/oauth/index.hbs +2 -2
- package/views/config/service.hbs +3 -4
- package/views/dashboard.hbs +5 -7
- package/views/error.hbs +22 -7
- package/views/gateways/gateway.hbs +2 -2
- package/views/partials/add_account_modal.hbs +7 -10
- package/views/partials/document_store_header.hbs +1 -1
- package/views/partials/editor_scope_info.hbs +0 -1
- package/views/partials/oauth_config_header.hbs +1 -1
- package/views/partials/side_menu.hbs +3 -3
- package/views/partials/webhook_form.hbs +2 -2
- package/views/templates/index.hbs +1 -1
- package/views/templates/template.hbs +8 -8
- package/views/tokens/index.hbs +6 -6
- package/views/tokens/new.hbs +1 -1
- package/views/webhooks/index.hbs +4 -4
- package/views/webhooks/webhook.hbs +7 -7
- package/workers/api.js +148 -2436
- package/workers/smtp.js +2 -1
- package/lib/imapproxy/imap-core/test/client.js +0 -46
- package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
- package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
- package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
- package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
- package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
- package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
- package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
- package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
- package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
- package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
- package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
- package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
- package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
- package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
- package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
- package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
- package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
- package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
- package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
- package/lib/imapproxy/imap-core/test/test-client.js +0 -152
- package/lib/imapproxy/imap-core/test/test-server.js +0 -623
- package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
- package/test/api-test.js +0 -899
- package/test/autoreply-test.js +0 -327
- package/test/bounce-test.js +0 -151
- package/test/complaint-test.js +0 -256
- package/test/fixtures/autoreply/LICENSE +0 -27
- package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
- package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
- package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
- package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
- package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
- package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
- package/test/fixtures/bounces/163.eml +0 -2521
- package/test/fixtures/bounces/fastmail.eml +0 -242
- package/test/fixtures/bounces/gmail.eml +0 -252
- package/test/fixtures/bounces/hotmail.eml +0 -655
- package/test/fixtures/bounces/mailru.eml +0 -121
- package/test/fixtures/bounces/outlook.eml +0 -1107
- package/test/fixtures/bounces/postfix.eml +0 -101
- package/test/fixtures/bounces/rambler.eml +0 -116
- package/test/fixtures/bounces/workmail.eml +0 -142
- package/test/fixtures/bounces/yahoo.eml +0 -139
- package/test/fixtures/bounces/zoho.eml +0 -83
- package/test/fixtures/bounces/zonemta.eml +0 -100
- package/test/fixtures/complaints/LICENSE +0 -27
- package/test/fixtures/complaints/amazonses.eml +0 -72
- package/test/fixtures/complaints/dmarc.eml +0 -59
- package/test/fixtures/complaints/hotmail.eml +0 -49
- package/test/fixtures/complaints/optout.eml +0 -40
- package/test/fixtures/complaints/standard-arf.eml +0 -68
- package/test/fixtures/complaints/yahoo.eml +0 -68
- package/test/oauth2-apps-test.js +0 -301
- package/test/sendonly-test.js +0 -160
- package/test/test-config.js +0 -34
- package/test/webhooks-server.js +0 -39
package/lib/oauth2-apps.js
CHANGED
|
@@ -1192,7 +1192,8 @@ class OAuth2AppsHandler {
|
|
|
1192
1192
|
await this.setMeta(id, { authFlag: flag });
|
|
1193
1193
|
}
|
|
1194
1194
|
} catch (err) {
|
|
1195
|
-
//
|
|
1195
|
+
// Log but don't throw - flag setting is non-critical
|
|
1196
|
+
logger.error({ msg: 'Failed to set OAuth flag', provider: 'gmail', err });
|
|
1196
1197
|
}
|
|
1197
1198
|
}
|
|
1198
1199
|
},
|
|
@@ -1234,7 +1235,8 @@ class OAuth2AppsHandler {
|
|
|
1234
1235
|
await this.setMeta(id, { authFlag: flag });
|
|
1235
1236
|
}
|
|
1236
1237
|
} catch (err) {
|
|
1237
|
-
//
|
|
1238
|
+
// Log but don't throw - flag setting is non-critical
|
|
1239
|
+
logger.error({ msg: 'Failed to set OAuth flag', provider: 'gmailService', err });
|
|
1238
1240
|
}
|
|
1239
1241
|
}
|
|
1240
1242
|
},
|
|
@@ -1275,7 +1277,8 @@ class OAuth2AppsHandler {
|
|
|
1275
1277
|
await this.setMeta(id, { authFlag: flag });
|
|
1276
1278
|
}
|
|
1277
1279
|
} catch (err) {
|
|
1278
|
-
//
|
|
1280
|
+
// Log but don't throw - flag setting is non-critical
|
|
1281
|
+
logger.error({ msg: 'Failed to set OAuth flag', provider: 'outlook', err });
|
|
1279
1282
|
}
|
|
1280
1283
|
}
|
|
1281
1284
|
},
|
|
@@ -1310,7 +1313,8 @@ class OAuth2AppsHandler {
|
|
|
1310
1313
|
await this.setMeta(id, { authFlag: flag });
|
|
1311
1314
|
}
|
|
1312
1315
|
} catch (err) {
|
|
1313
|
-
//
|
|
1316
|
+
// Log but don't throw - flag setting is non-critical
|
|
1317
|
+
logger.error({ msg: 'Failed to set OAuth flag', provider: 'mailRu', err });
|
|
1314
1318
|
}
|
|
1315
1319
|
}
|
|
1316
1320
|
},
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const logger = require('./logger');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default retry configuration for Redis operations
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_RETRY_OPTIONS = {
|
|
9
|
+
maxAttempts: 3,
|
|
10
|
+
baseDelay: 100, // 100ms base delay
|
|
11
|
+
maxDelay: 2000 // 2 second max delay
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Transient error codes that warrant a retry
|
|
16
|
+
*/
|
|
17
|
+
const TRANSIENT_ERROR_CODES = new Set(['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE', 'EHOSTUNREACH', 'ECONNREFUSED']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Checks if an error is transient and should be retried
|
|
21
|
+
* @param {Error} err - The error to check
|
|
22
|
+
* @returns {boolean} True if the error is transient
|
|
23
|
+
*/
|
|
24
|
+
function isTransientError(err) {
|
|
25
|
+
if (!err) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check for known transient error codes
|
|
30
|
+
if (err.code && TRANSIENT_ERROR_CODES.has(err.code)) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check for Redis connection errors
|
|
35
|
+
if (err.name === 'ReplyError' && /LOADING|BUSY|READONLY|CLUSTERDOWN/.test(err.message)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check for connection lost errors
|
|
40
|
+
if (err.message && /connection.*lost|connection.*closed|socket.*closed/i.test(err.message)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Calculates delay for exponential backoff
|
|
49
|
+
* @param {number} attempt - Current attempt number (1-based)
|
|
50
|
+
* @param {number} baseDelay - Base delay in milliseconds
|
|
51
|
+
* @param {number} maxDelay - Maximum delay in milliseconds
|
|
52
|
+
* @returns {number} Delay in milliseconds
|
|
53
|
+
*/
|
|
54
|
+
function calculateBackoffDelay(attempt, baseDelay, maxDelay) {
|
|
55
|
+
// Exponential backoff with jitter
|
|
56
|
+
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
|
|
57
|
+
const jitter = Math.random() * 0.1 * exponentialDelay; // 10% jitter
|
|
58
|
+
return Math.min(exponentialDelay + jitter, maxDelay);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sleeps for the specified duration
|
|
63
|
+
* @param {number} ms - Duration in milliseconds
|
|
64
|
+
* @returns {Promise<void>}
|
|
65
|
+
*/
|
|
66
|
+
function sleep(ms) {
|
|
67
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extracts errors from Redis multi/exec results.
|
|
72
|
+
*
|
|
73
|
+
* ioredis multi/exec returns results as an array of [error, value] tuples,
|
|
74
|
+
* where each tuple corresponds to a command in the transaction:
|
|
75
|
+
* [[null, 'OK'], [null, 1], [Error, null]]
|
|
76
|
+
*
|
|
77
|
+
* This function scans through the results and returns the first error found.
|
|
78
|
+
*
|
|
79
|
+
* @param {Array<[Error|null, any]>} results - Results from exec(), array of [error, value] tuples
|
|
80
|
+
* @returns {Error|null} First error found, or null if no errors
|
|
81
|
+
*/
|
|
82
|
+
function extractMultiError(results) {
|
|
83
|
+
if (!Array.isArray(results)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const result of results) {
|
|
88
|
+
if (Array.isArray(result) && result[0]) {
|
|
89
|
+
return result[0];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extracts values from Redis multi/exec results.
|
|
98
|
+
*
|
|
99
|
+
* ioredis multi/exec returns results as an array of [error, value] tuples:
|
|
100
|
+
* [[null, 'OK'], [null, 1], [null, 'value']]
|
|
101
|
+
*
|
|
102
|
+
* This function extracts just the values (second element of each tuple),
|
|
103
|
+
* returning them in the same order as the original commands.
|
|
104
|
+
*
|
|
105
|
+
* Also handles non-tuple results for compatibility with mock Redis clients
|
|
106
|
+
* or other edge cases where results may not be in tuple format.
|
|
107
|
+
*
|
|
108
|
+
* @param {Array<[Error|null, any]|any>} results - Results from exec()
|
|
109
|
+
* @returns {Array<any>} Array of values extracted from tuples
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* // Standard ioredis results
|
|
113
|
+
* extractMultiValues([[null, 'OK'], [null, 42]]) // => ['OK', 42]
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* // Mixed format (some mocks return plain values)
|
|
117
|
+
* extractMultiValues([[null, 'value'], 'plain']) // => ['value', 'plain']
|
|
118
|
+
*/
|
|
119
|
+
function extractMultiValues(results) {
|
|
120
|
+
if (!Array.isArray(results)) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return results.map(result => {
|
|
125
|
+
if (Array.isArray(result)) {
|
|
126
|
+
return result[1];
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Wraps Redis multi/exec operations with consistent error handling and retry logic
|
|
134
|
+
*/
|
|
135
|
+
class RedisTransaction {
|
|
136
|
+
/**
|
|
137
|
+
* Creates a new RedisTransaction
|
|
138
|
+
* @param {Object} redis - ioredis client instance
|
|
139
|
+
* @param {Object} options - Configuration options
|
|
140
|
+
* @param {Object} [options.logger] - Logger instance
|
|
141
|
+
* @param {number} [options.maxAttempts] - Maximum retry attempts
|
|
142
|
+
* @param {number} [options.baseDelay] - Base delay for retries in ms
|
|
143
|
+
* @param {number} [options.maxDelay] - Maximum delay for retries in ms
|
|
144
|
+
*/
|
|
145
|
+
constructor(redis, options = {}) {
|
|
146
|
+
this.redis = redis;
|
|
147
|
+
this.logger = options.logger || logger;
|
|
148
|
+
this.retryOptions = {
|
|
149
|
+
maxAttempts: options.maxAttempts || DEFAULT_RETRY_OPTIONS.maxAttempts,
|
|
150
|
+
baseDelay: options.baseDelay || DEFAULT_RETRY_OPTIONS.baseDelay,
|
|
151
|
+
maxDelay: options.maxDelay || DEFAULT_RETRY_OPTIONS.maxDelay
|
|
152
|
+
};
|
|
153
|
+
this.commands = [];
|
|
154
|
+
this.commandNames = [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Adds a command to the transaction
|
|
159
|
+
* @param {string} command - Redis command name
|
|
160
|
+
* @param {...any} args - Command arguments
|
|
161
|
+
* @returns {RedisTransaction} this instance for chaining
|
|
162
|
+
*/
|
|
163
|
+
add(command, ...args) {
|
|
164
|
+
this.commands.push({ command, args });
|
|
165
|
+
this.commandNames.push(command);
|
|
166
|
+
return this;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Executes the transaction with retry logic
|
|
171
|
+
* @returns {Promise<Object>} Object containing results array and any error
|
|
172
|
+
*/
|
|
173
|
+
async exec() {
|
|
174
|
+
const { maxAttempts, baseDelay, maxDelay } = this.retryOptions;
|
|
175
|
+
|
|
176
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
177
|
+
try {
|
|
178
|
+
const multi = this.redis.multi();
|
|
179
|
+
|
|
180
|
+
// Add all commands to the multi
|
|
181
|
+
for (const { command, args } of this.commands) {
|
|
182
|
+
multi[command](...args);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const results = await multi.exec();
|
|
186
|
+
const error = extractMultiError(results);
|
|
187
|
+
|
|
188
|
+
if (error && isTransientError(error) && attempt < maxAttempts) {
|
|
189
|
+
const delay = calculateBackoffDelay(attempt, baseDelay, maxDelay);
|
|
190
|
+
this.logger.warn({
|
|
191
|
+
msg: 'Redis transaction encountered transient error, retrying',
|
|
192
|
+
attempt,
|
|
193
|
+
maxAttempts,
|
|
194
|
+
delay,
|
|
195
|
+
error: error.message,
|
|
196
|
+
commands: this.commandNames
|
|
197
|
+
});
|
|
198
|
+
await sleep(delay);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
results,
|
|
204
|
+
values: extractMultiValues(results),
|
|
205
|
+
error
|
|
206
|
+
};
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (isTransientError(err) && attempt < maxAttempts) {
|
|
209
|
+
const delay = calculateBackoffDelay(attempt, baseDelay, maxDelay);
|
|
210
|
+
this.logger.warn({
|
|
211
|
+
msg: 'Redis transaction failed with transient error, retrying',
|
|
212
|
+
attempt,
|
|
213
|
+
maxAttempts,
|
|
214
|
+
delay,
|
|
215
|
+
error: err.message,
|
|
216
|
+
commands: this.commandNames
|
|
217
|
+
});
|
|
218
|
+
await sleep(delay);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Executes the transaction and throws if any command failed
|
|
228
|
+
* @returns {Promise<Array>} Array of values from successful commands
|
|
229
|
+
*/
|
|
230
|
+
async execOrThrow() {
|
|
231
|
+
const { results, values, error } = await this.exec();
|
|
232
|
+
|
|
233
|
+
if (error) {
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return values;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Executes an atomic update operation with retry logic
|
|
243
|
+
* Sets multiple hash fields in a single transaction
|
|
244
|
+
*
|
|
245
|
+
* @param {Object} redis - ioredis client instance
|
|
246
|
+
* @param {string} key - Redis hash key
|
|
247
|
+
* @param {Object} fields - Object of field-value pairs to set
|
|
248
|
+
* @param {Object} options - Configuration options
|
|
249
|
+
* @param {Object} [options.logger] - Logger instance
|
|
250
|
+
* @param {number} [options.expireSeconds] - Optional TTL in seconds
|
|
251
|
+
* @returns {Promise<Object>} Result object with success status
|
|
252
|
+
*/
|
|
253
|
+
async function atomicUpdate(redis, key, fields, options = {}) {
|
|
254
|
+
const txn = new RedisTransaction(redis, options);
|
|
255
|
+
|
|
256
|
+
if (Object.keys(fields).length > 0) {
|
|
257
|
+
txn.add('hmset', key, fields);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (options.expireSeconds) {
|
|
261
|
+
txn.add('expire', key, options.expireSeconds);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const { error, values } = await txn.exec();
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
success: !error,
|
|
268
|
+
error,
|
|
269
|
+
values
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Performs a batch get operation for multiple keys
|
|
275
|
+
*
|
|
276
|
+
* @param {Object} redis - ioredis client instance
|
|
277
|
+
* @param {Array<string>} keys - Array of Redis keys to get
|
|
278
|
+
* @param {Object} options - Configuration options
|
|
279
|
+
* @param {string} [options.type='get'] - Type of get operation ('get', 'hgetall', 'smembers')
|
|
280
|
+
* @returns {Promise<Array>} Array of values
|
|
281
|
+
*/
|
|
282
|
+
async function batchGet(redis, keys, options = {}) {
|
|
283
|
+
if (!keys.length) {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const type = options.type || 'get';
|
|
288
|
+
const txn = new RedisTransaction(redis, options);
|
|
289
|
+
|
|
290
|
+
for (const key of keys) {
|
|
291
|
+
txn.add(type, key);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return txn.execOrThrow();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Performs a batch set operation for multiple key-value pairs
|
|
299
|
+
*
|
|
300
|
+
* @param {Object} redis - ioredis client instance
|
|
301
|
+
* @param {Array<{key: string, value: any}>} items - Array of key-value pairs
|
|
302
|
+
* @param {Object} options - Configuration options
|
|
303
|
+
* @param {number} [options.expireSeconds] - Optional TTL in seconds for all keys
|
|
304
|
+
* @returns {Promise<Object>} Result object with success status
|
|
305
|
+
*/
|
|
306
|
+
async function batchSet(redis, items, options = {}) {
|
|
307
|
+
if (!items.length) {
|
|
308
|
+
return { success: true, values: [] };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const txn = new RedisTransaction(redis, options);
|
|
312
|
+
|
|
313
|
+
for (const { key, value } of items) {
|
|
314
|
+
txn.add('set', key, value);
|
|
315
|
+
if (options.expireSeconds) {
|
|
316
|
+
txn.add('expire', key, options.expireSeconds);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const { error, values } = await txn.exec();
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
success: !error,
|
|
324
|
+
error,
|
|
325
|
+
values
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Performs a conditional set operation (only if key exists)
|
|
331
|
+
* Uses hSetExists custom command if available, falls back to check-then-set
|
|
332
|
+
*
|
|
333
|
+
* @param {Object} redis - ioredis client instance
|
|
334
|
+
* @param {string} key - Redis hash key
|
|
335
|
+
* @param {string} field - Hash field name
|
|
336
|
+
* @param {any} value - Value to set
|
|
337
|
+
* @param {Object} options - Configuration options
|
|
338
|
+
* @returns {Promise<Object>} Result with success status and whether value was set
|
|
339
|
+
*/
|
|
340
|
+
async function conditionalSet(redis, key, field, value, options = {}) {
|
|
341
|
+
// Check if hSetExists is available (custom Lua command)
|
|
342
|
+
if (typeof redis.hSetExists === 'function') {
|
|
343
|
+
const txn = new RedisTransaction(redis, options);
|
|
344
|
+
txn.add('hSetExists', key, field, value);
|
|
345
|
+
const { error, values } = await txn.exec();
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
success: !error,
|
|
349
|
+
error,
|
|
350
|
+
wasSet: values[0] === 1
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Fallback: use HSETNX behavior with existence check
|
|
355
|
+
const exists = await redis.exists(key);
|
|
356
|
+
if (!exists) {
|
|
357
|
+
return {
|
|
358
|
+
success: true,
|
|
359
|
+
wasSet: false
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await redis.hset(key, field, value);
|
|
364
|
+
return {
|
|
365
|
+
success: true,
|
|
366
|
+
wasSet: true
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Executes a rate-limited increment operation atomically
|
|
372
|
+
*
|
|
373
|
+
* @param {Object} redis - ioredis client instance
|
|
374
|
+
* @param {string} key - Redis key
|
|
375
|
+
* @param {number} count - Amount to increment by
|
|
376
|
+
* @param {number} expireSeconds - TTL for the key in seconds
|
|
377
|
+
* @param {Object} options - Configuration options
|
|
378
|
+
* @returns {Promise<Object>} Result with new value and success status
|
|
379
|
+
*/
|
|
380
|
+
async function atomicIncrement(redis, key, count, expireSeconds, options = {}) {
|
|
381
|
+
const txn = new RedisTransaction(redis, options);
|
|
382
|
+
txn.add('incrby', key, count);
|
|
383
|
+
txn.add('expire', key, expireSeconds);
|
|
384
|
+
|
|
385
|
+
const { error, values } = await txn.exec();
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
success: !error,
|
|
389
|
+
error,
|
|
390
|
+
value: values[0]
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Executes a push and trim operation atomically (for bounded lists)
|
|
396
|
+
*
|
|
397
|
+
* @param {Object} redis - ioredis client instance
|
|
398
|
+
* @param {string} key - Redis list key
|
|
399
|
+
* @param {any} value - Value to push
|
|
400
|
+
* @param {number} maxLength - Maximum list length to maintain
|
|
401
|
+
* @param {Object} options - Configuration options
|
|
402
|
+
* @param {string} [options.direction='right'] - Push direction ('left' or 'right')
|
|
403
|
+
* @returns {Promise<Object>} Result with success status
|
|
404
|
+
*/
|
|
405
|
+
async function boundedPush(redis, key, value, maxLength, options = {}) {
|
|
406
|
+
const direction = options.direction || 'right';
|
|
407
|
+
const pushCmd = direction === 'left' ? 'lpush' : 'rpush';
|
|
408
|
+
const trimStart = direction === 'left' ? 0 : -maxLength;
|
|
409
|
+
const trimEnd = direction === 'left' ? maxLength - 1 : -1;
|
|
410
|
+
|
|
411
|
+
const txn = new RedisTransaction(redis, options);
|
|
412
|
+
txn.add(pushCmd, key, value);
|
|
413
|
+
txn.add('ltrim', key, trimStart, trimEnd);
|
|
414
|
+
|
|
415
|
+
const { error, values } = await txn.exec();
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
success: !error,
|
|
419
|
+
error,
|
|
420
|
+
listLength: values[0]
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Executes a get-and-delete operation atomically
|
|
426
|
+
*
|
|
427
|
+
* @param {Object} redis - ioredis client instance
|
|
428
|
+
* @param {string} key - Redis key
|
|
429
|
+
* @param {Object} options - Configuration options
|
|
430
|
+
* @param {string} [options.type='get'] - Type of get operation ('get', 'lrange', 'hgetall')
|
|
431
|
+
* @param {Array} [options.rangeArgs] - Arguments for lrange [start, stop]
|
|
432
|
+
* @returns {Promise<Object>} Result with value and success status
|
|
433
|
+
*/
|
|
434
|
+
async function getAndDelete(redis, key, options = {}) {
|
|
435
|
+
const type = options.type || 'get';
|
|
436
|
+
const txn = new RedisTransaction(redis, options);
|
|
437
|
+
|
|
438
|
+
if (type === 'lrange' && options.rangeArgs) {
|
|
439
|
+
txn.add('lrange', key, options.rangeArgs[0], options.rangeArgs[1]);
|
|
440
|
+
} else {
|
|
441
|
+
txn.add(type, key);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
txn.add('del', key);
|
|
445
|
+
|
|
446
|
+
const { error, values } = await txn.exec();
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
success: !error,
|
|
450
|
+
error,
|
|
451
|
+
value: values[0]
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Creates a RedisTransaction builder with a fluent API
|
|
457
|
+
*
|
|
458
|
+
* @param {Object} redis - ioredis client instance
|
|
459
|
+
* @param {Object} options - Configuration options
|
|
460
|
+
* @returns {RedisTransaction} New transaction instance
|
|
461
|
+
*/
|
|
462
|
+
function createTransaction(redis, options = {}) {
|
|
463
|
+
return new RedisTransaction(redis, options);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
module.exports = {
|
|
467
|
+
// Class export
|
|
468
|
+
RedisTransaction,
|
|
469
|
+
|
|
470
|
+
// Helper functions
|
|
471
|
+
createTransaction,
|
|
472
|
+
atomicUpdate,
|
|
473
|
+
batchGet,
|
|
474
|
+
batchSet,
|
|
475
|
+
conditionalSet,
|
|
476
|
+
atomicIncrement,
|
|
477
|
+
boundedPush,
|
|
478
|
+
getAndDelete,
|
|
479
|
+
|
|
480
|
+
// Utility functions
|
|
481
|
+
isTransientError,
|
|
482
|
+
extractMultiError,
|
|
483
|
+
extractMultiValues
|
|
484
|
+
};
|