imapkit 0.0.0-stage → 4.0.0
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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { states } = require('../command-states');
|
|
4
|
+
const DeflateLayer = require('../deflate-layer');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @help Adds COMPRESS=DEFLATE [RFC4978] capability
|
|
8
|
+
* @help Raw DEFLATE in both directions after the tagged OK,
|
|
9
|
+
* @help every burst of responses ends with a sync flush
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
module.exports = function (server) {
|
|
13
|
+
server.registerCapability('COMPRESS=DEFLATE');
|
|
14
|
+
|
|
15
|
+
const isActive = connection => connection.transport instanceof DeflateLayer && connection.transport.active;
|
|
16
|
+
|
|
17
|
+
// RFC 8437 section 4.1: after UNAUTHENTICATE the server ends its compression after the CRLF of
|
|
18
|
+
// the OK, and the client after the CRLF of the command, so anything it compressed later is dropped
|
|
19
|
+
server.resetHandlers.push(connection => {
|
|
20
|
+
if (isActive(connection)) {
|
|
21
|
+
connection.discardInput();
|
|
22
|
+
connection.transport.endInput();
|
|
23
|
+
connection.transport.end();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
server.setCommandHandler(
|
|
28
|
+
'COMPRESS',
|
|
29
|
+
(connection, parsed, data, callback) => {
|
|
30
|
+
const args = parsed.attributes || [];
|
|
31
|
+
|
|
32
|
+
// RFC 4978 section 5: compress = "COMPRESS" SP algorithm, algorithm = "DEFLATE"
|
|
33
|
+
if (args.length !== 1 || !args[0] || args[0].type !== 'ATOM') {
|
|
34
|
+
connection.sendStatus(parsed, data, 'BAD', 'COMPRESS expects a compression mechanism');
|
|
35
|
+
return callback();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (args[0].value.toUpperCase() !== 'DEFLATE') {
|
|
39
|
+
connection.sendStatus(parsed, data, 'BAD', 'Unknown compression mechanism');
|
|
40
|
+
return callback();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// RFC 4978 section 3: BAD if COMPRESS is already active, NO is for compression by another
|
|
44
|
+
// layer, such as TLS, which ImapKit never negotiates. Dovecot answers NO here
|
|
45
|
+
if (isActive(connection)) {
|
|
46
|
+
connection.sendStatus(parsed, data, 'BAD', 'DEFLATE active via COMPRESS', 'COMPRESSIONACTIVE');
|
|
47
|
+
return callback();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
connection.sendStatus(parsed, data, 'OK', 'DEFLATE active');
|
|
51
|
+
|
|
52
|
+
if (connection.transport) {
|
|
53
|
+
// the layer of an earlier session, it passes data on as is since UNAUTHENTICATE
|
|
54
|
+
connection.transport.destroy();
|
|
55
|
+
}
|
|
56
|
+
// compression starts right after the CRLF of the tagged OK
|
|
57
|
+
connection.transport = new DeflateLayer({
|
|
58
|
+
writeRaw: chunk => connection.writeRaw(chunk),
|
|
59
|
+
onData: chunk => connection.onData(chunk),
|
|
60
|
+
onError: err => {
|
|
61
|
+
// the input can not be decompressed, so there is nothing left to talk about
|
|
62
|
+
if (connection.options.debug) {
|
|
63
|
+
console.log('COMPRESS error: %s', err.message);
|
|
64
|
+
}
|
|
65
|
+
connection.sendStatus({ tag: '*' }, false, 'BYE', 'Invalid compressed data', false, 'COMPRESS ERROR');
|
|
66
|
+
connection.end();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return callback();
|
|
71
|
+
},
|
|
72
|
+
// RFC 4978 section 3: the client MUST NOT send further commands until it has seen the result of COMPRESS.
|
|
73
|
+
// Those commands would be uncompressed, and after an OK the server expects compressed input
|
|
74
|
+
{ states: states.AUTHENTICATED, noPipelining: true }
|
|
75
|
+
);
|
|
76
|
+
};
|
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @help Adds CONDSTORE [RFC7162] capability
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { macros: FETCH_MACROS } = require('../commands/fetch');
|
|
8
|
+
const { badError } = require('../commands/handlers/search');
|
|
9
|
+
const { INVALID_KEYWORD_CHAR } = require('../commands/handlers/flags');
|
|
10
|
+
const { MAX_NUMBER64, isNumber } = require('../numbers');
|
|
11
|
+
const { registerEnable, isEnabled: isExtensionEnabled } = require('./enable');
|
|
12
|
+
|
|
13
|
+
module.exports = function (server) {
|
|
14
|
+
// Register capability, always usable
|
|
15
|
+
server.registerCapability('CONDSTORE');
|
|
16
|
+
|
|
17
|
+
registerEnable(server, 'CONDSTORE');
|
|
18
|
+
|
|
19
|
+
// HIGHESTMODSEQ must be a positive number, even for an empty mailbox
|
|
20
|
+
const getHighestModseq = mailbox => Math.max(Number(mailbox.HIGHESTMODSEQ) || 0, 1);
|
|
21
|
+
|
|
22
|
+
const bumpModseq = mailbox => {
|
|
23
|
+
mailbox.HIGHESTMODSEQ = getHighestModseq(mailbox) + 1;
|
|
24
|
+
return mailbox.HIGHESTMODSEQ;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// RFC 8437 section 4.1: after UNAUTHENTICATE the session behaves as if no CONDSTORE enabling command was issued
|
|
28
|
+
server.resetHandlers.push(connection => {
|
|
29
|
+
connection.condstoreEnabled = false;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ENABLE QRESYNC is a CONDSTORE enabling command as well (RFC 7162 section 3.2.3)
|
|
33
|
+
const isEnabled = connection => !!connection.condstoreEnabled || isExtensionEnabled(connection, 'CONDSTORE') || isExtensionEnabled(connection, 'QRESYNC');
|
|
34
|
+
|
|
35
|
+
// shared with QRESYNC, which builds on CONDSTORE
|
|
36
|
+
server.condstore = { isEnabled, getHighestModseq };
|
|
37
|
+
|
|
38
|
+
const sendHighestModseq = (connection, mailbox, parsed, data) => {
|
|
39
|
+
connection.send(
|
|
40
|
+
{
|
|
41
|
+
tag: '*',
|
|
42
|
+
command: 'OK',
|
|
43
|
+
attributes: [
|
|
44
|
+
{
|
|
45
|
+
type: 'SECTION',
|
|
46
|
+
section: [
|
|
47
|
+
{
|
|
48
|
+
type: 'ATOM',
|
|
49
|
+
value: 'HIGHESTMODSEQ'
|
|
50
|
+
},
|
|
51
|
+
getHighestModseq(mailbox)
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
type: 'TEXT',
|
|
56
|
+
value: 'Highest'
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
'CONDSTORE INFO',
|
|
61
|
+
parsed,
|
|
62
|
+
data
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Marks the session as CONDSTORE aware. The first CONDSTORE enabling command
|
|
67
|
+
// issued with a mailbox selected reports HIGHESTMODSEQ for that mailbox
|
|
68
|
+
const enableCondstore = (connection, parsed, data) => {
|
|
69
|
+
if (isEnabled(connection)) {
|
|
70
|
+
connection.condstoreEnabled = true;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
connection.condstoreEnabled = true;
|
|
74
|
+
if (connection.state === 'Selected' && connection.selectedMailbox) {
|
|
75
|
+
sendHighestModseq(connection, connection.selectedMailbox, parsed, data);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Set modseq values when a message is created or initialized
|
|
80
|
+
server.messageHandlers.push((server, message, mailbox) => {
|
|
81
|
+
const modseq = Number(message.MODSEQ);
|
|
82
|
+
if (modseq > 0 && Number.isSafeInteger(modseq)) {
|
|
83
|
+
// keep the value from storage
|
|
84
|
+
message.MODSEQ = modseq;
|
|
85
|
+
mailbox.HIGHESTMODSEQ = Math.max(getHighestModseq(mailbox), modseq);
|
|
86
|
+
} else {
|
|
87
|
+
message.MODSEQ = bumpModseq(mailbox);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Expunging messages (EXPUNGE, UID EXPUNGE, CLOSE, MOVE) changes the mailbox, all messages removed
|
|
92
|
+
// at once share the incremented mod-sequence (RFC 7162 section 3.2)
|
|
93
|
+
server.on('expunge', mailbox => {
|
|
94
|
+
bumpModseq(mailbox);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// RFC 7162 section 3.2.11: SELECT or EXAMINE closes the selected mailbox, a CONDSTORE server marks
|
|
98
|
+
// where the responses for the new mailbox start
|
|
99
|
+
server.closedChecks.push(() => true);
|
|
100
|
+
|
|
101
|
+
server.allowedStatus.push('HIGHESTMODSEQ');
|
|
102
|
+
server.statusHandlers.HIGHESTMODSEQ = (connection, mailbox) => getHighestModseq(mailbox);
|
|
103
|
+
|
|
104
|
+
// Wraps an existing command handler. Commands that are not available are left alone
|
|
105
|
+
const wrapHandler = (command, wrapper) => {
|
|
106
|
+
const prevHandler = server.getCommandHandler(command);
|
|
107
|
+
if (!prevHandler) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
server.setCommandHandler(command, (connection, parsed, data, callback) => wrapper(prevHandler, connection, parsed, data, callback));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const sendBad = (connection, parsed, data, callback, message) => {
|
|
114
|
+
connection.sendStatus(parsed, data, 'BAD', message, false, 'CONDSTORE FAILED');
|
|
115
|
+
return callback();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// RFC 7162 section 7: chgsince-fetch-mod = "CHANGEDSINCE" SP mod-sequence-value (1 or more), store-modifier =/
|
|
119
|
+
// "UNCHANGEDSINCE" SP mod-sequence-valzer (0 or more). Both are 1*DIGIT, so not a quoted string, up to 63 bits
|
|
120
|
+
const isValidModseq = (value, allowZero) =>
|
|
121
|
+
!!value && value.type === 'ATOM' && isNumber(value.value, MAX_NUMBER64) && (allowZero || BigInt(value.value) > 0n);
|
|
122
|
+
|
|
123
|
+
const selectWrapper = (prevHandler, connection, parsed, data, callback) => {
|
|
124
|
+
if (hasCondstoreOption(parsed.attributes && parsed.attributes[1], parsed.attributes, 1)) {
|
|
125
|
+
// SELECT and EXAMINE always report HIGHESTMODSEQ, so no need to send it here
|
|
126
|
+
connection.condstoreEnabled = true;
|
|
127
|
+
parsed.condstoreOption = true;
|
|
128
|
+
}
|
|
129
|
+
prevHandler(connection, parsed, data, callback);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
wrapHandler('SELECT', selectWrapper);
|
|
133
|
+
wrapHandler('EXAMINE', selectWrapper);
|
|
134
|
+
|
|
135
|
+
wrapHandler('STATUS', (prevHandler, connection, parsed, data, callback) => {
|
|
136
|
+
const items = parsed.attributes && parsed.attributes[1];
|
|
137
|
+
if (Array.isArray(items) && items.some(item => item && item.type === 'ATOM' && String(item.value).toUpperCase() === 'HIGHESTMODSEQ')) {
|
|
138
|
+
enableCondstore(connection, parsed, data);
|
|
139
|
+
}
|
|
140
|
+
prevHandler(connection, parsed, data, callback);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const fetchWrapper = (prevHandler, connection, parsed, data, callback) => {
|
|
144
|
+
// RFC 4466 2.4: fetch-modifiers are the last argument, a single fetch-att may take arguments of its own
|
|
145
|
+
const attributes = parsed.attributes || [];
|
|
146
|
+
const index = Math.max(attributes.length - 1, 2);
|
|
147
|
+
const changedsince = getCondstoreValue(attributes[index], 'CHANGEDSINCE', attributes, index);
|
|
148
|
+
|
|
149
|
+
if (changedsince) {
|
|
150
|
+
if (!isValidModseq(changedsince)) {
|
|
151
|
+
return sendBad(connection, parsed, data, callback, 'Invalid syntax for CHANGEDSINCE, number expected');
|
|
152
|
+
}
|
|
153
|
+
parsed.changedsince = Number(changedsince.value);
|
|
154
|
+
// CHANGEDSINCE implicitly adds the MODSEQ data item
|
|
155
|
+
addModseqItem(parsed);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (changedsince || hasModseqItem(parsed)) {
|
|
159
|
+
enableCondstore(connection, parsed, data);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
prevHandler(connection, parsed, data, callback);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
wrapHandler('FETCH', fetchWrapper);
|
|
166
|
+
wrapHandler('UID FETCH', fetchWrapper);
|
|
167
|
+
|
|
168
|
+
const storeWrapper = (prevHandler, connection, parsed, data, callback) => {
|
|
169
|
+
const unchangedsince = getCondstoreValue(parsed.attributes && parsed.attributes[1], 'UNCHANGEDSINCE', parsed.attributes, 1);
|
|
170
|
+
|
|
171
|
+
if (unchangedsince) {
|
|
172
|
+
if (!isValidModseq(unchangedsince, true)) {
|
|
173
|
+
return sendBad(connection, parsed, data, callback, 'Invalid syntax for UNCHANGEDSINCE, number expected');
|
|
174
|
+
}
|
|
175
|
+
parsed.unchangedsince = Number(unchangedsince.value);
|
|
176
|
+
enableCondstore(connection, parsed, data);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Per command state for tracking changed messages
|
|
180
|
+
parsed.condstoreStore = {
|
|
181
|
+
isUid: (parsed.command || '').toUpperCase() === 'UID STORE',
|
|
182
|
+
messages: new Map(),
|
|
183
|
+
modified: [],
|
|
184
|
+
done: false
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
prevHandler(connection, parsed, data, callback);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
wrapHandler('STORE', storeWrapper);
|
|
191
|
+
wrapHandler('UID STORE', storeWrapper);
|
|
192
|
+
|
|
193
|
+
// RFC 7162 section 3.1.5: MODSEQ [<entry-name> <entry-type-req>] <mod-sequence-valzer>. Mod-sequences
|
|
194
|
+
// are not stored per flag, so the entry name and type are checked but otherwise ignored
|
|
195
|
+
const searchModseq = (connection, message, index, ...args) => message.MODSEQ >= args[args.length - 1];
|
|
196
|
+
searchModseq.argumentTypes = list =>
|
|
197
|
+
typeof list[0] === 'string' && /^\d+$/.test(list[0]) ? [parseModseqValzer] : [parseEntryName, parseEntryType, parseModseqValzer];
|
|
198
|
+
server.searchHandlers.MODSEQ = searchModseq;
|
|
199
|
+
|
|
200
|
+
// highest mod-sequence of a list of messages
|
|
201
|
+
const highestModseq = messages => messages.reduce((highest, message) => Math.max(highest, message.MODSEQ), 0);
|
|
202
|
+
|
|
203
|
+
server.fetchHandlers.MODSEQ = function (connection, message) {
|
|
204
|
+
return [message.MODSEQ]; // Must be a list
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
server.fetchFilters.push((connection, message, parsed, index) => {
|
|
208
|
+
if ('changedsince' in parsed && !(parsed.changedsince < message.MODSEQ)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// remember \Seen state, FETCH might set it implicitly
|
|
213
|
+
if (!parsed.condstoreFetch) {
|
|
214
|
+
parsed.condstoreFetch = new Map();
|
|
215
|
+
}
|
|
216
|
+
parsed.condstoreFetch.set(index, {
|
|
217
|
+
message,
|
|
218
|
+
seen: message.flags.indexOf('\\Seen') >= 0
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return true;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
server.storeFilters.push((connection, message, parsed, index) => {
|
|
225
|
+
const state = parsed.condstoreStore;
|
|
226
|
+
if (!state) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if ('unchangedsince' in parsed && message.MODSEQ > parsed.unchangedsince) {
|
|
231
|
+
// failed the UNCHANGEDSINCE test, reported with the MODIFIED response code
|
|
232
|
+
state.modified.push(state.isUid ? message.uid : index);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
state.messages.set(message, {
|
|
237
|
+
index,
|
|
238
|
+
before: snapshot(message),
|
|
239
|
+
bumped: false,
|
|
240
|
+
reported: false
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return true;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Bumps MODSEQ if the message metadata was changed by the current STORE
|
|
247
|
+
const checkStored = (connection, message, entry) => {
|
|
248
|
+
if (!entry.bumped && snapshot(message) !== entry.before) {
|
|
249
|
+
entry.bumped = true;
|
|
250
|
+
message.MODSEQ = bumpModseq(connection.selectedMailbox);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
server.outputHandlers.push((connection, response, description, parsed, data, extra) => {
|
|
255
|
+
// Flag changes made by another session (RFC 7162 section 3.1), these are not responses to a command
|
|
256
|
+
if (description === 'FLAG NOTIFICATION' && response && extra && isEnabled(connection)) {
|
|
257
|
+
setModseqValue(response.attributes[2], extra.MODSEQ);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!parsed || !response) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Untagged FETCH responses caused by STORE
|
|
266
|
+
if (description === 'FLAG UPDATE' && parsed.condstoreStore && extra && parsed.condstoreStore.messages.has(extra)) {
|
|
267
|
+
const entry = parsed.condstoreStore.messages.get(extra);
|
|
268
|
+
checkStored(connection, extra, entry);
|
|
269
|
+
entry.reported = true;
|
|
270
|
+
if (isEnabled(connection)) {
|
|
271
|
+
setModseqValue(response.attributes[2], extra.MODSEQ);
|
|
272
|
+
setUidValue(response.attributes[2], extra.uid);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// SEARCH, ESEARCH, SORT or THREAD with the MODSEQ search key, a CONDSTORE enabling command (RFC 7162 section 3.1).
|
|
278
|
+
// `extra.list` holds the messages the response is about
|
|
279
|
+
const isSearch = ['SEARCH', 'SORT', 'THREAD'].includes(response.command) && [response.command, 'UID ' + response.command].includes(description);
|
|
280
|
+
if ((isSearch || description === 'ESEARCH') && response.tag === '*' && extra && extra.keys && extra.keys.has('MODSEQ')) {
|
|
281
|
+
enableCondstore(connection, parsed, data);
|
|
282
|
+
// RFC 7162 section 3.1.9: THREAD responses are unchanged
|
|
283
|
+
if (extra.list.length && response.command !== 'THREAD') {
|
|
284
|
+
const modseq = [{ type: 'ATOM', value: 'MODSEQ' }, highestModseq(extra.list)];
|
|
285
|
+
// RFC 7162 sections 3.1.6 and 3.1.9 append "(MODSEQ n)" to SEARCH and SORT, RFC 4731 section 3.2 (and RFC 7162 section 3.1.10)
|
|
286
|
+
// adds "MODSEQ n" to ESEARCH
|
|
287
|
+
response.attributes.push(...(isSearch ? [modseq] : modseq));
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Untagged FETCH responses of FETCH, check for implicitly set \Seen
|
|
293
|
+
if (
|
|
294
|
+
response.tag === '*' &&
|
|
295
|
+
parsed.condstoreFetch &&
|
|
296
|
+
response.attributes &&
|
|
297
|
+
response.attributes[1] &&
|
|
298
|
+
response.attributes[1].value === 'FETCH' &&
|
|
299
|
+
parsed.condstoreFetch.has(response.attributes[0])
|
|
300
|
+
) {
|
|
301
|
+
const entry = parsed.condstoreFetch.get(response.attributes[0]);
|
|
302
|
+
const message = entry.message;
|
|
303
|
+
let changed = false;
|
|
304
|
+
if (!entry.seen && message.flags.indexOf('\\Seen') >= 0 && connection.selectedMailbox) {
|
|
305
|
+
entry.seen = true;
|
|
306
|
+
message.MODSEQ = bumpModseq(connection.selectedMailbox);
|
|
307
|
+
changed = true;
|
|
308
|
+
}
|
|
309
|
+
const list = response.attributes[2];
|
|
310
|
+
if (Array.isArray(list) && (findItem(list, 'MODSEQ') >= 0 || (changed && isEnabled(connection)))) {
|
|
311
|
+
setModseqValue(list, message.MODSEQ);
|
|
312
|
+
}
|
|
313
|
+
// RFC 7162 section 3.1: a FETCH that implicitly set \Seen includes UID and MODSEQ
|
|
314
|
+
if (Array.isArray(list) && changed && isEnabled(connection)) {
|
|
315
|
+
setUidValue(list, message.uid);
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Tagged response of STORE
|
|
321
|
+
if (response.tag === parsed.tag && parsed.condstoreStore && !parsed.condstoreStore.done) {
|
|
322
|
+
const state = parsed.condstoreStore;
|
|
323
|
+
state.done = true;
|
|
324
|
+
|
|
325
|
+
state.messages.forEach((entry, message) => {
|
|
326
|
+
checkStored(connection, message, entry);
|
|
327
|
+
// .SILENT stores send no FETCH responses, CONDSTORE aware clients still need the new MODSEQ values
|
|
328
|
+
if (!entry.reported && isEnabled(connection) && (entry.bumped || 'unchangedsince' in parsed)) {
|
|
329
|
+
const attributes = [
|
|
330
|
+
{
|
|
331
|
+
type: 'ATOM',
|
|
332
|
+
value: 'MODSEQ'
|
|
333
|
+
},
|
|
334
|
+
[message.MODSEQ]
|
|
335
|
+
];
|
|
336
|
+
// RFC 7162 section 3.1: UID and MODSEQ in every untagged FETCH caused by STORE
|
|
337
|
+
attributes.unshift({ type: 'ATOM', value: 'UID' }, message.uid);
|
|
338
|
+
connection.send(
|
|
339
|
+
{
|
|
340
|
+
tag: '*',
|
|
341
|
+
attributes: [
|
|
342
|
+
entry.index,
|
|
343
|
+
{
|
|
344
|
+
type: 'ATOM',
|
|
345
|
+
value: 'FETCH'
|
|
346
|
+
},
|
|
347
|
+
attributes
|
|
348
|
+
]
|
|
349
|
+
},
|
|
350
|
+
'CONDSTORE MODSEQ UPDATE',
|
|
351
|
+
parsed,
|
|
352
|
+
data,
|
|
353
|
+
message
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// RFC 7162 section 3.1.3 example 11: MODIFIED can also go with the tagged NO of a STORE on expunged messages
|
|
359
|
+
// (RFC 2180 section 4.2.3), it replaces EXPUNGEISSUED there since a response has only one response code
|
|
360
|
+
if ((response.command === 'OK' || response.command === 'NO') && state.modified.length) {
|
|
361
|
+
response.attributes = [
|
|
362
|
+
{
|
|
363
|
+
type: 'SECTION',
|
|
364
|
+
section: [
|
|
365
|
+
{
|
|
366
|
+
type: 'ATOM',
|
|
367
|
+
value: 'MODIFIED'
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
type: 'SEQUENCE',
|
|
371
|
+
value: state.modified.sort((a, b) => a - b).join(',')
|
|
372
|
+
}
|
|
373
|
+
]
|
|
374
|
+
}
|
|
375
|
+
].concat((response.attributes || []).filter(attr => response.command === 'OK' || !attr || attr.type !== 'SECTION'));
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (description === 'ENABLED' && Array.isArray(extra) && extra.indexOf('CONDSTORE') >= 0) {
|
|
381
|
+
// ENABLE CONDSTORE was issued
|
|
382
|
+
if (!connection.condstoreEnabled) {
|
|
383
|
+
connection.condstoreEnabled = true;
|
|
384
|
+
if (connection.state === 'Selected' && connection.selectedMailbox) {
|
|
385
|
+
parsed.condstoreReportHighest = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (description === 'ENABLE' && parsed.condstoreReportHighest && connection.selectedMailbox) {
|
|
392
|
+
parsed.condstoreReportHighest = false;
|
|
393
|
+
sendHighestModseq(connection, connection.selectedMailbox, parsed, data);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Add CONDSTORE info to SELECT and EXAMINE
|
|
398
|
+
if ((description === 'EXAMINE' || description === 'SELECT') && response.tag === parsed.tag && connection.selectedMailbox) {
|
|
399
|
+
// (CONDSTORE) option was used, show notice
|
|
400
|
+
if (parsed.condstoreOption) {
|
|
401
|
+
const last = response.attributes && response.attributes.slice(-1)[0];
|
|
402
|
+
if (!last || last.type !== 'TEXT') {
|
|
403
|
+
response.attributes = (response.attributes || []).concat({
|
|
404
|
+
type: 'TEXT',
|
|
405
|
+
value: 'CONDSTORE is now enabled'
|
|
406
|
+
});
|
|
407
|
+
} else {
|
|
408
|
+
last.value += ', CONDSTORE is now enabled';
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Send untagged info about highest modseq
|
|
413
|
+
sendHighestModseq(connection, connection.selectedMailbox, parsed, data);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// RFC 7162 section 7: mod-sequence-valzer = "0" / mod-sequence-value. Dovecot also accepts values above 63 bits
|
|
419
|
+
function parseModseqValzer(value) {
|
|
420
|
+
if (!isNumber(value, MAX_NUMBER64)) {
|
|
421
|
+
throw badError('MODSEQ expects a mod-sequence value');
|
|
422
|
+
}
|
|
423
|
+
return Number(value);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// RFC 7162 section 7: entry-flag-name = DQUOTE "/flags/" attr-flag DQUOTE, attr-flag is a flag without "\Recent"
|
|
427
|
+
function parseEntryName(value) {
|
|
428
|
+
const match = value.match(/^\/flags\/(\\?)(.+)$/i);
|
|
429
|
+
if (!match || INVALID_KEYWORD_CHAR.test(match[2]) || (match[1] && match[2].toUpperCase() === 'RECENT')) {
|
|
430
|
+
throw badError('MODSEQ expects an entry name like "/flags/\\\\seen"');
|
|
431
|
+
}
|
|
432
|
+
return value;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// RFC 7162 section 7: entry-type-req = "priv" / "shared" / "all"
|
|
436
|
+
function parseEntryType(value) {
|
|
437
|
+
if (!['PRIV', 'SHARED', 'ALL'].includes(value.toUpperCase())) {
|
|
438
|
+
throw badError('MODSEQ expects entry type priv, shared or all');
|
|
439
|
+
}
|
|
440
|
+
return value;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Serialized message metadata, used to detect changes
|
|
444
|
+
function snapshot(message) {
|
|
445
|
+
return JSON.stringify([message.flags || [], message['X-GM-LABELS'] || []]);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function findItem(list, name) {
|
|
449
|
+
if (!Array.isArray(list)) {
|
|
450
|
+
return -1;
|
|
451
|
+
}
|
|
452
|
+
for (let i = 0; i < list.length; i += 2) {
|
|
453
|
+
if (list[i] && list[i].type === 'ATOM' && String(list[i].value).toUpperCase() === name) {
|
|
454
|
+
return i;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return -1;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Sets or adds the MODSEQ value in a FETCH response attribute list
|
|
461
|
+
function setModseqValue(list, modseq) {
|
|
462
|
+
if (!Array.isArray(list)) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const pos = findItem(list, 'MODSEQ');
|
|
466
|
+
if (pos >= 0) {
|
|
467
|
+
list[pos + 1] = [modseq];
|
|
468
|
+
} else {
|
|
469
|
+
list.push(
|
|
470
|
+
{
|
|
471
|
+
type: 'ATOM',
|
|
472
|
+
value: 'MODSEQ'
|
|
473
|
+
},
|
|
474
|
+
[modseq]
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Adds the UID to a FETCH response attribute list unless it is there already
|
|
480
|
+
function setUidValue(list, uid) {
|
|
481
|
+
if (Array.isArray(list) && findItem(list, 'UID') < 0) {
|
|
482
|
+
list.push(
|
|
483
|
+
{
|
|
484
|
+
type: 'ATOM',
|
|
485
|
+
value: 'UID'
|
|
486
|
+
},
|
|
487
|
+
uid
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function isModseqAtom(item) {
|
|
493
|
+
return !!item && item.type === 'ATOM' && String(item.value).toUpperCase() === 'MODSEQ';
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function hasModseqItem(parsed) {
|
|
497
|
+
const items = parsed.attributes && parsed.attributes[1];
|
|
498
|
+
return Array.isArray(items) ? items.some(isModseqAtom) : isModseqAtom(items);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Adds MODSEQ to the list of FETCH data items, unless it is already there
|
|
502
|
+
function addModseqItem(parsed) {
|
|
503
|
+
if (!parsed.attributes || !parsed.attributes[1] || hasModseqItem(parsed)) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
let items = parsed.attributes[1];
|
|
507
|
+
if (!Array.isArray(items)) {
|
|
508
|
+
const name = String(items.value).toUpperCase();
|
|
509
|
+
const macro = items.type === 'ATOM' && Object.hasOwn(FETCH_MACROS, name) && FETCH_MACROS[name];
|
|
510
|
+
// a single fetch-att keeps the arguments that follow it, eg. RFC 8970 PREVIEW (LAZY)
|
|
511
|
+
items = macro ? macro.map(value => ({ type: 'ATOM', value })) : [items].concat(parsed.attributes.splice(2));
|
|
512
|
+
}
|
|
513
|
+
parsed.attributes[1] = items.concat({
|
|
514
|
+
type: 'ATOM',
|
|
515
|
+
value: 'MODSEQ'
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function hasCondstoreOption(attributes, parent, index) {
|
|
520
|
+
if (!attributes) {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
let condstoreOption = false;
|
|
524
|
+
if (Array.isArray(attributes)) {
|
|
525
|
+
for (let i = attributes.length - 1; i >= 0; i--) {
|
|
526
|
+
if (attributes[i] && attributes[i].type === 'ATOM' && attributes[i].value.toUpperCase() === 'CONDSTORE') {
|
|
527
|
+
attributes.splice(i, 1);
|
|
528
|
+
condstoreOption = true;
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// remove parameter if no other memebers were left
|
|
534
|
+
if (!attributes.length) {
|
|
535
|
+
parent.splice(index, 1);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return !!condstoreOption;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function getCondstoreValue(attributes, name, parent, index) {
|
|
542
|
+
if (!attributes) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
let condstoreValue = false;
|
|
546
|
+
if (Array.isArray(attributes)) {
|
|
547
|
+
for (let i = 0; i < attributes.length; i += 2) {
|
|
548
|
+
if (attributes[i] && attributes[i].type === 'ATOM' && attributes[i].value.toUpperCase() === name.toUpperCase()) {
|
|
549
|
+
// a missing value is reported as a syntax error
|
|
550
|
+
condstoreValue = attributes[i + 1] || { type: 'NIL' };
|
|
551
|
+
attributes.splice(i, 2);
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// remove parameter if no other memebers were left
|
|
557
|
+
if (!attributes.length) {
|
|
558
|
+
parent.splice(index, 1);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return condstoreValue;
|
|
563
|
+
}
|