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
@@ -1,11 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const { parentPort, threadId: workerThreadId } = require('worker_threads');
3
+ const { threadId: workerThreadId } = require('worker_threads');
4
4
  const crypto = require('crypto');
5
5
  const logger = require('../logger');
6
- const { webhooks: Webhooks } = require('../webhooks');
7
- const { getESClient } = require('../document-store');
8
- const { getThread } = require('../threads');
9
6
  const settings = require('../settings');
10
7
  const msgpack = require('msgpack5')();
11
8
  const { templates } = require('../templates');
@@ -19,6 +16,7 @@ const { getRawEmail } = require('../get-raw-email');
19
16
  const { getTemplate } = require('@postalsys/templates');
20
17
  const { deepEqual } = require('assert');
21
18
  const { arfDetect } = require('../arf-detect');
19
+ const { createTransaction } = require('../redis-operations');
22
20
  const simpleParser = require('mailparser').simpleParser;
23
21
  const libmime = require('libmime');
24
22
  const { bounceDetect } = require('../bounce-detect');
@@ -26,15 +24,20 @@ const ical = require('ical.js');
26
24
  const { llmPreProcess } = require('../llm-pre-process');
27
25
  const { oauth2Apps } = require('../oauth2-apps');
28
26
  const { Account } = require('../account');
29
- const util = require('util');
30
- const socks = require('socks');
31
- const nodemailer = require('nodemailer');
32
27
  const { removeBcc } = require('../get-raw-email');
33
- const { oauth2ProviderData } = require('../oauth2-apps');
28
+ const {
29
+ SmtpConfigBuilder,
30
+ NetworkRoutingBuilder,
31
+ NotificationBuilder,
32
+ ProviderMessageIdHandler,
33
+ SmtpErrorBuilder,
34
+ SentMailCopyDecider
35
+ } = require('./message-builder');
36
+ const { NotificationHandler, postMetrics } = require('./notification-handler');
37
+ const { getMailTransport } = require('./smtp-pool-manager');
34
38
 
35
39
  // Import various utility functions and constants
