imapkit 0.0.0-stage → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +608 -2
  3. package/bin/help.txt +98 -0
  4. package/bin/imapkit.js +108 -0
  5. package/cert/server.crt +20 -0
  6. package/cert/server.key +28 -0
  7. package/lib/addressparser.js +283 -0
  8. package/lib/arguments.js +112 -0
  9. package/lib/bodystructure.js +149 -0
  10. package/lib/command-states.js +109 -0
  11. package/lib/commands/append.js +313 -0
  12. package/lib/commands/capability.js +47 -0
  13. package/lib/commands/check.js +21 -0
  14. package/lib/commands/close.js +30 -0
  15. package/lib/commands/copy.js +115 -0
  16. package/lib/commands/create.js +52 -0
  17. package/lib/commands/delete.js +64 -0
  18. package/lib/commands/examine.js +7 -0
  19. package/lib/commands/expunge.js +27 -0
  20. package/lib/commands/fetch.js +229 -0
  21. package/lib/commands/handlers/fetch.js +209 -0
  22. package/lib/commands/handlers/flags.js +42 -0
  23. package/lib/commands/handlers/search.js +519 -0
  24. package/lib/commands/handlers/status.js +85 -0
  25. package/lib/commands/handlers/store.js +127 -0
  26. package/lib/commands/list.js +100 -0
  27. package/lib/commands/login.js +67 -0
  28. package/lib/commands/logout.js +41 -0
  29. package/lib/commands/lsub.js +87 -0
  30. package/lib/commands/noop.js +21 -0
  31. package/lib/commands/rename.js +102 -0
  32. package/lib/commands/search.js +76 -0
  33. package/lib/commands/select.js +289 -0
  34. package/lib/commands/status.js +63 -0
  35. package/lib/commands/store.js +151 -0
  36. package/lib/commands/subscribe.js +53 -0
  37. package/lib/commands/uid copy.js +7 -0
  38. package/lib/commands/uid fetch.js +5 -0
  39. package/lib/commands/uid search.js +5 -0
  40. package/lib/commands/uid store.js +5 -0
  41. package/lib/commands/unsubscribe.js +50 -0
  42. package/lib/dates.js +123 -0
  43. package/lib/deflate-layer.js +232 -0
  44. package/lib/envelope.js +82 -0
  45. package/lib/esearch.js +208 -0
  46. package/lib/framing.js +102 -0
  47. package/lib/list-extensions.js +36 -0
  48. package/lib/load-plugins.js +109 -0
  49. package/lib/mailbox-name.js +133 -0
  50. package/lib/mimeparser.js +778 -0
  51. package/lib/mock-client.js +233 -0
  52. package/lib/numbers.js +52 -0
  53. package/lib/plugins/acl.js +964 -0
  54. package/lib/plugins/appendlimit.js +83 -0
  55. package/lib/plugins/auth-plain.js +94 -0
  56. package/lib/plugins/binary.js +256 -0
  57. package/lib/plugins/catenate.js +253 -0
  58. package/lib/plugins/compress.js +76 -0
  59. package/lib/plugins/condstore.js +563 -0
  60. package/lib/plugins/context-search.js +321 -0
  61. package/lib/plugins/context-sort.js +19 -0
  62. package/lib/plugins/create-special-use.js +108 -0
  63. package/lib/plugins/enable.js +155 -0
  64. package/lib/plugins/esearch.js +156 -0
  65. package/lib/plugins/esort.js +60 -0
  66. package/lib/plugins/id.js +138 -0
  67. package/lib/plugins/idle.js +105 -0
  68. package/lib/plugins/imap4rev2.js +202 -0
  69. package/lib/plugins/list-extended.js +258 -0
  70. package/lib/plugins/list-status.js +31 -0
  71. package/lib/plugins/literalminus.js +20 -0
  72. package/lib/plugins/literalplus.js +18 -0
  73. package/lib/plugins/logindisabled.js +50 -0
  74. package/lib/plugins/messagelimit.js +234 -0
  75. package/lib/plugins/metadata-server.js +13 -0
  76. package/lib/plugins/metadata.js +475 -0
  77. package/lib/plugins/move.js +110 -0
  78. package/lib/plugins/multiappend.js +26 -0
  79. package/lib/plugins/multisearch.js +269 -0
  80. package/lib/plugins/namespace.js +67 -0
  81. package/lib/plugins/notify.js +654 -0
  82. package/lib/plugins/oauthbearer.js +217 -0
  83. package/lib/plugins/objectid.js +243 -0
  84. package/lib/plugins/partial.js +68 -0
  85. package/lib/plugins/preview.js +400 -0
  86. package/lib/plugins/qresync.js +525 -0
  87. package/lib/plugins/quota.js +285 -0
  88. package/lib/plugins/replace.js +145 -0
  89. package/lib/plugins/sasl-ir.js +12 -0
  90. package/lib/plugins/savedate.js +59 -0
  91. package/lib/plugins/savelimit.js +18 -0
  92. package/lib/plugins/searchres.js +82 -0
  93. package/lib/plugins/sort-display.js +23 -0
  94. package/lib/plugins/sort.js +132 -0
  95. package/lib/plugins/special-use.js +95 -0
  96. package/lib/plugins/starttls.js +57 -0
  97. package/lib/plugins/status-size.js +19 -0
  98. package/lib/plugins/thread-orderedsubject.js +16 -0
  99. package/lib/plugins/thread-references.js +16 -0
  100. package/lib/plugins/uidonly.js +135 -0
  101. package/lib/plugins/uidplus.js +124 -0
  102. package/lib/plugins/unauthenticate.js +28 -0
  103. package/lib/plugins/unselect.js +36 -0
  104. package/lib/plugins/utf8-accept.js +68 -0
  105. package/lib/plugins/x-gm-ext-1.js +456 -0
  106. package/lib/plugins/xoauth2.js +188 -0
  107. package/lib/plugins/xtoybird.js +282 -0
  108. package/lib/server.js +2880 -0
  109. package/lib/smtp-listener.js +51 -0
  110. package/lib/sorting.js +373 -0
  111. package/lib/threading.js +357 -0
  112. package/lib/utf8-session.js +123 -0
  113. package/lib/vanished.js +57 -0
  114. package/package.json +61 -5
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ const { updateSession } = require('../utf8-session');
4
+ const { registerEnable } = require('./enable');
5
+
6
+ /**
7
+ * @help Adds UTF8=ACCEPT [RFC9755] capability, loads ENABLE as well
8
+ * @help After ENABLE UTF8=ACCEPT mailbox names are UTF-8 instead of
9
+ * @help modified UTF-7 and strings are sent quoted as UTF-8
10
+ */
11
+
12
+ /**
13
+ * Checks if the header of a message holds 8-bit octets
14
+ *
15
+ * @param {String} raw Message source as a binary string
16
+ * @return {Boolean} true if the header has 8-bit octets
17
+ */
18
+ function has8bitHeader(raw) {
19
+ const match = raw.match(/\r?\n\r?\n/);
20
+ return /[\x80-\xff]/.test(match ? raw.substr(0, match.index) : raw);
21
+ }
22
+
23
+ module.exports = function (server) {
24
+ // RFC 9755 section 3: the client turns UTF-8 support on with ENABLE
25
+ server.registerCapability('UTF8=ACCEPT');
26
+
27
+ registerEnable(server, 'UTF8=ACCEPT');
28
+
29
+ // RFC 9755 section 3: a server that supports UTF8=ACCEPT accepts UTF-8 in quoted strings. Before
30
+ // ENABLE, mailbox names must still be modified UTF-7 and SEARCH needs CHARSET UTF-8 for 8-bit strings.
31
+ // See lib/utf8-session.js, the session state is shared with IMAP4rev2
32
+ server.utf8Accept = true;
33
+ server.connectionHandlers.push(updateSession);
34
+ // RFC 8437 section 3: UNAUTHENTICATE resets the ENABLEd extensions
35
+ server.resetHandlers.push(updateSession);
36
+
37
+ server.outputHandlers.push((connection, response, description, parsed, data, extra) => {
38
+ if (description === 'ENABLED' && Array.isArray(extra) && extra.indexOf('UTF8=ACCEPT') >= 0) {
39
+ // strings that are valid UTF-8 are sent quoted, mailbox names are UTF-8 in both directions
40
+ // ("&" is an ordinary character), SEARCH strings are UTF-8 and CHARSET is refused
41
+ updateSession(connection);
42
+ }
43
+ });
44
+
45
+ // RFC 9755 section 4: without ENABLE UTF8=ACCEPT, APPEND MUST be refused with NO
46
+ // if the message header has 8-bit characters
47
+ const appendHandler = server.getCommandHandler('APPEND');
48
+ server.setCommandHandler('APPEND', (connection, parsed, data, callback) => {
49
+ const args = parsed.attributes || [];
50
+ const raw = args[args.length - 1];
51
+ if (
52
+ // UTF-8 is on after ENABLE UTF8=ACCEPT, or IMAP4rev2, which permits 8-bit characters in the message
53
+ // (RFC 9051 section 6.3.12) and which RFC 9755 does not extend
54
+ !connection.utf8Enabled &&
55
+ args.length >= 2 &&
56
+ raw &&
57
+ ['LITERAL', 'LITERAL8'].indexOf(raw.type) >= 0 &&
58
+ has8bitHeader(raw.value || '')
59
+ ) {
60
+ connection.sendStatus(parsed, data, 'NO', 'Message header has 8-bit characters, ENABLE UTF8=ACCEPT first (RFC 9755 section 4)');
61
+ return callback();
62
+ }
63
+ return appendHandler(connection, parsed, data, callback);
64
+ });
65
+ };
66
+
67
+ // ENABLE UTF8=ACCEPT is the only way to use this extension
68
+ module.exports.requires = ['ENABLE'];
@@ -0,0 +1,456 @@
1
+ 'use strict';
2
+
3
+ const search = require('../commands/handlers/search');
4
+ const { MONTHS, isRealDate } = require('../dates');
5
+
6
+ const { badError } = search;
7
+
8
+ // X-GM-MSGID is a 64 bit unsigned number, so it is tracked as a BigInt
9
+
10
+ // Sample value from Gmail IMAP extensions API page
11
+ // https://developers.google.com/workspace/gmail/imap/imap-extensions
12
+ // Used as default, if server.options["HIGHESTX-GM-MSGID"]
13
+ // is missing
14
+ const SEED = '1278455344230334865';
15
+
16
+ const MAX_UINT64 = 2n ** 64n - 1n;
17
+
18
+ // X-GM-RAW mailbox shortcuts of in: and is: (Gmail search operators), as ImapKit criteria
19
+ const RAW_SHORTCUTS = {
20
+ 'in:inbox': ['X-GM-LABELS', '\\Inbox'],
21
+ 'in:sent': ['X-GM-LABELS', '\\Sent'],
22
+ 'in:drafts': ['X-GM-LABELS', '\\Drafts'],
23
+ 'in:trash': ['X-GM-LABELS', '\\Trash'],
24
+ 'in:spam': ['X-GM-LABELS', '\\Junk'],
25
+ 'in:anywhere': ['ALL'],
26
+ 'is:unread': ['UNSEEN'],
27
+ 'is:read': ['SEEN'],
28
+ 'is:starred': ['FLAGGED'],
29
+ 'is:important': ['X-GM-LABELS', '\\Important']
30
+ };
31
+
32
+ // X-GM-RAW operators that take a value, as the search key the value is given to
33
+ const RAW_OPERATORS = {
34
+ from: 'FROM',
35
+ to: 'TO',
36
+ cc: 'CC',
37
+ bcc: 'BCC',
38
+ subject: 'SUBJECT',
39
+ label: 'X-GM-LABELS',
40
+ larger: 'LARGER',
41
+ smaller: 'SMALLER',
42
+ after: 'SINCE',
43
+ before: 'BEFORE',
44
+ rfc822msgid: 'HEADER'
45
+ };
46
+
47
+ // Gmail operators that ImapKit does not implement, answered with NO instead of a wrong result
48
+ const RAW_UNSUPPORTED = ['is', 'has', 'list', 'filename', 'around', 'size', 'older', 'newer', 'older_than', 'newer_than', 'deliveredto', 'category'];
49
+
50
+ /**
51
+ * @help Adds Gmail specific X-GM-EXT-1 capability
52
+ * @help (https://developers.google.com/workspace/gmail/imap/imap-extensions)
53
+ * @help X-GM-MSGID and X-GM-THRID: FETCH and SEARCH. X-GM-THRID is the
54
+ * @help X-GM-MSGID of the message unless storage sets an X-GM-THRID
55
+ * @help value, so every message is its own thread unless the
56
+ * @help fixture groups messages. With OBJECTID, the messages of a
57
+ * @help THREADID share the X-GM-THRID of its first message
58
+ * @help X-GM-LABELS: FETCH, STORE (+/-, .SILENT) and SEARCH. System
59
+ * @help labels are atoms that start with "\" (\Inbox for INBOX, the
60
+ * @help special-use attribute for special-use mailboxes), other labels
61
+ * @help are mailbox names, sent like mailbox names (modified UTF-7, or
62
+ * @help UTF-8 after ENABLE UTF8=ACCEPT, quoted when not atoms). Labels
63
+ * @help have no side effects: the message is not copied to or removed
64
+ * @help from other mailboxes. In SEARCH a label that starts with "\"
65
+ * @help is a system label
66
+ * @help X-GM-RAW: a subset of the Gmail search syntax. Words and "quoted
67
+ * @help phrases" (TEXT), -term, OR, (groups), {any of}, from: to: cc:
68
+ * @help bcc: subject: label: in: (inbox sent drafts trash spam
69
+ * @help anywhere, or a label) is: (read unread starred important)
70
+ * @help larger: smaller: (k and m suffixes) after: before:
71
+ * @help (YYYY/MM/DD) and rfc822msgid:. Other Gmail operators are
72
+ * @help answered with NO
73
+ */
74
+
75
+ module.exports = function (server) {
76
+ server.registerCapability('X-GM-EXT-1');
77
+
78
+ server['HIGHESTX-GM-MSGID'] = BigInt(server.options['HIGHESTX-GM-MSGID'] || SEED);
79
+
80
+ // set X-GM-MSGID values when message is created / initialized
81
+ server.messageHandlers.push((connection, message, mailbox) => {
82
+ let labels;
83
+
84
+ if (!message['X-GM-MSGID']) {
85
+ server['HIGHESTX-GM-MSGID'] += 1n;
86
+ message['X-GM-MSGID'] = server['HIGHESTX-GM-MSGID'].toString();
87
+ } else if (/^\d+$/.test(message['X-GM-MSGID']) && BigInt(message['X-GM-MSGID']) > server['HIGHESTX-GM-MSGID']) {
88
+ // Storage might be shared with another server instance, do not reuse existing values
89
+ server['HIGHESTX-GM-MSGID'] = BigInt(message['X-GM-MSGID']);
90
+ }
91
+
92
+ // Ensure message has an array of labels
93
+ message['X-GM-LABELS'] = [].concat(message['X-GM-LABELS'] || []);
94
+
95
+ if (mailbox.path.toUpperCase() === 'INBOX') {
96
+ labels = ['\\Inbox'];
97
+ } else if (mailbox['special-use'] && mailbox['special-use'].length) {
98
+ labels = [].concat(mailbox['special-use']);
99
+ } else {
100
+ labels = [userLabel(mailbox.path)];
101
+ }
102
+
103
+ labels.forEach(label => {
104
+ server.ensureFlag(message['X-GM-LABELS'], label);
105
+ });
106
+ });
107
+
108
+ // A message starts its own thread unless storage puts it into one. With OBJECTID loaded, the messages of a THREADID
109
+ // (RFC 8474 section 5.2) share an X-GM-THRID: the one of the first message of that thread, like Gmail uses the
110
+ // X-GM-MSGID of the first message. This runs after every other message handler, so OBJECTID set the THREADID
111
+ // already, whatever the load order is
112
+ const gmThreads = new Map();
113
+ server.once('pluginsLoaded', () => {
114
+ server.messageHandlers.push((server, message) => {
115
+ const known = message.THREADID && gmThreads.get(message.THREADID);
116
+ if (!message['X-GM-THRID']) {
117
+ message['X-GM-THRID'] = known || message['X-GM-MSGID'];
118
+ }
119
+ if (message.THREADID && !known) {
120
+ gmThreads.set(message.THREADID, message['X-GM-THRID']);
121
+ }
122
+ });
123
+ });
124
+
125
+ // Retrieve X-GM-MSGID values with FETCH
126
+ server.fetchHandlers['X-GM-MSGID'] = function (connection, message) {
127
+ return {
128
+ type: 'ATOM',
129
+ value: message['X-GM-MSGID']
130
+ };
131
+ };
132
+
133
+ // Retrieve X-GM-LABELS values with FETCH
134
+ server.fetchHandlers['X-GM-LABELS'] = function (connection, message) {
135
+ return formatLabels(message);
136
+ };
137
+
138
+ // X-GM-MSGID and X-GM-THRID are 64 bit unsigned numbers
139
+ const idArgument = () => [
140
+ value => {
141
+ if (!/^\d{1,20}$/.test(value) || BigInt(value) > MAX_UINT64) {
142
+ throw badError('Expected a 64 bit unsigned number');
143
+ }
144
+ return BigInt(value).toString();
145
+ }
146
+ ];
147
+
148
+ server.searchHandlers['X-GM-MSGID'] = function (connection, message, sequence, xGmMsgid) {
149
+ return message['X-GM-MSGID'] === xGmMsgid;
150
+ };
151
+ server.searchHandlers['X-GM-MSGID'].argumentTypes = idArgument;
152
+
153
+ // Retrieve X-GM-THRID values with FETCH
154
+ server.fetchHandlers['X-GM-THRID'] = function (connection, message) {
155
+ return {
156
+ type: 'ATOM',
157
+ value: message['X-GM-THRID']
158
+ };
159
+ };
160
+
161
+ server.searchHandlers['X-GM-THRID'] = function (connection, message, sequence, xGmThrid) {
162
+ return message['X-GM-THRID'] === xGmThrid;
163
+ };
164
+ server.searchHandlers['X-GM-THRID'].argumentTypes = idArgument;
165
+
166
+ // SEARCH X-GM-LABELS label, the label is given in the form the session uses for mailbox names
167
+ server.searchHandlers['X-GM-LABELS'] = function (connection, message, sequence, label) {
168
+ return message['X-GM-LABELS'].some(stored => {
169
+ if (isSystemLabel(stored)) {
170
+ // system labels are matched without case, like flags
171
+ return stored.toLowerCase() === label.toLowerCase();
172
+ }
173
+ return connection.exportMailboxName(labelName(stored)) === label;
174
+ });
175
+ };
176
+
177
+ // SEARCH X-GM-RAW query, a subset of the Gmail search syntax
178
+ // the translated criteria are compiled once per SEARCH, they are the same array for every message
179
+ const rawMatchers = new WeakMap();
180
+ server.searchHandlers['X-GM-RAW'] = function (connection, message, sequence, criteria) {
181
+ if (!rawMatchers.has(criteria)) {
182
+ // without UTF8=ACCEPT the translated criteria take a CHARSET, Gmail queries are UTF-8
183
+ const params = connection.searchCharset ? criteria : ['CHARSET', 'UTF-8'].concat(criteria);
184
+ rawMatchers.set(criteria, search(connection, [], params, () => []).matches);
185
+ }
186
+ return rawMatchers.get(criteria)(message, sequence);
187
+ };
188
+ server.searchHandlers['X-GM-RAW'].argumentTypes = () => [parseRawQuery];
189
+
190
+ const setLabels = (message, labels) => {
191
+ message['X-GM-LABELS'] = [];
192
+ addLabels(message, labels);
193
+ };
194
+
195
+ const addLabels = (message, labels) => {
196
+ labels.forEach(label => server.ensureFlag(message['X-GM-LABELS'], label));
197
+ };
198
+
199
+ const removeLabels = (message, labels) => {
200
+ labels.forEach(label => server.removeFlag(message['X-GM-LABELS'], label));
201
+ };
202
+
203
+ [
204
+ ['X-GM-LABELS', setLabels],
205
+ ['+X-GM-LABELS', addLabels],
206
+ ['-X-GM-LABELS', removeLabels]
207
+ ].forEach(([name, update]) => {
208
+ // all labels are parsed before the message changes, an invalid one fails the STORE
209
+ const apply = (connection, message, flags) =>
210
+ update(
211
+ message,
212
+ flags.map(flag => parseLabel(connection, flag))
213
+ );
214
+
215
+ server.storeHandlers[name] = function (connection, message, flags, index, parsed, data) {
216
+ apply(connection, message, flags);
217
+ sendLabelUpdate(connection, parsed, data, index, message);
218
+ };
219
+
220
+ server.storeHandlers[name + '.SILENT'] = apply;
221
+
222
+ // labels are astrings (Gmail IMAP extensions): literals are accepted, NIL is a label name
223
+ server.storeHandlers[name].astringValues = server.storeHandlers[name + '.SILENT'].astringValues = true;
224
+ });
225
+
226
+ // Gmail keeps the same X-GM-MSGID for a message in every mailbox, so copies get the value of the source message
227
+ server.copyHandlers.push((server, source, copy) => {
228
+ if (source['X-GM-MSGID']) {
229
+ copy['X-GM-MSGID'] = source['X-GM-MSGID'];
230
+ copy['X-GM-THRID'] = source['X-GM-THRID'];
231
+ }
232
+ });
233
+ };
234
+
235
+ /**
236
+ * Labels are kept as strings in message["X-GM-LABELS"]. A system label like \Inbox starts with "\",
237
+ * any other label is a mailbox name in storage form. A mailbox name that itself starts with "\" is
238
+ * kept with one more "\" in front, so it can not be mistaken for a system label
239
+ */
240
+ const isSystemLabel = label => label.charAt(0) === '\\' && label.charAt(1) !== '\\';
241
+ const userLabel = name => (name.charAt(0) === '\\' ? '\\' + name : name);
242
+ const labelName = label => (label.charAt(0) === '\\' ? label.substr(1) : label);
243
+
244
+ /**
245
+ * Converts a STORE X-GM-LABELS value to a stored label. An atom that starts with "\" is a system
246
+ * label, anything else is a label name: an astring in the form the session uses for mailbox names
247
+ *
248
+ * @param {Object} connection IMAP connection
249
+ * @param {Object} attr Parsed value
250
+ * @return {String} Stored label
251
+ */
252
+ function parseLabel(connection, attr) {
253
+ const value = String((attr && attr.value) || '');
254
+ if (attr.type === 'ATOM' && value.charAt(0) === '\\') {
255
+ return value;
256
+ }
257
+ return userLabel(connection.importMailboxName(value));
258
+ }
259
+
260
+ /**
261
+ * Formats the labels of a message for a FETCH response. Label names are MAILBOX attributes, so the
262
+ * session gets them in its own form and quoted when they are not atoms
263
+ */
264
+ function formatLabels(message) {
265
+ return message['X-GM-LABELS'].map(label => (isSystemLabel(label) ? { type: 'ATOM', value: label } : { type: 'MAILBOX', value: labelName(label) }));
266
+ }
267
+
268
+ /**
269
+ * Translates an X-GM-RAW query (Gmail search syntax) to ImapKit search criteria. Terms are
270
+ * ANDed, OR binds tighter than that ("a b OR c" is a AND (b OR c)), "-" negates a term, ( ) groups
271
+ * terms and { } matches any of its terms.
272
+ *
273
+ * @param {String} query Query as a binary string, UTF-8
274
+ * @return {Array} Search criteria
275
+ * @throws {Error} BAD for a query that can not be parsed, NO for an unsupported operator
276
+ */
277
+ function parseRawQuery(query) {
278
+ const tokens = [];
279
+ const re = /\s*(?:([(){}])|"([^"]*)"|([^\s(){}"]+?:)"([^"]*)"|([^\s(){}"]+))/gy;
280
+ let match;
281
+ // end of the last token, a failed sticky match resets lastIndex
282
+ let end = 0;
283
+ while (end < query.length && (match = re.exec(query))) {
284
+ end = re.lastIndex;
285
+ if (match[1]) {
286
+ tokens.push({ type: match[1] });
287
+ } else if (typeof match[2] === 'string') {
288
+ tokens.push({ type: 'word', value: match[2] });
289
+ } else if (match[3]) {
290
+ tokens.push({ type: 'word', value: match[3] + match[4], operator: match[3].slice(0, -1), operand: match[4] });
291
+ } else {
292
+ tokens.push({ type: 'word', value: match[5] });
293
+ }
294
+ }
295
+ if (query.slice(end).trim()) {
296
+ throw badError('X-GM-RAW query has an unterminated quoted string');
297
+ }
298
+
299
+ let pos = 0;
300
+ const peek = () => tokens[pos];
301
+
302
+ // expression = 1*term, until the end or a closing bracket
303
+ const expression = end => {
304
+ const terms = [];
305
+ while (pos < tokens.length && (!peek() || peek().type !== end)) {
306
+ terms.push(term());
307
+ }
308
+ if (end) {
309
+ if (!peek() || peek().type !== end) {
310
+ throw badError('X-GM-RAW query is missing "' + end + '"');
311
+ }
312
+ pos++;
313
+ }
314
+ if (!terms.length) {
315
+ throw badError('X-GM-RAW query is empty');
316
+ }
317
+ return terms.length === 1 ? terms[0] : terms;
318
+ };
319
+
320
+ // term = unary *("OR" unary)
321
+ const term = () => {
322
+ let result = unary();
323
+ while (peek() && peek().type === 'word' && peek().value === 'OR' && tokens[pos + 1]) {
324
+ pos++;
325
+ result = ['OR', result, unary()];
326
+ }
327
+ return result;
328
+ };
329
+
330
+ const unary = () => {
331
+ const token = tokens[pos++];
332
+ if (!token || token.type === ')' || token.type === '}') {
333
+ throw badError('Unexpected end of X-GM-RAW query');
334
+ }
335
+ if (token.type === '(') {
336
+ return expression(')');
337
+ }
338
+ if (token.type === '{') {
339
+ const options = [];
340
+ while (peek() && peek().type !== '}') {
341
+ options.push(unary());
342
+ }
343
+ if (!peek() || !options.length) {
344
+ throw badError('X-GM-RAW query has an empty or unterminated { } group');
345
+ }
346
+ pos++;
347
+ return options.reduce((any, option) => ['OR', any, option]);
348
+ }
349
+ if (token.value === '-' && peek() && (peek().type === '(' || peek().type === '{')) {
350
+ return ['NOT', unary()];
351
+ }
352
+ if (token.value.charAt(0) === '-' && token.value.length > 1) {
353
+ return ['NOT', rawTerm(rawWord(token.value.substr(1)))];
354
+ }
355
+ return rawTerm(token);
356
+ };
357
+
358
+ return [expression()];
359
+ }
360
+
361
+ // splits "name:value" into an operator and an operand
362
+ function rawWord(value) {
363
+ const match = value.match(/^([a-z_0-9]+):(.+)$/i);
364
+ return match ? { value, operator: match[1], operand: match[2] } : { value, operator: false, operand: false };
365
+ }
366
+
367
+ function rawTerm(token) {
368
+ const { operator, operand } = token.operator ? token : rawWord(token.value);
369
+ if (!operator) {
370
+ return ['TEXT', token.value];
371
+ }
372
+
373
+ const name = operator.toLowerCase();
374
+ // own keys only, names like "constructor:" are plain text
375
+ const shortcutKey = name + ':' + operand.toLowerCase();
376
+ const shortcut = Object.hasOwn(RAW_SHORTCUTS, shortcutKey) && RAW_SHORTCUTS[shortcutKey];
377
+ if (shortcut) {
378
+ return shortcut;
379
+ }
380
+
381
+ const key = Object.hasOwn(RAW_OPERATORS, name) && RAW_OPERATORS[name];
382
+ if (key) {
383
+ switch (key) {
384
+ case 'LARGER':
385
+ case 'SMALLER': {
386
+ const size = operand.match(/^(\d+)([km]?)$/i);
387
+ if (!size) {
388
+ throw badError('X-GM-RAW ' + name + ': expects a size');
389
+ }
390
+ return [key, String(Number(size[1]) * { '': 1, k: 1024, m: 1024 * 1024 }[size[2].toLowerCase()])];
391
+ }
392
+ case 'SINCE':
393
+ case 'BEFORE': {
394
+ const date = operand.match(/^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/);
395
+ if (!date || !isRealDate(Number(date[3]), Number(date[2]) - 1, Number(date[1]))) {
396
+ throw badError('X-GM-RAW ' + name + ': expects a date as YYYY/MM/DD');
397
+ }
398
+ return [key, Number(date[3]) + '-' + MONTHS[Number(date[2]) - 1] + '-' + date[1]];
399
+ }
400
+ case 'HEADER':
401
+ return [key, 'Message-ID', operand];
402
+ default:
403
+ return [key, operand];
404
+ }
405
+ }
406
+
407
+ if (name === 'in') {
408
+ // any other mailbox is a label
409
+ return ['X-GM-LABELS', operand];
410
+ }
411
+
412
+ if (RAW_UNSUPPORTED.indexOf(name) >= 0) {
413
+ const err = new Error('X-GM-RAW operator ' + name + ':' + operand + ' is not supported by imapkit');
414
+ err.imapResponse = 'NO';
415
+ throw err;
416
+ }
417
+
418
+ // not an operator, like "http://example.com"
419
+ return ['TEXT', token.value];
420
+ }
421
+
422
+ function sendLabelUpdate(connection, parsed, data, index, message) {
423
+ const resp = [
424
+ {
425
+ type: 'ATOM',
426
+ value: 'X-GM-LABELS'
427
+ },
428
+ formatLabels(message)
429
+ ];
430
+
431
+ if ((parsed.command || '').toUpperCase() === 'UID STORE') {
432
+ resp.push({
433
+ type: 'ATOM',
434
+ value: 'UID'
435
+ });
436
+ resp.push(message.uid);
437
+ }
438
+
439
+ connection.send(
440
+ {
441
+ tag: '*',
442
+ attributes: [
443
+ index,
444
+ {
445
+ type: 'ATOM',
446
+ value: 'FETCH'
447
+ },
448
+ resp
449
+ ]
450
+ },
451
+ 'FLAG UPDATE',
452
+ parsed,
453
+ data,
454
+ message
455
+ );
456
+ }