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.
- package/CHANGELOG.md +88 -0
- package/data/google-crawlers.json +1 -1
- package/lib/account.js +20 -7
- package/lib/api-routes/account-routes.js +28 -5
- package/lib/api-routes/chat-routes.js +1 -1
- package/lib/api-routes/export-routes.js +316 -0
- package/lib/api-routes/message-routes.js +28 -23
- package/lib/api-routes/template-routes.js +28 -7
- package/lib/arf-detect.js +1 -1
- package/lib/autodetect-imap-settings.js +5 -5
- package/lib/consts.js +16 -0
- package/lib/db.js +3 -0
- package/lib/email-client/base-client.js +6 -4
- package/lib/email-client/gmail-client.js +205 -35
- package/lib/email-client/imap/mailbox.js +99 -8
- package/lib/email-client/imap/subconnection.js +5 -5
- package/lib/email-client/imap-client.js +76 -19
- package/lib/email-client/message-builder.js +3 -1
- package/lib/email-client/notification-handler.js +12 -9
- package/lib/email-client/outlook-client.js +364 -73
- package/lib/email-client/smtp-pool-manager.js +1 -1
- package/lib/export.js +528 -0
- package/lib/oauth/gmail.js +24 -16
- package/lib/oauth/mail-ru.js +26 -13
- package/lib/oauth/outlook.js +29 -19
- package/lib/oauth/pubsub/google.js +5 -0
- package/lib/routes-ui.js +268 -9
- package/lib/schemas.js +274 -81
- package/lib/stream-encrypt.js +263 -0
- package/lib/sub-script.js +2 -2
- package/lib/tools.js +194 -12
- package/lib/ui-routes/account-routes.js +23 -0
- package/lib/ui-routes/admin-config-routes.js +13 -6
- package/lib/ui-routes/admin-entities-routes.js +18 -0
- package/lib/webhooks.js +16 -20
- package/package.json +20 -20
- package/sbom.json +1 -1
- package/server.js +66 -7
- package/static/js/ace/ace.js +1 -1
- package/static/js/ace/ext-language_tools.js +1 -1
- package/static/licenses.html +118 -149
- package/translations/de.mo +0 -0
- package/translations/de.po +63 -36
- package/translations/en.mo +0 -0
- package/translations/en.po +64 -37
- package/translations/et.mo +0 -0
- package/translations/et.po +63 -36
- package/translations/fr.mo +0 -0
- package/translations/fr.po +63 -36
- package/translations/ja.mo +0 -0
- package/translations/ja.po +63 -36
- package/translations/messages.pot +84 -51
- package/translations/nl.mo +0 -0
- package/translations/nl.po +63 -36
- package/translations/pl.mo +0 -0
- package/translations/pl.po +63 -36
- package/views/accounts/account.hbs +375 -2
- package/views/config/network.hbs +45 -0
- package/views/config/service.hbs +35 -0
- package/workers/api.js +130 -47
- package/workers/documents.js +3 -2
- package/workers/export.js +933 -0
- package/workers/imap.js +34 -1
- package/workers/submit.js +33 -6
- 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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1525
|
-
|
|
1526
|
-
let
|
|
1527
|
-
let
|
|
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
|
-
|
|
1530
|
-
|
|
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
|
-
|
|
1535
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
162
|
+
this.logger.warn({ msg: 'IMAP reconnection error', account: this.account, err });
|
|
163
163
|
});
|
|
164
164
|
});
|
|
165
165
|
|