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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Joi = require('joi');
|
|
4
|
+
const { webhooks: Webhooks } = require('../webhooks');
|
|
5
|
+
const { failAction } = require('../tools');
|
|
6
|
+
const { handleError, throwNotFound } = require('./route-helpers');
|
|
7
|
+
const { settingsSchema, errorResponses } = require('../schemas');
|
|
8
|
+
|
|
9
|
+
const webhookErrorFlagSchema = Joi.object({
|
|
10
|
+
message: Joi.string().example('Request failed with status 500').description('Error message from the last failed delivery')
|
|
11
|
+
})
|
|
12
|
+
.unknown()
|
|
13
|
+
.allow(null)
|
|
14
|
+
.description('Information about the last webhook delivery error. Null if no errors have been registered')
|
|
15
|
+
.label('WebhookRouteErrorFlag');
|
|
16
|
+
|
|
17
|
+
const webhookCustomHeadersSchema = settingsSchema.webhooksCustomHeaders
|
|
18
|
+
.description('Custom HTTP headers added to webhook requests for this route')
|
|
19
|
+
.label('WebhookRouteCustomHeaders');
|
|
20
|
+
|
|
21
|
+
async function init(args) {
|
|
22
|
+
const { server, CORS_CONFIG } = args;
|
|
23
|
+
|
|
24
|
+
server.route({
|
|
25
|
+
method: 'GET',
|
|
26
|
+
path: '/v1/webhookRoutes',
|
|
27
|
+
|
|
28
|
+
async handler(request) {
|
|
29
|
+
try {
|
|
30
|
+
return await Webhooks.list(request.query.page, request.query.pageSize);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
handleError(request, err);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
options: {
|
|
37
|
+
description: 'List webhook routes',
|
|
38
|
+
notes: 'List custom webhook routes',
|
|
39
|
+
tags: ['api', 'Webhooks'],
|
|
40
|
+
|
|
41
|
+
plugins: {
|
|
42
|
+
'hapi-swagger': {
|
|
43
|
+
responses: errorResponses(400, 401, 403, 429, 500)
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
auth: {
|
|
48
|
+
strategy: 'api-token',
|
|
49
|
+
mode: 'required'
|
|
50
|
+
},
|
|
51
|
+
cors: CORS_CONFIG,
|
|
52
|
+
|
|
53
|
+
validate: {
|
|
54
|
+
options: {
|
|
55
|
+
stripUnknown: false,
|
|
56
|
+
abortEarly: false,
|
|
57
|
+
convert: true
|
|
58
|
+
},
|
|
59
|
+
failAction,
|
|
60
|
+
|
|
61
|
+
query: Joi.object({
|
|
62
|
+
page: Joi.number()
|
|
63
|
+
.integer()
|
|
64
|
+
.min(0)
|
|
65
|
+
.max(1024 * 1024)
|
|
66
|
+
.default(0)
|
|
67
|
+
.example(0)
|
|
68
|
+
.description('Page number (zero indexed, so use 0 for first page)')
|
|
69
|
+
.label('PageNumber'),
|
|
70
|
+
pageSize: Joi.number().integer().min(1).max(1000).default(20).example(20).description('How many entries per page').label('PageSize')
|
|
71
|
+
}).label('WebhookRoutesListRequest')
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
response: {
|
|
75
|
+
schema: Joi.object({
|
|
76
|
+
total: Joi.number().integer().example(120).description('How many matching entries').label('TotalNumber'),
|
|
77
|
+
page: Joi.number().integer().example(0).description('Current page (0-based index)').label('PageNumber'),
|
|
78
|
+
pages: Joi.number().integer().example(24).description('Total page count').label('PagesNumber'),
|
|
79
|
+
|
|
80
|
+
webhooks: Joi.array()
|
|
81
|
+
.items(
|
|
82
|
+
Joi.object({
|
|
83
|
+
id: Joi.string().max(256).required().example('AAABgS-UcAYAAAABAA').description('Webhook ID'),
|
|
84
|
+
name: Joi.string().max(256).example('Send to Slack').description('Name of the route').label('WebhookRouteName').required(),
|
|
85
|
+
description: Joi.string()
|
|
86
|
+
.allow('')
|
|
87
|
+
.max(1024)
|
|
88
|
+
.example('Something about the route')
|
|
89
|
+
.description('Optional description of the webhook route')
|
|
90
|
+
.label('WebhookRouteDescription'),
|
|
91
|
+
created: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was created'),
|
|
92
|
+
updated: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was last updated'),
|
|
93
|
+
enabled: Joi.boolean().example(true).description('Is the route enabled').label('WebhookRouteEnabled'),
|
|
94
|
+
targetUrl: settingsSchema.webhooks,
|
|
95
|
+
tcount: Joi.number().integer().example(123).description('How many times this route has been applied'),
|
|
96
|
+
webhookErrorFlag: webhookErrorFlagSchema,
|
|
97
|
+
customHeaders: webhookCustomHeadersSchema
|
|
98
|
+
}).label('WebhookRoutesListEntry')
|
|
99
|
+
)
|
|
100
|
+
.label('WebhookRoutesList')
|
|
101
|
+
}).label('WebhookRoutesListResponse'),
|
|
102
|
+
failAction: 'log'
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
server.route({
|
|
108
|
+
method: 'GET',
|
|
109
|
+
path: '/v1/webhookRoutes/webhookRoute/{webhookRoute}',
|
|
110
|
+
|
|
111
|
+
async handler(request) {
|
|
112
|
+
try {
|
|
113
|
+
let webhookRouteData = await Webhooks.get(request.params.webhookRoute);
|
|
114
|
+
if (!webhookRouteData) {
|
|
115
|
+
throwNotFound();
|
|
116
|
+
}
|
|
117
|
+
return webhookRouteData;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
handleError(request, err);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
options: {
|
|
124
|
+
description: 'Get webhook route information',
|
|
125
|
+
notes: 'Retrieve webhook route content and information',
|
|
126
|
+
tags: ['api', 'Webhooks'],
|
|
127
|
+
|
|
128
|
+
plugins: {
|
|
129
|
+
'hapi-swagger': {
|
|
130
|
+
responses: errorResponses(400, 401, 403, 404, 429, 500)
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
auth: {
|
|
135
|
+
strategy: 'api-token',
|
|
136
|
+
mode: 'required'
|
|
137
|
+
},
|
|
138
|
+
cors: CORS_CONFIG,
|
|
139
|
+
|
|
140
|
+
validate: {
|
|
141
|
+
options: {
|
|
142
|
+
stripUnknown: false,
|
|
143
|
+
abortEarly: false,
|
|
144
|
+
convert: true
|
|
145
|
+
},
|
|
146
|
+
failAction,
|
|
147
|
+
params: Joi.object({
|
|
148
|
+
webhookRoute: Joi.string().max(256).required().example('example').description('Webhook ID')
|
|
149
|
+
}).label('GetWebhookRouteRequest')
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
response: {
|
|
153
|
+
schema: Joi.object({
|
|
154
|
+
id: Joi.string().max(256).required().example('AAABgS-UcAYAAAABAA').description('Webhook ID'),
|
|
155
|
+
name: Joi.string().max(256).example('Send to Slack').description('Name of the route').label('WebhookRouteName').required(),
|
|
156
|
+
description: Joi.string()
|
|
157
|
+
.allow('')
|
|
158
|
+
.max(1024)
|
|
159
|
+
.example('Something about the route')
|
|
160
|
+
.description('Optional description of the webhook route')
|
|
161
|
+
.label('WebhookRouteDescription'),
|
|
162
|
+
created: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was created'),
|
|
163
|
+
updated: Joi.date().iso().example('2021-02-17T13:43:18.860Z').description('The time this route was last updated'),
|
|
164
|
+
enabled: Joi.boolean().example(true).description('Is the route enabled').label('WebhookRouteEnabled'),
|
|
165
|
+
targetUrl: settingsSchema.webhooks,
|
|
166
|
+
tcount: Joi.number().integer().example(123).description('How many times this route has been applied'),
|
|
167
|
+
v: Joi.number().integer().example(1).description('Internal version counter, increased on every update'),
|
|
168
|
+
webhookErrorFlag: webhookErrorFlagSchema,
|
|
169
|
+
customHeaders: webhookCustomHeadersSchema,
|
|
170
|
+
content: Joi.object({
|
|
171
|
+
fn: Joi.string().allow(null).example('return true;').description('Filter function. Null if not set'),
|
|
172
|
+
map: Joi.string().allow(null).example('payload.ts = Date.now(); return payload;').description('Mapping function. Null if not set')
|
|
173
|
+
}).label('WebhookRouteContent')
|
|
174
|
+
}).label('WebhookRouteResponse'),
|
|
175
|
+
failAction: 'log'
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = init;
|
package/lib/consts.js
CHANGED
|
@@ -107,6 +107,11 @@ module.exports = {
|
|
|
107
107
|
|
|
108
108
|
DEFAULT_MAX_LOG_LINES: 10000,
|
|
109
109
|
|
|
110
|
+
// Shared Sentry instance run by the EmailEngine developers. Used as the error
|
|
111
|
+
// reporting target when error reporting is enabled without a custom DSN. A DSN
|
|
112
|
+
// is a write-only credential, it can only be used to submit events.
|
|
113
|
+
COMMUNITY_SENTRY_DSN: 'https://bdd958f3e813a488904b0f254e0bb8a8@sentry.emailengine.dev/3',
|
|
114
|
+
|
|
110
115
|
PDKDF2_ITERATIONS: 600000,
|
|
111
116
|
PDKDF2_SALT_SIZE: 16,
|
|
112
117
|
PDKDF2_DIGEST: 'sha256', // 'sha512', 'sha256' or 'sha1'
|
|
@@ -240,6 +240,33 @@ class BaseClient {
|
|
|
240
240
|
return rid;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Normalizes an IMAP-style flag update request (add/delete/set) into plain add/delete
|
|
245
|
+
* flag lists. If `set` is present then it replaces the state of the flags the backend
|
|
246
|
+
* supports and add/delete are ignored, matching the IMAP backend behavior.
|
|
247
|
+
* @param {Object} [flags] - Flag update request ({ add, delete, set })
|
|
248
|
+
* @param {string[]} supportedFlags - Flags the backend can represent
|
|
249
|
+
* @returns {Object} Normalized flag lists ({ add: string[], delete: string[] })
|
|
250
|
+
*/
|
|
251
|
+
normalizeFlagUpdates(flags, supportedFlags) {
|
|
252
|
+
if (flags?.set) {
|
|
253
|
+
// If set exists then ignore add/delete calls
|
|
254
|
+
let setFlags = [].concat(flags.set);
|
|
255
|
+
let addFlags = [];
|
|
256
|
+
let deleteFlags = [];
|
|
257
|
+
for (let flag of supportedFlags) {
|
|
258
|
+
if (setFlags.includes(flag)) {
|
|
259
|
+
addFlags.push(flag);
|
|
260
|
+
} else {
|
|
261
|
+
deleteFlags.push(flag);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { add: addFlags, delete: deleteFlags };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { add: [].concat(flags?.add || []), delete: [].concat(flags?.delete || []) };
|
|
268
|
+
}
|
|
269
|
+
|
|
243
270
|
// Redis key generators for different data types
|
|
244
271
|
|
|
245
272
|
getAccountKey() {
|
|
@@ -721,7 +748,7 @@ class BaseClient {
|
|
|
721
748
|
}
|
|
722
749
|
|
|
723
750
|
// use existing response
|
|
724
|
-
switch (idempotencyData
|
|
751
|
+
switch (idempotencyData?.status) {
|
|
725
752
|
case 'completed':
|
|
726
753
|
// Return cached result
|
|
727
754
|
idempotencyData.returnValue = Object.assign({}, idempotencyData.result, {
|
|
@@ -3194,11 +3221,6 @@ class BaseClient {
|
|
|
3194
3221
|
async handleSubmitError(err, context) {
|
|
3195
3222
|
const { smtpSettings, networkRouting, gatewayData, gatewayObject, data, jobData, queueId, envelope } = context;
|
|
3196
3223
|
|
|
3197
|
-
// Handle permanent failures
|
|
3198
|
-
if (err.responseCode >= 500 && jobData.opts?.attempts <= jobData.attemptsMade) {
|
|
3199
|
-
jobData.nextAttempt = false;
|
|
3200
|
-
}
|
|
3201
|
-
|
|
3202
3224
|
// Build SMTP status from error
|
|
3203
3225
|
const smtpStatus = SmtpErrorBuilder.buildStatus(err, smtpSettings, networkRouting);
|
|
3204
3226
|
|
|
@@ -46,7 +46,7 @@ const SYSTEM_LABELS = {
|
|
|
46
46
|
|
|
47
47
|
// User-friendly names for system labels
|
|
48
48
|
const SYSTEM_NAMES = {
|
|
49
|
-
SENT: 'Sent
|
|
49
|
+
SENT: 'Sent',
|
|
50
50
|
INBOX: 'Inbox',
|
|
51
51
|
TRASH: 'Trash',
|
|
52
52
|
DRAFT: 'Drafts',
|
|
@@ -63,6 +63,12 @@ for (let label of Object.keys(SYSTEM_LABELS)) {
|
|
|
63
63
|
SYSTEM_LABELS_REV[SYSTEM_LABELS[label]] = label;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Convert an IMAP special-use label (e.g. '\Important') to its Gmail system label ID, pass every
|
|
67
|
+
// other value through as-is
|
|
68
|
+
function toGmailLabelId(label) {
|
|
69
|
+
return SYSTEM_LABELS_REV.hasOwnProperty(label) ? SYSTEM_LABELS_REV[label] : label;
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
// Timing constants for Gmail Pub/Sub watch
|
|
67
73
|
const RENEW_WATCH_TTL = 60 * 60 * 1000; // 1h - how often to check if watch needs renewal
|
|
68
74
|
const MIN_WATCH_TTL = 24 * 3600 * 1000; // 1day - minimum time before renewing watch
|
|
@@ -753,7 +759,7 @@ class GmailClient extends BaseClient {
|
|
|
753
759
|
// NB! Might throw if using unsupported search terms
|
|
754
760
|
const preparedQuery = this.prepareQuery(query.search);
|
|
755
761
|
if (preparedQuery) {
|
|
756
|
-
requestQuery.q =
|
|
762
|
+
requestQuery.q = preparedQuery;
|
|
757
763
|
}
|
|
758
764
|
}
|
|
759
765
|
|
|
@@ -875,16 +881,13 @@ class GmailClient extends BaseClient {
|
|
|
875
881
|
|
|
876
882
|
let sourceLabel = path && path !== '\\All' ? await this.getLabel(path) : null;
|
|
877
883
|
if (path && path !== '\\All' && !sourceLabel) {
|
|
878
|
-
|
|
879
|
-
error.info = {
|
|
880
|
-
response: `Mailbox doesn't exist: ${path}`
|
|
881
|
-
};
|
|
882
|
-
error.code = 'NotFound';
|
|
883
|
-
error.statusCode = 404;
|
|
884
|
-
throw error;
|
|
884
|
+
throw this.unknownPathError(path);
|
|
885
885
|
}
|
|
886
886
|
|
|
887
|
-
// Add TRASH label and remove source label
|
|
887
|
+
// Add TRASH label and remove source label. When deleting from the Trash folder itself both
|
|
888
|
+
// labels are TRASH - updateMessages resolves the add/remove conflict in favor of the add,
|
|
889
|
+
// keeping the request valid (Gmail API accounts only hold the gmail.modify scope, so a
|
|
890
|
+
// permanent batchDelete is not possible)
|
|
888
891
|
let labelsUpdate = { add: 'TRASH' };
|
|
889
892
|
if (sourceLabel) {
|
|
890
893
|
labelsUpdate.delete = sourceLabel.id;
|
|
@@ -911,51 +914,28 @@ class GmailClient extends BaseClient {
|
|
|
911
914
|
await this.prepare();
|
|
912
915
|
updates = updates || {};
|
|
913
916
|
|
|
914
|
-
let addLabelIds = new Set();
|
|
915
|
-
let removeLabelIds = new Set();
|
|
916
|
-
|
|
917
917
|
// Convert IMAP flags to Gmail labels
|
|
918
|
-
|
|
919
|
-
let labelUpdates = [];
|
|
920
|
-
|
|
921
|
-
for (let flag of [].concat(updates.flags.add || [])) {
|
|
922
|
-
labelUpdates.push(this.flagToLabel(flag));
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
for (let flag of [].concat(updates.flags.delete || [])) {
|
|
926
|
-
labelUpdates.push(this.flagToLabel(flag, true));
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
labelUpdates
|
|
930
|
-
.filter(label => label)
|
|
931
|
-
.forEach(label => {
|
|
932
|
-
if (label.add) {
|
|
933
|
-
addLabelIds.add(label.add);
|
|
934
|
-
}
|
|
935
|
-
if (label.remove) {
|
|
936
|
-
removeLabelIds.add(label.remove);
|
|
937
|
-
}
|
|
938
|
-
});
|
|
939
|
-
}
|
|
918
|
+
let { addLabelIds, removeLabelIds } = this.flagsToLabelIds(updates.flags);
|
|
940
919
|
|
|
941
920
|
// Process direct label updates
|
|
942
921
|
if (updates.labels) {
|
|
922
|
+
this.assertLabelSetSupported(updates.labels);
|
|
923
|
+
|
|
943
924
|
for (let label of [].concat(updates.labels.add || [])) {
|
|
944
|
-
|
|
945
|
-
if (SYSTEM_LABELS_REV.hasOwnProperty(label)) {
|
|
946
|
-
label = SYSTEM_LABELS_REV[label];
|
|
947
|
-
}
|
|
948
|
-
addLabelIds.add(label);
|
|
925
|
+
addLabelIds.add(toGmailLabelId(label));
|
|
949
926
|
}
|
|
950
927
|
|
|
951
928
|
for (let label of [].concat(updates.labels.delete || [])) {
|
|
952
|
-
|
|
953
|
-
label = SYSTEM_LABELS_REV[label];
|
|
954
|
-
}
|
|
955
|
-
removeLabelIds.add(label);
|
|
929
|
+
removeLabelIds.add(toGmailLabelId(label));
|
|
956
930
|
}
|
|
957
931
|
}
|
|
958
932
|
|
|
933
|
+
// Gmail rejects modify calls where the same label is both added and removed (deleting or
|
|
934
|
+
// moving a message within its current folder re-adds the source label) - the add wins
|
|
935
|
+
for (let label of addLabelIds) {
|
|
936
|
+
removeLabelIds.delete(label);
|
|
937
|
+
}
|
|
938
|
+
|
|
959
939
|
if (!addLabelIds.size && !removeLabelIds.size) {
|
|
960
940
|
return updates;
|
|
961
941
|
}
|
|
@@ -1024,6 +1004,17 @@ class GmailClient extends BaseClient {
|
|
|
1024
1004
|
await this.prepare();
|
|
1025
1005
|
updates = updates || {};
|
|
1026
1006
|
|
|
1007
|
+
// Reject unsupported operations before any API calls are made
|
|
1008
|
+
if (updates.labels) {
|
|
1009
|
+
this.assertLabelSetSupported(updates.labels);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
path = [].concat(path || []).join('/');
|
|
1013
|
+
|
|
1014
|
+
if (path && path !== '\\All' && !(await this.getLabel(path))) {
|
|
1015
|
+
throw this.unknownPathError(path);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1027
1018
|
// Step 1. Resolve matching messages
|
|
1028
1019
|
let messages = [];
|
|
1029
1020
|
let cursor;
|
|
@@ -1043,8 +1034,8 @@ class GmailClient extends BaseClient {
|
|
|
1043
1034
|
{ metadataOnly: true }
|
|
1044
1035
|
);
|
|
1045
1036
|
|
|
1046
|
-
if (messageListResult
|
|
1047
|
-
messages = messages.concat(messageListResult
|
|
1037
|
+
if (messageListResult.messages) {
|
|
1038
|
+
messages = messages.concat(messageListResult.messages);
|
|
1048
1039
|
if (messages.length >= maxMessages) {
|
|
1049
1040
|
messages = messages.slice(0, maxMessages);
|
|
1050
1041
|
break;
|
|
@@ -1064,43 +1055,25 @@ class GmailClient extends BaseClient {
|
|
|
1064
1055
|
return updates;
|
|
1065
1056
|
}
|
|
1066
1057
|
|
|
1067
|
-
let addLabelIds = new Set();
|
|
1068
|
-
let removeLabelIds = new Set();
|
|
1069
|
-
|
|
1070
1058
|
// Convert flags to label operations
|
|
1071
|
-
|
|
1072
|
-
let labelUpdates = [];
|
|
1073
|
-
|
|
1074
|
-
for (let flag of [].concat(updates.flags.add || [])) {
|
|
1075
|
-
labelUpdates.push(this.flagToLabel(flag));
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
for (let flag of [].concat(updates.flags.delete || [])) {
|
|
1079
|
-
labelUpdates.push(this.flagToLabel(flag, true));
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
labelUpdates
|
|
1083
|
-
.filter(label => label)
|
|
1084
|
-
.forEach(label => {
|
|
1085
|
-
if (label.add) {
|
|
1086
|
-
addLabelIds.add(label.add);
|
|
1087
|
-
}
|
|
1088
|
-
if (label.remove) {
|
|
1089
|
-
removeLabelIds.add(label.remove);
|
|
1090
|
-
}
|
|
1091
|
-
});
|
|
1092
|
-
}
|
|
1059
|
+
let { addLabelIds, removeLabelIds } = this.flagsToLabelIds(updates.flags);
|
|
1093
1060
|
|
|
1094
1061
|
if (updates.labels) {
|
|
1095
1062
|
for (let label of [].concat(updates.labels.add || [])) {
|
|
1096
|
-
addLabelIds.add(label);
|
|
1063
|
+
addLabelIds.add(toGmailLabelId(label));
|
|
1097
1064
|
}
|
|
1098
1065
|
|
|
1099
1066
|
for (let label of [].concat(updates.labels.delete || [])) {
|
|
1100
|
-
removeLabelIds.add(label);
|
|
1067
|
+
removeLabelIds.add(toGmailLabelId(label));
|
|
1101
1068
|
}
|
|
1102
1069
|
}
|
|
1103
1070
|
|
|
1071
|
+
// Gmail rejects batchModify calls where the same label is both added and removed (deleting
|
|
1072
|
+
// or moving messages within their current folder re-adds the source label) - the add wins
|
|
1073
|
+
for (let label of addLabelIds) {
|
|
1074
|
+
removeLabelIds.delete(label);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1104
1077
|
if (!addLabelIds.size && !removeLabelIds.size) {
|
|
1105
1078
|
return { flags: {}, labels: {} };
|
|
1106
1079
|
}
|
|
@@ -1141,13 +1114,7 @@ class GmailClient extends BaseClient {
|
|
|
1141
1114
|
|
|
1142
1115
|
let label = await this.getLabel(path);
|
|
1143
1116
|
if (!label) {
|
|
1144
|
-
|
|
1145
|
-
error.info = {
|
|
1146
|
-
response: `Mailbox doesn't exist: ${path}`
|
|
1147
|
-
};
|
|
1148
|
-
error.code = 'NotFound';
|
|
1149
|
-
error.statusCode = 404;
|
|
1150
|
-
throw error;
|
|
1117
|
+
throw this.unknownPathError(path);
|
|
1151
1118
|
}
|
|
1152
1119
|
let labelsUpdate = { add: [label.id] };
|
|
1153
1120
|
|
|
@@ -1155,13 +1122,7 @@ class GmailClient extends BaseClient {
|
|
|
1155
1122
|
|
|
1156
1123
|
let sourceLabel = sourcePath ? await this.getLabel(sourcePath) : null;
|
|
1157
1124
|
if (sourcePath && !sourceLabel) {
|
|
1158
|
-
|
|
1159
|
-
error.info = {
|
|
1160
|
-
response: `Mailbox doesn't exist: ${sourcePath}`
|
|
1161
|
-
};
|
|
1162
|
-
error.code = 'NotFound';
|
|
1163
|
-
error.statusCode = 404;
|
|
1164
|
-
throw error;
|
|
1125
|
+
throw this.unknownPathError(sourcePath);
|
|
1165
1126
|
}
|
|
1166
1127
|
|
|
1167
1128
|
if (sourceLabel) {
|
|
@@ -1190,24 +1151,12 @@ class GmailClient extends BaseClient {
|
|
|
1190
1151
|
|
|
1191
1152
|
let targetLabel = await this.getLabel(path);
|
|
1192
1153
|
if (!targetLabel) {
|
|
1193
|
-
|
|
1194
|
-
error.info = {
|
|
1195
|
-
response: `Mailbox doesn't exist: ${path}`
|
|
1196
|
-
};
|
|
1197
|
-
error.code = 'NotFound';
|
|
1198
|
-
error.statusCode = 404;
|
|
1199
|
-
throw error;
|
|
1154
|
+
throw this.unknownPathError(path);
|
|
1200
1155
|
}
|
|
1201
1156
|
|
|
1202
1157
|
let sourceLabel = source ? await this.getLabel(source) : null;
|
|
1203
1158
|
if (source && !sourceLabel) {
|
|
1204
|
-
|
|
1205
|
-
error.info = {
|
|
1206
|
-
response: `Mailbox doesn't exist: ${source}`
|
|
1207
|
-
};
|
|
1208
|
-
error.code = 'NotFound';
|
|
1209
|
-
error.statusCode = 404;
|
|
1210
|
-
throw error;
|
|
1159
|
+
throw this.unknownPathError(source);
|
|
1211
1160
|
}
|
|
1212
1161
|
|
|
1213
1162
|
let labelsUpdate = { add: targetLabel.id };
|
|
@@ -1251,7 +1200,7 @@ class GmailClient extends BaseClient {
|
|
|
1251
1200
|
|
|
1252
1201
|
const contentResponse = {
|
|
1253
1202
|
headers: {
|
|
1254
|
-
'content-type': attachmentData.
|
|
1203
|
+
'content-type': attachmentData.contentType || 'application/octet-stream',
|
|
1255
1204
|
'content-disposition': 'attachment' + filenameParam
|
|
1256
1205
|
},
|
|
1257
1206
|
contentType: attachmentData.contentType,
|
|
@@ -1421,7 +1370,7 @@ class GmailClient extends BaseClient {
|
|
|
1421
1370
|
|
|
1422
1371
|
// Map part IDs to content types
|
|
1423
1372
|
textParts[0].forEach(p => {
|
|
1424
|
-
bodyParts.set(p, '
|
|
1373
|
+
bodyParts.set(p, 'plain');
|
|
1425
1374
|
});
|
|
1426
1375
|
|
|
1427
1376
|
textParts[1].forEach(p => {
|
|
@@ -1481,13 +1430,7 @@ class GmailClient extends BaseClient {
|
|
|
1481
1430
|
|
|
1482
1431
|
let targetLabel = await this.getLabel(path);
|
|
1483
1432
|
if (!targetLabel) {
|
|
1484
|
-
|
|
1485
|
-
error.info = {
|
|
1486
|
-
response: `Mailbox doesn't exist: ${path}`
|
|
1487
|
-
};
|
|
1488
|
-
error.code = 'NotFound';
|
|
1489
|
-
error.statusCode = 404;
|
|
1490
|
-
throw error;
|
|
1433
|
+
throw this.unknownPathError(path);
|
|
1491
1434
|
}
|
|
1492
1435
|
|
|
1493
1436
|
// Generate raw message
|
|
@@ -2400,7 +2343,7 @@ class GmailClient extends BaseClient {
|
|
|
2400
2343
|
flags.push('\\Flagged');
|
|
2401
2344
|
}
|
|
2402
2345
|
|
|
2403
|
-
if (messageData.labelIds?.includes('
|
|
2346
|
+
if (messageData.labelIds?.includes('DRAFT')) {
|
|
2404
2347
|
flags.push('\\Draft');
|
|
2405
2348
|
}
|
|
2406
2349
|
|
|
@@ -2609,6 +2552,21 @@ class GmailClient extends BaseClient {
|
|
|
2609
2552
|
return term;
|
|
2610
2553
|
}
|
|
2611
2554
|
|
|
2555
|
+
/**
|
|
2556
|
+
* Builds the canonical 404 error for a mailbox path that does not match any Gmail label
|
|
2557
|
+
* @param {string} path - Requested mailbox path
|
|
2558
|
+
* @returns {Error} Error object with code and statusCode set
|
|
2559
|
+
*/
|
|
2560
|
+
unknownPathError(path) {
|
|
2561
|
+
let error = new Error('Unknown path');
|
|
2562
|
+
error.info = {
|
|
2563
|
+
response: `Mailbox doesn't exist: ${path}`
|
|
2564
|
+
};
|
|
2565
|
+
error.code = 'NotFound';
|
|
2566
|
+
error.statusCode = 404;
|
|
2567
|
+
return error;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2612
2570
|
/**
|
|
2613
2571
|
* Converts IMAP flags to Gmail label operations
|
|
2614
2572
|
* @param {string} flag - IMAP flag
|
|
@@ -2625,6 +2583,55 @@ class GmailClient extends BaseClient {
|
|
|
2625
2583
|
}
|
|
2626
2584
|
}
|
|
2627
2585
|
|
|
2586
|
+
/**
|
|
2587
|
+
* Converts an IMAP-style flag update request (add/delete/set) into Gmail label ID sets.
|
|
2588
|
+
* Set precedence is handled by BaseClient#normalizeFlagUpdates.
|
|
2589
|
+
* @param {Object} [flagUpdates] - Flag update request ({ add, delete, set })
|
|
2590
|
+
* @returns {Object} Label ID sets ({ addLabelIds: Set, removeLabelIds: Set })
|
|
2591
|
+
*/
|
|
2592
|
+
flagsToLabelIds(flagUpdates) {
|
|
2593
|
+
let addLabelIds = new Set();
|
|
2594
|
+
let removeLabelIds = new Set();
|
|
2595
|
+
|
|
2596
|
+
let normalized = this.normalizeFlagUpdates(flagUpdates, ['\\Seen', '\\Flagged']);
|
|
2597
|
+
|
|
2598
|
+
let labelUpdates = [];
|
|
2599
|
+
|
|
2600
|
+
for (let flag of normalized.add) {
|
|
2601
|
+
labelUpdates.push(this.flagToLabel(flag));
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
for (let flag of normalized.delete) {
|
|
2605
|
+
labelUpdates.push(this.flagToLabel(flag, true));
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
labelUpdates
|
|
2609
|
+
.filter(label => label)
|
|
2610
|
+
.forEach(label => {
|
|
2611
|
+
if (label.add) {
|
|
2612
|
+
addLabelIds.add(label.add);
|
|
2613
|
+
}
|
|
2614
|
+
if (label.remove) {
|
|
2615
|
+
removeLabelIds.add(label.remove);
|
|
2616
|
+
}
|
|
2617
|
+
});
|
|
2618
|
+
|
|
2619
|
+
return { addLabelIds, removeLabelIds };
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
/**
|
|
2623
|
+
* Throws if the update request uses `labels.set`, which Gmail API accounts do not support
|
|
2624
|
+
* @param {Object} labels - Label update request
|
|
2625
|
+
*/
|
|
2626
|
+
assertLabelSetSupported(labels) {
|
|
2627
|
+
if (labels.set) {
|
|
2628
|
+
let error = new Error('Replacing the full label set is not supported for Gmail API accounts');
|
|
2629
|
+
error.code = 'UnsupportedOperation';
|
|
2630
|
+
error.statusCode = 400;
|
|
2631
|
+
throw error;
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2628
2635
|
/**
|
|
2629
2636
|
* Converts IMAP SEARCH query to Gmail API query
|
|
2630
2637
|
* @param {Object} search - IMAP search object
|
|
@@ -2700,6 +2707,20 @@ class GmailClient extends BaseClient {
|
|
|
2700
2707
|
queryParts.push(search.gmailRaw);
|
|
2701
2708
|
}
|
|
2702
2709
|
|
|
2710
|
+
// Label filters - "has" matches messages with the label, "not" excludes them
|
|
2711
|
+
if (search.labels && typeof search.labels === 'object') {
|
|
2712
|
+
for (let label of [].concat(search.labels.has || [])) {
|
|
2713
|
+
if (label) {
|
|
2714
|
+
queryParts.push(`label:${this.formatSearchTerm(label)}`);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
for (let label of [].concat(search.labels.not || [])) {
|
|
2718
|
+
if (label) {
|
|
2719
|
+
queryParts.push(`-label:${this.formatSearchTerm(label)}`);
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2703
2724
|
// body search
|
|
2704
2725
|
if (search.body && typeof search.body === 'string') {
|
|
2705
2726
|
queryParts.push(`${this.formatSearchTerm(search.body)}`);
|
|
@@ -2791,7 +2812,7 @@ class GmailClient extends BaseClient {
|
|
|
2791
2812
|
case 'STARRED':
|
|
2792
2813
|
changes.flags[addedProp].push('\\Flagged');
|
|
2793
2814
|
break;
|
|
2794
|
-
case '
|
|
2815
|
+
case 'DRAFT':
|
|
2795
2816
|
changes.flags[addedProp].push('\\Draft');
|
|
2796
2817
|
break;
|
|
2797
2818
|
default:
|