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.
Files changed (95) hide show
  1. package/.github/workflows/deploy.yml +8 -3
  2. package/.github/workflows/release.yaml +6 -0
  3. package/CHANGELOG.md +59 -0
  4. package/Gruntfile.js +3 -1
  5. package/config/default.toml +2 -0
  6. package/data/google-crawlers.json +7 -1
  7. package/getswagger.sh +40 -4
  8. package/gettext-extract.js +163 -0
  9. package/lib/account.js +135 -72
  10. package/lib/api-routes/account-routes.js +684 -106
  11. package/lib/api-routes/blocklist-routes.js +344 -0
  12. package/lib/api-routes/chat-routes.js +32 -14
  13. package/lib/api-routes/delivery-test-routes.js +346 -0
  14. package/lib/api-routes/export-routes.js +28 -14
  15. package/lib/api-routes/gateway-routes.js +427 -0
  16. package/lib/api-routes/license-routes.js +156 -0
  17. package/lib/api-routes/mailbox-routes.js +344 -0
  18. package/lib/api-routes/message-routes.js +221 -187
  19. package/lib/api-routes/oauth2-app-routes.js +697 -0
  20. package/lib/api-routes/outbox-routes.js +185 -0
  21. package/lib/api-routes/pubsub-routes.js +102 -0
  22. package/lib/api-routes/route-helpers.js +58 -0
  23. package/lib/api-routes/settings-routes.js +357 -0
  24. package/lib/api-routes/stats-routes.js +111 -0
  25. package/lib/api-routes/submit-routes.js +461 -0
  26. package/lib/api-routes/template-routes.js +60 -75
  27. package/lib/api-routes/token-routes.js +297 -0
  28. package/lib/api-routes/webhook-route-routes.js +181 -0
  29. package/lib/autodetect-imap-settings.js +0 -2
  30. package/lib/consts.js +5 -0
  31. package/lib/email-client/base-client.js +28 -6
  32. package/lib/email-client/gmail-client.js +133 -112
  33. package/lib/email-client/imap/mailbox.js +34 -11
  34. package/lib/email-client/imap/subconnection.js +20 -13
  35. package/lib/email-client/imap/sync-operations.js +131 -3
  36. package/lib/email-client/imap-client.js +152 -75
  37. package/lib/email-client/notification-handler.js +1 -4
  38. package/lib/email-client/outlook-client.js +134 -75
  39. package/lib/export.js +97 -20
  40. package/lib/feature-flags.js +2 -2
  41. package/lib/gateway.js +4 -9
  42. package/lib/get-raw-email.js +5 -5
  43. package/lib/imapproxy/imap-core/lib/commands/starttls.js +18 -0
  44. package/lib/imapproxy/imap-core/lib/imap-command.js +6 -1
  45. package/lib/imapproxy/imap-core/lib/imap-connection.js +106 -24
  46. package/lib/imapproxy/imap-core/lib/imap-server.js +24 -0
  47. package/lib/imapproxy/imap-core/lib/imap-stream.js +26 -0
  48. package/lib/logger.js +24 -21
  49. package/lib/message-port-stream.js +113 -16
  50. package/lib/metrics-collector.js +0 -2
  51. package/lib/oauth2-apps.js +13 -4
  52. package/lib/outbox.js +24 -40
  53. package/lib/redis-operations.js +1 -1
  54. package/lib/reject-worker-calls.js +42 -0
  55. package/lib/routes-ui.js +37 -8778
  56. package/lib/schemas.js +429 -84
  57. package/lib/sentry.js +139 -0
  58. package/lib/settings.js +9 -3
  59. package/lib/stream-encrypt.js +1 -1
  60. package/lib/templates.js +1 -1
  61. package/lib/tokens.js +5 -3
  62. package/lib/tools.js +70 -4
  63. package/lib/ui-routes/account-routes.js +45 -212
  64. package/lib/ui-routes/admin-config-routes.js +928 -489
  65. package/lib/ui-routes/admin-entities-routes.js +1 -0
  66. package/lib/ui-routes/auth-routes.js +1339 -0
  67. package/lib/ui-routes/dashboard-routes.js +188 -0
  68. package/lib/ui-routes/document-store-routes.js +800 -0
  69. package/lib/ui-routes/export-routes.js +217 -0
  70. package/lib/ui-routes/internals-routes.js +354 -0
  71. package/lib/ui-routes/network-config-routes.js +759 -0
  72. package/lib/ui-routes/{oauth-routes.js → oauth-config-routes.js} +369 -91
  73. package/lib/ui-routes/route-helpers.js +314 -0
  74. package/lib/ui-routes/smtp-test-routes.js +236 -0
  75. package/lib/ui-routes/unsubscribe-routes.js +232 -0
  76. package/lib/webhook-request.js +36 -0
  77. package/lib/webhooks.js +8 -4
  78. package/package.json +13 -12
  79. package/sbom.json +1 -1
  80. package/server.js +222 -39
  81. package/static/licenses.html +160 -300
  82. package/translations/messages.pot +112 -132
  83. package/update-info.sh +19 -1
  84. package/views/config/logging.hbs +48 -0
  85. package/views/dashboard.hbs +7 -26
  86. package/views/internals/index.hbs +15 -0
  87. package/views/tokens/index.hbs +9 -0
  88. package/workers/api.js +200 -4424
  89. package/workers/documents.js +2 -22
  90. package/workers/export.js +103 -104
  91. package/workers/imap-proxy.js +3 -23
  92. package/workers/imap.js +32 -36
  93. package/workers/smtp.js +2 -22
  94. package/workers/submit.js +26 -35
  95. 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;
@@ -709,8 +709,6 @@ async function resolveUsingAutodiscovery(email, domain, source) {
709
709
  });
710
710
  });
711
711
 
712
- resp = resp.filter(entry => entry.account);
713
-
714
712
  return { imap, smtp, _source: source || 'autodiscover' };
715
713
  }
716
714
 
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.status) {
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 = this.prepareQuery(query.search);
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
- let error = new Error('Unknown path');
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
- if (updates.flags) {
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
- // Convert IMAP special-use to Gmail label
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
- if (SYSTEM_LABELS_REV.hasOwnProperty(label)) {
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?.messages) {
1047
- messages = messages.concat(messageListResult?.messages);
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
- if (updates.flags) {
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
- let error = new Error('Unknown path');
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
- let error = new Error('Unknown path');
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
- let error = new Error('Unknown path');
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
- let error = new Error('Unknown path');
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.mimeType || 'application/octet-stream',
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, 'text');
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
- let error = new Error('Unknown path');
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('DRAFTS')) {
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 'DRAFTS':
2815
+ case 'DRAFT':
2795
2816
  changes.flags[addedProp].push('\\Draft');
2796
2817
  break;
2797
2818
  default: