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.
- package/LICENSE +16 -0
- package/README.md +608 -2
- package/bin/help.txt +98 -0
- package/bin/imapkit.js +108 -0
- package/cert/server.crt +20 -0
- package/cert/server.key +28 -0
- package/lib/addressparser.js +283 -0
- package/lib/arguments.js +112 -0
- package/lib/bodystructure.js +149 -0
- package/lib/command-states.js +109 -0
- package/lib/commands/append.js +313 -0
- package/lib/commands/capability.js +47 -0
- package/lib/commands/check.js +21 -0
- package/lib/commands/close.js +30 -0
- package/lib/commands/copy.js +115 -0
- package/lib/commands/create.js +52 -0
- package/lib/commands/delete.js +64 -0
- package/lib/commands/examine.js +7 -0
- package/lib/commands/expunge.js +27 -0
- package/lib/commands/fetch.js +229 -0
- package/lib/commands/handlers/fetch.js +209 -0
- package/lib/commands/handlers/flags.js +42 -0
- package/lib/commands/handlers/search.js +519 -0
- package/lib/commands/handlers/status.js +85 -0
- package/lib/commands/handlers/store.js +127 -0
- package/lib/commands/list.js +100 -0
- package/lib/commands/login.js +67 -0
- package/lib/commands/logout.js +41 -0
- package/lib/commands/lsub.js +87 -0
- package/lib/commands/noop.js +21 -0
- package/lib/commands/rename.js +102 -0
- package/lib/commands/search.js +76 -0
- package/lib/commands/select.js +289 -0
- package/lib/commands/status.js +63 -0
- package/lib/commands/store.js +151 -0
- package/lib/commands/subscribe.js +53 -0
- package/lib/commands/uid copy.js +7 -0
- package/lib/commands/uid fetch.js +5 -0
- package/lib/commands/uid search.js +5 -0
- package/lib/commands/uid store.js +5 -0
- package/lib/commands/unsubscribe.js +50 -0
- package/lib/dates.js +123 -0
- package/lib/deflate-layer.js +232 -0
- package/lib/envelope.js +82 -0
- package/lib/esearch.js +208 -0
- package/lib/framing.js +102 -0
- package/lib/list-extensions.js +36 -0
- package/lib/load-plugins.js +109 -0
- package/lib/mailbox-name.js +133 -0
- package/lib/mimeparser.js +778 -0
- package/lib/mock-client.js +233 -0
- package/lib/numbers.js +52 -0
- package/lib/plugins/acl.js +964 -0
- package/lib/plugins/appendlimit.js +83 -0
- package/lib/plugins/auth-plain.js +94 -0
- package/lib/plugins/binary.js +256 -0
- package/lib/plugins/catenate.js +253 -0
- package/lib/plugins/compress.js +76 -0
- package/lib/plugins/condstore.js +563 -0
- package/lib/plugins/context-search.js +321 -0
- package/lib/plugins/context-sort.js +19 -0
- package/lib/plugins/create-special-use.js +108 -0
- package/lib/plugins/enable.js +155 -0
- package/lib/plugins/esearch.js +156 -0
- package/lib/plugins/esort.js +60 -0
- package/lib/plugins/id.js +138 -0
- package/lib/plugins/idle.js +105 -0
- package/lib/plugins/imap4rev2.js +202 -0
- package/lib/plugins/list-extended.js +258 -0
- package/lib/plugins/list-status.js +31 -0
- package/lib/plugins/literalminus.js +20 -0
- package/lib/plugins/literalplus.js +18 -0
- package/lib/plugins/logindisabled.js +50 -0
- package/lib/plugins/messagelimit.js +234 -0
- package/lib/plugins/metadata-server.js +13 -0
- package/lib/plugins/metadata.js +475 -0
- package/lib/plugins/move.js +110 -0
- package/lib/plugins/multiappend.js +26 -0
- package/lib/plugins/multisearch.js +269 -0
- package/lib/plugins/namespace.js +67 -0
- package/lib/plugins/notify.js +654 -0
- package/lib/plugins/oauthbearer.js +217 -0
- package/lib/plugins/objectid.js +243 -0
- package/lib/plugins/partial.js +68 -0
- package/lib/plugins/preview.js +400 -0
- package/lib/plugins/qresync.js +525 -0
- package/lib/plugins/quota.js +285 -0
- package/lib/plugins/replace.js +145 -0
- package/lib/plugins/sasl-ir.js +12 -0
- package/lib/plugins/savedate.js +59 -0
- package/lib/plugins/savelimit.js +18 -0
- package/lib/plugins/searchres.js +82 -0
- package/lib/plugins/sort-display.js +23 -0
- package/lib/plugins/sort.js +132 -0
- package/lib/plugins/special-use.js +95 -0
- package/lib/plugins/starttls.js +57 -0
- package/lib/plugins/status-size.js +19 -0
- package/lib/plugins/thread-orderedsubject.js +16 -0
- package/lib/plugins/thread-references.js +16 -0
- package/lib/plugins/uidonly.js +135 -0
- package/lib/plugins/uidplus.js +124 -0
- package/lib/plugins/unauthenticate.js +28 -0
- package/lib/plugins/unselect.js +36 -0
- package/lib/plugins/utf8-accept.js +68 -0
- package/lib/plugins/x-gm-ext-1.js +456 -0
- package/lib/plugins/xoauth2.js +188 -0
- package/lib/plugins/xtoybird.js +282 -0
- package/lib/server.js +2880 -0
- package/lib/smtp-listener.js +51 -0
- package/lib/sorting.js +373 -0
- package/lib/threading.js +357 -0
- package/lib/utf8-session.js +123 -0
- package/lib/vanished.js +57 -0
- package/package.json +61 -5
package/lib/threading.js
ADDED
|
@@ -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 };
|
package/lib/vanished.js
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|