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,217 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @help Adds AUTH=OAUTHBEARER [RFC7628] capability
5
+ * @help Works with and without SASL-IR [RFC4959]
6
+ * @help Valid login info, same as for XOAUTH2:
7
+ * @help Username (authzid, optional): testuser
8
+ * @help Access Token: testtoken
9
+ */
10
+
11
+ const KVSEP = '\x01';
12
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
13
+
14
+ /**
15
+ * Parses an OAUTHBEARER client response (RFC 7628 section 3.1)
16
+ *
17
+ * client-resp = (gs2-header kvsep *kvpair kvsep) / kvsep
18
+ * kvpair = key "=" value kvsep
19
+ * key = 1*(ALPHA)
20
+ * value = *(VCHAR / SP / HTAB / CR / LF)
21
+ *
22
+ * and its GS2 header (RFC 5801 section 4)
23
+ *
24
+ * gs2-header = [gs2-nonstd-flag ","] gs2-cb-flag "," [gs2-authzid] ","
25
+ * gs2-cb-flag = ("p=" cb-name) / "n" / "y"
26
+ * gs2-authzid = "a=" saslname
27
+ *
28
+ * @param {Buffer} input Decoded client response
29
+ * @return {Object} `{ kvsepOnly }`, `{ error }` when the response breaks the grammar, or
30
+ * `{ channelBinding, authzid, pairs }` where pairs maps keys to values
31
+ */
32
+ function parseClientResponse(input) {
33
+ const str = input.toString('binary');
34
+ if (str === KVSEP) {
35
+ return { kvsepOnly: true };
36
+ }
37
+
38
+ const header = str.match(/^(?:F,)?(n|y|p=[A-Za-z0-9.-]+),(?:a=([^,]+))?,/);
39
+ if (!header || str.charAt(header[0].length) !== KVSEP) {
40
+ return { error: 'Invalid GS2 header' };
41
+ }
42
+
43
+ let authzid = false;
44
+ if (header[2]) {
45
+ // saslname: "," and "=" are only allowed as "=2C" and "=3D", NUL is not allowed
46
+ if (/=(?!2C|3D)/.test(header[2]) || header[2].includes('\x00')) {
47
+ return { error: 'Invalid authorization identity' };
48
+ }
49
+ try {
50
+ authzid = utf8.decode(Buffer.from(header[2].replace(/=2C/g, ',').replace(/=3D/g, '='), 'binary'));
51
+ } catch {
52
+ return { error: 'Invalid UTF-8 in the authorization identity' };
53
+ }
54
+ }
55
+
56
+ // *kvpair kvsep: every pair ends with a kvsep of its own, so the split ends with two empty parts
57
+ const parts = str.substr(header[0].length + 1).split(KVSEP);
58
+ if (parts.pop() !== '' || parts.pop() !== '') {
59
+ return { error: 'The client response must end with %x01' };
60
+ }
61
+
62
+ const pairs = Object.create(null);
63
+ for (const pair of parts) {
64
+ const match = pair.match(/^([A-Za-z]+)=([\x20-\x7e\t\r\n]*)$/);
65
+ if (!match) {
66
+ return { error: 'Invalid key/value pair' };
67
+ }
68
+ pairs[match[1]] = match[2];
69
+ }
70
+
71
+ return { channelBinding: header[1].charAt(0) === 'p', authzid, pairs };
72
+ }
73
+
74
+ module.exports = function (server) {
75
+ server.registerCapability('AUTH=OAUTHBEARER', connection => connection.state === 'Not Authenticated');
76
+
77
+ const fail = (connection, parsed, data, command, text) => {
78
+ // RFC 5530 section 3: a rejected login is AUTHENTICATIONFAILED
79
+ connection.sendStatus(parsed, data, command, text, command === 'NO' && 'AUTHENTICATIONFAILED', 'AUTHENTICATE OAUTHBEARER FAILED');
80
+ };
81
+
82
+ /**
83
+ * Checks the client response. Returns the OAuth error status (RFC 6750 section 3.1) if the
84
+ * credentials are not accepted, or false if the user is logged in
85
+ */
86
+ const checkCredentials = request => {
87
+ if (request.channelBinding) {
88
+ // RFC 5801 section 5: "p" asks for channel binding, which this mechanism does not support
89
+ return 'invalid_request';
90
+ }
91
+
92
+ const { auth, port } = request.pairs;
93
+ // port: a decimal positive integer string without leading zeros (RFC 7628 section 3.1)
94
+ if (typeof port === 'string' && !/^[1-9][0-9]*$/.test(port)) {
95
+ return 'invalid_request';
96
+ }
97
+
98
+ // RFC 6750 section 2.1: "Bearer" 1*SP b64token, the scheme name is case insensitive
99
+ const bearer = typeof auth === 'string' && auth.match(/^Bearer +([A-Za-z0-9\-._~+/]+=*)$/i);
100
+ if (!bearer) {
101
+ // an empty auth value is how a client asks for the error details (RFC 7628 section 4.3)
102
+ return auth === '' ? 'invalid_token' : 'invalid_request';
103
+ }
104
+ const token = bearer[1];
105
+
106
+ let username = request.authzid;
107
+ if (username === false) {
108
+ // without an authzid the token tells who the user is
109
+ username = Object.keys(server.users).find(name => {
110
+ const user = server.users[name];
111
+ return user && user.xoauth2 && user.xoauth2.accessToken === token;
112
+ });
113
+ }
114
+ const user = server.getUser(username);
115
+ if (!user || !user.xoauth2 || user.xoauth2.accessToken !== token) {
116
+ return 'invalid_token';
117
+ }
118
+ request.username = username;
119
+ return false;
120
+ };
121
+
122
+ // Decodes a base64 client response, sends BAD and returns false if it is not valid base64
123
+ const decode = (connection, parsed, data, input) => {
124
+ const decoded = connection.decodeSaslResponse(input);
125
+ if (!decoded) {
126
+ fail(connection, parsed, data, 'BAD', 'Invalid base64 in SASL response');
127
+ }
128
+ return decoded;
129
+ };
130
+
131
+ // Waits for the next client response line. "*" cancels the exchange, which RFC 3501 section 6.2.2
132
+ // answers with BAD
133
+ const readResponse = (connection, parsed, data, onResponse) => {
134
+ connection.inputHandler = line => {
135
+ connection.inputHandler = false;
136
+ if (line === '*') {
137
+ return fail(connection, parsed, data, 'BAD', 'Authentication cancelled');
138
+ }
139
+ const decoded = decode(connection, parsed, data, line);
140
+ if (decoded) {
141
+ onResponse(decoded);
142
+ }
143
+ };
144
+ };
145
+
146
+ // Handles the decoded client response, sent with SASL-IR or after the empty continuation request
147
+ const authenticate = (connection, parsed, data, decoded) => {
148
+ const request = parseClientResponse(decoded);
149
+ if (request.error) {
150
+ return fail(connection, parsed, data, 'BAD', request.error + ' in the OAUTHBEARER client response');
151
+ }
152
+ if (request.kvsepOnly) {
153
+ // RFC 7628 section 3.1: as the first message the server may fail without discovery information
154
+ return fail(connection, parsed, data, 'NO', 'SASL authentication failed');
155
+ }
156
+
157
+ const status = checkCredentials(request);
158
+ if (!status) {
159
+ connection.state = 'Authenticated';
160
+ connection.username = request.username;
161
+ return connection.sendStatus(parsed, data, 'OK', 'SASL authentication succeeded', false, 'AUTHENTICATE OAUTHBEARER SUCCESS');
162
+ }
163
+
164
+ // RFC 7628 section 3.2.2: the error result is a JSON document in a continuation request, the
165
+ // client MUST answer with a single %x01 or cancel, and the server then fails the exchange
166
+ readResponse(connection, parsed, data, response => {
167
+ if (response.toString('binary') !== KVSEP) {
168
+ return fail(connection, parsed, data, 'BAD', 'The client must answer the error result with a single %x01 (RFC 7628 section 3.2.3)');
169
+ }
170
+ fail(connection, parsed, data, 'NO', 'SASL authentication failed');
171
+ });
172
+ connection.send(
173
+ {
174
+ tag: '+',
175
+ attributes: [
176
+ {
177
+ type: 'ATOM',
178
+ value: Buffer.from(JSON.stringify({ status })).toString('base64')
179
+ }
180
+ ]
181
+ },
182
+ 'AUTHENTICATE OAUTHBEARER CHALLENGE',
183
+ parsed,
184
+ data
185
+ );
186
+ };
187
+
188
+ server.setCommandHandler('AUTHENTICATE OAUTHBEARER', (connection, parsed, data, callback) => {
189
+ const args = parsed.attributes || [];
190
+
191
+ if (!args.length) {
192
+ // without an initial response the client sends its response after an empty challenge
193
+ readResponse(connection, parsed, data, decoded => authenticate(connection, parsed, data, decoded));
194
+ connection.write('+ \r\n');
195
+ return callback();
196
+ }
197
+
198
+ if (args.length !== 1 || !args[0] || args[0].type !== 'ATOM') {
199
+ fail(connection, parsed, data, 'BAD', 'Invalid arguments for AUTHENTICATE OAUTHBEARER');
200
+ return callback();
201
+ }
202
+
203
+ // RFC 4959 section 3: an initial response is only allowed when SASL-IR is advertised
204
+ if (!server.capabilities['SASL-IR'] || !server.capabilities['SASL-IR'](connection)) {
205
+ fail(connection, parsed, data, 'BAD', 'SASL-IR must be enabled to send Initial Response with the request');
206
+ return callback();
207
+ }
208
+
209
+ const decoded = decode(connection, parsed, data, args[0].value);
210
+ if (decoded) {
211
+ authenticate(connection, parsed, data, decoded);
212
+ }
213
+ return callback();
214
+ });
215
+ };
216
+
217
+ module.exports.parseClientResponse = parseClientResponse;
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+
3
+ const { badError } = require('../commands/handlers/search');
4
+
5
+ // RFC 8474 section 7: objectid = 1*255(ALPHA / DIGIT / "_" / "-"), case sensitive
6
+ const OBJECTID = /^[A-Za-z0-9_-]{1,255}$/;
7
+
8
+ // header fields that threading uses, in an unfolded header
9
+ const MESSAGE_ID_HEADER = /^Message-ID[ \t]*:(.*)$/im;
10
+ const REFERENCES_HEADER = /^References[ \t]*:(.*)$/im;
11
+ const IN_REPLY_TO_HEADER = /^In-Reply-To[ \t]*:(.*)$/im;
12
+
13
+ // uidvalidity a MAILBOXID was assigned for
14
+ const ASSIGNED_FOR = Symbol('mailboxid-uidvalidity');
15
+
16
+ /**
17
+ * @help Adds OBJECTID [RFC8474] capability
18
+ * @help MAILBOXID, EMAILID and THREADID values are generated (F1, M1, T1, ...)
19
+ * @help unless storage sets them for a mailbox ("MAILBOXID") or a message
20
+ * @help ("EMAILID", "THREADID"). Messages are threaded by their Message-ID,
21
+ * @help In-Reply-To and References headers across all mailboxes
22
+ *
23
+ * OBJECTID: https://www.rfc-editor.org/rfc/rfc8474.txt
24
+ *
25
+ * Additional response codes:
26
+ * - MAILBOXID for CREATE, SELECT and EXAMINE
27
+ *
28
+ * Additional STATUS items:
29
+ * - MAILBOXID
30
+ *
31
+ * Additional FETCH items:
32
+ * - EMAILID, THREADID
33
+ *
34
+ * Additional SEARCH keys:
35
+ * - EMAILID, THREADID
36
+ */
37
+ module.exports = function (server) {
38
+ server.registerCapability('OBJECTID');
39
+
40
+ // kind (F, M or T) of every value ever used, generated values must not collide with them
41
+ const used = new Map();
42
+ const counters = { F: 0, M: 0, T: 0 };
43
+ // current owner of each MAILBOXID, RFC 8474 section 4: never two mailboxes at the same time
44
+ const mailboxOwners = new Map();
45
+ // THREADID by EMAILID, RFC 8474 section 5.2: the same EMAILID always has the same THREADID
46
+ const emailThreads = new Map();
47
+ // THREADID by Message-ID, for threading
48
+ const messageIdThreads = new Map();
49
+
50
+ // RFC 8474 section 8.1: the prefix keeps generated ids from starting with a digit or a dash
51
+ const generate = prefix => {
52
+ let id;
53
+ do {
54
+ id = prefix + ++counters[prefix];
55
+ } while (used.has(id));
56
+ used.set(id, prefix);
57
+ return id;
58
+ };
59
+
60
+ const checkValue = (value, what, owner) => {
61
+ if (typeof value !== 'string' || !OBJECTID.test(value)) {
62
+ throw new Error('Invalid ' + what + ' value ' + JSON.stringify(value) + ' in ' + owner);
63
+ }
64
+ };
65
+
66
+ // RFC 8474 section 5.2: an id is used for one kind of object only
67
+ const checkUnique = (value, prefix) => {
68
+ if (used.has(value) && used.get(value) !== prefix) {
69
+ throw new Error('Object identifier ' + JSON.stringify(value) + ' is already used for another kind of object');
70
+ }
71
+ used.set(value, prefix);
72
+ };
73
+
74
+ /**
75
+ * Returns the MAILBOXID of a mailbox. A mailbox that got a new UIDVALIDITY (re-created after
76
+ * DELETE) is a new mailbox and gets a new id (RFC 8474 section 4)
77
+ */
78
+ const getMailboxId = mailbox => {
79
+ if (mailbox[ASSIGNED_FOR] === mailbox.uidvalidity && mailboxOwners.get(mailbox.MAILBOXID) === mailbox) {
80
+ return mailbox.MAILBOXID;
81
+ }
82
+
83
+ let id;
84
+ if (mailbox.MAILBOXID && mailbox[ASSIGNED_FOR] === undefined && !mailboxOwners.has(mailbox.MAILBOXID)) {
85
+ // value from storage
86
+ id = mailbox.MAILBOXID;
87
+ checkValue(id, 'MAILBOXID', 'mailbox ' + mailbox.path);
88
+ checkUnique(id, 'F');
89
+ } else {
90
+ // a new mailbox, or a copy of a deleted one (a \Noselect placeholder that is created again)
91
+ id = generate('F');
92
+ }
93
+
94
+ mailbox.MAILBOXID = id;
95
+ mailbox[ASSIGNED_FOR] = mailbox.uidvalidity;
96
+ mailboxOwners.set(id, mailbox);
97
+ return id;
98
+ };
99
+
100
+ // Assign ids when mailboxes are loaded or created. A \Noselect placeholder is not a mailbox
101
+ server.mailboxHandlers.push((server, mailbox) => {
102
+ if (mailbox.flags.indexOf('\\Noselect') >= 0) {
103
+ return;
104
+ }
105
+ if (mailbox.MAILBOXID && mailbox[ASSIGNED_FOR] === undefined && mailboxOwners.has(mailbox.MAILBOXID)) {
106
+ // two mailboxes in storage with the same id
107
+ throw new Error('Duplicate MAILBOXID value ' + JSON.stringify(mailbox.MAILBOXID) + ' in mailbox ' + mailbox.path);
108
+ }
109
+ getMailboxId(mailbox);
110
+ });
111
+
112
+ server.messageHandlers.push((server, message, mailbox) => {
113
+ const owner = 'a message of mailbox ' + mailbox.path;
114
+
115
+ if (message.EMAILID) {
116
+ checkValue(message.EMAILID, 'EMAILID', owner);
117
+ checkUnique(message.EMAILID, 'M');
118
+ } else {
119
+ message.EMAILID = generate('M');
120
+ }
121
+
122
+ if (message.THREADID) {
123
+ checkValue(message.THREADID, 'THREADID', owner);
124
+ checkUnique(message.THREADID, 'T');
125
+ }
126
+
127
+ const threadOfEmail = emailThreads.get(message.EMAILID);
128
+ if (threadOfEmail && message.THREADID && message.THREADID !== threadOfEmail) {
129
+ throw new Error('Messages with EMAILID ' + JSON.stringify(message.EMAILID) + ' must have the same THREADID');
130
+ }
131
+
132
+ const ids = getThreadingIds(message.raw);
133
+ message.THREADID = message.THREADID || threadOfEmail || findThread(ids.references) || generate('T');
134
+ emailThreads.set(message.EMAILID, message.THREADID);
135
+
136
+ // later replies, and later messages with a common parent, join this thread
137
+ [ids.messageId].concat(ids.references).forEach(id => {
138
+ if (id && !messageIdThreads.has(id)) {
139
+ messageIdThreads.set(id, message.THREADID);
140
+ }
141
+ });
142
+ });
143
+
144
+ // the nearest known parent decides the thread
145
+ const findThread = references => {
146
+ for (let i = references.length - 1; i >= 0; i--) {
147
+ if (messageIdThreads.has(references[i])) {
148
+ return messageIdThreads.get(references[i]);
149
+ }
150
+ }
151
+ return false;
152
+ };
153
+
154
+ // RFC 8474 section 5.1: the copy in the COPYUID pairing has the EMAILID of the source, after COPY as well as after MOVE
155
+ server.copyHandlers.push((server, source, copy) => {
156
+ if (source.EMAILID) {
157
+ copy.EMAILID = source.EMAILID;
158
+ copy.THREADID = source.THREADID;
159
+ }
160
+ });
161
+
162
+ // RFC 8474 section 4.3
163
+ server.allowedStatus.push('MAILBOXID');
164
+ server.statusHandlers.MAILBOXID = (connection, mailbox) => [{ type: 'ATOM', value: getMailboxId(mailbox) }];
165
+
166
+ // RFC 8474 section 5.3
167
+ server.fetchHandlers.EMAILID = (connection, message) => [{ type: 'ATOM', value: message.EMAILID }];
168
+ server.fetchHandlers.THREADID = (connection, message) => [{ type: 'ATOM', value: message.THREADID }];
169
+
170
+ // RFC 8474 section 6: search-key =/ "EMAILID" SP objectid / "THREADID" SP objectid
171
+ const objectIdArgument = value => {
172
+ if (!OBJECTID.test(value)) {
173
+ throw badError('Invalid object identifier');
174
+ }
175
+ return value;
176
+ };
177
+
178
+ const searchHandler = name => {
179
+ const handler = (connection, message, index, value) => message[name] === value;
180
+ handler.argumentTypes = () => [objectIdArgument];
181
+ server.searchHandlers[name] = handler;
182
+ };
183
+ searchHandler('EMAILID');
184
+ searchHandler('THREADID');
185
+
186
+ // resp-text-code =/ "MAILBOXID" SP "(" objectid ")"
187
+ const mailboxIdCode = mailbox => ({
188
+ type: 'SECTION',
189
+ section: [{ type: 'ATOM', value: 'MAILBOXID' }, [{ type: 'ATOM', value: getMailboxId(mailbox) }]]
190
+ });
191
+
192
+ server.outputHandlers.push((connection, response, description, parsed, data, extra) => {
193
+ // RFC 8474 section 4.1: MAILBOXID response code in the tagged OK of CREATE
194
+ if (description === 'CREATE' && response.command === 'OK' && extra) {
195
+ response.attributes = [mailboxIdCode(extra)].concat(response.attributes || []);
196
+ return;
197
+ }
198
+
199
+ // RFC 8474 section 4.2: untagged OK with MAILBOXID on every successful SELECT and EXAMINE
200
+ if (
201
+ (description === 'SELECT' || description === 'EXAMINE') &&
202
+ parsed &&
203
+ response.tag === parsed.tag &&
204
+ response.command === 'OK' &&
205
+ connection.selectedMailbox
206
+ ) {
207
+ connection.send(
208
+ {
209
+ tag: '*',
210
+ command: 'OK',
211
+ attributes: [mailboxIdCode(connection.selectedMailbox), { type: 'TEXT', value: 'Ok' }]
212
+ },
213
+ description + ' MAILBOXID',
214
+ parsed,
215
+ data
216
+ );
217
+ }
218
+ });
219
+ };
220
+
221
+ /**
222
+ * Reads the message ids that threading uses from the header of a message
223
+ *
224
+ * @param {String} raw Message source
225
+ * @return {Object} `{ messageId, references }`, references lists the ids of References and In-Reply-To, the parent last
226
+ */
227
+ function getThreadingIds(raw) {
228
+ raw = raw || '';
229
+ const end = raw.search(/\r?\n\r?\n/);
230
+ // unfolded header lines
231
+ const header = (end >= 0 ? raw.substr(0, end) : raw).replace(/\r?\n(?=[ \t])/g, '');
232
+
233
+ const getIds = regex => {
234
+ const match = header.match(regex);
235
+ return match ? match[1].match(/<[^<>\s]+>/g) || [] : [];
236
+ };
237
+
238
+ return {
239
+ messageId: getIds(MESSAGE_ID_HEADER)[0] || false,
240
+ // In-Reply-To names the parent, which is also the last entry of References
241
+ references: getIds(REFERENCES_HEADER).concat(getIds(IN_REPLY_TO_HEADER))
242
+ };
243
+ }
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @help Adds PARTIAL [RFC9394] capability, loads ESEARCH as well
5
+ * @help SEARCH RETURN (PARTIAL 1:100) or (PARTIAL -1:-100) returns a window of the results, FETCH and UID FETCH take a PARTIAL modifier
6
+ */
7
+
8
+ const esearch = require('./esearch');
9
+ const { parsePartialRange, selectPartial, registerPartialOption } = require('../esearch');
10
+
11
+ const isPartial = item => !!item && item.type === 'ATOM' && String(item.value).toUpperCase() === 'PARTIAL';
12
+
13
+ module.exports = function (server) {
14
+ // the PARTIAL search return option is returned in an ESEARCH response (RFC 9394 section 3.1)
15
+ esearch(server);
16
+
17
+ server.registerCapability('PARTIAL');
18
+ server.partialRangeLast = true;
19
+ registerPartialOption(server);
20
+
21
+ // RFC 9394 section 3.3: fetch-modifier =/ modifier-partial. The grammar (RFC 4466 section 2.4) allows
22
+ // the modifier for FETCH as well as for UID FETCH. The modifier is taken off the list, CHANGEDSINCE of
23
+ // CONDSTORE then filters the messages in the range (RFC 9394 section 3.4)
24
+ const fetchWrapper = (prevHandler, connection, parsed, data, callback) => {
25
+ const modifiers = parsed.attributes && parsed.attributes[2];
26
+ if (Array.isArray(modifiers)) {
27
+ for (let i = 0; i < modifiers.length; i += 2) {
28
+ if (!isPartial(modifiers[i])) {
29
+ continue;
30
+ }
31
+ try {
32
+ if (parsed.partialFetch) {
33
+ throw new Error('PARTIAL can be used only once');
34
+ }
35
+ parsed.partialFetch = parsePartialRange(modifiers[i + 1], true);
36
+ } catch (E) {
37
+ connection.sendStatus(parsed, data, 'BAD', E.message, false, (parsed.command || '').toUpperCase() + ' FAILED');
38
+ return callback();
39
+ }
40
+ modifiers.splice(i, 2);
41
+ i -= 2;
42
+ }
43
+ if (!modifiers.length) {
44
+ parsed.attributes.splice(2, 1);
45
+ }
46
+ }
47
+ prevHandler(connection, parsed, data, callback);
48
+ };
49
+
50
+ ['FETCH', 'UID FETCH'].forEach(command => {
51
+ const prevHandler = server.getCommandHandler(command);
52
+ server.setCommandHandler(command, (connection, parsed, data, callback) => fetchWrapper(prevHandler, connection, parsed, data, callback));
53
+ });
54
+
55
+ // only the messages in the PARTIAL range of the messages that the sequence set matched are returned,
56
+ // checked before the other filters
57
+ server.fetchFilters.unshift((connection, message, parsed) => {
58
+ if (!parsed.partialFetch) {
59
+ return true;
60
+ }
61
+ if (!parsed.partialFetchSet) {
62
+ const isUid = (parsed.command || '').toUpperCase() === 'UID FETCH';
63
+ const range = connection.getMessageRange(parsed.attributes[0].value, isUid).map(item => item[1]);
64
+ parsed.partialFetchSet = new Set(selectPartial(range, parsed.partialFetch));
65
+ }
66
+ return parsed.partialFetchSet.has(message);
67
+ });
68
+ };