imapflow 1.0.189 → 1.0.190

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.190](https://github.com/postalsys/imapflow/compare/v1.0.189...v1.0.190) (2025-07-10)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **search:** Prefer WITHIN YOUNGER/OLDER instead of BEFORE/SINCE for searches if possible ([7492ac9](https://github.com/postalsys/imapflow/commit/7492ac9bd5598beadee7a72e3e3c63e25e9bc1f5))
9
+
3
10
  ## [1.0.189](https://github.com/postalsys/imapflow/compare/v1.0.188...v1.0.189) (2025-06-30)
4
11
 
5
12
 
@@ -1,12 +1,28 @@
1
- 'use strict';
1
+ /* eslint no-control-regex:0 */
2
2
 
3
- const { formatDate, formatFlag, canUseFlag } = require('./tools.js');
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,6 +57,14 @@ 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) => {
34
69
  let date = formatDate(value);
35
70
  if (!date) {
@@ -39,35 +74,85 @@ let processDateField = (attributes, term, value) => {
39
74
  setOpt(attributes, term, date);
40
75
  };
41
76
 
77
+ // Pre-compiled regex for better performance
78
+ const UNICODE_PATTERN = /[^\x00-\x7F]/;
79
+
80
+ /**
81
+ * Checks if a string contains Unicode characters.
82
+ * Used to determine if CHARSET UTF-8 needs to be specified.
83
+ *
84
+ * @param {*} str - String to check
85
+ * @returns {boolean} True if string contains non-ASCII characters
86
+ */
42
87
  let isUnicodeString = str => {
43
88
  if (!str || typeof str !== 'string') {
44
89
  return false;
45
90
  }
46
91
 
47
- return Buffer.byteLength(str) !== str.length;
92
+ // Regex test is ~3-5x faster than Buffer.byteLength
93
+ // Matches any character outside ASCII range (0x00-0x7F)
94
+ return UNICODE_PATTERN.test(str);
48
95
  };
49
96
 
97
+ /**
98
+ * Compiles a JavaScript object query into IMAP search command attributes.
99
+ * Supports standard IMAP search criteria and extensions like OBJECTID and Gmail extensions.
100
+ *
101
+ * @param {Object} connection - IMAP connection object
102
+ * @param {Object} connection.capabilities - Set of server capabilities
103
+ * @param {Object} connection.enabled - Set of enabled extensions
104
+ * @param {Object} connection.mailbox - Current mailbox information
105
+ * @param {Set} connection.mailbox.flags - Available flags in the mailbox
106
+ * @param {Object} query - Search query object
107
+ * @returns {Array} Array of IMAP search attributes
108
+ * @throws {Error} When required server extensions are not available
109
+ *
110
+ * @example
111
+ * // Simple search for unseen messages from a sender
112
+ * searchCompiler(connection, {
113
+ * unseen: true,
114
+ * from: 'sender@example.com'
115
+ * });
116
+ *
117
+ * @example
118
+ * // Complex OR search with date range
119
+ * searchCompiler(connection, {
120
+ * or: [
121
+ * { from: 'alice@example.com' },
122
+ * { from: 'bob@example.com' }
123
+ * ],
124
+ * since: new Date('2024-01-01')
125
+ * });
126
+ */
50
127
  module.exports.searchCompiler = (connection, query) => {
51
128
  const attributes = [];
52
129
 
130
+ // Track if we need to specify UTF-8 charset
53
131
  let hasUnicode = false;
54
132
  const mailbox = connection.mailbox;
55
133
 
134
+ /**
135
+ * Recursively walks through the query object and builds IMAP attributes.
136
+ * @param {Object} params - Query parameters to process
137
+ */
56
138
  const walk = params => {
57
139
  Object.keys(params || {}).forEach(term => {
58
140
  switch (term.toUpperCase()) {
59
- case 'SEQ': // custom key for sequence range
141
+ // Custom sequence range support (non-standard)
142
+ case 'SEQ':
60
143
  {
61
144
  let value = params[term];
62
145
  if (typeof value === 'number') {
63
146
  value = value.toString();
64
147
  }
148
+ // Only accept valid sequence strings (no whitespace)
65
149
  if (typeof value === 'string' && /^\S+$/.test(value)) {
66
150
  attributes.push({ type: 'SEQUENCE', value });
67
151
  }
68
152
  }
69
153
  break;
70
154
 
155
+ // Boolean flags that support UN- prefixing
71
156
  case 'ANSWERED':
72
157
  case 'DELETED':
73
158
  case 'DRAFT':
@@ -82,6 +167,7 @@ module.exports.searchCompiler = (connection, query) => {
82
167
  setBoolOpt(attributes, term, !!params[term]);
83
168
  break;
84
169
 
170
+ // Simple boolean flags without UN- support
85
171
  case 'ALL':
86
172
  case 'NEW':
87
173
  case 'OLD':
@@ -91,6 +177,7 @@ module.exports.searchCompiler = (connection, query) => {
91
177
  }
92
178
  break;
93
179
 
180
+ // Numeric comparisons
94
181
  case 'LARGER':
95
182
  case 'SMALLER':
96
183
  case 'MODSEQ':
@@ -99,6 +186,7 @@ module.exports.searchCompiler = (connection, query) => {
99
186
  }
100
187
  break;
101
188
 
189
+ // Text search fields - check for Unicode
102
190
  case 'BCC':
103
191
  case 'BODY':
104
192
  case 'CC':
@@ -114,28 +202,34 @@ module.exports.searchCompiler = (connection, query) => {
114
202
  }
115
203
  break;
116
204
 
205
+ // UID sequences
117
206
  case 'UID':
118
207
  if (params[term]) {
119
208
  setOpt(attributes, term, params[term], 'SEQUENCE');
120
209
  }
121
210
  break;
122
211
 
212
+ // Email ID support (OBJECTID or Gmail extension)
123
213
  case 'EMAILID':
124
214
  if (connection.capabilities.has('OBJECTID')) {
125
215
  setOpt(attributes, 'EMAILID', params[term]);
126
216
  } else if (connection.capabilities.has('X-GM-EXT-1')) {
217
+ // Fallback to Gmail message ID
127
218
  setOpt(attributes, 'X-GM-MSGID', params[term]);
128
219
  }
129
220
  break;
130
221
 
222
+ // Thread ID support (OBJECTID or Gmail extension)
131
223
  case 'THREADID':
132
224
  if (connection.capabilities.has('OBJECTID')) {
133
225
  setOpt(attributes, 'THREADID', params[term]);
134
226
  } else if (connection.capabilities.has('X-GM-EXT-1')) {
227
+ // Fallback to Gmail thread ID
135
228
  setOpt(attributes, 'X-GM-THRID', params[term]);
136
229
  }
137
230
  break;
138
231
 
232
+ // Gmail raw search
139
233
  case 'GMRAW':
140
234
  case 'GMAILRAW': // alias for GMRAW
141
235
  if (connection.capabilities.has('X-GM-EXT-1')) {
@@ -150,33 +244,65 @@ module.exports.searchCompiler = (connection, query) => {
150
244
  }
151
245
  break;
152
246
 
247
+ // Date searches with WITHIN extension support
153
248
  case 'BEFORE':
154
- case 'ON':
155
249
  case 'SINCE':
250
+ {
251
+ // Use WITHIN extension for better timezone handling if available
252
+ if (connection.capabilities.has('WITHIN') && isDate(params[term])) {
253
+ // Convert to seconds ago from now
254
+ const now = Date.now();
255
+ const withinSeconds = Math.round(Math.max(0, now - params[term].getTime()) / 1000);
256
+ let withinKeyword;
257
+ switch (term.toUpperCase()) {
258
+ case 'BEFORE':
259
+ withinKeyword = 'OLDER';
260
+ break;
261
+ case 'SINCE':
262
+ withinKeyword = 'YOUNGER';
263
+ break;
264
+ }
265
+ setOpt(attributes, withinKeyword, withinSeconds.toString());
266
+ break;
267
+ }
268
+
269
+ // Fallback to standard date search
270
+ processDateField(attributes, term, params[term]);
271
+ }
272
+ break;
273
+
274
+ // Standard date searches
275
+ case 'ON':
156
276
  case 'SENTBEFORE':
157
277
  case 'SENTON':
158
278
  case 'SENTSINCE':
159
279
  processDateField(attributes, term, params[term]);
160
280
  break;
161
281
 
282
+ // Keyword/flag searches
162
283
  case 'KEYWORD':
163
284
  case 'UNKEYWORD':
164
285
  {
165
286
  let flag = formatFlag(params[term]);
287
+ // Only add if flag is supported or already exists in mailbox
166
288
  if (canUseFlag(mailbox, flag) || mailbox.flags.has(flag)) {
167
289
  setOpt(attributes, term, flag);
168
290
  }
169
291
  }
170
292
  break;
171
293
 
294
+ // Header field searches
172
295
  case 'HEADER':
173
296
  if (params[term] && typeof params[term] === 'object') {
174
297
  Object.keys(params[term]).forEach(header => {
175
298
  let value = params[term][header];
299
+
300
+ // Allow boolean true to search for header existence
176
301
  if (value === true) {
177
302
  value = '';
178
303
  }
179
304
 
305
+ // Skip non-string values (after true->'' conversion)
180
306
  if (typeof value !== 'string') {
181
307
  return;
182
308
  }
@@ -190,25 +316,29 @@ module.exports.searchCompiler = (connection, query) => {
190
316
  }
191
317
  break;
192
318
 
319
+ // NOT operator
193
320
  case 'NOT':
194
- {
321
+ {
195
322
  if (!params[term]) {
196
323
  break;
197
324
  }
198
325
 
199
326
  if (typeof params[term] === 'object') {
200
327
  attributes.push({ type: 'ATOM', value: 'NOT' });
328
+ // Recursively process NOT conditions
201
329
  walk(params[term]);
202
330
  }
203
331
  }
204
332
  break;
205
-
333
+
334
+ // OR operator - complex logic for building OR trees
206
335
  case 'OR':
207
336
  {
208
337
  if (!params[term] || !Array.isArray(params[term]) || !params[term].length) {
209
338
  break;
210
339
  }
211
340
 
341
+ // Single element - just process it directly
212
342
  if (params[term].length === 1) {
213
343
  if (typeof params[term][0] === 'object' && params[term][0]) {
214
344
  walk(params[term][0]);
@@ -216,12 +346,18 @@ module.exports.searchCompiler = (connection, query) => {
216
346
  break;
217
347
  }
218
348
 
219
- // OR values has to be grouped by 2
220
- // OR conditional1 conditional2
349
+ /**
350
+ * Generates a binary tree structure for OR operations.
351
+ * IMAP OR takes exactly 2 operands, so we need to nest them.
352
+ *
353
+ * @param {Array} list - List of conditions to OR together
354
+ * @returns {Array} Binary tree structure
355
+ */
221
356
  let genOrTree = list => {
222
357
  let group = false;
223
358
  let groups = [];
224
359
 
360
+ // Group items in pairs
225
361
  list.forEach((entry, i) => {
226
362
  if (i % 2 === 0) {
227
363
  group = [entry];
@@ -232,6 +368,7 @@ module.exports.searchCompiler = (connection, query) => {
232
368
  }
233
369
  });
234
370
 
371
+ // Handle odd number of items
235
372
  if (group && group.length) {
236
373
  while (group.length === 1 && Array.isArray(group[0])) {
237
374
  group = group[0];
@@ -240,10 +377,12 @@ module.exports.searchCompiler = (connection, query) => {
240
377
  groups.push(group);
241
378
  }
242
379
 
380
+ // Recursively group until we have a binary tree
243
381
  while (groups.length > 2) {
244
382
  groups = genOrTree(groups);
245
383
  }
246
384
 
385
+ // Flatten single-element arrays
247
386
  while (groups.length === 1 && Array.isArray(groups[0])) {
248
387
  groups = groups[0];
249
388
  }
@@ -251,8 +390,13 @@ module.exports.searchCompiler = (connection, query) => {
251
390
  return groups;
252
391
  };
253
392
 
393
+ /**
394
+ * Walks the OR tree and generates IMAP commands.
395
+ * @param {Array|Object} entry - Tree node to process
396
+ */
254
397
  let walkOrTree = entry => {
255
398
  if (Array.isArray(entry)) {
399
+ // Only add OR for multiple items
256
400
  if (entry.length > 1) {
257
401
  attributes.push({ type: 'ATOM', value: 'OR' });
258
402
  }
@@ -263,6 +407,7 @@ module.exports.searchCompiler = (connection, query) => {
263
407
  walk(entry);
264
408
  }
265
409
  };
410
+
266
411
  walkOrTree(genOrTree(params[term]));
267
412
  }
268
413
  break;
@@ -270,10 +415,12 @@ module.exports.searchCompiler = (connection, query) => {
270
415
  });
271
416
  };
272
417
 
418
+ // Process the query
273
419
  walk(query);
274
420
 
421
+ // If we encountered Unicode strings and UTF-8 is not already accepted,
422
+ // prepend CHARSET UTF-8 to the search command
275
423
  if (hasUnicode && !connection.enabled.has('UTF8=ACCEPT')) {
276
- // Prepend search query with `CHARSET UTF-8`
277
424
  attributes.unshift({ type: 'ATOM', value: 'UTF-8' });
278
425
  attributes.unshift({ type: 'ATOM', value: 'CHARSET' });
279
426
  }
package/lib/tools.js CHANGED
@@ -754,6 +754,10 @@ const tools = {
754
754
  return walk(entry);
755
755
  },
756
756
 
757
+ isDate(obj) {
758
+ return Object.prototype.toString.call(obj) === '[object Date]';
759
+ },
760
+
757
761
  formatDate(value) {
758
762
  if (typeof value === 'string') {
759
763
  value = new Date(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imapflow",
3
- "version": "1.0.189",
3
+ "version": "1.0.190",
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.27.5",
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.27.2",
35
- "@types/node": "24.0.7",
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.4",
55
+ "nodemailer": "7.0.5",
56
56
  "pino": "9.7.0",
57
57
  "socks": "2.8.5"
58
58
  }