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.
Files changed (136) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account/account-state.js +248 -0
  4. package/lib/account.js +45 -193
  5. package/lib/api-routes/account-routes.js +1023 -0
  6. package/lib/api-routes/message-routes.js +1377 -0
  7. package/lib/consts.js +12 -2
  8. package/lib/email-client/base-client.js +282 -771
  9. package/lib/email-client/gmail/gmail-api.js +243 -0
  10. package/lib/email-client/gmail-client.js +145 -53
  11. package/lib/email-client/imap/mailbox.js +24 -698
  12. package/lib/email-client/imap/sync-operations.js +812 -0
  13. package/lib/email-client/imap-client.js +1 -1
  14. package/lib/email-client/message-builder.js +566 -0
  15. package/lib/email-client/notification-handler.js +314 -0
  16. package/lib/email-client/outlook/graph-api.js +326 -0
  17. package/lib/email-client/outlook-client.js +159 -113
  18. package/lib/email-client/smtp-pool-manager.js +196 -0
  19. package/lib/imapproxy/imap-server.js +3 -12
  20. package/lib/oauth/gmail.js +4 -4
  21. package/lib/oauth/mail-ru.js +30 -5
  22. package/lib/oauth/outlook.js +57 -3
  23. package/lib/oauth/pubsub/google.js +30 -11
  24. package/lib/oauth/scope-checker.js +202 -0
  25. package/lib/oauth2-apps.js +8 -4
  26. package/lib/redis-operations.js +484 -0
  27. package/lib/routes-ui.js +283 -2582
  28. package/lib/tools.js +4 -196
  29. package/lib/ui-routes/account-routes.js +1931 -0
  30. package/lib/ui-routes/admin-config-routes.js +1233 -0
  31. package/lib/ui-routes/admin-entities-routes.js +2367 -0
  32. package/lib/ui-routes/oauth-routes.js +992 -0
  33. package/lib/utils/network.js +237 -0
  34. package/package.json +10 -10
  35. package/sbom.json +1 -1
  36. package/static/js/app.js +5 -5
  37. package/static/licenses.html +79 -19
  38. package/translations/de.mo +0 -0
  39. package/translations/de.po +97 -86
  40. package/translations/en.mo +0 -0
  41. package/translations/en.po +80 -75
  42. package/translations/et.mo +0 -0
  43. package/translations/et.po +96 -86
  44. package/translations/fr.mo +0 -0
  45. package/translations/fr.po +97 -86
  46. package/translations/ja.mo +0 -0
  47. package/translations/ja.po +96 -86
  48. package/translations/messages.pot +105 -91
  49. package/translations/nl.mo +0 -0
  50. package/translations/nl.po +98 -86
  51. package/translations/pl.mo +0 -0
  52. package/translations/pl.po +96 -86
  53. package/views/account/security.hbs +4 -4
  54. package/views/accounts/account.hbs +13 -13
  55. package/views/accounts/register/imap-server.hbs +12 -12
  56. package/views/config/document-store/pre-processing/index.hbs +4 -2
  57. package/views/config/oauth/app.hbs +6 -7
  58. package/views/config/oauth/index.hbs +2 -2
  59. package/views/config/service.hbs +3 -4
  60. package/views/dashboard.hbs +5 -7
  61. package/views/error.hbs +22 -7
  62. package/views/gateways/gateway.hbs +2 -2
  63. package/views/partials/add_account_modal.hbs +7 -10
  64. package/views/partials/document_store_header.hbs +1 -1
  65. package/views/partials/editor_scope_info.hbs +0 -1
  66. package/views/partials/oauth_config_header.hbs +1 -1
  67. package/views/partials/side_menu.hbs +3 -3
  68. package/views/partials/webhook_form.hbs +2 -2
  69. package/views/templates/index.hbs +1 -1
  70. package/views/templates/template.hbs +8 -8
  71. package/views/tokens/index.hbs +6 -6
  72. package/views/tokens/new.hbs +1 -1
  73. package/views/webhooks/index.hbs +4 -4
  74. package/views/webhooks/webhook.hbs +7 -7
  75. package/workers/api.js +148 -2436
  76. package/workers/smtp.js +2 -1
  77. package/lib/imapproxy/imap-core/test/client.js +0 -46
  78. package/lib/imapproxy/imap-core/test/fixtures/append.eml +0 -1196
  79. package/lib/imapproxy/imap-core/test/fixtures/chunks.js +0 -44
  80. package/lib/imapproxy/imap-core/test/fixtures/fix1.eml +0 -6
  81. package/lib/imapproxy/imap-core/test/fixtures/fix2.eml +0 -599
  82. package/lib/imapproxy/imap-core/test/fixtures/fix3.eml +0 -32
  83. package/lib/imapproxy/imap-core/test/fixtures/fix4.eml +0 -6
  84. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.eml +0 -599
  85. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.js +0 -2740
  86. package/lib/imapproxy/imap-core/test/fixtures/mimetorture.json +0 -1411
  87. package/lib/imapproxy/imap-core/test/fixtures/mimetree.js +0 -85
  88. package/lib/imapproxy/imap-core/test/fixtures/nodemailer.eml +0 -582
  89. package/lib/imapproxy/imap-core/test/fixtures/ryan_finnie_mime_torture.eml +0 -599
  90. package/lib/imapproxy/imap-core/test/fixtures/simple.eml +0 -42
  91. package/lib/imapproxy/imap-core/test/fixtures/simple.json +0 -164
  92. package/lib/imapproxy/imap-core/test/imap-compile-stream-test.js +0 -671
  93. package/lib/imapproxy/imap-core/test/imap-compiler-test.js +0 -272
  94. package/lib/imapproxy/imap-core/test/imap-indexer-test.js +0 -236
  95. package/lib/imapproxy/imap-core/test/imap-parser-test.js +0 -922
  96. package/lib/imapproxy/imap-core/test/memory-notifier.js +0 -129
  97. package/lib/imapproxy/imap-core/test/prepare.sh +0 -74
  98. package/lib/imapproxy/imap-core/test/protocol-test.js +0 -1756
  99. package/lib/imapproxy/imap-core/test/search-test.js +0 -1356
  100. package/lib/imapproxy/imap-core/test/test-client.js +0 -152
  101. package/lib/imapproxy/imap-core/test/test-server.js +0 -623
  102. package/lib/imapproxy/imap-core/test/tools-test.js +0 -22
  103. package/test/api-test.js +0 -899
  104. package/test/autoreply-test.js +0 -327
  105. package/test/bounce-test.js +0 -151
  106. package/test/complaint-test.js +0 -256
  107. package/test/fixtures/autoreply/LICENSE +0 -27
  108. package/test/fixtures/autoreply/rfc3834-01.eml +0 -23
  109. package/test/fixtures/autoreply/rfc3834-02.eml +0 -24
  110. package/test/fixtures/autoreply/rfc3834-03.eml +0 -26
  111. package/test/fixtures/autoreply/rfc3834-04.eml +0 -48
  112. package/test/fixtures/autoreply/rfc3834-05.eml +0 -19
  113. package/test/fixtures/autoreply/rfc3834-06.eml +0 -59
  114. package/test/fixtures/bounces/163.eml +0 -2521
  115. package/test/fixtures/bounces/fastmail.eml +0 -242
  116. package/test/fixtures/bounces/gmail.eml +0 -252
  117. package/test/fixtures/bounces/hotmail.eml +0 -655
  118. package/test/fixtures/bounces/mailru.eml +0 -121
  119. package/test/fixtures/bounces/outlook.eml +0 -1107
  120. package/test/fixtures/bounces/postfix.eml +0 -101
  121. package/test/fixtures/bounces/rambler.eml +0 -116
  122. package/test/fixtures/bounces/workmail.eml +0 -142
  123. package/test/fixtures/bounces/yahoo.eml +0 -139
  124. package/test/fixtures/bounces/zoho.eml +0 -83
  125. package/test/fixtures/bounces/zonemta.eml +0 -100
  126. package/test/fixtures/complaints/LICENSE +0 -27
  127. package/test/fixtures/complaints/amazonses.eml +0 -72
  128. package/test/fixtures/complaints/dmarc.eml +0 -59
  129. package/test/fixtures/complaints/hotmail.eml +0 -49
  130. package/test/fixtures/complaints/optout.eml +0 -40
  131. package/test/fixtures/complaints/standard-arf.eml +0 -68
  132. package/test/fixtures/complaints/yahoo.eml +0 -68
  133. package/test/oauth2-apps-test.js +0 -301
  134. package/test/sendonly-test.js +0 -160
  135. package/test/test-config.js +0 -34
  136. package/test/webhooks-server.js +0 -39
@@ -1192,7 +1192,8 @@ class OAuth2AppsHandler {
1192
1192
  await this.setMeta(id, { authFlag: flag });
1193
1193
  }
1194
1194
  } catch (err) {
1195
- // ignore
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
- // ignore
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
- // ignore
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
- // ignore
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
+ };