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,258 @@
1
+ 'use strict';
2
+
3
+ const { isDeepStrictEqual } = require('util');
4
+ const { getListExtensions } = require('../list-extensions');
5
+
6
+ /**
7
+ * @help Adds LIST-EXTENDED [RFC5258] capability: selection options
8
+ * @help SUBSCRIBED, REMOTE and RECURSIVEMATCH, return options SUBSCRIBED
9
+ * @help and CHILDREN, multiple mailbox patterns and CHILDINFO.
10
+ * @help \Noselect mailboxes are listed as \NonExistent
11
+ */
12
+
13
+ const { isAstring } = require('../arguments');
14
+
15
+ /**
16
+ * Checks if a LIST command uses the extended syntax (RFC 5258 section 1): selection options before
17
+ * the reference, a list of patterns, or more than 2 arguments (return options)
18
+ *
19
+ * @param {Array} args Command arguments
20
+ * @return {Boolean} true for an extended LIST command
21
+ */
22
+ const isExtended = args => Array.isArray(args[0]) || Array.isArray(args[1]) || args.length > 2;
23
+
24
+ module.exports = function (server) {
25
+ const extensions = getListExtensions(server);
26
+ if (extensions.enabled) {
27
+ // already loaded by LIST-STATUS
28
+ return;
29
+ }
30
+ extensions.enabled = true;
31
+
32
+ server.registerCapability('LIST-EXTENDED');
33
+
34
+ // RFC 5258 section 3.1. There are no remote mailboxes, so REMOTE changes nothing
35
+ extensions.selectionOptions.SUBSCRIBED = {
36
+ type: 'base',
37
+ returnOption: 'SUBSCRIBED',
38
+ includeNonExistent: true,
39
+ match: folder => !!folder.subscribed
40
+ };
41
+ extensions.selectionOptions.REMOTE = { type: 'independent' };
42
+ extensions.selectionOptions.RECURSIVEMATCH = { type: 'modifier' };
43
+
44
+ // RFC 5258 section 3.2. Children attributes are always returned, so CHILDREN needs no handling
45
+ extensions.returnOptions.SUBSCRIBED = {};
46
+ extensions.returnOptions.CHILDREN = {};
47
+
48
+ /**
49
+ * Parses selection or return options (RFC 5258 section 6, option-extension = tag [SP option-value])
50
+ *
51
+ * @param {Array} list Parsed option list
52
+ * @param {Object} registry Known options
53
+ * @param {String} kind "selection" or "return", for error messages
54
+ * @param {Object} connection IMAPConnection instance
55
+ * @return {Map} option name to value (true for options without a value)
56
+ */
57
+ const parseOptions = (list, registry, kind, connection) => {
58
+ const options = new Map();
59
+ for (let i = 0; i < list.length; i++) {
60
+ const item = list[i];
61
+ if (!item || item.type !== 'ATOM') {
62
+ throw new Error('Invalid ' + kind + ' option');
63
+ }
64
+ const name = item.value.toUpperCase();
65
+ const option = registry[name];
66
+ if (!option) {
67
+ // RFC 5258 section 3: "A server MUST respond to options it does not recognize with a BAD response"
68
+ throw new Error('Unknown ' + kind + ' option ' + name);
69
+ }
70
+
71
+ let value = true;
72
+ if (option.parse) {
73
+ if (!Array.isArray(list[i + 1])) {
74
+ throw new Error(name + ' ' + kind + ' option requires a value');
75
+ }
76
+ value = option.parse(list[++i], connection);
77
+ } else if (Array.isArray(list[i + 1])) {
78
+ throw new Error(name + ' ' + kind + ' option takes no value');
79
+ }
80
+
81
+ // RFC 5258 section 3: a repeated option counts once, so it can not have different values
82
+ if (options.has(name) && !isDeepStrictEqual(options.get(name), value)) {
83
+ throw new Error(name + ' ' + kind + ' option is repeated with a different value');
84
+ }
85
+ options.set(name, value);
86
+ }
87
+ return options;
88
+ };
89
+
90
+ /**
91
+ * Parses the arguments of an extended LIST command:
92
+ * list = "LIST" [SP list-select-opts] SP mailbox SP mbox-or-pat [SP list-return-opts]
93
+ *
94
+ * @param {Array} args Command arguments
95
+ * @param {Object} connection IMAPConnection instance
96
+ * @return {Object} `{ selection, reference, patterns, returns }`
97
+ */
98
+ const parseArguments = (args, connection) => {
99
+ let selection = new Map();
100
+ let returns = new Map();
101
+ let pos = 0;
102
+
103
+ if (Array.isArray(args[0])) {
104
+ selection = parseOptions(args[0], extensions.selectionOptions, 'selection', connection);
105
+ pos++;
106
+ }
107
+
108
+ const reference = args[pos];
109
+ let patterns = args[pos + 1];
110
+ const rest = args.slice(pos + 2);
111
+
112
+ if (!isAstring(reference)) {
113
+ throw new Error('LIST expects a reference name');
114
+ }
115
+
116
+ // patterns = "(" list-mailbox *(SP list-mailbox) ")"
117
+ patterns = Array.isArray(patterns) ? patterns : [patterns];
118
+ if (!patterns.length || !patterns.every(isAstring)) {
119
+ throw new Error('LIST expects a mailbox pattern or a list of patterns');
120
+ }
121
+
122
+ // list-return-opts = "RETURN" SP "(" [return-option *(SP return-option)] ")"
123
+ if (rest.length) {
124
+ if (rest.length !== 2 || !rest[0] || rest[0].type !== 'ATOM' || rest[0].value.toUpperCase() !== 'RETURN' || !Array.isArray(rest[1])) {
125
+ throw new Error('LIST expects RETURN and a list of return options after the patterns');
126
+ }
127
+ returns = parseOptions(rest[1], extensions.returnOptions, 'return', connection);
128
+ }
129
+
130
+ // RFC 5258 section 3.1: RECURSIVEMATCH (any list-select-mod-opt) needs a list-select-base-opt
131
+ const types = [...selection.keys()].map(name => extensions.selectionOptions[name].type);
132
+ if (types.includes('modifier') && !types.includes('base')) {
133
+ throw new Error('RECURSIVEMATCH must be used together with a selection option like SUBSCRIBED');
134
+ }
135
+
136
+ // a selection option implies its return option, eg. SUBSCRIBED
137
+ selection.forEach((value, name) => {
138
+ const implied = extensions.selectionOptions[name].returnOption;
139
+ if (implied && !returns.has(implied)) {
140
+ returns.set(implied, true);
141
+ }
142
+ });
143
+
144
+ return {
145
+ selection,
146
+ reference: reference.value,
147
+ // RFC 5258 section 3: an empty pattern is ignored in an extended LIST command
148
+ patterns: patterns.map(pattern => pattern.value).filter(pattern => pattern),
149
+ returns
150
+ };
151
+ };
152
+
153
+ const exists = folder => folder.flags.indexOf('\\Noselect') < 0;
154
+
155
+ /**
156
+ * Mailbox attributes for an extended LIST response. \NonExistent replaces \Noselect (RFC 5258
157
+ * section 3, \NonExistent implies \Noselect), children attributes are computed (RFC 5258 section 4),
158
+ * \Subscribed is only listed with the SUBSCRIBED return option (RFC 5258 section 3.2)
159
+ */
160
+ const getAttributes = (folder, isExisting, hasChildren, returns) =>
161
+ server
162
+ .listAttributes(folder, { exists: isExisting, subscribed: returns.has('SUBSCRIBED') && folder.subscribed, hasChildren })
163
+ .map(flag => ({ type: 'ATOM', value: flag }));
164
+
165
+ const listHandler = server.getCommandHandler('LIST');
166
+
167
+ server.setCommandHandler('LIST', (connection, parsed, data, callback) => {
168
+ const args = parsed.attributes || [];
169
+ if (!isExtended(args)) {
170
+ // RFC 3501 LIST
171
+ return listHandler(connection, parsed, data, callback);
172
+ }
173
+
174
+ let request;
175
+ try {
176
+ request = parseArguments(args, connection);
177
+ } catch (err) {
178
+ connection.sendStatus(parsed, data, 'BAD', err.message, false, 'INVALID COMMAND');
179
+ return callback();
180
+ }
181
+
182
+ const selection = [...request.selection.keys()].map(name => ({ name, ...extensions.selectionOptions[name] }));
183
+ const filters = selection.filter(option => option.match);
184
+ const includeNonExistent = selection.some(option => option.includeNonExistent);
185
+ const recursive = request.selection.has('RECURSIVEMATCH');
186
+ // RFC 5258 section 6: CHILDINFO lists the list-select-base-opt options, always quoted
187
+ const childInfo = selection.filter(option => option.type === 'base').map(option => ({ type: 'STRING', value: option.name }));
188
+
189
+ // does a mailbox satisfy the selection criteria. Without options that means an existing mailbox
190
+ const matches = folder => (includeNonExistent || exists(folder)) && filters.every(option => option.match(folder, connection));
191
+
192
+ // RFC 5258 section 3.1: SUBSCRIBED lists subscribed names, also the ones that are not mailboxes
193
+ let folders = server.folderCache;
194
+ if (includeNonExistent) {
195
+ folders = Object.assign(Object.create(null), folders, server.getSubscriptionTree());
196
+ }
197
+
198
+ // RFC 5258 section 3: a mailbox that matches several patterns is listed once
199
+ const candidates = new Set();
200
+ request.patterns.forEach(pattern => {
201
+ server.matchFolders(request.reference, pattern, path => connection.exportMailboxName(path), folders).forEach(folder => candidates.add(folder));
202
+ });
203
+
204
+ candidates.forEach(folder => {
205
+ const isExisting = exists(folder);
206
+ const isMatch = matches(folder);
207
+ const descendants = server.getDescendants(folder.path, folders);
208
+ const hasChildren = descendants.some(exists);
209
+
210
+ let hasChildInfo = false;
211
+ if (recursive) {
212
+ // RFC 5258 section 3.5: a matching mailbox gets CHILDINFO if a descendant matches as well.
213
+ // A mailbox that does not match is only listed for a matching descendant that is not
214
+ // listed itself (redundant CHILDINFO SHOULD be suppressed)
215
+ hasChildInfo = descendants.some(descendant => matches(descendant) && (isMatch || !candidates.has(descendant)));
216
+ }
217
+
218
+ // RFC 5258 section 3.5: without selection filters a mailbox that does not exist but has
219
+ // existing descendants is listed as "\NonExistent \HasChildren"
220
+ const listed = isMatch || hasChildInfo || (!filters.length && !isExisting && hasChildren);
221
+ if (!listed) {
222
+ return;
223
+ }
224
+
225
+ const attributes = [
226
+ getAttributes(folder, isExisting, hasChildren, request.returns),
227
+ server.getSeparator(folder),
228
+ connection.exportMailboxName(folder.path)
229
+ ];
230
+ if (hasChildInfo) {
231
+ // mbox-list-extended = "(" mbox-list-extended-item *(SP mbox-list-extended-item) ")"
232
+ attributes.push([{ type: 'STRING', value: 'CHILDINFO' }, childInfo]);
233
+ }
234
+
235
+ connection.send(
236
+ {
237
+ tag: '*',
238
+ command: 'LIST',
239
+ attributes
240
+ },
241
+ 'LIST ITEM',
242
+ parsed,
243
+ data,
244
+ folder
245
+ );
246
+
247
+ request.returns.forEach((value, name) => {
248
+ const option = extensions.returnOptions[name];
249
+ if (option.onItem) {
250
+ option.onItem(connection, folder, value, { matched: isMatch, exists: isExisting }, parsed, data);
251
+ }
252
+ });
253
+ });
254
+
255
+ connection.sendStatus(parsed, data, 'OK', 'Completed', false, 'LIST');
256
+ return callback();
257
+ });
258
+ };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const { getListExtensions } = require('../list-extensions');
4
+ const { parseStatusItems, sendStatus } = require('../commands/handlers/status');
5
+ const listExtended = require('./list-extended');
6
+
7
+ /**
8
+ * @help Adds LIST-STATUS [RFC5819] capability, the STATUS return option
9
+ * @help of LIST. Loads LIST-EXTENDED as well
10
+ */
11
+
12
+ module.exports = function (server) {
13
+ // the STATUS return option needs the extended LIST syntax (RFC 5819 section 4)
14
+ listExtended(server);
15
+
16
+ server.registerCapability('LIST-STATUS');
17
+
18
+ // status-option = "STATUS" SP "(" status-att *(SP status-att) ")"
19
+ getListExtensions(server).returnOptions.STATUS = {
20
+ parse: (list, connection) => parseStatusItems(server, list, connection),
21
+
22
+ // RFC 5819 section 2: a STATUS response follows the LIST response of every selectable mailbox
23
+ // that matches the selection criteria. Mailboxes listed only for CHILDINFO or as \NonExistent
24
+ // get none. The selected mailbox is no exception (RFC 9051 section 6.3.11)
25
+ onItem: (connection, folder, items, info, parsed, data) => {
26
+ if (info.matched && info.exists) {
27
+ sendStatus(connection, folder.path, folder, items, parsed, data);
28
+ }
29
+ }
30
+ };
31
+ };
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @help Enables LITERAL- [RFC7888] capability
5
+ * @help Non-synchronizing literals up to 4096 octets, a larger
6
+ * @help one is dropped and answered with BAD [TOOBIG]
7
+ * @help Can not be loaded with LITERAL+
8
+ */
9
+
10
+ module.exports = function (server) {
11
+ // RFC 7888 section 5: servers MUST NOT advertise both LITERAL+ and LITERAL-
12
+ if (server.capabilities['LITERAL+']) {
13
+ throw new Error('LITERAL- can not be enabled together with LITERAL+');
14
+ }
15
+ server.registerCapability('LITERAL-');
16
+ // loaded by name, LITERAL+ can not replace it any more (see IMAP4rev2)
17
+ server.impliedLiteralMinus = false;
18
+ server.literalPlus = true;
19
+ server.nonSyncLiteralLimit = 4096;
20
+ };
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @help Enables LITERAL+ [RFC7888] capability
5
+ * @help Can not be loaded with LITERAL-
6
+ */
7
+
8
+ module.exports = function (server) {
9
+ // RFC 7888 section 5: servers MUST NOT advertise both LITERAL+ and LITERAL-. IMAP4rev2 adds LITERAL- only
10
+ // when LITERAL+ is not loaded, LITERAL+ replaces it then, as it allows more (RFC 9051 section 4.3)
11
+ if (server.capabilities['LITERAL-'] && !server.impliedLiteralMinus) {
12
+ throw new Error('LITERAL+ can not be enabled together with LITERAL-');
13
+ }
14
+ delete server.capabilities['LITERAL-'];
15
+ server.registerCapability('LITERAL+');
16
+ server.literalPlus = true;
17
+ server.nonSyncLiteralLimit = Infinity;
18
+ };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @help Disables LOGIN support for unencrypted connections
5
+ */
6
+
7
+ module.exports = function (server) {
8
+ server.registerCapability('LOGINDISABLED', connection => {
9
+ return !connection.secureConnection && connection.state === 'Not Authenticated';
10
+ });
11
+
12
+ // Retrieve actual LOGIN handler
13
+ // Will be run if conditions are met
14
+ const oldHandler = server.getCommandHandler('LOGIN');
15
+
16
+ // Override LOGIN
17
+ server.setCommandHandler('LOGIN', (connection, parsed, data, callback) => {
18
+ // If the connection is unsecure, do not allow LOGIN
19
+ if (!connection.secureConnection) {
20
+ connection.send(
21
+ {
22
+ tag: parsed.tag,
23
+ command: 'NO',
24
+ attributes: [
25
+ {
26
+ type: 'SECTION',
27
+ section: [
28
+ {
29
+ type: 'ATOM',
30
+ value: 'PRIVACYREQUIRED'
31
+ }
32
+ ]
33
+ },
34
+ {
35
+ type: 'TEXT',
36
+ value: 'Run STARTTLS first'
37
+ }
38
+ ]
39
+ },
40
+ 'LOGIN FAILED',
41
+ parsed,
42
+ data
43
+ );
44
+ return callback();
45
+ }
46
+
47
+ // Reroute command to actual LOGIN handler
48
+ oldHandler(connection, parsed, data, callback);
49
+ });
50
+ };
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ const { badError } = require('../commands/handlers/search');
4
+ const { parsePartialRange } = require('../esearch');
5
+ const { MAX_NUMBER, isNzNumber } = require('../numbers');
6
+
7
+ /**
8
+ * @help Adds MESSAGELIMIT [RFC9738] capability, advertised as MESSAGELIMIT=<n>
9
+ * @help Server option "messageLimit" sets n (default 1000). FETCH, STORE, SEARCH, MOVE, UID EXPUNGE
10
+ * @help and their UID variants only work on the n messages with the highest UIDs and add
11
+ * @help [MESSAGELIMIT n uid] to the tagged OK. COPY, APPEND (MULTIAPPEND), SORT and THREAD of
12
+ * @help more messages fail with NO [MESSAGELIMIT ...]. Adds the UIDAFTER and UIDBEFORE search keys.
13
+ * @help Can not be loaded with SAVELIMIT
14
+ *
15
+ * MESSAGELIMIT: https://www.rfc-editor.org/rfc/rfc9738
16
+ */
17
+
18
+ // RFC 9738 section 3: the advertised limit SHOULD NOT be lower than 1000
19
+ const DEFAULT_LIMIT = 1000;
20
+
21
+ // RFC 9738 section 3.1: commands that operate on the messages with the highest UIDs and return the MESSAGELIMIT response code.
22
+ // EXPUNGE, CLOSE and STATUS UNSEEN MUST NOT be limited
23
+ const PARTIAL_COMMANDS = new Set(['FETCH', 'UID FETCH', 'STORE', 'UID STORE', 'MOVE', 'UID MOVE', 'UID EXPUNGE']);
24
+ const SEARCH_COMMANDS = new Set(['SEARCH', 'UID SEARCH']);
25
+ // RFC 9738 section 3.3: SORT and THREAD can not be run on more messages than the limit
26
+ const SORT_COMMANDS = new Set(['SORT', 'UID SORT', 'THREAD', 'UID THREAD']);
27
+
28
+ /**
29
+ * Reads the limit from the "messageLimit" server option
30
+ *
31
+ * @param {Object} server IMAPServer
32
+ * @param {String} name Capability name for the error message
33
+ * @return {Number} limit
34
+ */
35
+ function getLimit(server, name) {
36
+ if (server.messageLimit) {
37
+ // RFC 9738 section 3: SAVELIMIT is advertised instead of MESSAGELIMIT, never both
38
+ throw new Error(name + ' can not be enabled together with ' + server.messageLimit.name);
39
+ }
40
+ const limit = 'messageLimit' in server.options ? server.options.messageLimit : DEFAULT_LIMIT;
41
+ // message-limit = nz-number
42
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_NUMBER) {
43
+ throw new TypeError('Invalid messageLimit option, expecting a positive integer');
44
+ }
45
+ server.messageLimit = { name, limit };
46
+ return limit;
47
+ }
48
+
49
+ /**
50
+ * Refuses COPY and APPEND of more messages than the limit, shared with the SAVELIMIT plugin. COPY and
51
+ * MULTIAPPEND APPEND are atomic, so nothing is copied or appended (RFC 9738 section 3.1)
52
+ *
53
+ * @param {Object} server IMAPServer
54
+ * @param {Number} limit Message limit
55
+ */
56
+ function addSaveLimit(server, limit) {
57
+ server.appendChecks.push((connection, mailbox, messages, options) => {
58
+ if (messages.length <= limit) {
59
+ return false;
60
+ }
61
+ if (options.command) {
62
+ return { code: ['MESSAGELIMIT', limit], text: options.command + ' exceeds the limit of ' + limit + ' messages, nothing was appended' };
63
+ }
64
+ // MOVE is not atomic, it is cut to the limit before it gets here
65
+ if (!options.move) {
66
+ // the lowest UID the command would have processed, the messages are in UID order
67
+ const lastUid = messages[messages.length - limit].uid;
68
+ return { code: ['MESSAGELIMIT', limit, lastUid], text: 'Too many messages to copy, try a smaller subset' };
69
+ }
70
+ return false;
71
+ });
72
+ }
73
+
74
+ module.exports = function (server) {
75
+ const limit = getLimit(server, 'MESSAGELIMIT');
76
+ server.registerCapability('MESSAGELIMIT=' + limit);
77
+ addSaveLimit(server, limit);
78
+
79
+ const commandName = parsed => String(parsed.command || '').toUpperCase();
80
+
81
+ // FETCH, STORE, MOVE and UID EXPUNGE only operate on the messages with the highest UIDs, "the server is REQUIRED
82
+ // to process messages from highest to lowest UID". COPY is refused by addSaveLimit instead
83
+ server.rangeLimits.push((connection, parsed, range) => {
84
+ const command = commandName(parsed);
85
+ // a FETCH with the PARTIAL modifier was checked before it ran, its range is not cut
86
+ if (!PARTIAL_COMMANDS.has(command) || parsed.partialFetch) {
87
+ return false;
88
+ }
89
+ // UID EXPUNGE only operates on the \Deleted messages of the set
90
+ const operated = command === 'UID EXPUNGE' ? range.filter(entry => entry[1].flags.indexOf('\\Deleted') >= 0) : range;
91
+ if (operated.length <= limit) {
92
+ return false;
93
+ }
94
+ const dropped = new Set(operated.slice(0, -limit));
95
+ parsed.messageLimitUid = operated[operated.length - limit][1].uid;
96
+ // section 3.1: "when the MESSAGELIMIT response code is returned, the server is REQUIRED to process messages
97
+ // from highest to lowest UID", the FETCH, STORE, MOVE and UID EXPUNGE examples respond in that order
98
+ parsed.highestFirst = true;
99
+ return range.filter(entry => !dropped.has(entry)).reverse();
100
+ });
101
+
102
+ // RFC 9738 section 3.1: with the PARTIAL FETCH modifier (RFC 9394 section 3.3), the PARTIAL range is the message
103
+ // count, a larger range is refused without doing any work. The PARTIAL plugin answers an invalid modifier with BAD
104
+ server.commandChecks.push((connection, parsed) => {
105
+ const modifiers = server.capabilities.PARTIAL && parsed.attributes && parsed.attributes[2];
106
+ const command = commandName(parsed);
107
+ if (!Array.isArray(modifiers) || (command !== 'FETCH' && command !== 'UID FETCH')) {
108
+ return false;
109
+ }
110
+ const position = modifiers.findIndex((item, i) => !(i % 2) && item && item.type === 'ATOM' && String(item.value).toUpperCase() === 'PARTIAL');
111
+ let range;
112
+ try {
113
+ range = position >= 0 && parsePartialRange(modifiers[position + 1], true);
114
+ } catch {
115
+ return false;
116
+ }
117
+ if (range && range.to - range.from + 1 > limit) {
118
+ return { command: 'NO', code: ['MESSAGELIMIT', limit], text: command + ' exceeds the limit of ' + limit + ' messages' };
119
+ }
120
+ return false;
121
+ });
122
+
123
+ // RFC 9738 section 3.1: SEARCH counts the searched messages, not the matching ones. The messages a search
124
+ // looks at are the ones its top level sequence set, UID, UIDAFTER and UIDBEFORE keys allow
125
+ server.searchLimits.push((connection, messages, query) => {
126
+ // the running command, SEARCH and SORT do not pass it to the search
127
+ const parsed = connection._runningCommand && connection._runningCommand.parsed;
128
+ const command = parsed && commandName(parsed);
129
+ if (messages.length <= limit || (!SEARCH_COMMANDS.has(command) && !SORT_COMMANDS.has(command))) {
130
+ return false;
131
+ }
132
+ const candidates = messages.filter(message => isCandidate(query, message));
133
+ if (candidates.length <= limit) {
134
+ return false;
135
+ }
136
+ if (SORT_COMMANDS.has(command)) {
137
+ const err = new Error(command + ' exceeds the limit of ' + limit + ' messages, narrow it down with UIDAFTER or UIDBEFORE');
138
+ err.imapResponse = 'NO';
139
+ err.responseCode = ['MESSAGELIMIT', limit];
140
+ throw err;
141
+ }
142
+ // with SEARCHRES, only these results are saved in "$" (RFC 9738 section 3.4)
143
+ parsed.messageLimitUid = candidates[candidates.length - limit].uid;
144
+ return candidates.slice(-limit);
145
+ });
146
+
147
+ // RFC 9738 section 3.2: UIDAFTER <uid> is "UID <uid>+1:*", UIDBEFORE <uid> is "UID 1:<uid>-1"
148
+ const parseUniqueId = value => {
149
+ // uniqueid = nz-number
150
+ if (!isNzNumber(value)) {
151
+ throw badError('UIDAFTER and UIDBEFORE expect a UID');
152
+ }
153
+ return Number(value);
154
+ };
155
+ const uidAfter = (connection, message, index, uid) => message.uid > uid;
156
+ uidAfter.argumentTypes = () => [parseUniqueId];
157
+ const uidBefore = (connection, message, index, uid) => message.uid < uid;
158
+ uidBefore.argumentTypes = () => [parseUniqueId];
159
+ server.searchHandlers.UIDAFTER = uidAfter;
160
+ server.searchHandlers.UIDBEFORE = uidBefore;
161
+
162
+ const outputHandler = (connection, response, description, parsed, data) => {
163
+ // section 3.1 UID SEARCH example: the results go from the highest UID down, (MODSEQ n) of CONDSTORE stays last
164
+ if (parsed && parsed.messageLimitUid && response.tag === '*' && response.command === 'SEARCH' && Array.isArray(response.attributes)) {
165
+ const modseq = response.attributes.filter(Array.isArray);
166
+ response.attributes = response.attributes
167
+ .filter(attr => !Array.isArray(attr))
168
+ .reverse()
169
+ .concat(modseq);
170
+ return;
171
+ }
172
+ if (!parsed || !parsed.messageLimitUid || response.tag !== parsed.tag) {
173
+ return;
174
+ }
175
+ const lastUid = parsed.messageLimitUid;
176
+ parsed.messageLimitUid = false;
177
+ if (response.command !== 'OK') {
178
+ return;
179
+ }
180
+
181
+ // resp-text-code =/ "MESSAGELIMIT" SP message-limit [SP uniqueid]
182
+ const code = { type: 'SECTION', section: [{ type: 'ATOM', value: 'MESSAGELIMIT' }, limit, lastUid] };
183
+ const attributes = response.attributes || [];
184
+ if (attributes[0] && attributes[0].type === 'SECTION') {
185
+ // RFC 9738 section 3.1: when the tagged OK carries another response code (EXPUNGEISSUED there, and here
186
+ // also HIGHESTMODSEQ, MODIFIED), MESSAGELIMIT is sent in an untagged NO
187
+ connection.send(
188
+ {
189
+ tag: '*',
190
+ command: 'NO',
191
+ attributes: [code, { type: 'TEXT', value: 'Only the last ' + limit + ' messages were processed' }]
192
+ },
193
+ 'MESSAGELIMIT',
194
+ parsed,
195
+ data
196
+ );
197
+ return;
198
+ }
199
+ response.attributes = [code].concat(attributes);
200
+ };
201
+
202
+ // registered once every plugin is loaded, so that the response codes other plugins add to the tagged OK
203
+ // are already there
204
+ server.once('pluginsLoaded', () => server.outputHandlers.push(outputHandler));
205
+ };
206
+
207
+ /**
208
+ * Checks if a message passes the keys of a search query that every match must pass: sequence sets,
209
+ * UID, UIDAFTER and UIDBEFORE at the top level, or in parenthesized lists at the top level
210
+ *
211
+ * @param {Object} node AND node of the query tree, see commands/handlers/search.js
212
+ * @param {Object} message Message
213
+ * @return {Boolean} true if the message has to be searched
214
+ */
215
+ function isCandidate(node, message) {
216
+ return node.args.every(arg => {
217
+ switch (arg.key) {
218
+ case 'AND':
219
+ return isCandidate(arg, message);
220
+ case '_SEQ':
221
+ case 'UID':
222
+ return arg.set.has(message);
223
+ case 'UIDAFTER':
224
+ return message.uid > arg.args[0];
225
+ case 'UIDBEFORE':
226
+ return message.uid < arg.args[0];
227
+ default:
228
+ return true;
229
+ }
230
+ });
231
+ }
232
+
233
+ module.exports.getLimit = getLimit;
234
+ module.exports.addSaveLimit = addSaveLimit;
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ const { setup } = require('./metadata');
4
+
5
+ /**
6
+ * @help Adds METADATA-SERVER [RFC5464] capability, like METADATA but
7
+ * @help only for server annotations (mailbox name ""). With METADATA
8
+ * @help also loaded, only METADATA is advertised
9
+ */
10
+
11
+ module.exports = function (server) {
12
+ setup(server, false);
13
+ };