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
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isUtf8 } = require('buffer');
|
|
4
|
+
const { getMessageData, render } = require('../../mimeparser');
|
|
5
|
+
const { monthIndex, dateKey, parseDateTime, parseHeaderDate } = require('../../dates');
|
|
6
|
+
const { MAX_NUMBER, MAX_NUMBER64, isNumber } = require('../../numbers');
|
|
7
|
+
|
|
8
|
+
// RFC 3501 6.4.4 search keys and their arguments
|
|
9
|
+
const searchKeys = {
|
|
10
|
+
ALL: [],
|
|
11
|
+
ANSWERED: [],
|
|
12
|
+
BCC: ['string'],
|
|
13
|
+
BEFORE: ['date'],
|
|
14
|
+
BODY: ['string'],
|
|
15
|
+
CC: ['string'],
|
|
16
|
+
DELETED: [],
|
|
17
|
+
DRAFT: [],
|
|
18
|
+
FLAGGED: [],
|
|
19
|
+
FROM: ['string'],
|
|
20
|
+
HEADER: ['string', 'string'],
|
|
21
|
+
KEYWORD: ['string'],
|
|
22
|
+
LARGER: ['number64'],
|
|
23
|
+
NEW: [],
|
|
24
|
+
NOT: ['key'],
|
|
25
|
+
OLD: [],
|
|
26
|
+
ON: ['date'],
|
|
27
|
+
OR: ['key', 'key'],
|
|
28
|
+
RECENT: [],
|
|
29
|
+
SEEN: [],
|
|
30
|
+
SENTBEFORE: ['date'],
|
|
31
|
+
SENTON: ['date'],
|
|
32
|
+
SENTSINCE: ['date'],
|
|
33
|
+
SINCE: ['date'],
|
|
34
|
+
SMALLER: ['number64'],
|
|
35
|
+
SUBJECT: ['string'],
|
|
36
|
+
TEXT: ['string'],
|
|
37
|
+
TO: ['string'],
|
|
38
|
+
UID: ['sequence'],
|
|
39
|
+
UNANSWERED: [],
|
|
40
|
+
UNDELETED: [],
|
|
41
|
+
UNDRAFT: [],
|
|
42
|
+
UNFLAGGED: [],
|
|
43
|
+
UNKEYWORD: ['string'],
|
|
44
|
+
UNSEEN: []
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Charsets accepted for the CHARSET argument (RFC 3501 6.4.4: US-ASCII must be supported)
|
|
48
|
+
const charsets = ['US-ASCII', 'UTF-8'];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates an error that is reported to the client as BAD
|
|
52
|
+
*/
|
|
53
|
+
function badError(message) {
|
|
54
|
+
const err = new Error(message);
|
|
55
|
+
err.imapResponse = 'BAD';
|
|
56
|
+
return err;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parses an RFC 3501 date argument (date-day "-" date-month "-" date-year) to a comparable YYYY-MM-DD string
|
|
61
|
+
*/
|
|
62
|
+
function parseQueryDate(value) {
|
|
63
|
+
const match = (value || '').toString().match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/);
|
|
64
|
+
const date = match && dateKey(Number(match[1]), monthIndex(match[2]), Number(match[3]));
|
|
65
|
+
if (!date) {
|
|
66
|
+
throw badError('Invalid date argument ' + value);
|
|
67
|
+
}
|
|
68
|
+
return date;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Date of a date-time value, disregarding time and timezone, as a comparable YYYY-MM-DD string,
|
|
73
|
+
* or false when it can not be parsed
|
|
74
|
+
*
|
|
75
|
+
* @param {String} dateTime Date-time value, e.g. "14-Sep-2013 21:22:28 -0300"
|
|
76
|
+
* @return {String|Boolean} date or false
|
|
77
|
+
*/
|
|
78
|
+
function getDateKey(dateTime) {
|
|
79
|
+
const date = parseDateTime(dateTime);
|
|
80
|
+
return date ? dateKey(date.day, date.month, date.year) : false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Date of the internal date of a message, disregarding time and timezone, or false when it can not be parsed
|
|
85
|
+
*/
|
|
86
|
+
function getInternalDate(message) {
|
|
87
|
+
return getDateKey(message.internaldate);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Date of the Date header of a message, disregarding time and timezone (RFC 3501 section 6.4.4, unlike the
|
|
92
|
+
* sent date of SORT it is not adjusted to UTC). Falls back to the internal date
|
|
93
|
+
*/
|
|
94
|
+
function getSentDate(message) {
|
|
95
|
+
const date = parseHeaderDate(getMessageData(message).tree.parsedHeader.date);
|
|
96
|
+
return date ? dateKey(date.day, date.month, date.year) : getInternalDate(message);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Converts the parsed search criteria of a command to the values the search takes, parenthesized lists stay nested
|
|
101
|
+
*
|
|
102
|
+
* @param {Array} attributes Parsed arguments
|
|
103
|
+
* @return {Array} values
|
|
104
|
+
* @throws {Error} BAD error for an argument that can not be a search key or its value
|
|
105
|
+
*/
|
|
106
|
+
function criteriaValues(attributes) {
|
|
107
|
+
const convert = (argument, i) => {
|
|
108
|
+
if (Array.isArray(argument)) {
|
|
109
|
+
return argument.map(convert);
|
|
110
|
+
}
|
|
111
|
+
if (!argument || ['STRING', 'ATOM', 'LITERAL', 'SEQUENCE'].indexOf(argument.type) < 0) {
|
|
112
|
+
throw badError('Invalid search criteria argument #' + (i + 1));
|
|
113
|
+
}
|
|
114
|
+
return argument.value;
|
|
115
|
+
};
|
|
116
|
+
return attributes.map(convert);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Answers a failed search with NO, or BAD for `err.imapResponse` "BAD": the BADCHARSET response code with the
|
|
121
|
+
* supported charsets (RFC 3501 section 7.1, RFC 5256 section 3), or the response code a search limit set
|
|
122
|
+
* (`err.responseCode`), and the text
|
|
123
|
+
*
|
|
124
|
+
* @param {Object} connection IMAP connection
|
|
125
|
+
* @param {Object} parsed Parsed command
|
|
126
|
+
* @param {String} data Raw command
|
|
127
|
+
* @param {Error} err Search error
|
|
128
|
+
* @param {String} description Description for output handlers
|
|
129
|
+
*/
|
|
130
|
+
function sendSearchError(connection, parsed, data, err, description) {
|
|
131
|
+
const attributes = [];
|
|
132
|
+
if (err.code === 'BADCHARSET') {
|
|
133
|
+
attributes.push({
|
|
134
|
+
type: 'SECTION',
|
|
135
|
+
section: [{ type: 'ATOM', value: 'BADCHARSET' }, err.charsets.map(value => ({ type: 'ATOM', value }))]
|
|
136
|
+
});
|
|
137
|
+
} else if (err.responseCode) {
|
|
138
|
+
attributes.push({ type: 'SECTION', section: err.responseCode.map(value => ({ type: 'ATOM', value: String(value) })) });
|
|
139
|
+
}
|
|
140
|
+
attributes.push({ type: 'TEXT', value: err.message });
|
|
141
|
+
connection.send({ tag: parsed.tag, command: err.imapResponse === 'BAD' ? 'BAD' : 'NO', attributes }, description, parsed, data);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Lower cases ASCII letters only, so that 8-bit octets in binary strings stay intact
|
|
146
|
+
*/
|
|
147
|
+
function asciiLowerCase(str) {
|
|
148
|
+
return str.replace(/[A-Z]+/g, chars => chars.toLowerCase());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Checks if a string contains another one, ignoring ASCII case
|
|
153
|
+
*
|
|
154
|
+
* @param {String} haystack String to search in
|
|
155
|
+
* @param {String} needle String to look for, already lower cased with asciiLowerCase
|
|
156
|
+
*/
|
|
157
|
+
function contains(haystack, needle) {
|
|
158
|
+
return asciiLowerCase(haystack).indexOf(needle) >= 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Returns the search keys with the types of their arguments, including the keys that plugins
|
|
163
|
+
* define in `server.searchHandlers`. If a plugin handler takes more than 3 params
|
|
164
|
+
* (connection, message, index), the remaining ones are its arguments. A plugin handler can
|
|
165
|
+
* describe its arguments instead with an `argumentTypes(list)` method, that gets the criteria
|
|
166
|
+
* following the key and returns the list of types. A type can also be a function that gets the
|
|
167
|
+
* argument value and returns the parsed value, or throws for an invalid one.
|
|
168
|
+
*
|
|
169
|
+
* @param {Object} server IMAP server
|
|
170
|
+
* @return {Object} search key to list of argument types, or to a function that returns the list
|
|
171
|
+
*/
|
|
172
|
+
function getSearchKeys(server) {
|
|
173
|
+
const keys = Object.assign({}, searchKeys);
|
|
174
|
+
const pluginHandlers = server.searchHandlers;
|
|
175
|
+
Object.keys(pluginHandlers).forEach(key => {
|
|
176
|
+
if (!(key in keys)) {
|
|
177
|
+
const handler = pluginHandlers[key];
|
|
178
|
+
keys[key] = typeof handler.argumentTypes === 'function' ? handler.argumentTypes : new Array(Math.max(handler.length - 3, 0)).fill('string');
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
return keys;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Checks if SEARCH criteria refer to messages by sequence number (RFC 3501 section 5.5), that is,
|
|
186
|
+
* if a sequence set is used as a search key. Arguments of search keys, like the UID set of UID
|
|
187
|
+
* or a string that looks like a number, are not search keys.
|
|
188
|
+
*
|
|
189
|
+
* @param {Object} server IMAP server
|
|
190
|
+
* @param {Array} attributes Parsed SEARCH arguments, nested lists are arrays
|
|
191
|
+
* @return {Boolean} true if a sequence set key occurs
|
|
192
|
+
*/
|
|
193
|
+
function hasSequenceSetKey(server, attributes) {
|
|
194
|
+
const keys = getSearchKeys(server);
|
|
195
|
+
const walk = list => {
|
|
196
|
+
// arguments of the previous key that are values, not keys
|
|
197
|
+
let skip = 0;
|
|
198
|
+
return list.some((item, i) => {
|
|
199
|
+
if (Array.isArray(item)) {
|
|
200
|
+
return walk(item);
|
|
201
|
+
}
|
|
202
|
+
if (skip) {
|
|
203
|
+
skip--;
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const value = ((item && item.value) || '').toString();
|
|
207
|
+
const key = value.toUpperCase();
|
|
208
|
+
if (key === 'CHARSET' && i === 0) {
|
|
209
|
+
skip = 1;
|
|
210
|
+
} else if (Object.hasOwn(keys, key)) {
|
|
211
|
+
// NOT and OR take keys as arguments, these are checked as keys
|
|
212
|
+
let types = keys[key];
|
|
213
|
+
if (typeof types === 'function') {
|
|
214
|
+
types = types(list.slice(i + 1).map(next => (Array.isArray(next) ? next : ((next && next.value) || '').toString())));
|
|
215
|
+
}
|
|
216
|
+
skip = types.filter(type => type !== 'key').length;
|
|
217
|
+
} else if (/^[\d*][\d*,:]*$/.test(value)) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
return walk([].concat(attributes || []));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Searches messages. Errors thrown for the client are answered with BAD when `imapResponse` is "BAD",
|
|
228
|
+
* otherwise NO. SORT and THREAD send `responseCode` (a list of atoms) as the response code
|
|
229
|
+
*
|
|
230
|
+
* @param {Object} connection IMAP connection
|
|
231
|
+
* @param {Array} messageSource Messages of the selected mailbox, as the session sees them
|
|
232
|
+
* @param {Array} params Search criteria: strings, and arrays for parenthesized lists
|
|
233
|
+
* @param {Function} [getMessageRange] Resolves the sequence sets of the criteria, `(range, isUid)`, defaults to
|
|
234
|
+
* the selected mailbox of the connection. Used to search a mailbox that is not selected
|
|
235
|
+
* @return {Object} `{ list, numbers, keys, matches }`, the matching messages, the sequence numbers by UID,
|
|
236
|
+
* the set of search keys used in the criteria and a `matches(message, index)` function that checks
|
|
237
|
+
* a message against the same criteria later. Sequence sets in the criteria stay as they were resolved
|
|
238
|
+
* now, they are not evaluated again
|
|
239
|
+
*/
|
|
240
|
+
module.exports = function (connection, messageSource, params, getMessageRange) {
|
|
241
|
+
const resolveRange = getMessageRange || ((range, isUid) => connection.getMessageRange(range, isUid));
|
|
242
|
+
const numbers = {};
|
|
243
|
+
// search keys used in the criteria, other than sequence sets
|
|
244
|
+
const usedKeys = new Set();
|
|
245
|
+
const keys = getSearchKeys(connection.server);
|
|
246
|
+
const pluginHandlers = connection.server.searchHandlers;
|
|
247
|
+
|
|
248
|
+
params = [].concat(params || []);
|
|
249
|
+
|
|
250
|
+
// IMAP4rev1 search strings are US-ASCII unless a CHARSET is given (RFC 3501 6.4.4). A plugin can
|
|
251
|
+
// fix the charset of a session with connection.searchCharset, e.g. UTF-8 after ENABLE UTF8=ACCEPT,
|
|
252
|
+
// or change the default with connection.defaultSearchCharset (IMAP4rev2 assumes UTF-8, RFC 9051 6.4.4)
|
|
253
|
+
let searchCharset = connection.searchCharset || connection.defaultSearchCharset || 'US-ASCII';
|
|
254
|
+
|
|
255
|
+
if (typeof params[0] === 'string' && params[0].toUpperCase() === 'CHARSET') {
|
|
256
|
+
if (connection.searchCharset) {
|
|
257
|
+
// RFC 9755 section 3: a CHARSET conflicts with the charset of the session
|
|
258
|
+
throw badError('CHARSET is not allowed, search strings are always ' + connection.searchCharset + ' in this session');
|
|
259
|
+
}
|
|
260
|
+
params.shift();
|
|
261
|
+
const charset = params.shift();
|
|
262
|
+
if (typeof charset !== 'string') {
|
|
263
|
+
throw badError('CHARSET expects a charset name');
|
|
264
|
+
}
|
|
265
|
+
if (charsets.indexOf(charset.toUpperCase()) < 0) {
|
|
266
|
+
const err = new Error('Unsupported charset ' + charset);
|
|
267
|
+
err.imapResponse = 'NO';
|
|
268
|
+
err.code = 'BADCHARSET';
|
|
269
|
+
err.charsets = charsets;
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
searchCharset = charset.toUpperCase();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// strings must be valid in the declared charset, a client must not send 8-bit text as US-ASCII
|
|
276
|
+
const checkString = (key, value) => {
|
|
277
|
+
if (searchCharset === 'US-ASCII' && /[\u0080-\u00ff]/.test(value)) {
|
|
278
|
+
throw badError(key + ' argument has 8-bit characters, use CHARSET UTF-8');
|
|
279
|
+
}
|
|
280
|
+
if (searchCharset === 'UTF-8' && !isUtf8(Buffer.from(value, 'binary'))) {
|
|
281
|
+
throw badError(key + ' argument is not valid UTF-8');
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
if (!params.length) {
|
|
287
|
+
throw badError('SEARCH expects search criteria, empty query given');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Parses one search key and its arguments from a list of criteria into a node of the query tree
|
|
291
|
+
const parseKey = list => {
|
|
292
|
+
if (!list.length) {
|
|
293
|
+
throw badError('Unexpected end of search criteria');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const param = list.shift();
|
|
297
|
+
|
|
298
|
+
if (Array.isArray(param)) {
|
|
299
|
+
// a parenthesized list of keys that all must match
|
|
300
|
+
return { key: 'AND', args: parseList([].concat(param)) };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const key = param.toUpperCase();
|
|
304
|
+
|
|
305
|
+
// a plugin can remove keys from a session, e.g. NEW, OLD and RECENT are not in the IMAP4rev2 grammar
|
|
306
|
+
if (!Object.prototype.hasOwnProperty.call(keys, key) || (connection.disabledSearchKeys && connection.disabledSearchKeys.has(key))) {
|
|
307
|
+
// a sequence set, plugins may support other forms of it than numbers
|
|
308
|
+
let range;
|
|
309
|
+
try {
|
|
310
|
+
range = resolveRange(param, false);
|
|
311
|
+
} catch (E) {
|
|
312
|
+
throw /^[\d,:*]+$/.test(param) ? E : badError('Invalid search key ' + param);
|
|
313
|
+
}
|
|
314
|
+
return { key: '_SEQ', args: [range] };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
usedKeys.add(key);
|
|
318
|
+
const types = typeof keys[key] === 'function' ? keys[key](list) : keys[key];
|
|
319
|
+
const args = types.map(type => {
|
|
320
|
+
if (type === 'key') {
|
|
321
|
+
return parseKey(list);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!list.length || typeof list[0] !== 'string') {
|
|
325
|
+
throw badError(key + ' expects ' + types.length + ' argument' + (types.length > 1 ? 's' : ''));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const value = list.shift();
|
|
329
|
+
if (typeof type === 'function') {
|
|
330
|
+
return type(value);
|
|
331
|
+
}
|
|
332
|
+
switch (type) {
|
|
333
|
+
case 'date':
|
|
334
|
+
return parseQueryDate(value);
|
|
335
|
+
case 'number':
|
|
336
|
+
case 'number64': {
|
|
337
|
+
// RFC 3501 section 9: number is 32-bit. LARGER and SMALLER take a number64 in IMAP4rev2 (RFC 9051
|
|
338
|
+
// section 9), so in a session with connection.number64 set
|
|
339
|
+
const number64 = type === 'number64' && connection.number64;
|
|
340
|
+
if (!isNumber(value, number64 ? MAX_NUMBER64 : MAX_NUMBER)) {
|
|
341
|
+
throw badError(key + ' expects a number' + (number64 ? '64' : ''));
|
|
342
|
+
}
|
|
343
|
+
return Number(value);
|
|
344
|
+
}
|
|
345
|
+
case 'sequence':
|
|
346
|
+
return resolveRange(value, true);
|
|
347
|
+
default:
|
|
348
|
+
return checkString(key, value);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
return { key, args };
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const parseList = list => {
|
|
356
|
+
if (!list.length) {
|
|
357
|
+
throw badError('Empty search criteria list');
|
|
358
|
+
}
|
|
359
|
+
const nodes = [];
|
|
360
|
+
while (list.length) {
|
|
361
|
+
nodes.push(parseKey(list));
|
|
362
|
+
}
|
|
363
|
+
return nodes;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const query = { key: 'AND', args: parseList(params) };
|
|
367
|
+
|
|
368
|
+
// a sequence set argument resolves to [number, message] pairs, turn these into a lookup set
|
|
369
|
+
const toMessageSet = range => new Set(range.map(item => item[1]));
|
|
370
|
+
const prepare = node => {
|
|
371
|
+
if (node.key === '_SEQ' || node.key === 'UID') {
|
|
372
|
+
node.set = toMessageSet(node.args[0]);
|
|
373
|
+
}
|
|
374
|
+
// lower case the string to look for once, not for every message
|
|
375
|
+
if (['BCC', 'BODY', 'CC', 'FROM', 'SUBJECT', 'TEXT', 'TO'].indexOf(node.key) >= 0) {
|
|
376
|
+
node.needle = asciiLowerCase(node.args[0]);
|
|
377
|
+
} else if (node.key === 'HEADER') {
|
|
378
|
+
node.needle = asciiLowerCase(node.args[1]);
|
|
379
|
+
}
|
|
380
|
+
node.args.forEach(arg => {
|
|
381
|
+
if (arg && typeof arg === 'object' && arg.key) {
|
|
382
|
+
prepare(arg);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
prepare(query);
|
|
387
|
+
|
|
388
|
+
const hasFlag = (message, flag) => message.flags.indexOf(flag) >= 0;
|
|
389
|
+
|
|
390
|
+
// header lines as [lowercase name, unfolded value]
|
|
391
|
+
const getHeaders = message =>
|
|
392
|
+
(getMessageData(message).tree.header || []).map(line => {
|
|
393
|
+
const parts = line.split(':');
|
|
394
|
+
return [(parts.shift() || '').trim().toLowerCase(), parts.join(':').replace(/\r?\n(?=[ \t])/g, '')];
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const matchHeader = (message, name, needle) => {
|
|
398
|
+
name = name.toLowerCase();
|
|
399
|
+
return getHeaders(message).some(header => header[0] === name && contains(header[1], needle));
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const matches = (node, message, index) => {
|
|
403
|
+
const args = node.args;
|
|
404
|
+
if (Object.prototype.hasOwnProperty.call(pluginHandlers, node.key)) {
|
|
405
|
+
// plugin defined search key, which may also override a built-in one
|
|
406
|
+
return !!pluginHandlers[node.key].apply(null, [connection, message, index].concat(args));
|
|
407
|
+
}
|
|
408
|
+
switch (node.key) {
|
|
409
|
+
case 'AND':
|
|
410
|
+
return args.every(arg => matches(arg, message, index));
|
|
411
|
+
case '_SEQ':
|
|
412
|
+
case 'UID':
|
|
413
|
+
return node.set.has(message);
|
|
414
|
+
case 'ALL':
|
|
415
|
+
return true;
|
|
416
|
+
case 'ANSWERED':
|
|
417
|
+
return hasFlag(message, '\\Answered');
|
|
418
|
+
case 'BCC':
|
|
419
|
+
case 'CC':
|
|
420
|
+
case 'FROM':
|
|
421
|
+
case 'SUBJECT':
|
|
422
|
+
case 'TO':
|
|
423
|
+
return matchHeader(message, node.key, node.needle);
|
|
424
|
+
case 'HEADER':
|
|
425
|
+
return matchHeader(message, args[0], node.needle);
|
|
426
|
+
case 'BEFORE': {
|
|
427
|
+
const date = getInternalDate(message);
|
|
428
|
+
return !!date && date < args[0];
|
|
429
|
+
}
|
|
430
|
+
case 'ON':
|
|
431
|
+
return getInternalDate(message) === args[0];
|
|
432
|
+
case 'SINCE': {
|
|
433
|
+
const date = getInternalDate(message);
|
|
434
|
+
return !!date && date >= args[0];
|
|
435
|
+
}
|
|
436
|
+
case 'SENTBEFORE': {
|
|
437
|
+
const date = getSentDate(message);
|
|
438
|
+
return !!date && date < args[0];
|
|
439
|
+
}
|
|
440
|
+
case 'SENTON':
|
|
441
|
+
return getSentDate(message) === args[0];
|
|
442
|
+
case 'SENTSINCE': {
|
|
443
|
+
const date = getSentDate(message);
|
|
444
|
+
return !!date && date >= args[0];
|
|
445
|
+
}
|
|
446
|
+
case 'BODY':
|
|
447
|
+
return contains(render(getMessageData(message).tree, true), node.needle);
|
|
448
|
+
case 'TEXT':
|
|
449
|
+
return contains(getMessageData(message).raw, node.needle);
|
|
450
|
+
case 'DELETED':
|
|
451
|
+
return hasFlag(message, '\\Deleted');
|
|
452
|
+
case 'DRAFT':
|
|
453
|
+
return hasFlag(message, '\\Draft');
|
|
454
|
+
case 'FLAGGED':
|
|
455
|
+
return hasFlag(message, '\\Flagged');
|
|
456
|
+
case 'KEYWORD':
|
|
457
|
+
return hasFlag(message, args[0]);
|
|
458
|
+
case 'LARGER':
|
|
459
|
+
return getMessageData(message).raw.length > args[0];
|
|
460
|
+
case 'SMALLER':
|
|
461
|
+
return getMessageData(message).raw.length < args[0];
|
|
462
|
+
// \Recent is a session flag, see IMAPConnection#isRecent
|
|
463
|
+
case 'NEW':
|
|
464
|
+
return connection.isRecent(message) && !hasFlag(message, '\\Seen');
|
|
465
|
+
case 'OLD':
|
|
466
|
+
return !connection.isRecent(message);
|
|
467
|
+
case 'RECENT':
|
|
468
|
+
return connection.isRecent(message);
|
|
469
|
+
case 'SEEN':
|
|
470
|
+
return hasFlag(message, '\\Seen');
|
|
471
|
+
case 'NOT':
|
|
472
|
+
return !matches(args[0], message, index);
|
|
473
|
+
case 'OR':
|
|
474
|
+
return matches(args[0], message, index) || matches(args[1], message, index);
|
|
475
|
+
case 'UNANSWERED':
|
|
476
|
+
return !hasFlag(message, '\\Answered');
|
|
477
|
+
case 'UNDELETED':
|
|
478
|
+
return !hasFlag(message, '\\Deleted');
|
|
479
|
+
case 'UNDRAFT':
|
|
480
|
+
return !hasFlag(message, '\\Draft');
|
|
481
|
+
case 'UNFLAGGED':
|
|
482
|
+
return !hasFlag(message, '\\Flagged');
|
|
483
|
+
case 'UNKEYWORD':
|
|
484
|
+
return !hasFlag(message, args[0]);
|
|
485
|
+
case 'UNSEEN':
|
|
486
|
+
return !hasFlag(message, '\\Seen');
|
|
487
|
+
default:
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// plugins can narrow down the messages that are looked at (server.searchLimits), sequence numbers stay as they are
|
|
493
|
+
const searched = connection.server.searchLimits.reduce((messages, limit) => limit(connection, messages, query) || messages, messageSource);
|
|
494
|
+
const searchedSet = searched !== messageSource && new Set(searched);
|
|
495
|
+
|
|
496
|
+
const list = [];
|
|
497
|
+
messageSource.forEach((message, i) => {
|
|
498
|
+
if (searchedSet && !searchedSet.has(message)) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (matches(query, message, i + 1)) {
|
|
502
|
+
numbers[message.uid] = i + 1;
|
|
503
|
+
list.push(message);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
list,
|
|
509
|
+
numbers,
|
|
510
|
+
keys: usedKeys,
|
|
511
|
+
matches: (message, index) => matches(query, message, index)
|
|
512
|
+
};
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
module.exports.hasSequenceSetKey = hasSequenceSetKey;
|
|
516
|
+
module.exports.getDateKey = getDateKey;
|
|
517
|
+
module.exports.badError = badError;
|
|
518
|
+
module.exports.criteriaValues = criteriaValues;
|
|
519
|
+
module.exports.sendSearchError = sendSearchError;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* STATUS data items, shared by the STATUS command and the STATUS return option of LIST (LIST-STATUS,
|
|
5
|
+
* RFC 5819). A STATUS item can only be requested when it is listed in `server.allowedStatus`.
|
|
6
|
+
* Plugins add their items to `server.statusHandlers` (consulted before the built-in items) and to
|
|
7
|
+
* `server.allowedStatus`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const statusHandlers = {
|
|
11
|
+
MESSAGES: (connection, mailbox) => mailbox.messages.length,
|
|
12
|
+
RECENT: (connection, mailbox, status) => status.recent,
|
|
13
|
+
UIDNEXT: (connection, mailbox) => mailbox.uidnext,
|
|
14
|
+
UIDVALIDITY: (connection, mailbox) => mailbox.uidvalidity,
|
|
15
|
+
UNSEEN: (connection, mailbox, status) => status.unseen || 0,
|
|
16
|
+
// RFC 9051 section 6.3.11. Not in RFC 3501, QUOTA lists it in allowedStatus, IMAP4rev2 allows it per session
|
|
17
|
+
DELETED: (connection, mailbox, status) => status.flags['\\Deleted'] || 0
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// RFC 3501 section 9 atom = 1*ATOM-CHAR
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validates a list of STATUS data item names (RFC 3501 section 9, "(" status-att *(SP status-att) ")")
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} server IMAPServer instance
|
|
26
|
+
* @param {Array} list Parsed list of status items
|
|
27
|
+
* @param {Object} [connection] IMAP connection, its `disabledStatusItems` set lists items the session can not use,
|
|
28
|
+
* `addedStatusItems` the items only this session can use (DELETED after ENABLE IMAP4rev2)
|
|
29
|
+
* @return {Array} upper case item names
|
|
30
|
+
* @throws {Error} if the list is empty or has an invalid item, the message is for the BAD response
|
|
31
|
+
*/
|
|
32
|
+
function parseStatusItems(server, list, connection) {
|
|
33
|
+
if (!Array.isArray(list) || !list.length) {
|
|
34
|
+
throw new Error('Expecting a list of status items');
|
|
35
|
+
}
|
|
36
|
+
const disabled = connection && connection.disabledStatusItems;
|
|
37
|
+
const added = connection && connection.addedStatusItems;
|
|
38
|
+
return list.map((item, i) => {
|
|
39
|
+
const name = item && item.type === 'ATOM' && item.value.toUpperCase();
|
|
40
|
+
if (!name || (server.allowedStatus.indexOf(name) < 0 && !(added && added.has(name))) || (disabled && disabled.has(name))) {
|
|
41
|
+
throw new Error('Invalid status element (' + (i + 1) + ')');
|
|
42
|
+
}
|
|
43
|
+
return name;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Sends an untagged STATUS response
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} connection IMAPConnection instance
|
|
51
|
+
* @param {String} path Storage name of the mailbox
|
|
52
|
+
* @param {Object} mailbox Mailbox object
|
|
53
|
+
* @param {Array} items Upper case item names from parseStatusItems
|
|
54
|
+
* @param {Object} parsed Parsed command
|
|
55
|
+
* @param {Object} data Command data
|
|
56
|
+
*/
|
|
57
|
+
function sendStatus(connection, path, mailbox, items, parsed, data) {
|
|
58
|
+
connection.send(statusResponse(connection, path, mailbox, items), 'STATUS', parsed, data);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Builds an untagged STATUS response
|
|
63
|
+
*
|
|
64
|
+
* @param {Object} connection IMAPConnection instance
|
|
65
|
+
* @param {String} path Storage name of the mailbox
|
|
66
|
+
* @param {Object} mailbox Mailbox object
|
|
67
|
+
* @param {Array} items Upper case item names
|
|
68
|
+
* @return {Object} STATUS response
|
|
69
|
+
*/
|
|
70
|
+
function statusResponse(connection, path, mailbox, items) {
|
|
71
|
+
const server = connection.server;
|
|
72
|
+
const status = server.getStatus(mailbox);
|
|
73
|
+
const list = [];
|
|
74
|
+
items.forEach(item => {
|
|
75
|
+
list.push({ type: 'ATOM', value: item }, (server.statusHandlers[item] || statusHandlers[item])(connection, mailbox, status));
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
tag: '*',
|
|
79
|
+
command: 'STATUS',
|
|
80
|
+
// the mailbox name is converted for the session in IMAPConnection#send
|
|
81
|
+
attributes: [{ type: 'MAILBOX', value: path }, list]
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { parseStatusItems, sendStatus, statusResponse };
|