emailengine-app 2.61.5 → 2.62.1

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 (65) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/data/google-crawlers.json +1 -1
  3. package/lib/account.js +20 -7
  4. package/lib/api-routes/account-routes.js +28 -5
  5. package/lib/api-routes/chat-routes.js +1 -1
  6. package/lib/api-routes/export-routes.js +316 -0
  7. package/lib/api-routes/message-routes.js +28 -23
  8. package/lib/api-routes/template-routes.js +28 -7
  9. package/lib/arf-detect.js +1 -1
  10. package/lib/autodetect-imap-settings.js +5 -5
  11. package/lib/consts.js +16 -0
  12. package/lib/db.js +3 -0
  13. package/lib/email-client/base-client.js +6 -4
  14. package/lib/email-client/gmail-client.js +205 -35
  15. package/lib/email-client/imap/mailbox.js +99 -8
  16. package/lib/email-client/imap/subconnection.js +5 -5
  17. package/lib/email-client/imap-client.js +76 -19
  18. package/lib/email-client/message-builder.js +3 -1
  19. package/lib/email-client/notification-handler.js +12 -9
  20. package/lib/email-client/outlook-client.js +364 -73
  21. package/lib/email-client/smtp-pool-manager.js +1 -1
  22. package/lib/export.js +528 -0
  23. package/lib/oauth/gmail.js +24 -16
  24. package/lib/oauth/mail-ru.js +26 -13
  25. package/lib/oauth/outlook.js +29 -19
  26. package/lib/oauth/pubsub/google.js +5 -0
  27. package/lib/routes-ui.js +268 -9
  28. package/lib/schemas.js +274 -81
  29. package/lib/stream-encrypt.js +263 -0
  30. package/lib/sub-script.js +2 -2
  31. package/lib/tools.js +194 -12
  32. package/lib/ui-routes/account-routes.js +23 -0
  33. package/lib/ui-routes/admin-config-routes.js +13 -6
  34. package/lib/ui-routes/admin-entities-routes.js +18 -0
  35. package/lib/webhooks.js +16 -20
  36. package/package.json +20 -20
  37. package/sbom.json +1 -1
  38. package/server.js +66 -7
  39. package/static/js/ace/ace.js +1 -1
  40. package/static/js/ace/ext-language_tools.js +1 -1
  41. package/static/licenses.html +118 -149
  42. package/translations/de.mo +0 -0
  43. package/translations/de.po +63 -36
  44. package/translations/en.mo +0 -0
  45. package/translations/en.po +64 -37
  46. package/translations/et.mo +0 -0
  47. package/translations/et.po +63 -36
  48. package/translations/fr.mo +0 -0
  49. package/translations/fr.po +63 -36
  50. package/translations/ja.mo +0 -0
  51. package/translations/ja.po +63 -36
  52. package/translations/messages.pot +84 -51
  53. package/translations/nl.mo +0 -0
  54. package/translations/nl.po +63 -36
  55. package/translations/pl.mo +0 -0
  56. package/translations/pl.po +63 -36
  57. package/views/accounts/account.hbs +375 -2
  58. package/views/config/network.hbs +45 -0
  59. package/views/config/service.hbs +35 -0
  60. package/workers/api.js +130 -47
  61. package/workers/documents.js +3 -2
  62. package/workers/export.js +933 -0
  63. package/workers/imap.js +34 -1
  64. package/workers/submit.js +33 -6
  65. package/workers/webhooks.js +20 -4
@@ -11,6 +11,7 @@ const he = require('he');
11
11
  const { BaseClient, metricsMeta } = require('./base-client');
12
12
  const { mimeHtml } = require('@postalsys/email-text-tools');
13
13
  const { emitChangeEvent } = require('../tools');
14
+ const crypto = require('crypto');
14
15
  const { Gateway } = require('../gateway');
15
16
 
