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,357 @@
1
+ 'use strict';
2
+
3
+ // THREAD and UID THREAD (RFC 5256 section 3) and the ORDEREDSUBJECT and REFERENCES algorithms
4
+
5
+ const { states } = require('./command-states');
6
+ const { getMessageData } = require('./mimeparser');
7
+ const { collationKey, baseSubject, sentTime, parseMessageIds, searchMessages } = require('./sorting');
8
+
9
+ /**
10
+ * Creates a thread tree node. A node without a message is a dummy
11
+ */
12
+ function createNode(entry) {
13
+ return { entry: entry || null, parent: null, children: [] };
14
+ }
15
+
16
+ // Sent date order with the sequence number as tie-breaker (RFC 5256 section 2.2). A dummy is
17
+ // ordered by its first child
18
+ function compareNodes(a, b) {
19
+ while (!a.entry && a.children.length) {
20
+ a = a.children[0];
21
+ }
22
+ while (!b.entry && b.children.length) {
23
+ b = b.children[0];
24
+ }
25
+ return a.entry.date - b.entry.date || a.entry.seq - b.entry.seq;
26
+ }
27
+
28
+ /**
29
+ * Lists the nodes of trees in pre-order (parents before their children). Iterative, as message
30
+ * references can make a thread as deep as the mailbox is large.
31
+ *
32
+ * @param {Array} nodes Top level nodes
33
+ * @return {Array} All nodes
34
+ */
35
+ function preOrder(nodes) {
36
+ const list = [];
37
+ const stack = nodes.slice().reverse();
38
+ while (stack.length) {
39
+ const node = stack.pop();
40
+ list.push(node);
41
+ for (let i = node.children.length - 1; i >= 0; i--) {
42
+ stack.push(node.children[i]);
43
+ }
44
+ }
45
+ return list;
46
+ }
47
+
48
+ /**
49
+ * Serializes thread nodes (RFC 5256 section 5): every thread is a thread-list, a node is followed by its
50
+ * only child, or by one thread-list per child when it has more. A dummy has no number of its own.
51
+ *
52
+ * @param {Array} roots Top level nodes
53
+ * @param {Boolean} isUid If true, list UIDs instead of sequence numbers
54
+ * @return {String} thread-data without the "THREAD" keyword
55
+ */
56
+ function formatThreads(roots, isUid) {
57
+ const output = [];
58
+ // strings to write and nodes to expand, the next one is at the end
59
+ const stack = [];
60
+ const pushLists = nodes => {
61
+ for (let i = nodes.length - 1; i >= 0; i--) {
62
+ stack.push(')', nodes[i], '(');
63
+ }
64
+ };
65
+ pushLists(roots);
66
+ while (stack.length) {
67
+ const item = stack.pop();
68
+ if (typeof item === 'string') {
69
+ output.push(item);
70
+ continue;
71
+ }
72
+ const children = item.children;
73
+ if (children.length === 1) {
74
+ stack.push(children[0]);
75
+ } else {
76
+ pushLists(children);
77
+ }
78
+ if (item.entry) {
79
+ if (children.length) {
80
+ stack.push(' ');
81
+ }
82
+ output.push(isUid ? item.entry.message.uid : item.entry.seq);
83
+ }
84
+ }
85
+ return output.join('');
86
+ }
87
+
88
+ /**
89
+ * ORDEREDSUBJECT (RFC 5256 section 3): messages grouped by base subject, every thread is its first
90
+ * message with the others as children, threads ordered by the sent date of their first message
91
+ *
92
+ * @param {Array} entries Searched messages as `{ message, seq, date, subject }`
93
+ * @return {Array} Top level nodes
94
+ */
95
+ function orderedSubject(entries) {
96
+ const compareSubjects = (a, b) => (a.subject.id < b.subject.id ? -1 : a.subject.id > b.subject.id ? 1 : 0);
97
+ const sorted = entries.slice().sort((a, b) => compareSubjects(a, b) || a.date - b.date || a.seq - b.seq);
98
+ const threads = [];
99
+ let current = null;
100
+ for (const entry of sorted) {
101
+ if (current && current.entry.subject.id === entry.subject.id) {
102
+ current.children.push(createNode(entry));
103
+ } else {
104
+ current = createNode(entry);
105
+ threads.push(current);
106
+ }
107
+ }
108
+ return threads.sort(compareNodes);
109
+ }
110
+
111
+ /**
112
+ * REFERENCES (RFC 5256 section 3), steps 1 to 6
113
+ *
114
+ * @param {Array} entries Searched messages in mailbox order as `{ message, seq, date, subject }`
115
+ * @return {Array} Top level nodes
116
+ */
117
+ function references(entries) {
118
+ const byId = new Map();
119
+ const all = [];
120
+
121
+ const getNode = id => {
122
+ if (!byId.has(id)) {
123
+ const node = createNode();
124
+ byId.set(id, node);
125
+ all.push(node);
126
+ }
127
+ return byId.get(id);
128
+ };
129
+
130
+ const isDescendant = (node, ancestor) => {
131
+ for (let current = node; current; current = current.parent) {
132
+ if (current === ancestor) {
133
+ return true;
134
+ }
135
+ }
136
+ return false;
137
+ };
138
+
139
+ const unlink = node => {
140
+ if (node.parent) {
141
+ node.parent.children.splice(node.parent.children.indexOf(node), 1);
142
+ node.parent = null;
143
+ }
144
+ };
145
+
146
+ // links a parent and a child, unless that would introduce a loop. Only a node with children can have
147
+ // the parent as a descendant, so the walk up from the parent is skipped for the others
148
+ const link = (parent, child) => {
149
+ if (parent !== child && (!child.children.length || !isDescendant(parent, child))) {
150
+ parent.children.push(child);
151
+ child.parent = parent;
152
+ }
153
+ };
154
+
155
+ // (1) link the messages by their references
156
+ for (const entry of entries) {
157
+ const header = getMessageData(entry.message).tree.parsedHeader;
158
+
159
+ // (1.A) a message without a valid Message-ID, or with one that an earlier message already has, gets a unique one
160
+ const messageId = parseMessageIds(header['message-id'])[0];
161
+ let node = messageId !== undefined && getNode(messageId);
162
+ if (!node || node.entry) {
163
+ node = createNode();
164
+ all.push(node);
165
+ }
166
+ node.entry = entry;
167
+
168
+ // the References header, or else the first Message ID of In-Reply-To
169
+ let refs = parseMessageIds(header.references);
170
+ if (!refs.length) {
171
+ refs = parseMessageIds(header['in-reply-to']).slice(0, 1);
172
+ }
173
+
174
+ // (1.A) each reference is the parent of the next one, existing links are kept
175
+ const refNodes = refs.map(getNode);
176
+ for (let i = 1; i < refNodes.length; i++) {
177
+ if (!refNodes[i].parent) {
178
+ link(refNodes[i - 1], refNodes[i]);
179
+ }
180
+ }
181
+
182
+ // (1.B) the last reference is the parent of the message, replacing an existing link
183
+ const parent = refNodes[refNodes.length - 1] || null;
184
+ if (node.parent !== parent) {
185
+ unlink(node);
186
+ if (parent) {
187
+ link(parent, node);
188
+ }
189
+ }
190
+ }
191
+
192
+ // (2) messages without a parent are the top level
193
+ const roots = all.filter(node => !node.parent);
194
+
195
+ // (3) prune dummies: drop the ones without children, put the children of the others in their place,
196
+ // but not at the top level if there is more than one child
197
+ const replacement = (node, topLevel) => {
198
+ if (node.entry) {
199
+ return [node];
200
+ }
201
+ if (!node.children.length) {
202
+ return [];
203
+ }
204
+ return topLevel && node.children.length > 1 ? [node] : node.children;
205
+ };
206
+ // children before their parents, so a dummy is replaced by children that are already pruned
207
+ for (const node of preOrder(roots).reverse()) {
208
+ node.children = node.children.flatMap(child => replacement(child, false));
209
+ node.children.forEach(child => {
210
+ child.parent = node;
211
+ });
212
+ }
213
+ let top = roots.flatMap(node => replacement(node, true));
214
+ top.forEach(node => {
215
+ node.parent = null;
216
+ });
217
+
218
+ // (4) sort the top level by sent date, a dummy by its first child
219
+ top.forEach(node => {
220
+ if (!node.entry) {
221
+ node.children.sort(compareNodes);
222
+ }
223
+ });
224
+ top.sort(compareNodes);
225
+
226
+ // (5) gather threads with the same base subject, the subject of a dummy is the one of its first child
227
+ const threadSubject = node => (node.entry ? node.entry.subject : node.children[0].entry.subject);
228
+ const isReply = node => !!node.entry && node.entry.subject.isReply;
229
+ const table = new Map();
230
+
231
+ // (5.B) one message per base subject, a dummy or a message that is not a reply or forward is preferred
232
+ for (const node of top) {
233
+ const subject = threadSubject(node);
234
+ if (!subject.id) {
235
+ continue;
236
+ }
237
+ const old = table.get(subject.id);
238
+ if (!old || (old.entry && (!node.entry || (isReply(old) && !isReply(node))))) {
239
+ table.set(subject.id, node);
240
+ }
241
+ }
242
+
243
+ // (5.C) merge the other threads with the same subject into the one in the table
244
+ const removed = new Set();
245
+ for (const node of top) {
246
+ const subject = threadSubject(node);
247
+ if (!subject.id) {
248
+ continue;
249
+ }
250
+ const other = table.get(subject.id);
251
+ if (other === node) {
252
+ continue;
253
+ }
254
+ removed.add(node);
255
+ if (!other.entry && !node.entry) {
256
+ node.children.forEach(child => link(other, child));
257
+ } else if (!other.entry || (isReply(node) && !isReply(other))) {
258
+ link(other, node);
259
+ } else {
260
+ // both become children of a new dummy that takes the place of the message in the table
261
+ const dummy = createNode();
262
+ top[top.indexOf(other)] = dummy;
263
+ link(dummy, other);
264
+ link(dummy, node);
265
+ table.set(subject.id, dummy);
266
+ }
267
+ }
268
+ top = top.filter(node => !removed.has(node));
269
+
270
+ // (6) sort all sets of siblings by sent date, the deepest ones first
271
+ for (const node of preOrder(top).reverse()) {
272
+ node.children.sort(compareNodes);
273
+ }
274
+ top.sort(compareNodes);
275
+ return top;
276
+ }
277
+
278
+ const ALGORITHMS = {
279
+ ORDEREDSUBJECT: orderedSubject,
280
+ REFERENCES: references
281
+ };
282
+
283
+ /**
284
+ * Adds the THREAD and UID THREAD commands, unless another algorithm plugin did already, and the
285
+ * THREAD=<algorithm> capability
286
+ *
287
+ * @param {Object} server IMAP server
288
+ * @param {String} algorithm "ORDEREDSUBJECT" or "REFERENCES"
289
+ */
290
+ function addThreadAlgorithm(server, algorithm) {
291
+ server.registerCapability('THREAD=' + algorithm);
292
+
293
+ if (server.threadAlgorithms) {
294
+ server.threadAlgorithms[algorithm] = ALGORITHMS[algorithm];
295
+ return;
296
+ }
297
+ server.threadAlgorithms = { [algorithm]: ALGORITHMS[algorithm] };
298
+
299
+ const threadHandler = (isUid, connection, parsed, data, callback) => {
300
+ const command = isUid ? 'UID THREAD' : 'THREAD';
301
+ const attributes = parsed.attributes || [];
302
+
303
+ // RFC 5256 section 5: thread-alg = "ORDEREDSUBJECT" / "REFERENCES" / thread-alg-ext, an atom
304
+ const name = attributes[0] && attributes[0].type === 'ATOM' ? attributes[0].value.toUpperCase() : '';
305
+ if (!Object.hasOwn(server.threadAlgorithms, name)) {
306
+ connection.sendStatus(
307
+ parsed,
308
+ data,
309
+ 'BAD',
310
+ name ? 'Unsupported threading algorithm ' + name : command + ' expects a threading algorithm atom',
311
+ false,
312
+ command + ' FAILED'
313
+ );
314
+ return callback();
315
+ }
316
+
317
+ const result = searchMessages(connection, parsed, data, attributes.slice(1));
318
+ if (!result) {
319
+ return callback();
320
+ }
321
+
322
+ // base subjects are compared with the collation (RFC 5256 sections 3 and 7)
323
+ const entries = result.list.map(message => {
324
+ const base = baseSubject(getMessageData(message).tree.parsedHeader.subject);
325
+ return {
326
+ message,
327
+ seq: result.numbers[message.uid],
328
+ date: sentTime(message),
329
+ // a binary string of the collation key, these compare like the octets of the key
330
+ subject: { id: collationKey(base.subject).toString('binary'), isReply: base.isReply }
331
+ };
332
+ });
333
+
334
+ connection.send(
335
+ {
336
+ tag: '*',
337
+ command: 'THREAD',
338
+ // thread-data has no SP between thread-lists, so it can not be built from nested arrays
339
+ attributes: entries.length ? [{ type: 'TEXT', value: formatThreads(server.threadAlgorithms[name](entries), isUid) }] : []
340
+ },
341
+ command,
342
+ parsed,
343
+ data,
344
+ // the search result, so CONDSTORE knows about a MODSEQ search key (RFC 7162 section 3.1.9)
345
+ result
346
+ );
347
+ connection.sendStatus(parsed, data, 'OK', command + ' completed', false, command);
348
+ return callback();
349
+ };
350
+
351
+ // RFC 5256 section 3: EXPUNGE responses are not permitted while responding to THREAD, but are during UID THREAD.
352
+ // The search criteria start after the algorithm and the charset
353
+ server.setCommandHandler('THREAD', threadHandler.bind(null, false), { states: states.SELECTED, searchCriteria: 2, noExpunge: true });
354
+ server.setCommandHandler('UID THREAD', threadHandler.bind(null, true), { states: states.SELECTED, searchCriteria: 2 });
355
+ }
356
+
357
+ module.exports = { addThreadAlgorithm };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const { isUtf8 } = require('buffer');
4
+ const mailboxName = require('./mailbox-name');
5
+ const { isEnabled } = require('./plugins/enable');
6
+
7
+ /**
8
+ * UTF-8 support of a session, shared by the UTF8=ACCEPT (RFC 9755) and IMAP4rev2 (RFC 9051) plugins. Both
9
+ * turn on UTF-8 mailbox names and quoted strings with ENABLE, and they can be loaded in any order, so the
10
+ * state of a session is always computed from what it has enabled, see updateSession().
11
+ */
12
+
13
+ // RFC 9755 section 3: mailbox names MUST NOT contain control characters, DEL, LINE SEPARATOR or
14
+ // PARAGRAPH SEPARATOR. RFC 5198 section 2 (Net-Unicode) also forbids a leading BOM and unassigned code points
15
+ // eslint-disable-next-line no-control-regex
16
+ const INVALID_NAME_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]|^\ufeff|\p{Cn}/u;
17
+
18
+ function badError(message) {
19
+ const err = new Error(message);
20
+ err.imapResponse = 'BAD';
21
+ return err;
22
+ }
23
+
24
+ /**
25
+ * Throws a BAD error for a mailbox name that is not Net-Unicode (RFC 9755 section 3, RFC 9051 section 5.1).
26
+ * RFC 5198 section 2 also requires Normalization Form C
27
+ *
28
+ * @param {String|Boolean} decoded Mailbox name as a unicode string, false if it could not be decoded
29
+ * @return {String} the decoded name
30
+ */
31
+ function checkNetUnicode(decoded) {
32
+ if (decoded === false || INVALID_NAME_CHARS.test(decoded)) {
33
+ throw badError('Mailbox name must be valid Net-Unicode without control characters (RFC 9755 section 3)');
34
+ }
35
+ if (decoded.normalize('NFC') !== decoded) {
36
+ throw badError('Mailbox name must be in Unicode Normalization Form C (RFC 5198 section 2, RFC 9051 section 5.1)');
37
+ }
38
+ return decoded;
39
+ }
40
+
41
+ /**
42
+ * Checks a modified UTF-7 mailbox name from a client that did not enable UTF-8. The RFC 9755
43
+ * section 3 rules apply to these names as well, e.g. "&AA0-" encodes CR
44
+ *
45
+ * @param {String} name Mailbox name as a binary string
46
+ * @return {String} Storage name
47
+ * @throws {Error} BAD error if the name is not valid
48
+ */
49
+ function importMutf7Name(name) {
50
+ const error = mailboxName(name);
51
+ if (error) {
52
+ throw badError(error);
53
+ }
54
+ checkNetUnicode(mailboxName.decode(name));
55
+ return name;
56
+ }
57
+
58
+ /**
59
+ * Converts a UTF-8 mailbox name from the client to the modified UTF-7 storage name
60
+ *
61
+ * @param {String} name Mailbox name as a binary string
62
+ * @return {String} Storage name
63
+ * @throws {Error} BAD error if the name is not valid
64
+ */
65
+ function importUtf8Name(name) {
66
+ const buf = Buffer.from(name, 'binary');
67
+ return mailboxName.encode(checkNetUnicode(isUtf8(buf) && buf.toString('utf-8')));
68
+ }
69
+
70
+ /**
71
+ * Converts a modified UTF-7 storage name to UTF-8. A name that is not valid modified UTF-7
72
+ * is sent as it is.
73
+ *
74
+ * @param {String} path Storage name
75
+ * @return {String} Mailbox name as a binary string
76
+ */
77
+ function exportUtf8Name(path) {
78
+ const decoded = mailboxName.decode(path);
79
+ return decoded === false ? path : Buffer.from(decoded, 'utf-8').toString('binary');
80
+ }
81
+
82
+ /**
83
+ * Sets the UTF-8 options of a session from the extensions it has enabled. Run it when the session starts,
84
+ * after ENABLE and after UNAUTHENTICATE (once the ENABLE plugin has cleared `connection.enabled`).
85
+ *
86
+ * - With the UTF8=ACCEPT plugin, quoted strings may hold UTF-8 even before ENABLE (RFC 9755 section 3) and
87
+ * modified UTF-7 names must decode to Net-Unicode. Without it, 8-bit quoted strings are a syntax error until
88
+ * ENABLE IMAP4rev2, like RFC 3501 says
89
+ * - ENABLE UTF8=ACCEPT or IMAP4rev2: mailbox names are UTF-8 in both directions and strings that are valid
90
+ * UTF-8 are sent quoted (RFC 9755 section 3, RFC 9051 sections 4.3 and 5.1)
91
+ * - ENABLE UTF8=ACCEPT: SEARCH strings are UTF-8 and CHARSET is refused (RFC 9755 section 3)
92
+ * - ENABLE IMAP4rev2: SEARCH strings are UTF-8 unless a CHARSET says otherwise (RFC 9051 section 6.4.4)
93
+ *
94
+ * @param {Object} connection IMAP connection
95
+ */
96
+ function updateSession(connection) {
97
+ const server = connection.server;
98
+ const accept = isEnabled(connection, 'UTF8=ACCEPT');
99
+ const rev2 = isEnabled(connection, 'IMAP4rev2');
100
+ const utf8Names = accept || rev2;
101
+
102
+ // UTF-8 names and quoted strings are on, plugins check this instead of the extension names
103
+ connection.utf8Enabled = utf8Names;
104
+ connection.parserOptions.utf8 = !!server.utf8Accept || rev2;
105
+ connection.compilerOptions.utf8 = utf8Names;
106
+ connection.searchCharset = accept ? 'UTF-8' : false;
107
+ connection.defaultSearchCharset = rev2 ? 'UTF-8' : false;
108
+
109
+ if (utf8Names) {
110
+ connection.importMailboxName = importUtf8Name;
111
+ connection.exportMailboxName = exportUtf8Name;
112
+ } else {
113
+ // back to the IMAPConnection methods
114
+ delete connection.exportMailboxName;
115
+ if (server.utf8Accept) {
116
+ connection.importMailboxName = importMutf7Name;
117
+ } else {
118
+ delete connection.importMailboxName;
119
+ }
120
+ }
121
+ }
122
+
123
+ module.exports = { updateSession };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * VANISHED responses (RFC 7162 section 3.2.10) in place of EXPUNGE notifications. Used by QRESYNC
5
+ * and by UIDONLY (RFC 9586 section 3.4), which both report expunged messages by UID.
6
+ */
7
+
8
+ const { toSequenceSet } = require('./esearch');
9
+
10
+ /**
11
+ * Checks if a queued notification is an EXPUNGE response for a removed message
12
+ *
13
+ * @param {Object} notification Queued notification
14
+ * @return {Boolean} true for an EXPUNGE notification
15
+ */
16
+ function isExpungeNotification(notification) {
17
+ const name = !!notification.message && !!notification.attributes && notification.attributes[1];
18
+ return !!name && name.type === 'ATOM' && String(name.value).toUpperCase() === 'EXPUNGE';
19
+ }
20
+
21
+ /**
22
+ * Replaces the EXPUNGE notifications of a queue with VANISHED responses, consecutive EXPUNGE
23
+ * notifications become one VANISHED response. The queue is not changed
24
+ *
25
+ * @param {Array} queue Queued notifications
26
+ * @return {Array} notifications to send
27
+ */
28
+ function toVanished(queue) {
29
+ if (!queue.some(isExpungeNotification)) {
30
+ return queue;
31
+ }
32
+ // groups of consecutive expunges, as lists of UIDs, between the other notifications
33
+ const groups = [];
34
+ queue.forEach(notification => {
35
+ if (!isExpungeNotification(notification)) {
36
+ groups.push(notification);
37
+ } else if (Array.isArray(groups[groups.length - 1])) {
38
+ groups[groups.length - 1].push(notification.message);
39
+ } else {
40
+ groups.push([notification.message]);
41
+ }
42
+ });
43
+ // the removed messages go with the response for output handlers, like `message` of EXPUNGE
44
+ return groups.map(group =>
45
+ Array.isArray(group)
46
+ ? {
47
+ tag: '*',
48
+ command: 'VANISHED',
49
+ attributes: [{ type: 'SEQUENCE', value: toSequenceSet(group.map(message => message.uid)) }],
50
+ notification: true,
51
+ messages: group
52
+ }
53
+ : group
54
+ );
55
+ }
56
+
57
+ module.exports = { isExpungeNotification, toVanished };
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
- "name": "imapkit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "imapkit",
3
+ "version": "4.0.1",
4
+ "description": "Scriptable, strictly RFC compliant in-memory IMAP server for testing IMAP clients",
5
+ "main": "lib/server.js",
6
+ "bin": {
7
+ "imapkit": "bin/imapkit.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "cert",
12
+ "lib"
13
+ ],
14
+ "scripts": {
15
+ "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true",
16
+ "test": "npm run lint && npm run test:unit",
17
+ "test:unit": "node --test test/*.js",
18
+ "test:coverage": "node --test --experimental-test-coverage --test-coverage-include='lib/**' --test-coverage-lines=94 test/*.js",
19
+ "lint": "eslint",
20
+ "format": "prettier --write .",
21
+ "format:check": "prettier --check .",
22
+ "compare": "node compare/compare.js",
23
+ "dovecot:start": "bash compare/dovecot.sh start",
24
+ "dovecot:stop": "bash compare/dovecot.sh stop",
25
+ "update": "rm -rf node_modules package-lock.json && ncu -u && npm install"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/postalsys/imapkit.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/postalsys/imapkit/issues"
33
+ },
34
+ "homepage": "https://imapkit.com",
35
+ "keywords": [
36
+ "imap",
37
+ "imap-server",
38
+ "mock",
39
+ "testing",
40
+ "test-server",
41
+ "imap4rev2",
42
+ "rfc3501",
43
+ "rfc9051",
44
+ "email"
45
+ ],
46
+ "author": "Postal Systems OÜ",
47
+ "license": "MIT",
48
+ "dependencies": {
49
+ "imap-handler": "1.3.1",
50
+ "smtp-server": "3.19.17"
51
+ },
52
+ "devDependencies": {
53
+ "@eslint/js": "10.0.1",
54
+ "eslint": "10.12.0",
55
+ "globals": "17.13.0",
56
+ "imapflow": "2.2.7",
57
+ "prettier": "3.9.9"
58
+ },
59
+ "engines": {
60
+ "node": ">=20.0.0"
61
+ }
62
+ }