36
40
  const {
37
- getLocalAddress,
38
41
  getSignedFormDataSync,
39
42
  getServiceSecret,
40
43
  convertDataUrisToAttachments,
@@ -44,7 +47,6 @@ const {
44
47
  readEnvValue,
45
48
  emitChangeEvent,
46
49
  filterEmptyObjectValues,
47
- resolveCredentials,
48
50
  getDateBuckets
49
51
  } = require('../tools');
50
52
 
@@ -54,17 +56,13 @@ const {
54
56
  ACCOUNT_INITIALIZED_NOTIFY,
55
57
  REDIS_PREFIX,
56
58
  MESSAGE_NEW_NOTIFY,
57
- MESSAGE_DELETED_NOTIFY,
58
- MESSAGE_UPDATED_NOTIFY,
59
59
  EMAIL_BOUNCE_NOTIFY,
60
- MAILBOX_DELETED_NOTIFY,
61
60
  DEFAULT_DELIVERY_ATTEMPTS,
62
61
  MIME_BOUNDARY_PREFIX,
63
62
  DEFAULT_DOWNLOAD_CHUNK_SIZE,
64
63
  EMAIL_COMPLAINT_NOTIFY,
65
64
  MAX_INLINE_ATTACHMENT_SIZE,
66
65
  DEFAULT_MAX_IMAP_AUTH_FAILURE_TIME,
67
- TLS_DEFAULTS,
68
66
  EMAIL_DELIVERY_ERROR_NOTIFY,
69
67
  EMAIL_SENT_NOTIFY
70
68
  } = require('../consts');
@@ -75,154 +73,9 @@ const DOWNLOAD_CHUNK_SIZE = getByteSize(readEnvValue('EENGINE_CHUNK_SIZE')) || D
75
73
  // Configure maximum time to wait before disabling IMAP on authentication failures
76
74
  const MAX_IMAP_AUTH_FAILURE_TIME = getDuration(readEnvValue('EENGINE_MAX_IMAP_AUTH_FAILURE_TIME')) || DEFAULT_MAX_IMAP_AUTH_FAILURE_TIME;
77
75
 
78
- /**
79
- * Sends metrics data to the parent thread for aggregation
80
- * @param {Object} meta - Metadata to include with the metric
81
- * @param {Object} logger - Logger instance
82
- * @param {string} key - Metric key identifier
83
- * @param {string} method - Metric method (e.g., 'inc', 'dec')
84
- * @param {...any} args - Additional arguments for the metric
85
- */
86
- async function metricsMeta(meta, logger, key, method, ...args) {
87
- try {
88
- parentPort.postMessage({
89
- cmd: 'metrics',
90
- key,
91
- method,
92
- args,
93
- meta: meta || {}
94
- });
95
- } catch (err) {
96
- logger.error({ msg: 'Failed to post metrics to parent', err });
97
- }
98
- }
99
-
100
76
  // Map to track pending idempotency operations to prevent duplicate processing
101
77
  const pendingIdempotencyOperations = new Map();
102
78
 
103
- // Cache for SMTP connection pools to reuse connections
104
- const SMTP_POOLS = new Map();
105
- // Track last usage time for each pool to enable LRU-based cleanup
106
- const SMTP_POOL_LAST_USED = new Map();
107
-
108
- // Maximum idle time for SMTP pool connections (10 minutes of inactivity)
109
- const SMTP_POOL_MAX_IDLE = 10 * 60 * 1000;
110
- // Cleanup interval for idle SMTP pools (2 minutes)
111
- const SMTP_POOL_CLEANUP_INTERVAL = 2 * 60 * 1000;
112
-
113
- // Periodic cleanup of idle SMTP pool connections
114
- let smtpPoolCleanupTimer = setInterval(() => {
115
- const now = Date.now();
116
- const idleKeys = [];
117
-
118
- for (const [poolKey, lastUsed] of SMTP_POOL_LAST_USED.entries()) {
119
- if (now - lastUsed > SMTP_POOL_MAX_IDLE) {
120
- idleKeys.push(poolKey);
121
- }
122
- }
123
-
124
- for (const poolKey of idleKeys) {
125
- const transporter = SMTP_POOLS.get(poolKey);
126
- if (transporter) {
127
- // Check if the transporter has active connections
128
- if (transporter._connectionPool && transporter._connectionPool.size > 0) {
129
- // Still has active connections, update last used time
130
- SMTP_POOL_LAST_USED.set(poolKey, now);
131
- logger.trace({ msg: 'SMTP pool still has active connections, skipping cleanup', poolKey });
132
- continue;
133
- }
134
-
135
- logger.debug({ msg: 'Cleaning up idle SMTP pool connection', poolKey, idleTime: now - SMTP_POOL_LAST_USED.get(poolKey) });
136
- try {
137
- transporter.close();
138
- } catch (err) {
139
- logger.error({ msg: 'Failed to close idle SMTP transporter', poolKey, err });
140
- }
141
- }
142
- SMTP_POOLS.delete(poolKey);
143
- SMTP_POOL_LAST_USED.delete(poolKey);
144
- }
145
-
146
- if (idleKeys.length > 0) {
147
- logger.info({ msg: 'SMTP pool cleanup completed', cleaned: idleKeys.length, remaining: SMTP_POOLS.size });
148
- }
149
- }, SMTP_POOL_CLEANUP_INTERVAL);
150
-
151
- // Prevent the timer from keeping the process alive
152
- smtpPoolCleanupTimer.unref();
153
-
154
- /**
155
- * Gets or creates a reusable SMTP transport for the given configuration
156
- * Connection pooling improves performance by reusing SMTP connections
157
- * @param {Object} smtpSettings - SMTP configuration settings
158
- * @returns {Object} Nodemailer transport instance
159
- */
160
- function getMailTransport(smtpSettings) {
161
- // Extract only the settings that affect connection identity
162
- let limitedSettings = {};
163
- for (let key of ['name', 'localAddress', 'auth', 'host', 'port', 'secure', 'transactionLog', 'proxy']) {
164
- limitedSettings[key] = smtpSettings[key];
165
- }
166
-
167
- // Create a unique key for this SMTP configuration
168
- let serializedSettings = JSON.stringify(limitedSettings);
169
- let poolKey = crypto.createHash('sha256').update(serializedSettings).digest('hex');
170
-
171
- // Return existing transport if available
172
- let transporter;
173
- if (SMTP_POOLS.has(poolKey)) {
174
- transporter = SMTP_POOLS.get(poolKey);
175
- // Update last used time for LRU tracking
176
- SMTP_POOL_LAST_USED.set(poolKey, Date.now());
177
- return transporter;
178
- }
179
-
180
- // Configure connection pooling settings
181
- smtpSettings.pool = true;
182
- smtpSettings.maxConnections = 1;
183
- smtpSettings.maxMessages = 100;
184
- smtpSettings.socketTimeout = 2 * 60 * 1000; // 2 minute timeout
185
-
186
- // Create new transport with pooling enabled
187
- transporter = nodemailer.createTransport(smtpSettings);
188
- transporter.set('proxy_socks_module', socks);
189
-
190
- // Handle connection pool cleanup when idle
191
- transporter.once('clear', () => {
192
- // all emails processed and connection timed out
193
- logger.trace({ msg: 'Clearing disconnected SMTP pool', poolKey });
194
- SMTP_POOLS.delete(poolKey);
195
- SMTP_POOL_LAST_USED.delete(poolKey);
196
- try {
197
- transporter.close();
198
- } catch (closeErr) {
199
- logger.error({ msg: 'Failed to close transporter', err: closeErr });
200
- }
201
- transporter = null;
202
- });
203
-
204
- // Handle transport errors by removing from pool
205
- transporter.once('error', err => {
206
- // not sure what happned, but do not re-use this transporter object anymore
207
- logger.error({ msg: 'Transporter failed', err });
208
- SMTP_POOLS.delete(poolKey);
209
- SMTP_POOL_LAST_USED.delete(poolKey);
210
- try {
211
- transporter.close();
212
- } catch (closeErr) {
213
- logger.error({ msg: 'Failed to close transporter', err: closeErr });
214
- }
215
- transporter = null;
216
- });
217
-
218
- // Cache the transport for reuse
219
- SMTP_POOLS.set(poolKey, transporter);
220
- SMTP_POOL_LAST_USED.set(poolKey, Date.now());
221
- logger.trace({ msg: 'Created SMTP pool', poolKey });
222
-
223
- return transporter;
224
- }
225
-
226
79
  /**
227
80
  * Check if an error is a transient error that should be retried
228
81
  * @param {Error} err - The error to check
@@ -329,6 +182,14 @@ class BaseClient {
329
182
 
330
183
  // Track sub-connections (e.g., for IMAP IDLE)
331
184
  this.subconnections = [];
185
+
186
+ // Initialize notification handler for webhook and event delivery
187
+ this.notificationHandler = new NotificationHandler({
188
+ account: this.account,
189
+ logger: this.logger,
190
+ flowProducer: this.flowProducer,
191
+ documentsQueue: this.documentsQueue
192
+ });
332
193
  }
333
194
 
334
195
  // Stub methods to be implemented by subclasses
@@ -463,18 +324,17 @@ class BaseClient {
463
324
  * Sends notification on first successful connection
464
325
  */
465
326
  async setStateVal() {
466
- let [[e1], [e2], [e3, prevVal], [e4, incrVal], [e5, stateVal]] = await this.redis
467
- .multi()
468
- .hSetExists(this.getAccountKey(), 'state', this.state)
469
- .hSetBigger(this.getAccountKey(), 'runIndex', this.runIndex.toString())
470
- .hget(this.getAccountKey(), `state:count:${this.state}`)
471
- .hIncrbyExists(this.getAccountKey(), `state:count:${this.state}`, 1)
472
- .hget(this.getAccountKey(), 'state')
473
- .exec();
474
-
475
- if (e1 || e2 || e3 || e4 || e5) {
476
- throw e1 || e2 || e3 || e4 || e5;
477
- }
327
+ const txn = createTransaction(this.redis, { logger: this.logger });
328
+ txn.add('hSetExists', this.getAccountKey(), 'state', this.state);
329
+ txn.add('hSetBigger', this.getAccountKey(), 'runIndex', this.runIndex.toString());
330
+ txn.add('hget', this.getAccountKey(), `state:count:${this.state}`);
331
+ txn.add('hIncrbyExists', this.getAccountKey(), `state:count:${this.state}`, 1);
332
+ txn.add('hget', this.getAccountKey(), 'state');
333
+
334
+ const values = await txn.execOrThrow();
335
+ const prevVal = values[2];
336
+ const incrVal = values[3];
337
+ const stateVal = values[4];
478
338
 
479
339
  // Detect first successful connection
480
340
  if (stateVal === 'connected' && incrVal === 1 && prevVal === '0') {
@@ -532,31 +392,30 @@ class BaseClient {
532
392
 
533
393
  // Track error occurrences
534
394
  if (isFirstOccurrence) {
535
- await this.redis
536
- .multi()
537
- .hSetExists(this.getAccountKey(), 'lastError:errorCount', 1)
538
- .hSetExists(this.getAccountKey(), 'lastError:first', new Date().toISOString())
539
- .exec();
395
+ const txn = createTransaction(this.redis, { logger: this.logger });
396
+ txn.add('hSetExists', this.getAccountKey(), 'lastError:errorCount', 1);
397
+ txn.add('hSetExists', this.getAccountKey(), 'lastError:first', new Date().toISOString());
398
+ await txn.exec();
540
399
  } else {
541
400
  let errorCount;
542
401
  let firstError;
543
402
 
544
- let [[err1, ec], [err2, fe]] = await this.redis
545
- .multi()
546
- .hIncrbyExists(this.getAccountKey(), `lastError:errorCount`, 1)
547
- .hget(this.getAccountKey(), 'lastError:first')
548
- .exec();
403
+ const txn = createTransaction(this.redis, { logger: this.logger });
404
+ txn.add('hIncrbyExists', this.getAccountKey(), 'lastError:errorCount', 1);
405
+ txn.add('hget', this.getAccountKey(), 'lastError:first');
406
+ const { error, values } = await txn.exec();
549
407
 
550
- if (!err1 && !err2) {
551
- errorCount = ec || 0;
408
+ if (!error) {
409
+ errorCount = values[0] || 0;
410
+ const fe = values[1];
552
411
  if (fe) {
553
- fe = new Date(fe);
554
- if (fe.toString() !== 'Invalid Date') {
555
- firstError = fe;
412
+ const parsedDate = new Date(fe);
413
+ if (parsedDate.toString() !== 'Invalid Date') {
414
+ firstError = parsedDate;
556
415
  }
557
416
  }
558
417
  } else {
559
- this.logger.error({ msg: 'Redis error while checking error state counters', err1, err2 });
418
+ this.logger.error({ msg: 'Redis error while checking error state counters', error });
560
419
  }
561
420
 
562
421
  // Handle repeated authentication errors by disabling IMAP
@@ -577,19 +436,19 @@ class BaseClient {
577
436
  imapData.disabled = true;
578
437
 
579
438
  // Disable IMAP and reset error counters
580
- await this.redis
581
- .multi()
582
- .hSetExists(this.getAccountKey(), 'imap', JSON.stringify(imapData))
583
- .hdel(this.getAccountKey(), 'lastError:errorCount', 'lastError:first')
584
- .hSetExists(
585
- this.getAccountKey(),
586
- 'lastErrorState',
587
- JSON.stringify({
588
- description: 'IMAP was disabled for the account due to exceeding the authentication error threshold',
589
- response: data.response
590
- })
591
- )
592
- .exec();
439
+ const disableTxn = createTransaction(this.redis, { logger: this.logger });
440
+ disableTxn.add('hSetExists', this.getAccountKey(), 'imap', JSON.stringify(imapData));
441
+ disableTxn.add('hdel', this.getAccountKey(), 'lastError:errorCount', 'lastError:first');
442
+ disableTxn.add(
443
+ 'hSetExists',
444
+ this.getAccountKey(),
445
+ 'lastErrorState',
446
+ JSON.stringify({
447
+ description: 'IMAP was disabled for the account due to exceeding the authentication error threshold',
448
+ response: data.response
449
+ })
450
+ );
451
+ await disableTxn.exec();
593
452
 
594
453
  this.logger.info({
595
454
  msg: 'IMAP was disabled for the account due to exceeding the authentication error threshold',
@@ -614,22 +473,16 @@ class BaseClient {
614
473
 
615
474
  /**
616
475
  * Sends notifications for various email events through webhooks and queues
617
- * Handles special processing for certain event types and document store integration
476
+ * Handles error state tracking for connection/auth errors before delegating
477
+ * to the notification handler for actual delivery
618
478
  * @param {Object} mailbox - Mailbox information
619
479
  * @param {string} event - Event type constant
620
480
  * @param {Object} data - Event data payload
621
481
  * @param {Object} extraOpts - Additional options
622
482
  */
623
483
  async notify(mailbox, event, data, extraOpts) {
624
- extraOpts = extraOpts || {};
625
- const { skipWebhook, canSync = true } = extraOpts;
626
-
627
- // Track event metrics
628
- metricsMeta({ account: this.account }, this.logger, 'events', 'inc', {
629
- event
630
- });
631
-
632
484
  // Handle error state tracking for connection/auth errors
485
+ // This is BaseClient-specific logic that must run before notification
633
486
  switch (event) {
634
487
  case 'connectError':
635
488
  case 'authenticationError': {
@@ -643,133 +496,8 @@ class BaseClient {
643
496
  }
644
497
  }
645
498
 
646
- let serviceUrl = (await settings.get('serviceUrl')) || null;
647
-
648
- // Build notification payload
649
- let payload = {
650
- serviceUrl,
651
- account: this.account,
652
- date: new Date().toISOString()
653
- };
654
-
655
- let path = (mailbox && mailbox.path) || (data && data.path);
656
- if (path) {
657
- payload.path = path;
658
- }
659
-
660
- if (mailbox && mailbox.listingEntry && mailbox.listingEntry.specialUse) {
661
- payload.specialUse = mailbox.listingEntry.specialUse;
662
- }
663
-
664
- if (event) {
665
- payload.event = event;
666
- }
667
-
668
- if (data) {
669
- payload.data = data;
670
- }
671
-
672
- let queueKeep = (await settings.get('queueKeep')) || true;
673
-
674
- // Determine if we need to sync with document store (ElasticSearch)
675
- let addDocumentQueueJob =
676
- canSync &&
677
- this.documentsQueue &&
678
- [MESSAGE_NEW_NOTIFY, MESSAGE_DELETED_NOTIFY, MESSAGE_UPDATED_NOTIFY, EMAIL_BOUNCE_NOTIFY, MAILBOX_DELETED_NOTIFY].includes(event) &&
679
- (await settings.get('documentStoreEnabled'));
680
-
681
- // Generate thread ID for new messages if needed
682
- if (addDocumentQueueJob && payload.data && event === MESSAGE_NEW_NOTIFY && !payload.data.threadId) {
683
- // Generate a thread ID for the email. This is also stored in ElasticSearch.
684
- const { index, client } = await getESClient(logger);
685
- try {
686
- if (client) {
687
- let thread = await getThread(client, index, this.account, payload.data, logger);
688
- if (thread) {
689
- payload.data.threadId = thread;
690
- logger.info({
691
- msg: 'Provisioned thread ID for a message',
692
- account: this.account,
693
- message: payload.data.id,
694
- threadId: payload.data.threadId
695
- });
696
- }
697
- }
698
- } catch (err) {
699
- if (logger.notifyError) {
700
- logger.notifyError(err, event => {
701
- event.setUser(this.account);
702
- event.addMetadata('ee', {
703
- index
704
- });
705
- });
706
- }
707
- logger.error({ msg: 'Failed to resolve thread', account: this.account, message: payload.data.id, err });
708
- }
709
- }
710
-
711
- // Configure job options for queuing
712
- const defaultJobOptions = {
713
- removeOnComplete: queueKeep,
714
- removeOnFail: queueKeep,
715
- attempts: 10,
716
- backoff: {
717
- type: 'exponential',
718
- delay: 5000
719
- }
720
- };
721
-
722
- // use more attempts for ElasticSearch updates
723
- const documentJobOptions = Object.assign(structuredClone(defaultJobOptions), { attempts: 16 });
724
-
725
- // Process notifications with flow (webhook + document store) or separately
726
- if (!skipWebhook && addDocumentQueueJob) {
727
- // add both jobs as a Flow
728
-
729
- let notifyPayload = await Webhooks.formatPayload(event, payload);
730
-
731
- const queueFlow = [
732
- {
733
- name: event,
734
- data: payload,
735
- queueName: 'documents'
736
- }
737
- ];
738
-
739
- await Webhooks.pushToQueue(event, notifyPayload, {
740
- routesOnly: true,
741
- queueFlow
742
- });
743
-
744
- await this.flowProducer.add(
745
- {
746
- name: event,
747
- data: notifyPayload,
748
- queueName: 'notify',
749
- children: queueFlow
750
- },
751
- {
752
- queuesOptions: {
753
- notify: {
754
- defaultJobOptions
755
- },
756
- documents: {
757
- defaultJobOptions: documentJobOptions
758
- }
759
- }
760
- }
761
- );
762
- } else {
763
- // add to queues as normal jobs
764
-
765
- if (!skipWebhook) {
766
- await Webhooks.pushToQueue(event, await Webhooks.formatPayload(event, payload));
767
- }
768
-
769
- if (addDocumentQueueJob) {
770
- await this.documentsQueue.add(event, payload, documentJobOptions);
771
- }
772
- }
499
+ // Delegate to notification handler for webhook and document store delivery
500
+ await this.notificationHandler.notify(mailbox, event, data, extraOpts);
773
501
  }
774
502
 
775
503
  /**
@@ -801,7 +529,7 @@ class BaseClient {
801
529
  }
802
530
 
803
531
  // Check if token needs refresh (with 30 second buffer)
804
- if (!accountData.oauth2.accessToken || !accountData.oauth2.expires || accountData.oauth2.expires < new Date(now - 30 * 1000)) {
532
+ if (!accountData.oauth2.accessToken || !accountData.oauth2.expires || accountData.oauth2.expires < new Date(now + 30 * 1000)) {
805
533
  // renew access token
806
534
  try {
807
535
  accountData = await accountObject.renewAccessToken({
@@ -3176,210 +2904,66 @@ class BaseClient {
3176
2904
  * @returns {Object} Delivery result with response and messageId
3177
2905
  */
3178
2906
  async submitMessage(data) {
3179
- let accountData = await this.accountObject.loadAccountData();
2907
+ const accountData = await this.accountObject.loadAccountData();
3180
2908
 
3181
2909
  // Verify SMTP capability
3182
2910
  if (!accountData.smtp && !accountData.oauth2 && !data.gateway) {
3183
- // can not make connection
3184
- let err = new Error('SMTP configuration not found');
2911
+ const err = new Error('SMTP configuration not found');
3185
2912
  err.code = 'SMTPUnavailable';
3186
2913
  err.statusCode = 404;
3187
2914
  throw err;
3188
2915
  }
3189
2916
 
3190
- // Load gateway configuration if specified
3191
- let gatewayData;
3192
- let gatewayObject;
3193
- if (data.gateway) {
3194
- gatewayObject = new Gateway({ gateway: data.gateway, redis: this.redis, secret: this.secret });
3195
- try {
3196
- gatewayData = await gatewayObject.loadGatewayData();
3197
- } catch (err) {
3198
- this.logger.info({ msg: 'Failed to load gateway data', messageId: data.messageId, gateway: data.gateway, err });
3199
- }
3200
- }
3201
-
3202
- let smtpConnectionConfig;
3203
-
3204
- // Configure SMTP connection based on authentication type
3205
- if (gatewayData) {
3206
- // Use gateway configuration
3207
- smtpConnectionConfig = {
3208
- host: gatewayData.host,
3209
- port: gatewayData.port,
3210
- secure: gatewayData.secure
3211
- };
3212
- if (gatewayData.user || gatewayData.pass) {
3213
- smtpConnectionConfig.auth = {
3214
- user: gatewayData.user || '',
3215
- pass: gatewayData.pass || ''
3216
- };
3217
- }
3218
- } else if (accountData.oauth2 && accountData.oauth2.auth) {
3219
- // load OAuth2 tokens
3220
- const { oauth2User, accessToken, oauth2App } = await this.loadOAuth2AccountCredentials(accountData, this, 'smtp');
3221
- const providerData = oauth2ProviderData(oauth2App.provider, oauth2App.cloud);
3222
-
3223
- smtpConnectionConfig = Object.assign(
3224
- {
3225
- auth: {
3226
- user: oauth2User,
3227
- accessToken
3228
- },
3229
- resyncDelay: 900
3230
- },
3231
- providerData.smtp || {}
3232
- );
3233
- } else {
3234
- // deep copy of smtp settings
3235
- smtpConnectionConfig = JSON.parse(JSON.stringify(accountData.smtp));
3236
- }
3237
-
3238
- let { raw, hasBcc, envelope, messageId, queueId, reference, job: jobData } = data;
3239
-
3240
- let smtpAuth = smtpConnectionConfig.auth;
3241
- // If authentication server is set then it overrides authentication data
3242
- if (smtpConnectionConfig.useAuthServer) {
3243
- try {
3244
- smtpAuth = await resolveCredentials(this.account, 'smtp');
3245
- } catch (err) {
3246
- err.authenticationFailed = true;
3247
- this.logger.error({
3248
- account: this.account,
3249
- err
3250
- });
3251
- throw err;
3252
- }
3253
- }
3254
-
3255
- // Select local address for outbound connection
3256
- let { localAddress: address, name, addressSelector: selector } = await getLocalAddress(this.redis, 'smtp', this.account, data.localAddress);
3257
- this.logger.info({
3258
- msg: 'Selected local address',
3259
- account: this.account,
3260
- proto: 'SMTP',
3261
- address,
3262
- name,
3263
- selector,
3264
- requestedLocalAddress: data.localAddress
2917
+ // Build SMTP configuration using SmtpConfigBuilder
2918
+ const smtpConfigBuilder = new SmtpConfigBuilder({
2919
+ redis: this.redis,
2920
+ secret: this.secret,
2921
+ logger: this.logger,
2922
+ account: this.account
3265
2923
  });
3266
2924
 
3267
- // Configure SMTP logger
3268
- let smtpLogger = {};
3269
- let smtpSettings = Object.assign(
3270
- {
3271
- name,
3272
- localAddress: address,
3273
- transactionLog: true,
3274
- logger: smtpLogger
3275
- },
3276
- smtpConnectionConfig
3277
- );
2925
+ // Load gateway if specified
2926
+ const { gatewayData, gatewayObject } = await smtpConfigBuilder.loadGateway(data.gateway, data.messageId);
3278
2927
 
3279
- // Set authentication
3280
- if (smtpAuth) {
3281
- smtpSettings.auth = {
3282
- user: smtpAuth.user
3283
- };
3284
-
3285
- if (smtpAuth.accessToken) {
3286
- smtpSettings.auth.type = 'OAuth2';
3287
- smtpSettings.auth.accessToken = smtpAuth.accessToken;
3288
- } else {
3289
- smtpSettings.auth.pass = smtpAuth.pass;
3290
- }
3291
- }
2928
+ // Build connection configuration
2929
+ const smtpConnectionConfig = await smtpConfigBuilder.buildConnectionConfig({
2930
+ gatewayData,
2931
+ accountData,
2932
+ loadOAuth2Credentials: this.loadOAuth2AccountCredentials.bind(this),
2933
+ context: this
2934
+ });
3292
2935
 
3293
- // Configure TLS with defaults
3294
- if (!smtpSettings.tls) {
3295
- smtpSettings.tls = {};
3296
- }
3297
- for (let key of Object.keys(TLS_DEFAULTS)) {
3298
- if (!(key in smtpSettings.tls)) {
3299
- smtpSettings.tls[key] = TLS_DEFAULTS[key];
3300
- }
3301
- }
2936
+ // Resolve auth server credentials if configured
2937
+ const smtpAuth = await smtpConfigBuilder.resolveAuthServer(smtpConnectionConfig);
3302
2938
 
3303
- // Set up logger wrapper
3304
- for (let level of ['trace', 'debug', 'info', 'warn', 'error', 'fatal']) {
3305
- smtpLogger[level] = (data, message, ...args) => {
3306
- if (args && args.length) {
3307
- message = util.format(message, ...args);
3308
- }
3309
- data.msg = message;
3310
- data.sub = 'nodemailer';
3311
- if (typeof this.logger[level] === 'function') {
3312
- this.logger[level](data);
3313
- } else {
3314
- this.logger.debug(data);
3315
- }
3316
- };
3317
- }
2939
+ // Build complete SMTP settings
2940
+ const smtpSettings = await smtpConfigBuilder.buildSmtpSettings({
2941
+ smtpConnectionConfig,
2942
+ smtpAuth,
2943
+ accountData,
2944
+ data
2945
+ });
3318
2946
 
3319
- // set up proxy if needed
3320
- if (data.proxy) {
3321
- smtpSettings.proxy = data.proxy;
3322
- } else if (accountData.proxy) {
3323
- smtpSettings.proxy = accountData.proxy;
3324
- } else {
3325
- let proxyUrl = await settings.get('proxyUrl');
3326
- let proxyEnabled = await settings.get('proxyEnabled');
3327
- if (proxyEnabled && proxyUrl && !smtpSettings.proxy) {
3328
- smtpSettings.proxy = proxyUrl;
3329
- }
3330
- }
2947
+ // Build network routing info for notifications
2948
+ const networkRouting = NetworkRoutingBuilder.build(smtpSettings, data);
3331
2949
 
3332
- // Override EHLO hostname if configured
3333
- if (accountData.smtpEhloName) {
3334
- smtpSettings.name = accountData.smtpEhloName;
3335
- }
2950
+ // Extract message data
2951
+ let { raw, hasBcc, envelope, messageId, queueId, reference, job: jobData } = data;
3336
2952
 
3337
2953
  // Verify job still exists
3338
2954
  const submitJobEntry = await this.submitQueue.getJob(jobData.id);
3339
2955
  if (!submitJobEntry) {
3340
- // already failed?
3341
- this.logger.error({
3342
- msg: 'Submit job was not found',
3343
- job: jobData.id
3344
- });
2956
+ this.logger.error({ msg: 'Submit job was not found', job: jobData.id });
3345
2957
  return false;
3346
2958
  }
3347
2959
 
3348
- // Handle certificate errors if configured
3349
- const ignoreMailCertErrors = await settings.get('ignoreMailCertErrors');
3350
- if (ignoreMailCertErrors && smtpSettings?.tls?.rejectUnauthorized !== false) {
3351
- smtpSettings.tls = smtpSettings.tls || {};
3352
- smtpSettings.tls.rejectUnauthorized = false;
3353
- }
3354
-
3355
- // Prepare network routing info for notifications
3356
- const networkRouting = smtpSettings.localAddress || smtpSettings.proxy ? {} : null;
3357
-
3358
- if (networkRouting && smtpSettings.localAddress) {
3359
- networkRouting.localAddress = smtpSettings.localAddress;
3360
- }
3361
-
3362
- if (networkRouting && smtpSettings.proxy) {
3363
- networkRouting.proxy = smtpSettings.proxy;
3364
- }
3365
-
3366
- if (networkRouting && smtpSettings.name) {
3367
- networkRouting.name = smtpSettings.name;
3368
- }
3369
-
3370
- if (networkRouting && data.localAddress && data.localAddress !== networkRouting.localAddress) {
3371
- networkRouting.requestedLocalAddress = data.localAddress;
3372
- }
3373
-
3374
- // Get or create SMTP transport from pool
2960
+ // Get SMTP transport from pool
3375
2961
  const transporter = getMailTransport(smtpSettings);
3376
2962
 
3377
2963
  try {
2964
+ // Update job progress
3378
2965
  try {
3379
- // try to update job progress
3380
- await submitJobEntry.updateProgress({
3381
- status: 'smtp-starting'
3382
- });
2966
+ await submitJobEntry.updateProgress({ status: 'smtp-starting' });
3383
2967
  } catch (err) {
3384
2968
  // ignore
3385
2969
  }
@@ -3388,7 +2972,6 @@ class BaseClient {
3388
2972
  const info = await transporter.sendMail({
3389
2973
  envelope,
3390
2974
  messageId,
3391
- // make sure that Bcc line is removed from the version sent to SMTP
3392
2975
  raw: !hasBcc ? raw : await removeBcc(raw),
3393
2976
  dsn: data.dsn || null
3394
2977
  });
@@ -3398,40 +2981,13 @@ class BaseClient {
3398
2981
  await this.redis.hSetExists(this.getAccountKey(), 'smtpServerEhlo', JSON.stringify(info.ehlo));
3399
2982
  }
3400
2983
 
3401
- // special rules for MTA servers
3402
-
3403
- let originalMessageId;
3404
-
3405
- // Hotmail - extract actual Message-ID from response
3406
- let hotmailMessageIdMatch = (info.response || '').toString().match(/^250 2.0.0 OK (<[^>]+\.prod\.outlook\.com>)/);
3407
- if (hotmailMessageIdMatch && hotmailMessageIdMatch[1] !== info.messageId) {
3408
- // MessageId was overridden
3409
- originalMessageId = info.messageId;
3410
- info.messageId = hotmailMessageIdMatch[1];
3411
- }
3412
-
3413
- // AWS SES - construct Message-ID from response
3414
- let awsSesHostMatch = (smtpSettings.host || '').toString().match(/\.([^.]+)\.(amazonaws\.com|awsapps\.com)$/i);
3415
- let awsSesMessageIdMatch = (info.response || '').toString().match(/^250 Ok ([0-9a-f-]+)$/);
3416
- if (awsSesHostMatch && awsSesMessageIdMatch) {
3417
- let region = awsSesHostMatch[1].toLowerCase().trim();
3418
- let messageId = awsSesMessageIdMatch[1].toLowerCase().trim();
3419
- if (region === 'us-east-1') {
3420
- region = 'email';
3421
- }
3422
-
3423
- // MessageId was overridden
3424
- originalMessageId = info.messageId;
3425
- info.messageId = '<' + messageId + (!/@/.test(messageId) ? '@' + region + '.amazonses.com' : '') + '>';
3426
- }
3427
-
3428
- // done
2984
+ // Handle provider-specific message ID extraction
2985
+ const originalMessageId = ProviderMessageIdHandler.processResponse(info, smtpSettings.host);
3429
2986
 
2987
+ // Update job progress
3430
2988
  try {
3431
- // try to update job progress
3432
2989
  await submitJobEntry.updateProgress({
3433
2990
  status: 'smtp-completed',
3434
-
3435
2991
  response: info.response,
3436
2992
  messageId: info.messageId,
3437
2993
  originalMessageId
@@ -3441,269 +2997,223 @@ class BaseClient {
3441
2997
  }
3442
2998
 
3443
2999
  // Send success notification
3444
- await this.notify(false, EMAIL_SENT_NOTIFY, {
3445
- messageId: info.messageId,
3446
- originalMessageId,
3447
- response: info.response,
3448
- queueId,
3449
- envelope,
3450
- networkRouting
3451
- });
3000
+ await this.notify(
3001
+ false,
3002
+ EMAIL_SENT_NOTIFY,
3003
+ NotificationBuilder.buildSuccessPayload({ info, originalMessageId, queueId, envelope, networkRouting })
3004
+ );
3452
3005
 
3453
- // clean up possible cached SMTP error
3006
+ // Clear cached SMTP error
3454
3007
  try {
3455
- await this.redis.hset(
3456
- this.getAccountKey(),
3457
- 'smtpStatus',
3458
- JSON.stringify({
3459
- created: Date.now(),
3460
- status: 'ok',
3461
- response: info.response
3462
- })
3463
- );
3008
+ await this.redis.hset(this.getAccountKey(), 'smtpStatus', JSON.stringify({ created: Date.now(), status: 'ok', response: info.response }));
3464
3009
  } catch (err) {
3465
- // ignore?
3466
- }
3467
-
3468
- // The default is to copy message to Sent Mail folder
3469
- let shouldCopy = !Object.prototype.hasOwnProperty.call(accountData, 'copy');
3470
-
3471
- // Account specific setting
3472
- if (typeof accountData.copy === 'boolean') {
3473
- shouldCopy = accountData.copy;
3474
- }
3475
-
3476
- // Suppress uploads for Gmail and Outlook
3477
- // Unfortunately, previous default schema for all added accounts was copy=true, so can't prefer account specific setting here
3478
-
3479
- // Emails for delegated accounts will be uploaded as the sender is different.
3480
- // SMTP is disabled for shared mailboxes, so we need to send using the main account.
3481
- let skipIfOutlook = this.isOutlook && (!accountData.oauth2 || !accountData.oauth2.auth || !accountData.oauth2.auth.delegatedUser);
3482
-
3483
- if ((this.isGmail || skipIfOutlook) && !gatewayData) {
3484
- shouldCopy = false;
3485
- }
3486
-
3487
- // Message specific setting, overrides all other settings
3488
- if (typeof data.copy === 'boolean') {
3489
- shouldCopy = data.copy;
3010
+ // ignore
3490
3011
  }
3491
3012
 
3492
- // Check if IMAP is available
3493
- if ((!accountData.imap && !accountData.oauth2) || (accountData.imap && accountData.imap.disabled)) {
3494
- // IMAP is disabled for this account
3495
- shouldCopy = false;
3496
- }
3013
+ // Handle sent mail copy to folder
3014
+ const shouldCopy = SentMailCopyDecider.shouldCopy({
3015
+ accountData,
3016
+ data,
3017
+ isGmail: this.isGmail,
3018
+ isOutlook: this.isOutlook,
3019
+ gatewayData
3020
+ });
3497
3021
 
3498
- let connectionOptions = { allowSecondary: true };
3022
+ const connectionOptions = { allowSecondary: true };
3499
3023
 
3500
3024
  if (shouldCopy) {
3501
- // NB! IMAP only
3502
- // Upload the message to Sent Mail folder
3503
-
3504
- try {
3505
- this.checkIMAPConnection(connectionOptions);
3506
-
3507
- // Find or use specified sent folder
3508
- let sentMailbox =
3509
- data.sentMailPath && typeof data.sentMailPath === 'string'
3510
- ? {
3511
- path: data.sentMailPath
3512
- }
3513
- : await this.getSpecialUseMailbox('\\Sent');
3514
-
3515
- if (sentMailbox) {
3516
- if (raw.buffer) {
3517
- // convert from a Uint8Array to a Buffer
3518
- raw = Buffer.from(raw);
3519
- }
3520
-
3521
- const connectionClient = await this.getImapConnection(connectionOptions, 'submitMessage');
3522
-
3523
- // Upload message with Seen flag
3524
- await connectionClient.append(sentMailbox.path, raw, ['\\Seen']);
3525
-
3526
- // Return to IDLE if using primary connection
3527
- if (connectionClient === this.imapClient && this.imapClient.mailbox && !this.imapClient.idling) {
3528
- // force back to IDLE
3529
- this.imapClient.idle().catch(err => {
3530
- this.logger.error({ msg: 'IDLE error', err });
3531
- });
3532
- }
3533
- }
3534
- } catch (err) {
3535
- this.logger.error({ msg: 'Failed to upload Sent mail', queueId, messageId, err });
3536
- }
3025
+ await this.uploadToSentFolder(raw, data, queueId, messageId, connectionOptions);
3537
3026
  }
3538
3027
 
3539
- // Add \Answered flag to referenced message if needed
3028
+ // Update reference flags if needed
3540
3029
  if (reference && reference.update) {
3541
- try {
3542
- this.checkIMAPConnection(connectionOptions);
3543
- await this.updateMessage(
3544
- reference.message,
3545
- {
3546
- flags: {
3547
- add: ['\\Answered'].concat(reference.action === 'forward' ? '$Forwarded' : [])
3548
- }
3549
- },
3550
- connectionOptions
3551
- );
3552
- } catch (err) {
3553
- this.logger.error({ msg: 'Failed to update reference flags', queueId, messageId, reference, err });
3554
- }
3030
+ await this.updateReferenceFlags(reference, queueId, messageId, connectionOptions);
3555
3031
  }
3556
3032
 
3557
3033
  // Update feedback key if provided
3558
3034
  if (data.feedbackKey) {
3559
- await this.redis
3560
- .multi()
3561
- .hset(data.feedbackKey, 'success', 'true')
3562
- .expire(1 * 60 * 60);
3035
+ await this.updateFeedbackKey(data.feedbackKey, true);
3563
3036
  }
3564
3037
 
3565
3038
  // Update gateway usage stats
3566
3039
  if (gatewayData) {
3567
- try {
3568
- await gatewayObject.update({
3569
- lastError: null,
3570
- lastUse: new Date(),
3571
- deliveries: { inc: 1 }
3572
- });
3573
- } catch (err) {
3574
- this.logger.error({ msg: 'Failed to update gateway', queueId, messageId, reference, gateway: gatewayData.gateway, err });
3575
- }
3040
+ await this.updateGatewayStats(gatewayObject, queueId, messageId, reference);
3576
3041
  }
3577
3042
 
3578
- return {
3579
- response: info.response,
3580
- messageId: info.messageId
3581
- };
3043
+ return { response: info.response, messageId: info.messageId };
3582
3044
  } catch (err) {
3583
- // Handle permanent failures
3584
- if (err.responseCode >= 500 && jobData.opts?.attempts <= jobData.attemptsMade) {
3585
- jobData.nextAttempt = false;
3586
- }
3587
-
3588
- // Track SMTP errors for diagnostics
3589
- let smtpStatus = false;
3590
- switch (err.code) {
3591
- case 'ESOCKET':
3592
- if (err.cert && err.reason) {
3593
- smtpStatus = {
3594
- description: `Certificate check for ${smtpSettings.host}:${smtpSettings.port} failed. ${err.reason}`
3595
- };
3596
- }
3597
- break;
3598
- case 'EMESSAGE':
3599
- case 'ESTREAM':
3600
- case 'EENVELOPE':
3601
- // Ignore. Too generic or message related
3602
- break;
3603
- case 'ETIMEDOUT':
3604
- // firewall?
3605
- smtpStatus = {
3606
- description: `Request timed out. Possibly a firewall issue or a wrong hostname/port (${smtpSettings.host}:${smtpSettings.port}).`
3607
- };
3608
- break;
3609
- case 'ETLS':
3610
- smtpStatus = {
3611
- description: `EmailEngine failed to set up TLS session with ${smtpSettings.host}:${smtpSettings.port}`
3612
- };
3613
- break;
3614
- case 'EDNS':
3615
- smtpStatus = {
3616
- description: `EmailEngine failed to resolve DNS record for ${smtpSettings.host}`
3617
- };
3618
- break;
3619
- case 'ECONNECTION':
3620
- smtpStatus = {
3621
- description: `EmailEngine failed to establish TCP connection against ${smtpSettings.host}`
3622
- };
3623
- break;
3624
- case 'EPROTOCOL':
3625
- smtpStatus = {
3626
- description: `Unexpected response from ${smtpSettings.host}`
3627
- };
3628
- break;
3629
- case 'EAUTH':
3630
- smtpStatus = {
3631
- description: `Authentication failed`
3632
- };
3633
- break;
3634
- }
3045
+ await this.handleSubmitError(err, {
3046
+ smtpSettings,
3047
+ networkRouting,
3048
+ gatewayData,
3049
+ gatewayObject,
3050
+ data,
3051
+ jobData,
3052
+ queueId,
3053
+ envelope
3054
+ });
3055
+ throw err;
3056
+ }
3057
+ }
3635
3058
 
3636
- if (smtpStatus) {
3637
- let lastError = Object.assign(
3638
- {
3639
- created: Date.now(),
3640
- status: 'error',
3641
- response: err.response,
3642
- responseCode: err.responseCode,
3643
- code: err.code,
3644
- command: err.command,
3645
- networkRouting
3646
- },
3647
- smtpStatus
3648
- );
3059
+ /**
3060
+ * Uploads sent message to the Sent folder via IMAP
3061
+ * @param {Buffer} raw - Raw message content
3062
+ * @param {Object} data - Message data
3063
+ * @param {string} queueId - Queue ID for logging
3064
+ * @param {string} messageId - Message ID for logging
3065
+ * @param {Object} connectionOptions - IMAP connection options
3066
+ */
3067
+ async uploadToSentFolder(raw, data, queueId, messageId, connectionOptions) {
3068
+ try {
3069
+ this.checkIMAPConnection(connectionOptions);
3649
3070
 
3650
- // store SMTP error for the account
3651
- try {
3652
- await this.redis.hset(this.getAccountKey(), 'smtpStatus', JSON.stringify(lastError));
3653
- } catch (err) {
3654
- // ignore?
3071
+ const sentMailbox =
3072
+ data.sentMailPath && typeof data.sentMailPath === 'string' ? { path: data.sentMailPath } : await this.getSpecialUseMailbox('\\Sent');
3073
+
3074
+ if (sentMailbox) {
3075
+ if (raw.buffer) {
3076
+ raw = Buffer.from(raw);
3655
3077
  }
3656
3078
 
3657
- // Update gateway error status
3658
- if (gatewayData) {
3659
- try {
3660
- await gatewayObject.update({
3661
- lastError,
3662
- lastUse: new Date()
3663
- });
3664
- } catch (err) {
3665
- // ignore?
3666
- }
3079
+ const connectionClient = await this.getImapConnection(connectionOptions, 'submitMessage');
3080
+ await connectionClient.append(sentMailbox.path, raw, ['\\Seen']);
3081
+
3082
+ if (connectionClient === this.imapClient && this.imapClient.mailbox && !this.imapClient.idling) {
3083
+ this.imapClient.idle().catch(err => {
3084
+ this.logger.error({ msg: 'IDLE error', err });
3085
+ });
3667
3086
  }
3668
3087
  }
3088
+ } catch (err) {
3089
+ this.logger.error({ msg: 'Failed to upload Sent mail', queueId, messageId, err });
3090
+ }
3091
+ }
3669
3092
 
3670
- // Update feedback key with failure status
3671
- if (data.feedbackKey && !jobData.nextAttempt) {
3672
- await this.redis
3673
- .multi()
3674
- .hset(data.feedbackKey, 'success', 'false')
3675
- .hset(data.feedbackKey, 'error', ((smtpStatus && smtpStatus.description) || '').toString() || 'Failed to send email')
3676
- .expire(data.feedbackKey, 1 * 60 * 60)
3677
- .exec();
3678
- }
3093
+ /**
3094
+ * Updates flags on the referenced message (for replies/forwards)
3095
+ * @param {Object} reference - Reference message info
3096
+ * @param {string} queueId - Queue ID for logging
3097
+ * @param {string} messageId - Message ID for logging
3098
+ * @param {Object} connectionOptions - IMAP connection options
3099
+ */
3100
+ async updateReferenceFlags(reference, queueId, messageId, connectionOptions) {
3101
+ try {
3102
+ this.checkIMAPConnection(connectionOptions);
3103
+ await this.updateMessage(
3104
+ reference.message,
3105
+ {
3106
+ flags: {
3107
+ add: ['\\Answered'].concat(reference.action === 'forward' ? '$Forwarded' : [])
3108
+ }
3109
+ },
3110
+ connectionOptions
3111
+ );
3112
+ } catch (err) {
3113
+ this.logger.error({ msg: 'Failed to update reference flags', queueId, messageId, reference, err });
3114
+ }
3115
+ }
3116
+
3117
+ /**
3118
+ * Updates feedback key with success/failure status
3119
+ * @param {string} feedbackKey - Redis key for feedback
3120
+ * @param {boolean} success - Whether the operation succeeded
3121
+ * @param {string} [errorMessage] - Error message if failed
3122
+ */
3123
+ async updateFeedbackKey(feedbackKey, success, errorMessage) {
3124
+ const feedbackTxn = createTransaction(this.redis, { logger: this.logger });
3125
+ feedbackTxn.add('hset', feedbackKey, 'success', success ? 'true' : 'false');
3126
+ if (!success && errorMessage) {
3127
+ feedbackTxn.add('hset', feedbackKey, 'error', errorMessage);
3128
+ }
3129
+ feedbackTxn.add('expire', feedbackKey, 1 * 60 * 60);
3130
+ await feedbackTxn.exec();
3131
+ }
3679
3132
 
3680
- // Send delivery error notification
3681
- await this.notify(false, EMAIL_DELIVERY_ERROR_NOTIFY, {
3133
+ /**
3134
+ * Updates gateway usage statistics after successful send
3135
+ * @param {Object} gatewayObject - Gateway object
3136
+ * @param {string} queueId - Queue ID for logging
3137
+ * @param {string} messageId - Message ID for logging
3138
+ * @param {Object} reference - Reference message info
3139
+ */
3140
+ async updateGatewayStats(gatewayObject, queueId, messageId, reference) {
3141
+ try {
3142
+ await gatewayObject.update({
3143
+ lastError: null,
3144
+ lastUse: new Date(),
3145
+ deliveries: { inc: 1 }
3146
+ });
3147
+ } catch (err) {
3148
+ this.logger.error({
3149
+ msg: 'Failed to update gateway',
3682
3150
  queueId,
3683
- envelope,
3151
+ messageId,
3152
+ reference,
3153
+ gateway: gatewayObject.gateway,
3154
+ err
3155
+ });
3156
+ }
3157
+ }
3684
3158
 
3685
- messageId: data.messageId,
3159
+ /**
3160
+ * Handles errors during message submission
3161
+ * @param {Error} err - The error that occurred
3162
+ * @param {Object} context - Error handling context
3163
+ */
3164
+ async handleSubmitError(err, context) {
3165
+ const { smtpSettings, networkRouting, gatewayData, gatewayObject, data, jobData, queueId, envelope } = context;
3686
3166
 
3687
- error: err.message,
3688
- errorCode: err.code,
3167
+ // Handle permanent failures
3168
+ if (err.responseCode >= 500 && jobData.opts?.attempts <= jobData.attemptsMade) {
3169
+ jobData.nextAttempt = false;
3170
+ }
3689
3171
 
3690
- smtpResponse: err.response,
3691
- smtpResponseCode: err.responseCode,
3692
- smtpCommand: err.command,
3172
+ // Build SMTP status from error
3173
+ const smtpStatus = SmtpErrorBuilder.buildStatus(err, smtpSettings, networkRouting);
3693
3174
 
3694
- networkRouting,
3175
+ if (smtpStatus) {
3176
+ // Store SMTP error for the account
3177
+ try {
3178
+ await this.redis.hset(this.getAccountKey(), 'smtpStatus', JSON.stringify(smtpStatus));
3179
+ } catch (storeErr) {
3180
+ // ignore
3181
+ }
3695
3182
 
3696
- job: jobData
3697
- });
3183
+ // Update gateway error status
3184
+ if (gatewayData) {
3185
+ try {
3186
+ await gatewayObject.update({ lastError: smtpStatus, lastUse: new Date() });
3187
+ } catch (updateErr) {
3188
+ // ignore
3189
+ }
3190
+ }
3191
+ }
3698
3192
 
3699
- // Enhance error with additional context
3700
- err.code = err.code || 'SubmitFail';
3701
- err.statusCode = Number(err.responseCode) || null;
3193
+ // Update feedback key with failure status
3194
+ if (data.feedbackKey && !jobData.nextAttempt) {
3195
+ const errorMessage = (smtpStatus && smtpStatus.description) || 'Failed to send email';
3196
+ await this.updateFeedbackKey(data.feedbackKey, false, errorMessage);
3197
+ }
3702
3198
 
3703
- err.info = { networkRouting };
3199
+ // Send delivery error notification
3200
+ await this.notify(
3201
+ false,
3202
+ EMAIL_DELIVERY_ERROR_NOTIFY,
3203
+ NotificationBuilder.buildErrorPayload({
3204
+ error: err,
3205
+ queueId,
3206
+ envelope,
3207
+ messageId: data.messageId,
3208
+ networkRouting,
3209
+ jobData
3210
+ })
3211
+ );
3704
3212
 
3705
- throw err;
3706
- }
3213
+ // Enhance error with additional context
3214
+ err.code = err.code || 'SubmitFail';
3215
+ err.statusCode = Number(err.responseCode) || null;
3216
+ err.info = { networkRouting };
3707
3217
  }
3708
3218
 
3709
3219
  // stub - to be implemented by subclasses
@@ -3712,4 +3222,5 @@ class BaseClient {
3712
3222
  }
3713
3223
  }
3714
3224
 
3715
- module.exports = { BaseClient, metricsMeta };
3225
+ // Re-export postMetrics from notification-handler for backward compatibility
3226
+ module.exports = { BaseClient, metricsMeta: postMetrics };