16
17
  const {
@@ -20,11 +21,16 @@ const {
20
21
  EMAIL_SENT_NOTIFY,
21
22
  REDIS_PREFIX,
22
23
  AUTH_ERROR_NOTIFY,
23
- AUTH_SUCCESS_NOTIFY
24
+ AUTH_SUCCESS_NOTIFY,
25
+ DEFAULT_GMAIL_EXPORT_BATCH_SIZE
24
26
  } = require('../consts');
25
27
 
28
+ const settings = require('../settings');
29
+
26
30
  const { GMAIL_API_BASE, LIST_BATCH_SIZE, request: gmailApiRequest } = require('./gmail/gmail-api');
27
31
 
32
+ const MAX_GMAIL_BATCH_SIZE = 50;
33
+
28
34
  // Labels to exclude from folder listings
29
35
  const SKIP_LABELS = ['UNREAD', 'STARRED', 'IMPORTANT', 'CHAT', 'CATEGORY_PERSONAL'];
30
36
 
@@ -602,23 +608,39 @@ class GmailClient extends BaseClient {
602
608
 
603
609
  return folderData;
604
610
  })
605
- .filter(value => value)
606
- .sort((a, b) => {
607
- // Sort: INBOX first, then special folders, then alphabetical
608
- if (a.path === 'INBOX') {
609
- return -1;
610
- } else if (b.path === 'INBOX') {
611
- return 1;
612
- }
611
+ .filter(value => value);
612
+
613
+ // Add virtual "All Mail" folder for Gmail API
614
+ // This allows exporting all messages without scanning individual labels
615
+ mailboxes.unshift({
616
+ id: 'virtual_all',
617
+ path: '\\All',
618
+ delimiter: '/',
619
+ parentPath: '',
620
+ name: 'All Mail',
621
+ listed: true,
622
+ subscribed: false,
623
+ noSelect: true,
624
+ specialUse: '\\All',
625
+ specialUseSource: 'extension'
626
+ });
613
627
 
614
- if (a.specialUse && !b.specialUse) {
615
- return -1;
616
- } else if (!a.specialUse && b.specialUse) {
617
- return 1;
618
- }
628
+ mailboxes.sort((a, b) => {
629
+ // Sort: INBOX first, then special folders, then alphabetical
630
+ if (a.path === 'INBOX') {
631
+ return -1;
632
+ } else if (b.path === 'INBOX') {
633
+ return 1;
634
+ }
619
635
 
620
- return a.path.toLowerCase().localeCompare(b.path.toLowerCase());
621
- });
636
+ if (a.specialUse && !b.specialUse) {
637
+ return -1;
638
+ } else if (!a.specialUse && b.specialUse) {
639
+ return 1;
640
+ }
641
+
642
+ return a.path.toLowerCase().localeCompare(b.path.toLowerCase());
643
+ });
622
644
 
623
645
  return mailboxes;
624
646
  }
@@ -658,6 +680,7 @@ class GmailClient extends BaseClient {
658
680
  return false;
659
681
  }
660
682
  requestQuery.labelIds = [label.id];
683
+ path = query.path;
661
684
  }
662
685
 
663
686
  let messageList = [];
