imapflow 1.0.189 → 1.0.191
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/CHANGELOG.md +14 -0
- package/lib/search-compiler.js +163 -10
- package/lib/tools.js +4 -0
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.191](https://github.com/postalsys/imapflow/compare/v1.0.190...v1.0.191) (2025-07-10)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **search:** Use the next day when searching BEFORE/SENTBEFORE to include current day in the range as well ([9e6d749](https://github.com/postalsys/imapflow/commit/9e6d749ac781c6240b3113f2ec8d329ad831d740))
|
|
9
|
+
|
|
10
|
+
## [1.0.190](https://github.com/postalsys/imapflow/compare/v1.0.189...v1.0.190) (2025-07-10)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **search:** Prefer WITHIN YOUNGER/OLDER instead of BEFORE/SINCE for searches if possible ([7492ac9](https://github.com/postalsys/imapflow/commit/7492ac9bd5598beadee7a72e3e3c63e25e9bc1f5))
|
|
16
|
+
|
|
3
17
|
## [1.0.189](https://github.com/postalsys/imapflow/compare/v1.0.188...v1.0.189) (2025-06-30)
|
|
4
18
|
|
|
5
19
|
|
package/lib/search-compiler.js
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint no-control-regex:0 */
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
'use strict';
|
|
4
4
|
|
|
5
|
+
const { formatDate, formatFlag, canUseFlag, isDate } = require('./tools.js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sets a boolean flag in the IMAP search attributes.
|
|
9
|
+
* Automatically handles UN- prefixing for falsy values.
|
|
10
|
+
*
|
|
11
|
+
* @param {Array} attributes - Array to append the attribute to
|
|
12
|
+
* @param {string} term - The flag name (e.g., 'SEEN', 'DELETED')
|
|
13
|
+
* @param {boolean} value - Whether to set or unset the flag
|
|
14
|
+
* @example
|
|
15
|
+
* setBoolOpt(attributes, 'SEEN', false) // Adds 'UNSEEN'
|
|
16
|
+
* setBoolOpt(attributes, 'UNSEEN', false) // Adds 'SEEN' (removes UN prefix)
|
|
17
|
+
*/
|
|
5
18
|
let setBoolOpt = (attributes, term, value) => {
|
|
6
19
|
if (!value) {
|
|
20
|
+
// For falsy values, toggle the UN- prefix
|
|
7
21
|
if (/^un/i.test(term)) {
|
|
22
|
+
// Remove existing UN prefix
|
|
8
23
|
term = term.slice(2);
|
|
9
24
|
} else {
|
|
25
|
+
// Add UN prefix
|
|
10
26
|
term = 'UN' + term;
|
|
11
27
|
}
|
|
12
28
|
}
|
|
@@ -14,15 +30,26 @@ let setBoolOpt = (attributes, term, value) => {
|
|
|
14
30
|
attributes.push({ type: 'ATOM', value: term.toUpperCase() });
|
|
15
31
|
};
|
|
16
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Adds a search option with its value(s) to the attributes array.
|
|
35
|
+
* Handles NOT operations and array values.
|
|
36
|
+
*
|
|
37
|
+
* @param {Array} attributes - Array to append the attribute to
|
|
38
|
+
* @param {string} term - The search term (e.g., 'FROM', 'SUBJECT')
|
|
39
|
+
* @param {*} value - The value for the search term (string, array, or falsy for NOT)
|
|
40
|
+
* @param {string} [type='ATOM'] - The attribute type
|
|
41
|
+
*/
|
|
17
42
|
let setOpt = (attributes, term, value, type) => {
|
|
18
43
|
type = type || 'ATOM';
|
|
19
44
|
|
|
45
|
+
// Handle NOT operations for false or null values
|
|
20
46
|
if (value === false || value === null) {
|
|
21
47
|
attributes.push({ type, value: 'NOT' });
|
|
22
48
|
}
|
|
23
49
|
|
|
24
50
|
attributes.push({ type, value: term.toUpperCase() });
|
|
25
51
|
|
|
52
|
+
// Handle array values (e.g., multiple UIDs)
|
|
26
53
|
if (Array.isArray(value)) {
|
|
27
54
|
value.forEach(entry => attributes.push({ type, value: (entry || '').toString() }));
|
|
28
55
|
} else {
|
|
@@ -30,7 +57,21 @@ let setOpt = (attributes, term, value, type) => {
|
|
|
30
57
|
}
|
|
31
58
|
};
|
|
32
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Processes date fields for IMAP search.
|
|
62
|
+
* Converts JavaScript dates to IMAP date format.
|
|
63
|
+
*
|
|
64
|
+
* @param {Array} attributes - Array to append the attribute to
|
|
65
|
+
* @param {string} term - The date search term (e.g., 'BEFORE', 'SINCE')
|
|
66
|
+
* @param {*} value - Date value to format
|
|
67
|
+
*/
|
|
33
68
|
let processDateField = (attributes, term, value) => {
|
|
69
|
+
if (['BEFORE', 'SENTBEFORE'].includes(term.toUpperCase()) && isDate(value) && value.toISOString().substring(11) !== '00:00:00.000Z') {
|
|
70
|
+
// Set to next day to include current day as well, othwerise BEFORE+AFTER
|
|
71
|
+
// searches for the same day but different time values do not match anything
|
|
72
|
+
value = new Date(value.getTime() + 24 * 3600 * 1000);
|
|
73
|
+
}
|
|
74
|
+
|
|
34
75
|
let date = formatDate(value);
|
|
35
76
|
if (!date) {
|
|
36
77
|
return;
|
|
@@ -39,35 +80,85 @@ let processDateField = (attributes, term, value) => {
|
|
|
39
80
|
setOpt(attributes, term, date);
|
|
40
81
|
};
|
|
41
82
|
|
|
83
|
+
// Pre-compiled regex for better performance
|
|
84
|
+
const UNICODE_PATTERN = /[^\x00-\x7F]/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Checks if a string contains Unicode characters.
|
|
88
|
+
* Used to determine if CHARSET UTF-8 needs to be specified.
|
|
89
|
+
*
|
|
90
|
+
* @param {*} str - String to check
|
|
91
|
+
* @returns {boolean} True if string contains non-ASCII characters
|
|
92
|
+
*/
|
|
42
93
|
let isUnicodeString = str => {
|
|
43
94
|
if (!str || typeof str !== 'string') {
|
|
44
95
|
return false;
|
|
45
96
|
}
|
|
46
97
|
|
|
47
|
-
|
|
98
|
+
// Regex test is ~3-5x faster than Buffer.byteLength
|
|
99
|
+
// Matches any character outside ASCII range (0x00-0x7F)
|
|
100
|
+
return UNICODE_PATTERN.test(str);
|
|
48
101
|
};
|
|
49
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Compiles a JavaScript object query into IMAP search command attributes.
|
|
105
|
+
* Supports standard IMAP search criteria and extensions like OBJECTID and Gmail extensions.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object} connection - IMAP connection object
|
|
108
|
+
* @param {Object} connection.capabilities - Set of server capabilities
|
|
109
|
+
* @param {Object} connection.enabled - Set of enabled extensions
|
|
110
|
+
* @param {Object} connection.mailbox - Current mailbox information
|
|
111
|
+
* @param {Set} connection.mailbox.flags - Available flags in the mailbox
|
|
112
|
+
* @param {Object} query - Search query object
|
|
113
|
+
* @returns {Array} Array of IMAP search attributes
|
|
114
|
+
* @throws {Error} When required server extensions are not available
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* // Simple search for unseen messages from a sender
|
|
118
|
+
* searchCompiler(connection, {
|
|
119
|
+
* unseen: true,
|
|
120
|
+
* from: 'sender@example.com'
|
|
121
|
+
* });
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* // Complex OR search with date range
|
|
125
|
+
* searchCompiler(connection, {
|
|
126
|
+
* or: [
|
|
127
|
+
* { from: 'alice@example.com' },
|
|
128
|
+
* { from: 'bob@example.com' }
|
|
129
|
+
* ],
|
|
130
|
+
* since: new Date('2024-01-01')
|
|
131
|
+
* });
|
|
132
|
+
*/
|
|
50
133
|
module.exports.searchCompiler = (connection, query) => {
|
|
51
134
|
const attributes = [];
|
|
52
135
|
|
|
136
|
+
// Track if we need to specify UTF-8 charset
|
|
53
137
|
let hasUnicode = false;
|
|
54
138
|
const mailbox = connection.mailbox;
|
|
55
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Recursively walks through the query object and builds IMAP attributes.
|
|
142
|
+
* @param {Object} params - Query parameters to process
|
|
143
|
+
*/
|
|
56
144
|
const walk = params => {
|
|
57
145
|
Object.keys(params || {}).forEach(term => {
|
|
58
146
|
switch (term.toUpperCase()) {
|
|
59
|
-
|
|
147
|
+
// Custom sequence range support (non-standard)
|
|
148
|
+
case 'SEQ':
|
|
60
149
|
{
|
|
61
150
|
let value = params[term];
|
|
62
151
|
if (typeof value === 'number') {
|
|
63
152
|
value = value.toString();
|
|
64
153
|
}
|
|
154
|
+
// Only accept valid sequence strings (no whitespace)
|
|
65
155
|
if (typeof value === 'string' && /^\S+$/.test(value)) {
|
|
66
156
|
attributes.push({ type: 'SEQUENCE', value });
|
|
67
157
|
}
|
|
68
158
|
}
|
|
69
159
|
break;
|
|
70
160
|
|
|
161
|
+
// Boolean flags that support UN- prefixing
|
|
71
162
|
case 'ANSWERED':
|
|
72
163
|
case 'DELETED':
|
|
73
164
|
case 'DRAFT':
|
|
@@ -82,6 +173,7 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
82
173
|
setBoolOpt(attributes, term, !!params[term]);
|
|
83
174
|
break;
|
|
84
175
|
|
|
176
|
+
// Simple boolean flags without UN- support
|
|
85
177
|
case 'ALL':
|
|
86
178
|
case 'NEW':
|
|
87
179
|
case 'OLD':
|
|
@@ -91,6 +183,7 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
91
183
|
}
|
|
92
184
|
break;
|
|
93
185
|
|
|
186
|
+
// Numeric comparisons
|
|
94
187
|
case 'LARGER':
|
|
95
188
|
case 'SMALLER':
|
|
96
189
|
case 'MODSEQ':
|
|
@@ -99,6 +192,7 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
99
192
|
}
|
|
100
193
|
break;
|
|
101
194
|
|
|
195
|
+
// Text search fields - check for Unicode
|
|
102
196
|
case 'BCC':
|
|
103
197
|
case 'BODY':
|
|
104
198
|
case 'CC':
|
|
@@ -114,28 +208,34 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
114
208
|
}
|
|
115
209
|
break;
|
|
116
210
|
|
|
211
|
+
// UID sequences
|
|
117
212
|
case 'UID':
|
|
118
213
|
if (params[term]) {
|
|
119
214
|
setOpt(attributes, term, params[term], 'SEQUENCE');
|
|
120
215
|
}
|
|
121
216
|
break;
|
|
122
217
|
|
|
218
|
+
// Email ID support (OBJECTID or Gmail extension)
|
|
123
219
|
case 'EMAILID':
|
|
124
220
|
if (connection.capabilities.has('OBJECTID')) {
|
|
125
221
|
setOpt(attributes, 'EMAILID', params[term]);
|
|
126
222
|
} else if (connection.capabilities.has('X-GM-EXT-1')) {
|
|
223
|
+
// Fallback to Gmail message ID
|
|
127
224
|
setOpt(attributes, 'X-GM-MSGID', params[term]);
|
|
128
225
|
}
|
|
129
226
|
break;
|
|
130
227
|
|
|
228
|
+
// Thread ID support (OBJECTID or Gmail extension)
|
|
131
229
|
case 'THREADID':
|
|
132
230
|
if (connection.capabilities.has('OBJECTID')) {
|
|
133
231
|
setOpt(attributes, 'THREADID', params[term]);
|
|
134
232
|
} else if (connection.capabilities.has('X-GM-EXT-1')) {
|
|
233
|
+
// Fallback to Gmail thread ID
|
|
135
234
|
setOpt(attributes, 'X-GM-THRID', params[term]);
|
|
136
235
|
}
|
|
137
236
|
break;
|
|
138
237
|
|
|
238
|
+
// Gmail raw search
|
|
139
239
|
case 'GMRAW':
|
|
140
240
|
case 'GMAILRAW': // alias for GMRAW
|
|
141
241
|
if (connection.capabilities.has('X-GM-EXT-1')) {
|
|
@@ -150,33 +250,65 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
150
250
|
}
|
|
151
251
|
break;
|
|
152
252
|
|
|
253
|
+
// Date searches with WITHIN extension support
|
|
153
254
|
case 'BEFORE':
|
|
154
|
-
case 'ON':
|
|
155
255
|
case 'SINCE':
|
|
256
|
+
{
|
|
257
|
+
// Use WITHIN extension for better timezone handling if available
|
|
258
|
+
if (connection.capabilities.has('WITHIN') && isDate(params[term])) {
|
|
259
|
+
// Convert to seconds ago from now
|
|
260
|
+
const now = Date.now();
|
|
261
|
+
const withinSeconds = Math.round(Math.max(0, now - params[term].getTime()) / 1000);
|
|
262
|
+
let withinKeyword;
|
|
263
|
+
switch (term.toUpperCase()) {
|
|
264
|
+
case 'BEFORE':
|
|
265
|
+
withinKeyword = 'OLDER';
|
|
266
|
+
break;
|
|
267
|
+
case 'SINCE':
|
|
268
|
+
withinKeyword = 'YOUNGER';
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
setOpt(attributes, withinKeyword, withinSeconds.toString());
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Fallback to standard date search
|
|
276
|
+
processDateField(attributes, term, params[term]);
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
|
|
280
|
+
// Standard date searches
|
|
281
|
+
case 'ON':
|
|
156
282
|
case 'SENTBEFORE':
|
|
157
283
|
case 'SENTON':
|
|
158
284
|
case 'SENTSINCE':
|
|
159
285
|
processDateField(attributes, term, params[term]);
|
|
160
286
|
break;
|
|
161
287
|
|
|
288
|
+
// Keyword/flag searches
|
|
162
289
|
case 'KEYWORD':
|
|
163
290
|
case 'UNKEYWORD':
|
|
164
291
|
{
|
|
165
292
|
let flag = formatFlag(params[term]);
|
|
293
|
+
// Only add if flag is supported or already exists in mailbox
|
|
166
294
|
if (canUseFlag(mailbox, flag) || mailbox.flags.has(flag)) {
|
|
167
295
|
setOpt(attributes, term, flag);
|
|
168
296
|
}
|
|
169
297
|
}
|
|
170
298
|
break;
|
|
171
299
|
|
|
300
|
+
// Header field searches
|
|
172
301
|
case 'HEADER':
|
|
173
302
|
if (params[term] && typeof params[term] === 'object') {
|
|
174
303
|
Object.keys(params[term]).forEach(header => {
|
|
175
304
|
let value = params[term][header];
|
|
305
|
+
|
|
306
|
+
// Allow boolean true to search for header existence
|
|
176
307
|
if (value === true) {
|
|
177
308
|
value = '';
|
|
178
309
|
}
|
|
179
310
|
|
|
311
|
+
// Skip non-string values (after true->'' conversion)
|
|
180
312
|
if (typeof value !== 'string') {
|
|
181
313
|
return;
|
|
182
314
|
}
|
|
@@ -190,25 +322,29 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
190
322
|
}
|
|
191
323
|
break;
|
|
192
324
|
|
|
325
|
+
// NOT operator
|
|
193
326
|
case 'NOT':
|
|
194
|
-
{
|
|
327
|
+
{
|
|
195
328
|
if (!params[term]) {
|
|
196
329
|
break;
|
|
197
330
|
}
|
|
198
331
|
|
|
199
332
|
if (typeof params[term] === 'object') {
|
|
200
333
|
attributes.push({ type: 'ATOM', value: 'NOT' });
|
|
334
|
+
// Recursively process NOT conditions
|
|
201
335
|
walk(params[term]);
|
|
202
336
|
}
|
|
203
337
|
}
|
|
204
338
|
break;
|
|
205
|
-
|
|
339
|
+
|
|
340
|
+
// OR operator - complex logic for building OR trees
|
|
206
341
|
case 'OR':
|
|
207
342
|
{
|
|
208
343
|
if (!params[term] || !Array.isArray(params[term]) || !params[term].length) {
|
|
209
344
|
break;
|
|
210
345
|
}
|
|
211
346
|
|
|
347
|
+
// Single element - just process it directly
|
|
212
348
|
if (params[term].length === 1) {
|
|
213
349
|
if (typeof params[term][0] === 'object' && params[term][0]) {
|
|
214
350
|
walk(params[term][0]);
|
|
@@ -216,12 +352,18 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
216
352
|
break;
|
|
217
353
|
}
|
|
218
354
|
|
|
219
|
-
|
|
220
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Generates a binary tree structure for OR operations.
|
|
357
|
+
* IMAP OR takes exactly 2 operands, so we need to nest them.
|
|
358
|
+
*
|
|
359
|
+
* @param {Array} list - List of conditions to OR together
|
|
360
|
+
* @returns {Array} Binary tree structure
|
|
361
|
+
*/
|
|
221
362
|
let genOrTree = list => {
|
|
222
363
|
let group = false;
|
|
223
364
|
let groups = [];
|
|
224
365
|
|
|
366
|
+
// Group items in pairs
|
|
225
367
|
list.forEach((entry, i) => {
|
|
226
368
|
if (i % 2 === 0) {
|
|
227
369
|
group = [entry];
|
|
@@ -232,6 +374,7 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
232
374
|
}
|
|
233
375
|
});
|
|
234
376
|
|
|
377
|
+
// Handle odd number of items
|
|
235
378
|
if (group && group.length) {
|
|
236
379
|
while (group.length === 1 && Array.isArray(group[0])) {
|
|
237
380
|
group = group[0];
|
|
@@ -240,10 +383,12 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
240
383
|
groups.push(group);
|
|
241
384
|
}
|
|
242
385
|
|
|
386
|
+
// Recursively group until we have a binary tree
|
|
243
387
|
while (groups.length > 2) {
|
|
244
388
|
groups = genOrTree(groups);
|
|
245
389
|
}
|
|
246
390
|
|
|
391
|
+
// Flatten single-element arrays
|
|
247
392
|
while (groups.length === 1 && Array.isArray(groups[0])) {
|
|
248
393
|
groups = groups[0];
|
|
249
394
|
}
|
|
@@ -251,8 +396,13 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
251
396
|
return groups;
|
|
252
397
|
};
|
|
253
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Walks the OR tree and generates IMAP commands.
|
|
401
|
+
* @param {Array|Object} entry - Tree node to process
|
|
402
|
+
*/
|
|
254
403
|
let walkOrTree = entry => {
|
|
255
404
|
if (Array.isArray(entry)) {
|
|
405
|
+
// Only add OR for multiple items
|
|
256
406
|
if (entry.length > 1) {
|
|
257
407
|
attributes.push({ type: 'ATOM', value: 'OR' });
|
|
258
408
|
}
|
|
@@ -263,6 +413,7 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
263
413
|
walk(entry);
|
|
264
414
|
}
|
|
265
415
|
};
|
|
416
|
+
|
|
266
417
|
walkOrTree(genOrTree(params[term]));
|
|
267
418
|
}
|
|
268
419
|
break;
|
|
@@ -270,10 +421,12 @@ module.exports.searchCompiler = (connection, query) => {
|
|
|
270
421
|
});
|
|
271
422
|
};
|
|
272
423
|
|
|
424
|
+
// Process the query
|
|
273
425
|
walk(query);
|
|
274
426
|
|
|
427
|
+
// If we encountered Unicode strings and UTF-8 is not already accepted,
|
|
428
|
+
// prepend CHARSET UTF-8 to the search command
|
|
275
429
|
if (hasUnicode && !connection.enabled.has('UTF8=ACCEPT')) {
|
|
276
|
-
// Prepend search query with `CHARSET UTF-8`
|
|
277
430
|
attributes.unshift({ type: 'ATOM', value: 'UTF-8' });
|
|
278
431
|
attributes.unshift({ type: 'ATOM', value: 'CHARSET' });
|
|
279
432
|
}
|
package/lib/tools.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imapflow",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.191",
|
|
4
4
|
"description": "IMAP Client for Node",
|
|
5
5
|
"main": "lib/imap-flow.js",
|
|
6
6
|
"types": "lib/imap-flow.d.ts",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
},
|
|
29
29
|
"homepage": "https://imapflow.com/",
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@babel/eslint-parser": "7.
|
|
31
|
+
"@babel/eslint-parser": "7.28.0",
|
|
32
32
|
"@babel/eslint-plugin": "7.27.1",
|
|
33
33
|
"@babel/plugin-syntax-class-properties": "7.12.13",
|
|
34
|
-
"@babel/preset-env": "7.
|
|
35
|
-
"@types/node": "24.0.
|
|
34
|
+
"@babel/preset-env": "7.28.0",
|
|
35
|
+
"@types/node": "24.0.12",
|
|
36
36
|
"eslint": "8.57.0",
|
|
37
37
|
"eslint-config-nodemailer": "1.2.0",
|
|
38
38
|
"eslint-config-prettier": "9.1.0",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"libmime": "5.3.7",
|
|
53
53
|
"libqp": "2.1.1",
|
|
54
54
|
"mailsplit": "5.4.5",
|
|
55
|
-
"nodemailer": "7.0.
|
|
55
|
+
"nodemailer": "7.0.5",
|
|
56
56
|
"pino": "9.7.0",
|
|
57
57
|
"socks": "2.8.5"
|
|
58
58
|
}
|