emailengine-app 2.68.1 → 2.70.0
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/.github/workflows/deploy.yml +8 -3
- package/.github/workflows/release.yaml +6 -0
- package/CHANGELOG.md +59 -0
- package/Gruntfile.js +3 -1
- package/config/default.toml +2 -0
- package/data/google-crawlers.json +7 -1
- package/getswagger.sh +40 -4
- package/gettext-extract.js +163 -0
- package/lib/account.js +135 -72
- package/lib/api-routes/account-routes.js +684 -106
- package/lib/api-routes/blocklist-routes.js +344 -0
- package/lib/api-routes/chat-routes.js +32 -14
- package/lib/api-routes/delivery-test-routes.js +346 -0
- package/lib/api-routes/export-routes.js +28 -14
- package/lib/api-routes/gateway-routes.js +427 -0
- package/lib/api-routes/license-routes.js +156 -0
- package/lib/api-routes/mailbox-routes.js +344 -0
- package/lib/api-routes/message-routes.js +221 -187
- package/lib/api-routes/oauth2-app-routes.js +697 -0
- package/lib/api-routes/outbox-routes.js +185 -0
- package/lib/api-routes/pubsub-routes.js +102 -0
- package/lib/api-routes/route-helpers.js +58 -0
- package/lib/api-routes/settings-routes.js +357 -0
- package/lib/api-routes/stats-routes.js +111 -0
- package/lib/api-routes/submit-routes.js +461 -0
- package/lib/api-routes/template-routes.js +60 -75
- package/lib/api-routes/token-routes.js +297 -0
- package/lib/api-routes/webhook-route-routes.js +181 -0
- package/lib/autodetect-imap-settings.js +0 -2
- package/lib/consts.js +5 -0
- package/lib/email-client/base-client.js +28 -6
- package/lib/email-client/gmail-client.js +133 -112
- package/lib/email-client/imap/mailbox.js +34 -11
- package/lib/email-client/imap/subconnection.js +20 -13
- package/lib/email-client/imap/sync-operations.js +131 -3
- package/lib/email-client/imap-client.js +152 -75
- package/lib/email-client/notification-handler.js +1 -4
- package/lib/email-client/outlook-client.js +134 -75
- package/lib/export.js +97 -20
- package/lib/feature-flags.js +2 -2
- package/lib/gateway.js +4 -9
- package/lib/get-raw-email.js +5 -5
- package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
- package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
- package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
- package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
- package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
- package/lib/logger.js +24 -21
- package/lib/message-port-stream.js +113 -16
- package/lib/metrics-collector.js +0 -2
- package/lib/oauth2-apps.js +13 -4
- package/lib/outbox.js +24 -40
- package/lib/redis-operations.js +1 -1
- package/lib/reject-worker-calls.js +42 -0
- package/lib/routes-ui.js +37 -8778
- package/lib/schemas.js +429 -84
- package/lib/sentry.js +139 -0
- package/lib/settings.js +9 -3
- package/lib/stream-encrypt.js +1 -1
- package/lib/templates.js +1 -1
- package/lib/tokens.js +5 -3
- package/lib/tools.js +70 -4
- package/lib/ui-routes/account-routes.js +45 -212
- package/lib/ui-routes/admin-config-routes.js +928 -489
- package/lib/ui-routes/admin-entities-routes.js +1 -0
- package/lib/ui-routes/auth-routes.js +1339 -0
- package/lib/ui-routes/dashboard-routes.js +188 -0
- package/lib/ui-routes/document-store-routes.js +800 -0
- package/lib/ui-routes/export-routes.js +217 -0
- package/lib/ui-routes/internals-routes.js +354 -0
- package/lib/ui-routes/network-config-routes.js +759 -0
- package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
- package/lib/ui-routes/route-helpers.js +314 -0
- package/lib/ui-routes/smtp-test-routes.js +236 -0
- package/lib/ui-routes/unsubscribe-routes.js +232 -0
- package/lib/webhook-request.js +36 -0
- package/lib/webhooks.js +8 -4
- package/package.json +13 -12
- package/sbom.json +1 -1
- package/server.js +222 -39
- package/static/licenses.html +160 -300
- package/translations/messages.pot +112 -132
- package/update-info.sh +19 -1
- package/views/config/logging.hbs +48 -0
- package/views/dashboard.hbs +7 -26
- package/views/internals/index.hbs +15 -0
- package/views/tokens/index.hbs +9 -0
- package/workers/api.js +200 -4424
- package/workers/documents.js +2 -22
- package/workers/export.js +103 -104
- package/workers/imap-proxy.js +3 -23
- package/workers/imap.js +32 -36
- package/workers/smtp.js +2 -22
- package/workers/submit.js +26 -35
- package/workers/webhooks.js +9 -43
|
@@ -41,6 +41,27 @@ const MAX_BATCH_SIZE = graphApi.MAX_BATCH_SIZE;
|
|
|
41
41
|
// Subscription is renewed automatically. But just in case, check once in an hour
|
|
42
42
|
const RENEW_WATCH_TTL = 60 * 60 * 1000; // 1h
|
|
43
43
|
|
|
44
|
+
// MS Graph folder hierarchy is defined by id/parentFolderId, not by "/" in folder
|
|
45
|
+
// names. To build an unambiguous, reversible pathName we percent-encode "%" and "/"
|
|
46
|
+
// inside a single displayName segment, keeping the real "/" as the delimiter BETWEEN
|
|
47
|
+
// segments. Encode "%" first then "/"; decode "%2F" first then "%25" so that a folder
|
|
48
|
+
// name literally containing "%2F" or "%25" round-trips correctly.
|
|
49
|
+
// Both helpers are pure and exception-safe: the typeof guard returns non-string input
|
|
50
|
+
// unchanged, and replaceAll on a string uses literal (not regex) replacement.
|
|
51
|
+
function encodeFolderSegment(name) {
|
|
52
|
+
if (typeof name !== 'string') {
|
|
53
|
+
return name;
|
|
54
|
+
}
|
|
55
|
+
return name.replaceAll('%', '%25').replaceAll('/', '%2F');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function decodeFolderSegment(segment) {
|
|
59
|
+
if (typeof segment !== 'string') {
|
|
60
|
+
return segment;
|
|
61
|
+
}
|
|
62
|
+
return segment.replaceAll('%2F', '/').replaceAll('%25', '%');
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
/*
|
|
45
66
|
Supported operations status:
|
|
46
67
|
✅ listMessages - with paging (cursor + page nr) and search queries (no support for to/cc/bcc queries)
|
|
@@ -625,7 +646,8 @@ class OutlookClient extends BaseClient {
|
|
|
625
646
|
'ccRecipients',
|
|
626
647
|
'bccRecipients',
|
|
627
648
|
'internetMessageId',
|
|
628
|
-
'bodyPreview'
|
|
649
|
+
'bodyPreview',
|
|
650
|
+
'categories'
|
|
629
651
|
];
|
|
630
652
|
expandFields = 'attachments($select=id,name,contentType,size,isInline,microsoft.graph.fileAttachment/contentId)';
|
|
631
653
|
}
|
|
@@ -647,6 +669,7 @@ class OutlookClient extends BaseClient {
|
|
|
647
669
|
|
|
648
670
|
let useOutlookSearch = false;
|
|
649
671
|
let skipToken = null;
|
|
672
|
+
let labelFilterActive = false;
|
|
650
673
|
|
|
651
674
|
// Handle search queries
|
|
652
675
|
if (query.search) {
|
|
@@ -668,15 +691,48 @@ class OutlookClient extends BaseClient {
|
|
|
668
691
|
// we need to have receivedDateTime as the first filtering property, otherwise ordering will fail
|
|
669
692
|
requestQuery.$filter = `receivedDateTime gt 1970-01-01T00:00:00.000Z and ${$filter}`;
|
|
670
693
|
}
|
|
694
|
+
// Category (label) filters compile to a categories/any() lambda. Some mailboxes refuse
|
|
695
|
+
// to combine that with $orderBy; remember it so we can retry without ordering below.
|
|
696
|
+
labelFilterActive = !!(query.search.labels && [].concat(query.search.labels.has || [], query.search.labels.not || []).some(Boolean));
|
|
671
697
|
}
|
|
672
698
|
}
|
|
673
699
|
|
|
674
700
|
let messages;
|
|
675
701
|
let totalMessages;
|
|
676
702
|
|
|
703
|
+
let messagesUrl = `/${this.oauth2UserPath}/${folder ? `mailFolders/${folder.id}/` : ''}messages`;
|
|
704
|
+
// The "not" form of a category filter may require advanced query capabilities; the header is
|
|
705
|
+
// harmless for the "has" form and for queries without a label filter we omit it entirely.
|
|
706
|
+
let requestOptions = labelFilterActive ? { headers: { ConsistencyLevel: 'eventual' } } : undefined;
|
|
707
|
+
|
|
677
708
|
// Execute the message list request
|
|
678
709
|
try {
|
|
679
|
-
let listing
|
|
710
|
+
let listing;
|
|
711
|
+
try {
|
|
712
|
+
listing = await this.request(messagesUrl, 'get', requestQuery, requestOptions);
|
|
713
|
+
} catch (err) {
|
|
714
|
+
// A categories/any() filter combined with $orderBy can be rejected as too complex
|
|
715
|
+
// ("InefficientFilter"). Retry once without server-side ordering and sort the page locally.
|
|
716
|
+
let graphCode = err?.oauthRequest?.response?.error?.code || '';
|
|
717
|
+
let graphMessage = err?.oauthRequest?.response?.error?.message || '';
|
|
718
|
+
let inefficientFilter = /inefficientfilter/i.test(graphCode) || /restriction or sort order is too complex/i.test(graphMessage);
|
|
719
|
+
|
|
720
|
+
if (labelFilterActive && requestQuery.$orderBy && inefficientFilter) {
|
|
721
|
+
this.logger.warn({
|
|
722
|
+
msg: 'Graph rejected ordered category filter, retrying without server-side ordering',
|
|
723
|
+
account: this.account,
|
|
724
|
+
path
|
|
725
|
+
});
|
|
726
|
+
delete requestQuery.$orderBy;
|
|
727
|
+
listing = await this.request(messagesUrl, 'get', requestQuery, requestOptions);
|
|
728
|
+
// Without $orderBy the server no longer guarantees order; sort this page newest-first
|
|
729
|
+
if (listing && Array.isArray(listing.value)) {
|
|
730
|
+
listing.value.sort((a, b) => new Date(b.receivedDateTime) - new Date(a.receivedDateTime));
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
throw err;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
680
736
|
|
|
681
737
|
totalMessages = !isNaN(listing['@odata.count']) ? Number(listing['@odata.count']) : undefined;
|
|
682
738
|
|
|
@@ -854,23 +910,35 @@ class OutlookClient extends BaseClient {
|
|
|
854
910
|
|
|
855
911
|
let folder;
|
|
856
912
|
if (!force) {
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
// If we're already in trash, force delete
|
|
861
|
-
if (!force && folder?.specialUse === '\\Trash') {
|
|
862
|
-
force = true;
|
|
863
|
-
}
|
|
913
|
+
// Fetch the listing once for both folder resolutions
|
|
914
|
+
let mailboxListing = (await this.getCachedMailboxListing()) || (await this.getMailboxListing());
|
|
864
915
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
916
|
+
// If the source folder is already the Trash folder, delete permanently
|
|
917
|
+
let sourceFolder;
|
|
918
|
+
try {
|
|
919
|
+
sourceFolder = await this.resolveFolder(path, { mailboxListing });
|
|
868
920
|
} catch (err) {
|
|
869
921
|
this.logger.error({
|
|
870
|
-
msg: 'Failed to resolve folder
|
|
922
|
+
msg: 'Failed to resolve source folder',
|
|
923
|
+
path,
|
|
871
924
|
err
|
|
872
925
|
});
|
|
873
926
|
}
|
|
927
|
+
|
|
928
|
+
if (sourceFolder?.specialUse === '\\Trash') {
|
|
929
|
+
force = true;
|
|
930
|
+
} else {
|
|
931
|
+
folder = await this.resolveFolder('\\Trash', { mailboxListing });
|
|
932
|
+
if (!folder) {
|
|
933
|
+
let error = new Error('Trash folder was not found');
|
|
934
|
+
error.info = {
|
|
935
|
+
response: 'Failed to resolve the Trash folder for the account'
|
|
936
|
+
};
|
|
937
|
+
error.code = 'TrashNotFound';
|
|
938
|
+
error.statusCode = 404;
|
|
939
|
+
throw error;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
874
942
|
}
|
|
875
943
|
|
|
876
944
|
// Step 1. Resolve matching messages
|
|
@@ -993,27 +1061,15 @@ class OutlookClient extends BaseClient {
|
|
|
993
1061
|
}
|
|
994
1062
|
|
|
995
1063
|
/**
|
|
996
|
-
*
|
|
1064
|
+
* Converts an IMAP-style flag update request into Graph API message properties.
|
|
1065
|
+
* Only \Seen and \Flagged can be represented in the Graph API. Set precedence is
|
|
1066
|
+
* handled by BaseClient#normalizeFlagUpdates.
|
|
1067
|
+
* @param {Object} [flags] - Flag update request ({ add, delete, set })
|
|
1068
|
+
* @returns {Object} Graph API PATCH properties ({ isRead, flag })
|
|
997
1069
|
*/
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
updates = updates || {};
|
|
1001
|
-
|
|
1002
|
-
let addFlags = updates.flags?.add || [];
|
|
1003
|
-
let deleteFlags = updates.flags?.delete || [];
|
|
1004
|
-
|
|
1005
|
-
// Handle flag set operations
|
|
1006
|
-
if (updates.flags?.set) {
|
|
1007
|
-
for (let flag of ['\\Seen', '\\Flagged']) {
|
|
1008
|
-
if (updates.flags.set.includes(flag)) {
|
|
1009
|
-
addFlags.push(flag);
|
|
1010
|
-
} else {
|
|
1011
|
-
deleteFlags.push(flag);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1070
|
+
buildFlagUpdates(flags) {
|
|
1071
|
+
let { add: addFlags, delete: deleteFlags } = this.normalizeFlagUpdates(flags, ['\\Seen', '\\Flagged']);
|
|
1015
1072
|
|
|
1016
|
-
// Map IMAP flags to Graph API properties
|
|
1017
1073
|
let flagUpdates = {};
|
|
1018
1074
|
|
|
1019
1075
|
if (addFlags.includes('\\Seen')) {
|
|
@@ -1030,6 +1086,19 @@ class OutlookClient extends BaseClient {
|
|
|
1030
1086
|
flagUpdates.flag = { flagStatus: 'notFlagged' };
|
|
1031
1087
|
}
|
|
1032
1088
|
|
|
1089
|
+
return flagUpdates;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Update message flags (Graph API only supports \Seen and \Flagged)
|
|
1094
|
+
*/
|
|
1095
|
+
async updateMessage(emailId, updates) {
|
|
1096
|
+
await this.prepare();
|
|
1097
|
+
updates = updates || {};
|
|
1098
|
+
|
|
1099
|
+
// Map IMAP flags to Graph API properties
|
|
1100
|
+
let flagUpdates = this.buildFlagUpdates(updates.flags);
|
|
1101
|
+
|
|
1033
1102
|
// Handle label (category) operations
|
|
1034
1103
|
if (updates.labels) {
|
|
1035
1104
|
let categories;
|
|
@@ -1122,36 +1191,8 @@ class OutlookClient extends BaseClient {
|
|
|
1122
1191
|
|
|
1123
1192
|
updates = updates || {};
|
|
1124
1193
|
|
|
1125
|
-
let addFlags = updates.flags?.add || [];
|
|
1126
|
-
let deleteFlags = updates.flags?.delete || [];
|
|
1127
|
-
|
|
1128
|
-
// Handle flag set operations
|
|
1129
|
-
if (updates.flags?.set) {
|
|
1130
|
-
for (let flag of ['\\Seen', '\\Flagged']) {
|
|
1131
|
-
if (updates.flags.set.includes(flag)) {
|
|
1132
|
-
addFlags.push(flag);
|
|
1133
|
-
} else {
|
|
1134
|
-
deleteFlags.push(flag);
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
1194
|
// Map IMAP flags to Graph API properties
|
|
1140
|
-
let flagUpdates =
|
|
1141
|
-
|
|
1142
|
-
if (addFlags.includes('\\Seen')) {
|
|
1143
|
-
flagUpdates.isRead = true;
|
|
1144
|
-
}
|
|
1145
|
-
if (deleteFlags.includes('\\Seen')) {
|
|
1146
|
-
flagUpdates.isRead = false;
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
if (addFlags.includes('\\Flagged')) {
|
|
1150
|
-
flagUpdates.flag = { flagStatus: 'flagged' };
|
|
1151
|
-
}
|
|
1152
|
-
if (deleteFlags.includes('\\Flagged')) {
|
|
1153
|
-
flagUpdates.flag = { flagStatus: 'notFlagged' };
|
|
1154
|
-
}
|
|
1195
|
+
let flagUpdates = this.buildFlagUpdates(updates.flags);
|
|
1155
1196
|
|
|
1156
1197
|
// Step 1. Resolve matching messages
|
|
1157
1198
|
let emailIds = search.emailIds || (await this.searchEmailIds(path, search));
|
|
@@ -1523,7 +1564,7 @@ class OutlookClient extends BaseClient {
|
|
|
1523
1564
|
|
|
1524
1565
|
const contentResponse = {
|
|
1525
1566
|
headers: {
|
|
1526
|
-
'content-type': attachmentData.
|
|
1567
|
+
'content-type': attachmentData.contentType || 'application/octet-stream',
|
|
1527
1568
|
'content-disposition': 'attachment' + filenameParam
|
|
1528
1569
|
},
|
|
1529
1570
|
contentType: attachmentData.contentType,
|
|
@@ -2500,7 +2541,9 @@ class OutlookClient extends BaseClient {
|
|
|
2500
2541
|
|
|
2501
2542
|
let subPaths = path.split('/');
|
|
2502
2543
|
|
|
2503
|
-
|
|
2544
|
+
// Decode the leaf so the literal folder name is sent to Graph; parent segments
|
|
2545
|
+
// stay encoded for resolveFolder (which matches against the encoded pathName).
|
|
2546
|
+
let displayName = decodeFolderSegment(subPaths.pop());
|
|
2504
2547
|
let parentPath = subPaths.join('/');
|
|
2505
2548
|
|
|
2506
2549
|
// Resolve parent folder if specified
|
|
@@ -2570,7 +2613,7 @@ class OutlookClient extends BaseClient {
|
|
|
2570
2613
|
mailboxId: mailbox.id,
|
|
2571
2614
|
path: []
|
|
2572
2615
|
.concat(parentFolder?.pathName || [])
|
|
2573
|
-
.concat(mailbox.displayName)
|
|
2616
|
+
.concat(encodeFolderSegment(mailbox.displayName))
|
|
2574
2617
|
.join('/'),
|
|
2575
2618
|
created: true
|
|
2576
2619
|
};
|
|
@@ -2637,8 +2680,10 @@ class OutlookClient extends BaseClient {
|
|
|
2637
2680
|
if (sourceName !== destinationName) {
|
|
2638
2681
|
let mailbox;
|
|
2639
2682
|
try {
|
|
2683
|
+
// sourceName/destinationName stay encoded for the comparison above;
|
|
2684
|
+
// decode only here so Graph receives the literal folder name.
|
|
2640
2685
|
mailbox = await this.request(`/${this.oauth2UserPath}/mailFolders/${sourceFolder.id}`, 'patch', {
|
|
2641
|
-
displayName: destinationName
|
|
2686
|
+
displayName: decodeFolderSegment(destinationName)
|
|
2642
2687
|
});
|
|
2643
2688
|
if (!mailbox) {
|
|
2644
2689
|
throw new Error('Failed to rename mailbox');
|
|
@@ -3077,7 +3122,7 @@ class OutlookClient extends BaseClient {
|
|
|
3077
3122
|
if (pathNamePrefix) {
|
|
3078
3123
|
entry.parentPath = pathNamePrefix;
|
|
3079
3124
|
}
|
|
3080
|
-
entry.pathName = `${pathNamePrefix ? `${pathNamePrefix}/` : ''}${entry.displayName}`;
|
|
3125
|
+
entry.pathName = `${pathNamePrefix ? `${pathNamePrefix}/` : ''}${encodeFolderSegment(entry.displayName)}`;
|
|
3081
3126
|
return entry;
|
|
3082
3127
|
})
|
|
3083
3128
|
);
|
|
@@ -3094,8 +3139,8 @@ class OutlookClient extends BaseClient {
|
|
|
3094
3139
|
|
|
3095
3140
|
// Traverse child folders
|
|
3096
3141
|
for (let entry of list) {
|
|
3097
|
-
//
|
|
3098
|
-
if (entry.childFolderCount
|
|
3142
|
+
// pathName percent-encodes any literal "/" in the segment, so child paths stay unambiguous
|
|
3143
|
+
if (entry.childFolderCount) {
|
|
3099
3144
|
await traverse(entry.pathName, entry.id);
|
|
3100
3145
|
}
|
|
3101
3146
|
}
|
|
@@ -3103,10 +3148,8 @@ class OutlookClient extends BaseClient {
|
|
|
3103
3148
|
|
|
3104
3149
|
await traverse();
|
|
3105
3150
|
|
|
3106
|
-
// keep only real
|
|
3107
|
-
mailboxListing = mailboxListing.filter(
|
|
3108
|
-
entry => (!entry['@odata.type'] || /^#?microsoft\.graph\.mailFolder$/.test(entry['@odata.type'])) && entry.displayName.indexOf('/') < 0
|
|
3109
|
-
);
|
|
3151
|
+
// keep only real mail folders (drop search folders and other non-mailFolder types)
|
|
3152
|
+
mailboxListing = mailboxListing.filter(entry => !entry['@odata.type'] || /^#?microsoft\.graph\.mailFolder$/.test(entry['@odata.type']));
|
|
3110
3153
|
|
|
3111
3154
|
return mailboxListing;
|
|
3112
3155
|
}
|
|
@@ -3120,8 +3163,9 @@ class OutlookClient extends BaseClient {
|
|
|
3120
3163
|
|
|
3121
3164
|
path = [].concat(path || []).join('/');
|
|
3122
3165
|
|
|
3123
|
-
|
|
3124
|
-
|
|
3166
|
+
// Callers that resolve multiple folders can pass a pre-fetched listing to avoid
|
|
3167
|
+
// repeated cache reads or Graph API folder crawls
|
|
3168
|
+
let mailboxListing = options.mailboxListing || (await this.getCachedMailboxListing()) || (await this.getMailboxListing());
|
|
3125
3169
|
|
|
3126
3170
|
if (options.byId) {
|
|
3127
3171
|
return mailboxListing.find(entry => entry.id === path);
|
|
@@ -3691,6 +3735,7 @@ class OutlookClient extends BaseClient {
|
|
|
3691
3735
|
bcc: extended && messageData.bccRecipients?.length ? messageData.bccRecipients.map(entry => entry.emailAddress).filter(entry => entry) : undefined,
|
|
3692
3736
|
|
|
3693
3737
|
messageId: messageData.internetMessageId,
|
|
3738
|
+
inReplyTo: messageData.inReplyTo || undefined,
|
|
3694
3739
|
|
|
3695
3740
|
headers: (extended && messageData.headers) || undefined,
|
|
3696
3741
|
|
|
@@ -4385,6 +4430,20 @@ class OutlookClient extends BaseClient {
|
|
|
4385
4430
|
filterParts.push(`receivedDateTime gt ${this.formatSearchTerm(search.since || search.sentSince, false)}`);
|
|
4386
4431
|
}
|
|
4387
4432
|
|
|
4433
|
+
// Category (label) filters - "has" matches messages tagged with the category, "not" excludes them
|
|
4434
|
+
if (search.labels && typeof search.labels === 'object') {
|
|
4435
|
+
for (let category of [].concat(search.labels.has || [])) {
|
|
4436
|
+
if (category) {
|
|
4437
|
+
filterParts.push(`categories/any(c:c eq ${this.formatSearchTerm(category)})`);
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
for (let category of [].concat(search.labels.not || [])) {
|
|
4441
|
+
if (category) {
|
|
4442
|
+
filterParts.push(`not (categories/any(c:c eq ${this.formatSearchTerm(category)}))`);
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4388
4447
|
// Limited header search support
|
|
4389
4448
|
for (let headerKey of Object.keys(search.header || {})) {
|
|
4390
4449
|
switch (headerKey.toLowerCase().trim()) {
|
|
@@ -4522,4 +4581,4 @@ class OutlookClient extends BaseClient {
|
|
|
4522
4581
|
}
|
|
4523
4582
|
}
|
|
4524
4583
|
|
|
4525
|
-
module.exports = { OutlookClient };
|
|
4584
|
+
module.exports = { OutlookClient, encodeFolderSegment, decodeFolderSegment };
|
package/lib/export.js
CHANGED
|
@@ -66,6 +66,39 @@ function createError(message, code, statusCode) {
|
|
|
66
66
|
return err;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Error classifiers used by the export worker to decide between retrying, skipping a single
|
|
70
|
+
// message, or failing the whole export job.
|
|
71
|
+
|
|
72
|
+
function isTransientError(err) {
|
|
73
|
+
if (['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPIPE', 'EHOSTUNREACH'].includes(err.code)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (err.statusCode >= 500 && err.statusCode < 600) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (err.code === 'Timeout' || err.message?.includes('timeout')) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Account.assertMessageFound reports a missing message as a Boom 404 that carries the
|
|
86
|
+
// machine-readable code and status only in err.output, while client/RPC errors set plain
|
|
87
|
+
// err.code/err.statusCode - check both shapes
|
|
88
|
+
function isSkippableError(err) {
|
|
89
|
+
let errCode = err.output?.payload?.code || err.code;
|
|
90
|
+
let statusCode = err.statusCode || err.output?.statusCode;
|
|
91
|
+
return errCode === 'MessageNotFound' || statusCode === 404 || err.message?.includes('Failed to generate message ID');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Account.listMessages reports unknown folders as FolderNotFound (Boom error with the code in
|
|
95
|
+
// the payload), the Gmail and Outlook backends throw NotFound directly. Outlook bulk delete can
|
|
96
|
+
// also throw TrashNotFound, but the export worker never deletes messages, so it is not matched.
|
|
97
|
+
function isFolderMissingError(err) {
|
|
98
|
+
let errCode = err.output?.payload?.code || err.code;
|
|
99
|
+
return ['FolderNotFound', 'NotFound'].includes(errCode);
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
async function tryAddToActiveSet(account, exportId) {
|
|
70
103
|
const maxConcurrent = (await settings.get('exportMaxConcurrent')) || DEFAULT_EXPORT_MAX_CONCURRENT;
|
|
71
104
|
const maxGlobalConcurrent = (await settings.get('exportMaxGlobalConcurrent')) || DEFAULT_EXPORT_MAX_GLOBAL_CONCURRENT;
|
|
@@ -114,6 +147,13 @@ async function getExportMaxAge() {
|
|
|
114
147
|
return DEFAULT_EXPORT_MAX_AGE;
|
|
115
148
|
}
|
|
116
149
|
|
|
150
|
+
// Resolve the retention window into the Redis `expire` TTL (seconds) and an absolute `expiresAt`
|
|
151
|
+
// timestamp (ms), used wherever an export key's expiry is (re)set.
|
|
152
|
+
async function getExportExpiry() {
|
|
153
|
+
const maxAge = await getExportMaxAge();
|
|
154
|
+
return { ttl: Math.ceil(maxAge / 1000), expiresAt: Date.now() + maxAge };
|
|
155
|
+
}
|
|
156
|
+
|
|
117
157
|
function toTimestamp(date) {
|
|
118
158
|
const ts = new Date(date).getTime();
|
|
119
159
|
if (isNaN(ts)) {
|
|
@@ -168,7 +208,8 @@ class Export {
|
|
|
168
208
|
startDate,
|
|
169
209
|
endDate,
|
|
170
210
|
textType: options.textType || '*',
|
|
171
|
-
|
|
211
|
+
// Preserve an explicit 0 ("unlimited" per the API contract); only fall back when unset.
|
|
212
|
+
maxBytes: Number.isInteger(options.maxBytes) ? options.maxBytes : 5 * 1024 * 1024,
|
|
172
213
|
includeAttachments: options.includeAttachments ? '1' : '0',
|
|
173
214
|
isEncrypted: isEncrypted ? '1' : '0',
|
|
174
215
|
foldersScanned: 0,
|
|
@@ -178,7 +219,6 @@ class Export {
|
|
|
178
219
|
messagesSkipped: 0,
|
|
179
220
|
bytesWritten: 0,
|
|
180
221
|
filePath,
|
|
181
|
-
lastProcessedScore: 0,
|
|
182
222
|
created: now,
|
|
183
223
|
expiresAt,
|
|
184
224
|
error: ''
|
|
@@ -325,11 +365,39 @@ class Export {
|
|
|
325
365
|
|
|
326
366
|
static async startProcessing(account, exportId) {
|
|
327
367
|
const exportKey = getExportKey(account, exportId);
|
|
328
|
-
const
|
|
329
|
-
const ttl =
|
|
330
|
-
|
|
368
|
+
const queueKey = getExportQueueKey(account, exportId);
|
|
369
|
+
const { ttl, expiresAt } = await getExportExpiry();
|
|
370
|
+
|
|
371
|
+
// Reset progress and clear any previously indexed queue so that each (re)run -- including a
|
|
372
|
+
// BullMQ stalled-job reprocess -- rebuilds the queue and rewrites the (truncated) output file
|
|
373
|
+
// from scratch, keeping counters and file content consistent.
|
|
374
|
+
await redis
|
|
375
|
+
.multi()
|
|
376
|
+
.hmset(exportKey, {
|
|
377
|
+
status: 'processing',
|
|
378
|
+
phase: 'indexing',
|
|
379
|
+
expiresAt,
|
|
380
|
+
foldersScanned: 0,
|
|
381
|
+
foldersTotal: 0,
|
|
382
|
+
messagesQueued: 0,
|
|
383
|
+
messagesExported: 0,
|
|
384
|
+
messagesSkipped: 0,
|
|
385
|
+
bytesWritten: 0,
|
|
386
|
+
truncated: '0'
|
|
387
|
+
})
|
|
388
|
+
.del(queueKey)
|
|
389
|
+
.expire(exportKey, ttl)
|
|
390
|
+
.exec();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
static async extendExpiry(account, exportId) {
|
|
394
|
+
const exportKey = getExportKey(account, exportId);
|
|
395
|
+
const queueKey = getExportQueueKey(account, exportId);
|
|
396
|
+
const { ttl, expiresAt } = await getExportExpiry();
|
|
331
397
|
|
|
332
|
-
|
|
398
|
+
// Keep both the export hash and the pending-message queue alive for the full retention window
|
|
399
|
+
// while a long export is still running, and surface the refreshed expiry to status readers.
|
|
400
|
+
await redis.multi().hset(exportKey, 'expiresAt', expiresAt).expire(exportKey, ttl).expire(queueKey, ttl).exec();
|
|
333
401
|
}
|
|
334
402
|
|
|
335
403
|
static async queueMessage(account, exportId, messageInfo) {
|
|
@@ -355,19 +423,23 @@ class Export {
|
|
|
355
423
|
await multi.exec();
|
|
356
424
|
}
|
|
357
425
|
|
|
358
|
-
static async getNextBatch(account, exportId,
|
|
426
|
+
static async getNextBatch(account, exportId, limit) {
|
|
359
427
|
const queueKey = getExportQueueKey(account, exportId);
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
|
|
428
|
+
// Atomically pop the lowest-scored (oldest) messages so each is processed exactly once.
|
|
429
|
+
// This avoids cursor-based reprocessing and the boundary drops that an exclusive score bound
|
|
430
|
+
// caused when two messages shared the same score.
|
|
431
|
+
const results = await redis.zpopmin(queueKey, limit);
|
|
363
432
|
|
|
433
|
+
// ZPOPMIN returns [member, score, member, score, ...]; only the decoded member is needed.
|
|
364
434
|
const messages = [];
|
|
365
435
|
for (let i = 0; i < results.length; i += 2) {
|
|
366
436
|
try {
|
|
367
|
-
|
|
368
|
-
messages.push({ ...info, score: Number(results[i + 1]) });
|
|
437
|
+
messages.push(msgpack.decode(Buffer.from(results[i], 'base64url')));
|
|
369
438
|
} catch (err) {
|
|
370
439
|
logger.error({ msg: 'Failed to decode message info', account, exportId, err });
|
|
440
|
+
// ZPOPMIN already removed the entry, so it will never be exported;
|
|
441
|
+
// count it as skipped to keep messagesQueued === exported + skipped
|
|
442
|
+
await Export.incrementSkipped(account, exportId);
|
|
371
443
|
}
|
|
372
444
|
}
|
|
373
445
|
|
|
@@ -383,19 +455,20 @@ class Export {
|
|
|
383
455
|
await redis.hincrby(getExportKey(account, exportId), 'messagesSkipped', 1);
|
|
384
456
|
}
|
|
385
457
|
|
|
386
|
-
static async updateLastProcessedScore(account, exportId, score) {
|
|
387
|
-
await redis.hset(getExportKey(account, exportId), 'lastProcessedScore', score);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
458
|
static async complete(account, exportId) {
|
|
391
459
|
const exportKey = getExportKey(account, exportId);
|
|
392
460
|
const queueKey = getExportQueueKey(account, exportId);
|
|
393
461
|
|
|
462
|
+
// Start the retention window at completion time so a long-running export stays downloadable for
|
|
463
|
+
// the full retention period rather than the time left over from when processing started.
|
|
464
|
+
const { ttl, expiresAt } = await getExportExpiry();
|
|
465
|
+
|
|
394
466
|
await redis
|
|
395
467
|
.multi()
|
|
396
|
-
.hmset(exportKey, { status: 'completed', phase: 'complete' })
|
|
468
|
+
.hmset(exportKey, { status: 'completed', phase: 'complete', expiresAt })
|
|
397
469
|
.del(queueKey)
|
|
398
470
|
.srem(ACTIVE_EXPORTS_KEY, `${account}:${exportId}`)
|
|
471
|
+
.expire(exportKey, ttl)
|
|
399
472
|
.exec();
|
|
400
473
|
|
|
401
474
|
logger.info({ msg: 'Export completed', account, exportId });
|
|
@@ -437,8 +510,9 @@ class Export {
|
|
|
437
510
|
|
|
438
511
|
for (const entry of activeExports) {
|
|
439
512
|
try {
|
|
440
|
-
// Find ':exp_' as separator since account IDs may contain colons
|
|
441
|
-
|
|
513
|
+
// Find ':exp_' as separator since account IDs may contain colons. The export id is
|
|
514
|
+
// always the trailing ':exp_<hex>' segment, so match the last occurrence.
|
|
515
|
+
const separatorIndex = entry.lastIndexOf(':exp_');
|
|
442
516
|
if (separatorIndex === -1) continue;
|
|
443
517
|
const account = entry.substring(0, separatorIndex);
|
|
444
518
|
const exportId = entry.substring(separatorIndex + 1);
|
|
@@ -452,7 +526,7 @@ class Export {
|
|
|
452
526
|
continue;
|
|
453
527
|
}
|
|
454
528
|
|
|
455
|
-
if (data && (data.status === 'processing' || data.status === 'queued' || data.status === '
|
|
529
|
+
if (data && (data.status === 'processing' || data.status === 'queued' || data.status === 'cancelled')) {
|
|
456
530
|
const job = await exportQueue.getJob(exportId).catch(() => null);
|
|
457
531
|
if (job) {
|
|
458
532
|
await job.remove().catch(() => {});
|
|
@@ -541,5 +615,8 @@ module.exports = {
|
|
|
541
615
|
getExportQueueKey,
|
|
542
616
|
getExportPath,
|
|
543
617
|
getExportMaxAge,
|
|
618
|
+
isTransientError,
|
|
619
|
+
isSkippableError,
|
|
620
|
+
isFolderMissingError,
|
|
544
621
|
DEFAULT_EXPORT_MAX_MESSAGE_SIZE
|
|
545
622
|
};
|
package/lib/feature-flags.js
CHANGED
|
@@ -21,11 +21,11 @@ for (let key of Object.keys(process.env)) {
|
|
|
21
21
|
if (/^EENGINE_FEATURE_/i.test(key)) {
|
|
22
22
|
let feature = formatFeatureKey(key.substring('EENGINE_FEATURE_'.length));
|
|
23
23
|
if (feature) {
|
|
24
|
-
let value = /^y|
|
|
24
|
+
let value = /^(y|yes|true|t|1)$/i.test((process.env[key] || '').toString().trim());
|
|
25
25
|
if (value) {
|
|
26
26
|
FEATURES.add(feature);
|
|
27
27
|
} else {
|
|
28
|
-
FEATURES.
|
|
28
|
+
FEATURES.delete(feature);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
}
|
package/lib/gateway.js
CHANGED
|
@@ -179,13 +179,8 @@ class Gateway {
|
|
|
179
179
|
throw error;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// existing user
|
|
185
|
-
state = 'existing';
|
|
186
|
-
} else {
|
|
187
|
-
state = 'new';
|
|
188
|
-
}
|
|
182
|
+
// HGET returns the previous value if the gateway entry already existed
|
|
183
|
+
let state = result[0][1] ? 'existing' : 'new';
|
|
189
184
|
|
|
190
185
|
return { gateway: this.gateway, state };
|
|
191
186
|
}
|
|
@@ -243,7 +238,7 @@ class Gateway {
|
|
|
243
238
|
let result = await this.redis.multi().del(this.getGatewayKey()).srem(`${REDIS_PREFIX}gateways`, this.gateway).exec();
|
|
244
239
|
if (!result) {
|
|
245
240
|
return {
|
|
246
|
-
|
|
241
|
+
gateway: this.gateway,
|
|
247
242
|
deleted: false
|
|
248
243
|
};
|
|
249
244
|
}
|
|
@@ -256,7 +251,7 @@ class Gateway {
|
|
|
256
251
|
|
|
257
252
|
if (!result[0] || !result[0][1]) {
|
|
258
253
|
return {
|
|
259
|
-
|
|
254
|
+
gateway: this.gateway,
|
|
260
255
|
deleted: false
|
|
261
256
|
};
|
|
262
257
|
}
|
package/lib/get-raw-email.js
CHANGED
|
@@ -229,15 +229,15 @@ async function processMessage(data, licenseInfo) {
|
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
if (data.headers) {
|
|
232
|
-
for (let key of Object.keys(headers)) {
|
|
232
|
+
for (let key of Object.keys(data.headers)) {
|
|
233
233
|
let casedKey = key.replace(/^.|-./g, c => c.toUpperCase());
|
|
234
|
-
switch (key) {
|
|
234
|
+
switch (key.toLowerCase()) {
|
|
235
235
|
case 'in-reply-to':
|
|
236
236
|
case 'references':
|
|
237
|
-
headers.update(casedKey, headers[key]);
|
|
237
|
+
headers.update(casedKey, data.headers[key]);
|
|
238
238
|
break;
|
|
239
239
|
default:
|
|
240
|
-
headers.add(casedKey, headers[key]);
|
|
240
|
+
headers.add(casedKey, data.headers[key]);
|
|
241
241
|
break;
|
|
242
242
|
}
|
|
243
243
|
}
|
|
@@ -266,7 +266,7 @@ async function processMessage(data, licenseInfo) {
|
|
|
266
266
|
});
|
|
267
267
|
|
|
268
268
|
let trackClicks = typeof data.trackClicks === 'boolean' ? data.trackClicks : trackingEnabled;
|
|
269
|
-
let trackOpens = typeof data.trackOpens === 'boolean' ? data.
|
|
269
|
+
let trackOpens = typeof data.trackOpens === 'boolean' ? data.trackOpens : trackingEnabled;
|
|
270
270
|
|
|
271
271
|
return {
|
|
272
272
|
raw: message,
|
|
@@ -15,6 +15,20 @@ module.exports = {
|
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
if (this._server.options.disableSTARTTLS) {
|
|
19
|
+
// STARTTLS is not advertised in this mode (e.g. the EmailEngine proxy);
|
|
20
|
+
// refuse it instead of upgrading against the built-in fallback cert.
|
|
21
|
+
return callback(null, {
|
|
22
|
+
response: 'NO',
|
|
23
|
+
message: 'STARTTLS not available'
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Block any further (possibly pipelined / injected) commands until the TLS
|
|
28
|
+
// handshake completes. _onCommand ignores commands while _upgrading is set,
|
|
29
|
+
// so a command sent in the same chunk as STARTTLS cannot be executed.
|
|
30
|
+
this._upgrading = true;
|
|
31
|
+
|
|
18
32
|
setImmediate(upgrade.bind(null, this));
|
|
19
33
|
|
|
20
34
|
callback(null, {
|
|
@@ -94,6 +108,10 @@ function upgrade(connection) {
|
|
|
94
108
|
(cipher && cipher.name) || 'N/A'
|
|
95
109
|
);
|
|
96
110
|
|
|
111
|
+
// Discard any plaintext the parser buffered before the handshake so it
|
|
112
|
+
// cannot be injected into the now-encrypted session.
|
|
113
|
+
connection._parser.reset();
|
|
114
|
+
|
|
97
115
|
connection._socket.pipe(connection._parser);
|
|
98
116
|
connection.writeStream.pipe(connection._socket);
|
|
99
117
|
});
|