@@ -756,7 +779,7 @@ class GmailClient extends BaseClient {
756
779
  let nextPageCursor = pageCursor.nextPageCursor(listingResult.nextPageToken);
757
780
  let prevPageCursor = pageCursor.prevPageCursor();
758
781
 
759
- if (options.metadataOnly) {
782
+ if (options.metadataOnly || query.metadataOnly) {
760
783
  // Return just IDs without fetching full content
761
784
  return {
762
785
  total: messageCount,
@@ -769,6 +792,8 @@ class GmailClient extends BaseClient {
769
792
  }
770
793
 
771
794
  // Fetch message content for matching messages in batches
795
+ // Use format=minimal for minimalFields option (faster, returns only id, threadId, labelIds, internalDate, sizeEstimate)
796
+ const messageFormat = options.minimalFields ? 'minimal' : undefined;
772
797
 
773
798
  let promises = [];
774
799
 
@@ -782,14 +807,16 @@ class GmailClient extends BaseClient {
782
807
  throw entry.reason;
783
808
  }
784
809
  if (entry.value) {
785
- messageList.push(this.formatMessage(entry.value, { path }));
810
+ messageList.push(this.formatMessage(entry.value, { path, minimalFields: options.minimalFields }));
786
811
  }
787
812
  }
788
813
  promises = [];
789
814
  };
790
815
 
791
816
  for (let { id: message } of listingResult.messages || []) {
792
- promises.push(this.request(`${GMAIL_API_BASE}/gmail/v1/users/me/messages/${message}`));
817
+ let requestUrl = `${GMAIL_API_BASE}/gmail/v1/users/me/messages/${message}`;
818
+ let requestParams = messageFormat ? { format: messageFormat } : undefined;
819
+ promises.push(this.request(requestUrl, 'get', requestParams));
793
820
  if (promises.length > LIST_BATCH_SIZE) {
794
821
  await resolvePromises();
795
822
  }
@@ -1027,13 +1054,11 @@ class GmailClient extends BaseClient {
1027
1054
  messages = messages.concat(messageListResult?.messages);
1028
1055
  if (messages.length >= maxMessages) {
1029
1056
  messages = messages.slice(0, maxMessages);
1030
- notDone = false;
1031
1057
  break;
1032
1058
  }
1033
1059
  }
1034
1060
 
1035
1061
  if (!messageListResult.nextPageCursor) {
1036
- notDone = false;
1037
1062
  break;
1038
1063
  }
1039
1064
  cursor = messageListResult.nextPageCursor;
@@ -1269,6 +1294,9 @@ class GmailClient extends BaseClient {
1269
1294
 
1270
1295
  let formattedMessage = this.formatMessage(messageData, { extended: true, textType: options.textType });
1271
1296
 
1297
+ // Resolve label IDs to human-readable names
1298
+ await this.resolveLabels(formattedMessage);
1299
+
1272
1300
  // Mark as seen if requested
1273
1301
  if (options.markAsSeen && (!formattedMessage.flags || !formattedMessage.flags.includes('\\Seen'))) {
1274
1302
  //
@@ -1325,6 +1353,64 @@ class GmailClient extends BaseClient {
1325
1353
  return formattedMessage;
1326
1354
  }
1327
1355
 
1356
+ /**
1357
+ * Fetches multiple messages in parallel for batch export operations
1358
+ * @param {string[]} emailIds - Array of message IDs
1359
+ * @param {Object} options - Fetch options
1360
+ * @returns {Object[]} Array of results with messageId, data, and error fields
1361
+ */
1362
+ async getMessages(emailIds, options) {
1363
+ options = options || {};
1364
+ await this.prepare();
1365
+
1366
+ // Pre-fetch labels to resolve label IDs to names
1367
+ const labelMap = new Map();
1368
+ try {
1369
+ const labelsResult = await this.getLabels();
1370
+ for (const label of labelsResult || []) {
1371
+ labelMap.set(label.id, label.name);
1372
+ }
1373
+ } catch (err) {
1374
+ this.logger.warn({ msg: 'Failed to fetch labels for export, using raw label IDs', account: this.account, err });
1375
+ }
1376
+
1377
+ const results = [];
1378
+ const settingsBatchSize = await settings.get('gmailExportBatchSize');
1379
+ const batchSize = Math.min(settingsBatchSize || DEFAULT_GMAIL_EXPORT_BATCH_SIZE, MAX_GMAIL_BATCH_SIZE);
1380
+
1381
+ for (let i = 0; i < emailIds.length; i += batchSize) {
1382
+ const batch = emailIds.slice(i, i + batchSize);
1383
+
1384
+ const batchResults = await Promise.all(
1385
+ batch.map(async emailId => {
1386
+ try {
1387
+ const requestQuery = { format: 'full' };
1388
+ const messageData = await this.request(`${GMAIL_API_BASE}/gmail/v1/users/me/messages/${emailId}`, 'get', requestQuery);
1389
+ const formattedMessage = this.formatMessage(messageData, { extended: true, textType: options.textType });
1390
+
1391
+ await this.resolveLabels(formattedMessage, labelMap);
1392
+
1393
+ return {
1394
+ messageId: emailId,
1395
+ data: formattedMessage,
1396
+ error: null
1397
+ };
1398
+ } catch (err) {
1399
+ return {
1400
+ messageId: emailId,
1401
+ data: null,
1402
+ error: { message: err.message, code: err.code, statusCode: err.statusCode }
1403
+ };
1404
+ }
1405
+ })
1406
+ );
1407
+
1408
+ results.push(...batchResults);
1409
+ }
1410
+
1411
+ return results;
1412
+ }
1413
+
1328
1414
  /**
1329
1415
  * Fetches text content for a message
1330
1416
  * @param {string} textId - Encoded text identifier
@@ -1521,19 +1607,45 @@ class GmailClient extends BaseClient {
1521
1607
  return false;
1522
1608
  }
1523
1609
 
1524
- // Prepare for Gmail API send
1525
- let contentType = 'message/rfc822';
1526
- let payload = raw;
1527
- let targetEndpoint = `/upload/gmail/v1/users/me/messages/send`;
1610
+ // Gmail JSON endpoint: 5MB body limit (~3.5MB raw before base64url overhead)
1611
+ // Gmail upload endpoint: 35MB raw RFC822
1612
+ let contentType;
1613
+ let payload;
1614
+ let targetEndpoint;
1615
+ const JSON_SEND_LIMIT = 3.5 * 1024 * 1024;
1528
1616
 
1529
- // Use different endpoint for thread replies
1530
- if (data?.reference?.threadId) {
1531
- targetEndpoint = `/gmail/v1/users/me/messages/send`;
1617
+ if (raw.length <= JSON_SEND_LIMIT) {
1618
+ // JSON endpoint with base64url encoding (retry-safe, no ArrayBuffer issues)
1532
1619
  contentType = 'application/json';
1533
- payload = {
1534
- raw: raw.toString('base64'),
1535
- threadId: data?.reference?.threadId
1536
- };
1620
+ payload = { raw: raw.toString('base64url') };
1621
+ targetEndpoint = `/gmail/v1/users/me/messages/send`;
1622
+ if (data?.reference?.threadId) {
1623
+ payload.threadId = data.reference.threadId;
1624
+ }
1625
+ } else if (data?.reference?.threadId) {
1626
+ // Large threaded reply: multipart upload preserves explicit threadId
1627
+ // via JSON metadata alongside the raw RFC822 message body
1628
+ const boundary = `ee_${crypto.randomBytes(16).toString('hex')}`;
1629
+ const metadata = JSON.stringify({ threadId: data.reference.threadId });
1630
+ const preamble = Buffer.from(
1631
+ `--${boundary}\r\n` +
1632
+ `Content-Type: application/json; charset=UTF-8\r\n` +
1633
+ `\r\n` +
1634
+ `${metadata}\r\n` +
1635
+ `--${boundary}\r\n` +
1636
+ `Content-Type: message/rfc822\r\n` +
1637
+ `\r\n`
1638
+ );
1639
+ const epilogue = Buffer.from(`\r\n--${boundary}--`);
1640
+
1641
+ contentType = `multipart/related; boundary=${boundary}`;
1642
+ payload = Buffer.concat([preamble, raw, epilogue]);
1643
+ targetEndpoint = `/upload/gmail/v1/users/me/messages/send?uploadType=multipart`;
1644
+ } else {
1645
+ // Large non-threaded message: simple upload with raw RFC822 Buffer
1646
+ contentType = 'message/rfc822';
1647
+ payload = raw;
1648
+ targetEndpoint = `/upload/gmail/v1/users/me/messages/send`;
1537
1649
  }
1538
1650
 
1539
1651
  // Send via Gmail API
@@ -1591,7 +1703,8 @@ class GmailClient extends BaseClient {
1591
1703
  await this.redis
1592
1704
  .multi()
1593
1705
  .hset(data.feedbackKey, 'success', 'true')
1594
- .expire(1 * 60 * 60);
1706
+ .expire(data.feedbackKey, 1 * 60 * 60)
1707
+ .exec();
1595
1708
  }
1596
1709
 
1597
1710
  return {
@@ -2042,6 +2155,35 @@ class GmailClient extends BaseClient {
2042
2155
  }
2043
2156
  }
2044
2157
 
2158
+ /**
2159
+ * Resolves label IDs to human-readable names on a formatted message.
2160
+ * Mutates formattedMessage.labels in place. Labels starting with '\\' are
2161
+ * treated as special-use labels and left as-is.
2162
+ *
2163
+ * @param {Object} formattedMessage - Message with a .labels array
2164
+ * @param {Map} [labelMap] - Optional pre-built id-to-name map (avoids an extra getLabels call)
2165
+ */
2166
+ async resolveLabels(formattedMessage, labelMap) {
2167
+ if (!Array.isArray(formattedMessage?.labels)) {
2168
+ return;
2169
+ }
2170
+
2171
+ if (!labelMap) {
2172
+ const labelsResult = await this.getLabels();
2173
+ labelMap = new Map();
2174
+ for (const label of labelsResult || []) {
2175
+ labelMap.set(label.id, label.name);
2176
+ }
2177
+ }
2178
+
2179
+ formattedMessage.labels = formattedMessage.labels.map(label => {
2180
+ if (label.startsWith('\\')) {
2181
+ return label;
2182
+ }
2183
+ return labelMap.get(label) || label;
2184
+ });
2185
+ }
2186
+
2045
2187
  /**
2046
2188
  * Resolves a label by path or ID
2047
2189
  * @param {string} path - Label path or ID
@@ -2292,7 +2434,7 @@ class GmailClient extends BaseClient {
2292
2434
  * @returns {Object} Formatted message
2293
2435
  */
2294
2436
  formatMessage(messageData, options) {
2295
- let { extended, path, textType } = options || {};
2437
+ let { extended, path, textType, minimalFields } = options || {};
2296
2438
 
2297
2439
  let date = messageData.internalDate && !isNaN(messageData.internalDate) ? new Date(Number(messageData.internalDate)) : undefined;
2298
2440
  if (date?.toString() === 'Invalid Date') {
@@ -2301,6 +2443,34 @@ class GmailClient extends BaseClient {
2301
2443
 
2302
2444
  let { flags, labels, category } = this.formatFlagsAndLabels(messageData);
2303
2445
 
2446
+ // For minimalFields mode (format=minimal), payload is not available
2447
+ // Return only basic fields: id, threadId, labelIds, internalDate, sizeEstimate
2448
+ if (minimalFields) {
2449
+ const result = {
2450
+ id: messageData.id,
2451
+ emailId: messageData.id || undefined,
2452
+ threadId: messageData.threadId || undefined,
2453
+ date: date ? date.toISOString() : undefined,
2454
+ flags,
2455
+ labels,
2456
+ category,
2457
+ unseen: !flags.includes('\\Seen') ? true : undefined,
2458
+ flagged: flags.includes('\\Flagged') ? true : undefined,
2459
+ draft: flags.includes('\\Draft') ? true : undefined,
2460
+ size: messageData.sizeEstimate
2461
+ };
2462
+
2463
+ // Set special-use based on labels
2464
+ for (let specialUseTag of ['\\Junk', '\\Sent', '\\Trash', '\\Inbox', '\\Drafts']) {
2465
+ if (result.labels && result.labels.includes(specialUseTag)) {
2466
+ result.messageSpecialUse = specialUseTag;
2467
+ break;
2468
+ }
2469
+ }
2470
+
2471
+ return result;
2472
+ }
2473
+
2304
2474
  let envelope = this.getEnvelope(messageData);
2305
2475
 
2306
2476
  // Extract all headers
@@ -2424,7 +2594,7 @@ class GmailClient extends BaseClient {
2424
2594
  * @returns {string} Formatted term
2425
2595
  */
2426
2596
  formatSearchTerm(term, quot = '"') {
2427
- if (typeof term === 'object' && term && Object.prototype.toString.apply(new Date()) === '[object Date]') {
2597
+ if (typeof term === 'object' && term && Object.prototype.toString.apply(term) === '[object Date]') {
2428
2598
  term = term.toISOString().substring(0, 10);
2429
2599
  }
2430
2600
 
@@ -99,6 +99,18 @@ class Mailbox {
99
99
 
100
100
  let mailboxInfo = connectionClient.mailbox;
101
101
 
102
+ // Log diagnostic info if mailbox object is missing or incomplete
103
+ if (!mailboxInfo) {
104
+ this.logger.warn({
105
+ msg: 'IMAP mailbox object is not available',
106
+ path: this.path,
107
+ connectionState: connectionClient.state,
108
+ usable: connectionClient.usable,
109
+ idling: connectionClient.idling
110
+ });
111
+ throw new Error('IMAP mailbox state is not available');
112
+ }
113
+
102
114
  let status = {
103
115
  path: this.path
104
116
  };
@@ -112,6 +124,21 @@ class Mailbox {
112
124
  // Total message count
113
125
  status.messages = mailboxInfo.exists ? mailboxInfo.exists : 0;
114
126
 
127
+ // Log diagnostic info if uidValidity is invalid
128
+ if (!status.uidValidity) {
129
+ this.logger.warn({
130
+ msg: 'Invalid uidValidity received from IMAP server',
131
+ path: this.path,
132
+ rawUidValidity: mailboxInfo.uidValidity,
133
+ rawUidValidityType: typeof mailboxInfo.uidValidity,
134
+ uidNext: status.uidNext,
135
+ messages: status.messages,
136
+ mailboxPath: mailboxInfo.path,
137
+ connectionState: connectionClient.state,
138
+ usable: connectionClient.usable
139
+ });
140
+ }
141
+
115
142
  return status;
116
143
  }
117
144
 
@@ -122,6 +149,20 @@ class Mailbox {
122
149
  async getStoredStatus() {
123
150
  let data = await this.connection.redis.hgetall(this.getMailboxKey());
124
151
  data = data || {};
152
+
153
+ // Log diagnostic info if stored uidValidity is invalid or missing
154
+ if (!validUidValidity(data.uidValidity)) {
155
+ this.logger.warn({
156
+ msg: 'Invalid or missing uidValidity in stored status',
157
+ path: this.path,
158
+ redisKey: this.getMailboxKey(),
159
+ rawUidValidity: data.uidValidity,
160
+ rawUidValidityType: typeof data.uidValidity,
161
+ hasData: Object.keys(data).length > 0,
162
+ storedKeys: Object.keys(data)
163
+ });
164
+ }
165
+
125
166
  return {
126
167
  path: data.path || this.path,
127
168
  uidValidity: validUidValidity(data.uidValidity) ? BigInt(data.uidValidity) : false,
@@ -135,6 +176,25 @@ class Mailbox {
135
176
  };
136
177
  }
137
178
 
179
+ /**
180
+ * Packs a UID into external reference format with validation and logging
181
+ * @param {number} uid - Message UID to pack
182
+ * @param {string} notificationType - Type of notification for logging context
183
+ * @returns {string|null} Packed UID or null if packing failed
184
+ */
185
+ async packUidWithLogging(uid, notificationType) {
186
+ let packedUid = await this.connection.packUid(this, uid);
187
+ if (!packedUid) {
188
+ this.logger.warn({
189
+ msg: `Skipping ${notificationType} notification - packUid returned invalid result`,
190
+ uid,
191
+ path: this.path
192
+ });
193
+ return null;
194
+ }
195
+ return packedUid;
196
+ }
197
+
138
198
  /**
139
199
  * Updates known mailbox state in Redis
140
200
  * @param {Object} data - Status data to store
@@ -149,10 +209,32 @@ class Mailbox {
149
209
  .map(key => {
150
210
  switch (key) {
151
211
  case 'path':
152
- case 'uidValidity':
153
212
  case 'highestModseq':
154
213
  case 'messages':
214
+ return [key, data[key].toString()];
215
+
216
+ case 'uidValidity':
217
+ // Skip storing invalid uidValidity to prevent sync state corruption
218
+ if (!validUidValidity(data[key])) {
219
+ this.logger.warn({
220
+ msg: 'Skipping invalid uidValidity value in updateStoredStatus',
221
+ path: this.path,
222
+ uidValidityValue: data[key]
223
+ });
224
+ return null;
225
+ }
226
+ return [key, data[key].toString()];
227
+
155
228
  case 'uidNext':
229
+ // Skip storing invalid uidNext to prevent sync state corruption
230
+ if (!data[key]) {
231
+ this.logger.warn({
232
+ msg: 'Skipping invalid uidNext value in updateStoredStatus',
233
+ path: this.path,
234
+ uidNextValue: data[key]
235
+ });
236
+ return null;
237
+ }
156
238
  return [key, data[key].toString()];
157
239
 
158
240
  case 'lastFullSync':
@@ -644,7 +726,10 @@ class Mailbox {
644
726
  */
645
727
 
646
728
  // Generate packed UID for external reference
647
- let packedUid = await this.connection.packUid(this, messageData.uid);
729
+ let packedUid = await this.packUidWithLogging(messageData.uid, 'delete');
730
+ if (!packedUid) {
731
+ return;
732
+ }
648
733
  await this.connection.notify(this, MESSAGE_DELETED_NOTIFY, {
649
734
  id: packedUid,
650
735
  uid: messageData.uid
@@ -707,7 +792,10 @@ class Mailbox {
707
792
 
708
793
  if (!messageInfo) {
709
794
  // Message not found after retries - send missing notification
710
- let packedUid = await this.connection.packUid(this, messageData.uid);
795
+ let packedUid = await this.packUidWithLogging(messageData.uid, 'missing');
796
+ if (!packedUid) {
797
+ return;
798
+ }
711
799
  await this.connection.notify(this, MESSAGE_MISSING_NOTIFY, {
712
800
  id: packedUid,
713
801
  uid: messageData.uid,
@@ -766,7 +854,7 @@ class Mailbox {
766
854
  if (this.mightBeAComplaint(messageInfo)) {
767
855
  try {
768
856
  // Download relevant attachments for ARF parsing
769
- for (let attachment of messageInfo.attachments) {
857
+ for (let attachment of messageInfo.attachments || []) {
770
858
  if (!['message/feedback-report', 'message/rfc822-headers', 'message/rfc822'].includes(attachment.contentType)) {
771
859
  continue;
772
860
  }
@@ -1074,7 +1162,7 @@ class Mailbox {
1074
1162
  // Fetch inline attachments referenced in HTML
1075
1163
  if (messageInfo.attachments?.length && messageInfo.text?.html) {
1076
1164
  // fetch inline attachments
1077
- for (let attachment of messageInfo.attachments) {
1165
+ for (let attachment of messageInfo.attachments || []) {
1078
1166
  if (attachment.encodedSize && attachment.encodedSize > MAX_INLINE_ATTACHMENT_SIZE) {
1079
1167
  // skip large attachments
1080
1168
  continue;
@@ -1366,7 +1454,7 @@ class Mailbox {
1366
1454
  let partList = [];
1367
1455
 
1368
1456
  // Collect CID-referenced attachments
1369
- for (let attachment of messageInfo.attachments) {
1457
+ for (let attachment of messageInfo.attachments || []) {
1370
1458
  let contentId = attachment.contentId && attachment.contentId.replace(/^<|>$/g, '');
1371
1459
  if (contentId && messageInfo.text.html.indexOf(contentId) >= 0) {
1372
1460
  attachmentList.set(contentId, {
@@ -1786,7 +1874,10 @@ class Mailbox {
1786
1874
  * @param {Object} changes - What changed
1787
1875
  */
1788
1876
  async processChanges(messageData, changes) {
1789
- let packedUid = await this.connection.packUid(this, messageData.uid);
1877
+ let packedUid = await this.packUidWithLogging(messageData.uid, 'update');
1878
+ if (!packedUid) {
1879
+ return;
1880
+ }
1790
1881
  await this.connection.notify(this, MESSAGE_UPDATED_NOTIFY, {
1791
1882
  id: packedUid,
1792
1883
  uid: messageData.uid,
@@ -2303,7 +2394,7 @@ class Mailbox {
2303
2394
  let partList = [];
2304
2395
 
2305
2396
  // Find images referenced by CID
2306
- for (let attachment of messageInfo.attachments) {
2397
+ for (let attachment of messageInfo.attachments || []) {
2307
2398
  let contentId = attachment.contentId && attachment.contentId.replace(/^<|>$/g, '');
2308
2399
  if (contentId && messageInfo.text.html.indexOf(contentId) >= 0) {
2309
2400
  attachmentList.set(contentId, { attachment, content: null });
@@ -112,7 +112,7 @@ class Subconnection extends EventEmitter {
112
112
  this.disabledReason = false;
113
113
  } catch (err) {
114
114
  // ended in an unconncted state
115
- this.logger.error({ msg: 'Failed to set up subconnection', err });
115
+ this.logger.warn({ msg: 'Failed to set up subconnection', err });
116
116
  }
117
117
  }
118
118
 
@@ -124,11 +124,11 @@ class Subconnection extends EventEmitter {
124
124
  try {
125
125
  prevImapClient.removeAllListeners();
126
126
  prevImapClient.once('error', err => {
127
- this.logger.error({ msg: 'IMAP connection error', previous: true, account: this.account, err });
127
+ this.logger.warn({ msg: 'IMAP connection error', previous: true, account: this.account, err });
128
128
  });
129
129
  prevImapClient.close();
130
130
  } catch (err) {
131
- this.logger.error({ msg: 'IMAP close error', err });
131
+ this.logger.warn({ msg: 'IMAP close error', err });
132
132
  } finally {
133
133
  if (prevImapClient === this.imapClient) {
134
134
  this.imapClient = null;
@@ -153,13 +153,13 @@ class Subconnection extends EventEmitter {
153
153
  imapClient.subConnection = true;
154
154
 
155
155
  imapClient.on('error', err => {
156
- imapClient?.log.error({ msg: 'IMAP connection error', account: this.account, err });
156
+ imapClient?.log.warn({ msg: 'IMAP connection error', account: this.account, err });
157
157
  if (imapClient !== this.imapClient || this._connecting) {
158
158
  return;
159
159
  }
160
160
  imapClient.close();
161
161
  this.reconnect().catch(err => {
162
- this.logger.error({ msg: 'IMAP reconnection error', account: this.account, err });
162
+ this.logger.warn({ msg: 'IMAP reconnection error', account: this.account, err });
163
163
  });
164
164
  });
165
165