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.
Files changed (114) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +608 -2
  3. package/bin/help.txt +98 -0
  4. package/bin/imapkit.js +108 -0
  5. package/cert/server.crt +20 -0
  6. package/cert/server.key +28 -0
  7. package/lib/addressparser.js +283 -0
  8. package/lib/arguments.js +112 -0
  9. package/lib/bodystructure.js +149 -0
  10. package/lib/command-states.js +109 -0
  11. package/lib/commands/append.js +313 -0
  12. package/lib/commands/capability.js +47 -0
  13. package/lib/commands/check.js +21 -0
  14. package/lib/commands/close.js +30 -0
  15. package/lib/commands/copy.js +115 -0
  16. package/lib/commands/create.js +52 -0
  17. package/lib/commands/delete.js +64 -0
  18. package/lib/commands/examine.js +7 -0
  19. package/lib/commands/expunge.js +27 -0
  20. package/lib/commands/fetch.js +229 -0
  21. package/lib/commands/handlers/fetch.js +209 -0
  22. package/lib/commands/handlers/flags.js +42 -0
  23. package/lib/commands/handlers/search.js +519 -0
  24. package/lib/commands/handlers/status.js +85 -0
  25. package/lib/commands/handlers/store.js +127 -0
  26. package/lib/commands/list.js +100 -0
  27. package/lib/commands/login.js +67 -0
  28. package/lib/commands/logout.js +41 -0
  29. package/lib/commands/lsub.js +87 -0
  30. package/lib/commands/noop.js +21 -0
  31. package/lib/commands/rename.js +102 -0
  32. package/lib/commands/search.js +76 -0
  33. package/lib/commands/select.js +289 -0
  34. package/lib/commands/status.js +63 -0
  35. package/lib/commands/store.js +151 -0
  36. package/lib/commands/subscribe.js +53 -0
  37. package/lib/commands/uid copy.js +7 -0
  38. package/lib/commands/uid fetch.js +5 -0
  39. package/lib/commands/uid search.js +5 -0
  40. package/lib/commands/uid store.js +5 -0
  41. package/lib/commands/unsubscribe.js +50 -0
  42. package/lib/dates.js +123 -0
  43. package/lib/deflate-layer.js +232 -0
  44. package/lib/envelope.js +82 -0
  45. package/lib/esearch.js +208 -0
  46. package/lib/framing.js +102 -0
  47. package/lib/list-extensions.js +36 -0
  48. package/lib/load-plugins.js +109 -0
  49. package/lib/mailbox-name.js +133 -0
  50. package/lib/mimeparser.js +778 -0
  51. package/lib/mock-client.js +233 -0
  52. package/lib/numbers.js +52 -0
  53. package/lib/plugins/acl.js +964 -0
  54. package/lib/plugins/appendlimit.js +83 -0
  55. package/lib/plugins/auth-plain.js +94 -0
  56. package/lib/plugins/binary.js +256 -0
  57. package/lib/plugins/catenate.js +253 -0
  58. package/lib/plugins/compress.js +76 -0
  59. package/lib/plugins/condstore.js +563 -0
  60. package/lib/plugins/context-search.js +321 -0
  61. package/lib/plugins/context-sort.js +19 -0
  62. package/lib/plugins/create-special-use.js +108 -0
  63. package/lib/plugins/enable.js +155 -0
  64. package/lib/plugins/esearch.js +156 -0
  65. package/lib/plugins/esort.js +60 -0
  66. package/lib/plugins/id.js +138 -0
  67. package/lib/plugins/idle.js +105 -0
  68. package/lib/plugins/imap4rev2.js +202 -0
  69. package/lib/plugins/list-extended.js +258 -0
  70. package/lib/plugins/list-status.js +31 -0
  71. package/lib/plugins/literalminus.js +20 -0
  72. package/lib/plugins/literalplus.js +18 -0
  73. package/lib/plugins/logindisabled.js +50 -0
  74. package/lib/plugins/messagelimit.js +234 -0
  75. package/lib/plugins/metadata-server.js +13 -0
  76. package/lib/plugins/metadata.js +475 -0
  77. package/lib/plugins/move.js +110 -0
  78. package/lib/plugins/multiappend.js +26 -0
  79. package/lib/plugins/multisearch.js +269 -0
  80. package/lib/plugins/namespace.js +67 -0
  81. package/lib/plugins/notify.js +654 -0
  82. package/lib/plugins/oauthbearer.js +217 -0
  83. package/lib/plugins/objectid.js +243 -0
  84. package/lib/plugins/partial.js +68 -0
  85. package/lib/plugins/preview.js +400 -0
  86. package/lib/plugins/qresync.js +525 -0
  87. package/lib/plugins/quota.js +285 -0
  88. package/lib/plugins/replace.js +145 -0
  89. package/lib/plugins/sasl-ir.js +12 -0
  90. package/lib/plugins/savedate.js +59 -0
  91. package/lib/plugins/savelimit.js +18 -0
  92. package/lib/plugins/searchres.js +82 -0
  93. package/lib/plugins/sort-display.js +23 -0
  94. package/lib/plugins/sort.js +132 -0
  95. package/lib/plugins/special-use.js +95 -0
  96. package/lib/plugins/starttls.js +57 -0
  97. package/lib/plugins/status-size.js +19 -0
  98. package/lib/plugins/thread-orderedsubject.js +16 -0
  99. package/lib/plugins/thread-references.js +16 -0
  100. package/lib/plugins/uidonly.js +135 -0
  101. package/lib/plugins/uidplus.js +124 -0
  102. package/lib/plugins/unauthenticate.js +28 -0
  103. package/lib/plugins/unselect.js +36 -0
  104. package/lib/plugins/utf8-accept.js +68 -0
  105. package/lib/plugins/x-gm-ext-1.js +456 -0
  106. package/lib/plugins/xoauth2.js +188 -0
  107. package/lib/plugins/xtoybird.js +282 -0
  108. package/lib/server.js +2880 -0
  109. package/lib/smtp-listener.js +51 -0
  110. package/lib/sorting.js +373 -0
  111. package/lib/threading.js +357 -0
  112. package/lib/utf8-session.js +123 -0
  113. package/lib/vanished.js +57 -0
  114. package/package.json +61 -5
package/lib/dates.js ADDED
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ // Month names of the RFC 3501 section 9 date-month rule, in the case they are sent in
4
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
5
+
6
+ /**
7
+ * Returns the index of a month name, ignoring case like all ABNF strings
8
+ *
9
+ * @param {String} name Three letter month name, e.g. "jan"
10
+ * @return {Number} 0 for January to 11 for December, or -1 for an unknown name
11
+ */
12
+ function monthIndex(name) {
13
+ const value = String(name || '').toLowerCase();
14
+ return MONTHS.findIndex(month => month.toLowerCase() === value);
15
+ }
16
+
17
+ /**
18
+ * Checks if day, month and year make a real date
19
+ *
20
+ * @param {Number|String} day Day of the month
21
+ * @param {Number} month Month index, 0 for January
22
+ * @param {Number|String} year Full year
23
+ * @return {Boolean} true if the date exists
24
+ */
25
+ function isRealDate(day, month, year) {
26
+ day = Number(day);
27
+ year = Number(year);
28
+ if (month < 0 || month > 11 || !Number.isInteger(day) || day < 1) {
29
+ return false;
30
+ }
31
+ return day <= new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
32
+ }
33
+
34
+ /**
35
+ * Converts day, month and year to a comparable YYYY-MM-DD string
36
+ *
37
+ * @param {Number|String} day Day of the month
38
+ * @param {Number} month Month index, 0 for January
39
+ * @param {Number|String} year Full year
40
+ * @return {String|Boolean} the date, or false for an impossible date
41
+ */
42
+ function dateKey(day, month, year) {
43
+ if (!isRealDate(day, month, year)) {
44
+ return false;
45
+ }
46
+ return String(year).padStart(4, '0') + '-' + String(month + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0');
47
+ }
48
+
49
+ /**
50
+ * Parses a date-time value of the RFC 3501 section 9 form, like an INTERNALDATE ("14-Sep-2013 21:22:28 -0300").
51
+ * A value with only the date part is accepted as well, its time fields are left undefined
52
+ *
53
+ * @param {String} value Date-time value
54
+ * @return {Object|null} `{ day, month, year, hours, minutes, seconds, zone }` with the month index, or null
55
+ */
56
+ function parseDateTime(value) {
57
+ const match = (value || '').toString().match(/^\s*(\d{1,2})-([A-Za-z]{3})-(\d{4})(?: (\d{2}):(\d{2}):(\d{2}) ([+-]\d{4}))?/);
58
+ if (!match || !isRealDate(match[1], monthIndex(match[2]), match[3])) {
59
+ return null;
60
+ }
61
+ const time = match[4] === undefined ? {} : { hours: Number(match[4]), minutes: Number(match[5]), seconds: Number(match[6]), zone: match[7] };
62
+ return Object.assign({ day: Number(match[1]), month: monthIndex(match[2]), year: Number(match[3]) }, time);
63
+ }
64
+
65
+ /**
66
+ * Parses the date-time of a Date header (RFC 5322 section 3.3, with the obsolete forms of section 4.3: comments,
67
+ * two and three digit years, zone names). SEARCH uses only the date as written (RFC 3501 section 6.4.4), SORT
68
+ * normalizes date and time to UTC with toTimestamp (RFC 5256 section 2.2), so both read the header with this
69
+ *
70
+ * @param {String} header Value of the Date header
71
+ * @return {Object|null} `{ day, month, year, hours, minutes, seconds, zone }` with the month index, or null if the
72
+ * header has no valid date. The time fields are undefined when the header has no time
73
+ */
74
+ function parseHeaderDate(header) {
75
+ const match = (header || '')
76
+ .toString()
77
+ .replace(/\([^()]*\)/g, ' ')
78
+ .match(/^\s*(?:[A-Za-z]+\s*,)?\s*(\d{1,2})\s+([A-Za-z]{3})\s+(\d{2,4})(?:\s+(\d{1,2})\s*:\s*(\d{2})(?:\s*:\s*(\d{2}))?(?:\s+([+-]\d{4}|[A-Za-z]+))?)?/);
79
+ if (!match) {
80
+ return null;
81
+ }
82
+ let year = Number(match[3]);
83
+ // RFC 5322 section 4.3: two digit years below 50 are 20xx, three digit years add 1900
84
+ if (match[3].length === 2) {
85
+ year += year < 50 ? 2000 : 1900;
86
+ } else if (match[3].length === 3) {
87
+ year += 1900;
88
+ }
89
+ const day = Number(match[1]);
90
+ const month = monthIndex(match[2]);
91
+ if (!isRealDate(day, month, year)) {
92
+ return null;
93
+ }
94
+ const time = match[4] === undefined ? {} : { hours: Number(match[4]), minutes: Number(match[5]), seconds: Number(match[6] || 0), zone: match[7] };
95
+ return Object.assign({ day, month, year }, time);
96
+ }
97
+
98
+ // RFC 5322 section 4.3 obsolete zones, military zones are treated as "-0000"
99
+ const ZONES = { UT: 0, GMT: 0, EST: -5, EDT: -4, CST: -6, CDT: -5, MST: -7, MDT: -6, PST: -8, PDT: -7 };
100
+
101
+ /**
102
+ * Converts date and time parts to milliseconds since the epoch, adjusted by the time zone. An unknown zone is UTC
103
+ *
104
+ * @param {Object} date `{ day, month, year, hours, minutes, seconds, zone }`, see parseDateTime and parseHeaderDate
105
+ * @return {Number} timestamp
106
+ */
107
+ function toTimestamp(date) {
108
+ const zone = date.zone || '';
109
+ let offset = 0;
110
+ if (/^[+-]\d{4}$/.test(zone)) {
111
+ const sign = zone.charAt(0) === '-' ? -1 : 1;
112
+ const zoneHours = Number(zone.substr(1, 2));
113
+ const zoneMinutes = Number(zone.substr(3, 2));
114
+ if (zoneMinutes < 60) {
115
+ offset = sign * (zoneHours * 60 + zoneMinutes);
116
+ }
117
+ } else if (Object.hasOwn(ZONES, zone.toUpperCase())) {
118
+ offset = ZONES[zone.toUpperCase()] * 60;
119
+ }
120
+ return Date.UTC(date.year, date.month, date.day, date.hours || 0, date.minutes || 0, date.seconds || 0) - offset * 60 * 1000;
121
+ }
122
+
123
+ module.exports = { MONTHS, monthIndex, isRealDate, dateKey, parseDateTime, parseHeaderDate, toTimestamp };
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ const zlib = require('zlib');
4
+
5
+ // Z_SYNC_FLUSH ends with an empty stored block, LEN 0 and NLEN 0xFFFF (RFC 1951 section 3.2.4)
6
+ const SYNC_MARKER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
7
+ // Z_FINISH right after a flush is an empty final block with fixed Huffman codes
8
+ const EMPTY_FINAL_BLOCK = Buffer.from([0x03, 0x00]);
9
+
10
+ /**
11
+ * One end of a COMPRESS=DEFLATE layer (RFC 4978): raw DEFLATE (RFC 1951, no zlib header or
12
+ * checksum) in both directions. The server plugin uses it, and so do the clients of the tests and
13
+ * the compare tool.
14
+ *
15
+ * Either direction can be terminated, which UNAUTHENTICATE requires (RFC 8437 section 4.1). Output
16
+ * terminates with a final DEFLATE block, data written after that goes out uncompressed once the
17
+ * compressed data is out. Input terminates where the peer ended its compression: at the end of
18
+ * its DEFLATE stream if it sent a final block, otherwise at the end of the sync flush that
19
+ * carried the last compressed data. The input is split at sync flush markers to find that point.
20
+ */
21
+ class DeflateLayer {
22
+ /**
23
+ * @param {Object} options
24
+ * @param {Function} options.writeRaw `(buffer)` sends octets to the peer
25
+ * @param {Function} options.onData `(buffer)` gets the input, decompressed while compression is active
26
+ * @param {Function} [options.onError] `(err)` called when the input is not valid DEFLATE data
27
+ */
28
+ constructor(options) {
29
+ this.writeRaw = options.writeRaw;
30
+ this.onData = options.onData;
31
+ this.onError = options.onError || (() => false);
32
+
33
+ // outgoing data is compressed
34
+ this.active = true;
35
+ // incoming data is compressed
36
+ this.inputActive = true;
37
+
38
+ this._deflate = zlib.createDeflateRaw();
39
+ this._deflate.on('data', chunk => this.writeRaw(chunk));
40
+ this._deflate.on('error', err => this.onError(err));
41
+ // writes that wait for the final compressed data, null when nothing is waiting
42
+ this._held = null;
43
+ this._endCallbacks = [];
44
+ this._flushScheduled = false;
45
+
46
+ this._inflate = zlib.createInflateRaw({ flush: zlib.constants.Z_SYNC_FLUSH });
47
+ this._inflate.on('data', chunk => {
48
+ // anything the peer compressed after it ended its compression is dropped
49
+ if (this.inputActive) {
50
+ this.onData(chunk);
51
+ }
52
+ });
53
+ this._inflate.on('error', err => {
54
+ this.inputActive = false;
55
+ this._pieces = [];
56
+ this._busy = false;
57
+ this.onError(err);
58
+ this._next();
59
+ });
60
+ // input waiting for the inflater, split at sync flush markers
61
+ this._pieces = [];
62
+ this._busy = false;
63
+ // octets given to the inflater
64
+ this._fed = 0;
65
+ // the peer may still send the empty final block of its DEFLATE stream
66
+ this._expectFinal = false;
67
+ // called once the received data is processed
68
+ this._idleCallbacks = [];
69
+ }
70
+
71
+ /**
72
+ * Sends data to the peer, compressed while compression is active
73
+ *
74
+ * @param {Buffer} data Data to send
75
+ */
76
+ write(data) {
77
+ if (this._held) {
78
+ this._held.push(data);
79
+ return;
80
+ }
81
+ if (!this.active) {
82
+ this.writeRaw(data);
83
+ return;
84
+ }
85
+ this._deflate.write(data);
86
+ if (!this._flushScheduled) {
87
+ // everything written in one go, like a burst of responses, is flushed together, so the peer
88
+ // gets it right away instead of when the compressor buffer fills up (RFC 4978 section 4)
89
+ this._flushScheduled = true;
90
+ process.nextTick(() => {
91
+ this._flushScheduled = false;
92
+ if (this.active && !this._deflate.destroyed) {
93
+ this._deflate.flush(zlib.constants.Z_SYNC_FLUSH);
94
+ }
95
+ });
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Terminates the outgoing compression with a final DEFLATE block. Later writes are sent
101
+ * uncompressed, after the compressed data.
102
+ *
103
+ * @param {Function} [callback] Called once the compressed data is written out
104
+ */
105
+ end(callback) {
106
+ if (callback) {
107
+ this._endCallbacks.push(callback);
108
+ }
109
+ if (!this.active) {
110
+ if (!this._held) {
111
+ this._runCallbacks('_endCallbacks');
112
+ }
113
+ return;
114
+ }
115
+ this.active = false;
116
+ this._held = [];
117
+ this._deflate.once('end', () => {
118
+ const held = this._held;
119
+ this._held = null;
120
+ held.forEach(data => this.writeRaw(data));
121
+ this._runCallbacks('_endCallbacks');
122
+ });
123
+ this._deflate.end();
124
+ }
125
+
126
+ // calls the callbacks of a list once, callbacks added meanwhile wait for the next run
127
+ _runCallbacks(key) {
128
+ const callbacks = this[key];
129
+ this[key] = [];
130
+ callbacks.forEach(callback => callback());
131
+ }
132
+
133
+ /**
134
+ * Handles octets received from the peer
135
+ *
136
+ * @param {Buffer} chunk Received data
137
+ */
138
+ receive(chunk) {
139
+ if (!this.inputActive && !this._busy && !this._pieces.length) {
140
+ this._receivePlain(chunk);
141
+ return;
142
+ }
143
+ let start = 0;
144
+ let pos;
145
+ while ((pos = chunk.indexOf(SYNC_MARKER, start)) >= 0) {
146
+ this._pieces.push(chunk.subarray(start, pos + SYNC_MARKER.length));
147
+ start = pos + SYNC_MARKER.length;
148
+ }
149
+ if (start < chunk.length) {
150
+ this._pieces.push(chunk.subarray(start));
151
+ }
152
+ this._next();
153
+ }
154
+
155
+ /**
156
+ * Tells the layer that the peer terminates its compression after the data decompressed so far
157
+ * (RFC 8437 section 4.1). Call it from onData, the rest of the input is then passed on as is.
158
+ */
159
+ endInput() {
160
+ this.inputActive = false;
161
+ }
162
+
163
+ /**
164
+ * Calls back once all received data is passed on
165
+ *
166
+ * @param {Function} callback Function to call
167
+ */
168
+ whenIdle(callback) {
169
+ this._idleCallbacks.push(callback);
170
+ this._next();
171
+ }
172
+
173
+ _next() {
174
+ if (this._busy) {
175
+ return;
176
+ }
177
+ if (!this._pieces.length) {
178
+ this._runCallbacks('_idleCallbacks');
179
+ return;
180
+ }
181
+ if (!this.inputActive) {
182
+ const rest = Buffer.concat(this._pieces);
183
+ this._pieces = [];
184
+ this._receivePlain(rest);
185
+ this._next();
186
+ return;
187
+ }
188
+
189
+ const piece = this._pieces.shift();
190
+ this._busy = true;
191
+ this._fed += piece.length;
192
+ this._inflate.write(piece, () => {
193
+ this._busy = false;
194
+ // octets the inflater did not use follow the end of the peer's DEFLATE stream
195
+ const unused = this._fed - this._inflate.bytesWritten;
196
+ if (unused > 0 || this._inflate.readableEnded) {
197
+ this.inputActive = false;
198
+ if (unused > 0) {
199
+ this._pieces.unshift(piece.subarray(piece.length - unused));
200
+ }
201
+ } else if (!this.inputActive) {
202
+ // the input ended at a sync flush, the final block may still follow
203
+ this._expectFinal = true;
204
+ }
205
+ this._next();
206
+ });
207
+ }
208
+
209
+ _receivePlain(chunk) {
210
+ if (this._expectFinal) {
211
+ this._expectFinal = false;
212
+ if (chunk.subarray(0, EMPTY_FINAL_BLOCK.length).equals(EMPTY_FINAL_BLOCK)) {
213
+ chunk = chunk.subarray(EMPTY_FINAL_BLOCK.length);
214
+ }
215
+ }
216
+ if (chunk.length) {
217
+ this.onData(chunk);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Frees the compressor and the decompressor
223
+ */
224
+ destroy() {
225
+ this.active = this.inputActive = false;
226
+ this._pieces = [];
227
+ this._deflate.destroy();
228
+ this._inflate.destroy();
229
+ }
230
+ }
231
+
232
+ module.exports = DeflateLayer;
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ // This module converts message structure into an ENVELOPE object
4
+
5
+ /**
6
+ * Convert a message object to an ENVELOPE object
7
+ *
8
+ * @param {Object} header Parsed header of a mime tree node
9
+ * @return {Object} ENVELOPE compatible object
10
+ */
11
+ module.exports = function (header) {
12
+ // RFC 3501 9: env-date and env-subject are nstring, NIL when the header is missing
13
+ const subject = header.subject;
14
+ return [
15
+ header.date || null,
16
+ typeof subject === 'string' ? subject : null,
17
+ processAddress(header.from),
18
+ processAddress(header.sender, header.from),
19
+ processAddress(header['reply-to'], header.from),
20
+ processAddress(header.to),
21
+ processAddress(header.cc),
22
+ processAddress(header.bcc),
23
+ // If this is an embedded MESSAGE/RFC822, then Gmail seems to
24
+ // have a bug here, it states '"NIL"' as the value, not 'NIL'
25
+ header['in-reply-to'] || null,
26
+ header['message-id'] || null
27
+ ];
28
+ };
29
+
30
+ /**
31
+ * Converts an address object to a list of arrays
32
+ * [{name: "User Name", addres:"user@example.com"}] -> [["User Name", null, "user", "example.com"]]
33
+ *
34
+ * @param {Array} arr An array of address objects
35
+ * @return {Array} A list of addresses
36
+ */
37
+ function processAddress(arr, def) {
38
+ arr = [].concat(arr || []);
39
+ if (!arr.length) {
40
+ arr = [].concat(def || []);
41
+ }
42
+ if (!arr.length) {
43
+ return null;
44
+ }
45
+ let result = [];
46
+ arr.forEach(addr => {
47
+ if (addr.group) {
48
+ // Handle group syntax
49
+ result.push([null, null, addr.name || '', null]);
50
+ result = result.concat(processAddress(addr.group) || []);
51
+ result.push([null, null, null, null]);
52
+ return;
53
+ }
54
+
55
+ let name = addr.name || null;
56
+ let address = addr.address || '';
57
+
58
+ if (!address && name) {
59
+ // a bare word ("To: localuser") is the mailbox, not the name
60
+ address = name;
61
+ name = null;
62
+ }
63
+
64
+ if (!address) {
65
+ return;
66
+ }
67
+
68
+ const at = address.lastIndexOf('@');
69
+ const user = at >= 0 ? address.substr(0, at) : address;
70
+ // RFC 3501 7.4.2 reserves a NIL host for group markers, so an address without a domain gets
71
+ // the placeholder host that Dovecot uses for the same input
72
+ const domain = (at >= 0 ? address.substr(at + 1) : '') || 'MISSING_DOMAIN';
73
+
74
+ result.push([name, null, user || null, domain]);
75
+ });
76
+
77
+ // env-from = "(" 1*address ")", there is no SP between the addresses (RFC 3501 section 9)
78
+ Object.defineProperty(result, 'adjacentLists', { value: true });
79
+ return result.length ? result : null;
80
+ }
81
+
82
+ module.exports.processAddress = processAddress;
package/lib/esearch.js ADDED
@@ -0,0 +1,208 @@
1
+ 'use strict';
2
+
3
+ const { badError } = require('./commands/handlers/search');
4
+ const { MAX_NUMBER } = require('./numbers');
5
+
6
+ /**
7
+ * ESEARCH response helpers (RFC 4731, RFC 4466 section 2.6.2). Used by the ESEARCH, SEARCHRES, PARTIAL,
8
+ * CONTEXT=SEARCH and MULTISEARCH plugins, and meant to be reused where SEARCH always answers with ESEARCH (IMAP4rev2, RFC 9051).
9
+ */
10
+
11
+ /**
12
+ * Formats a list of numbers as a compact sequence-set, e.g. [1, 2, 3, 5] becomes "1:3,5"
13
+ *
14
+ * @param {Array} numbers List of nz-numbers, in any order
15
+ * @return {String} sequence-set, empty for an empty list
16
+ */
17
+ function toSequenceSet(numbers) {
18
+ return toOrderedSet(Array.from(new Set(numbers)).sort((a, b) => a - b));
19
+ }
20
+
21
+ /**
22
+ * Formats a list of numbers as a sequence-set that keeps their order, for SORT results (RFC 5267
23
+ * section 3.2): only runs of ascending consecutive numbers become ranges, e.g. [5, 3, 4, 2] becomes "5,3:4,2"
24
+ *
25
+ * @param {Array} numbers List of nz-numbers in the requested order
26
+ * @return {String} sequence-set, empty for an empty list
27
+ */
28
+ function toOrderedSet(numbers) {
29
+ const ranges = [];
30
+ numbers.forEach(nr => {
31
+ const last = ranges[ranges.length - 1];
32
+ if (last && last[1] + 1 === nr) {
33
+ last[1] = nr;
34
+ } else {
35
+ ranges.push([nr, nr]);
36
+ }
37
+ });
38
+ return ranges.map(range => (range[0] === range[1] ? String(range[0]) : range[0] + ':' + range[1])).join(',');
39
+ }
40
+
41
+ /**
42
+ * Parses the argument of the PARTIAL search return option (RFC 5267 section 4.4, RFC 9394 section 4):
43
+ * partial-range-first = nz-number ":" nz-number, partial-range-last = "-" nz-number ":" "-" nz-number.
44
+ * "*" is not allowed, and 500:400 is the same as 400:500
45
+ *
46
+ * @param {Object} item Parsed argument
47
+ * @param {Boolean} allowLast If true, the partial-range-last form of RFC 9394 is accepted
48
+ * @return {Object} `{ range, from, to, fromEnd }`, `range` is the argument as the client sent it
49
+ */
50
+ function parsePartialRange(item, allowLast) {
51
+ const value = item && ['ATOM', 'SEQUENCE'].includes(item.type) ? String(item.value) : '';
52
+ const match = value.match(/^(-?)([1-9][0-9]*):(-?)([1-9][0-9]*)$/);
53
+ if (!match || match[1] !== match[3] || (match[1] && !allowLast)) {
54
+ throw badError('PARTIAL expects a range like 1:100' + (allowLast ? ' or -1:-100' : ''));
55
+ }
56
+ const first = Number(match[2]);
57
+ const last = Number(match[4]);
58
+ if (first > MAX_NUMBER || last > MAX_NUMBER) {
59
+ throw badError('PARTIAL range is out of bounds');
60
+ }
61
+ return { range: value, from: Math.min(first, last), to: Math.max(first, last), fromEnd: !!match[1] };
62
+ }
63
+
64
+ /**
65
+ * Picks the results that a PARTIAL range refers to. The first result is 1, and -1 is the last one
66
+ * (RFC 9394 section 3.1). Results outside the list are left out
67
+ *
68
+ * @param {Array} list Results in mailbox order
69
+ * @param {Object} partial Parsed range, see parsePartialRange
70
+ * @return {Array} results in the range, in mailbox order
71
+ */
72
+ function selectPartial(list, partial) {
73
+ if (!partial.fromEnd) {
74
+ return list.slice(partial.from - 1, partial.to);
75
+ }
76
+ return list.slice(Math.max(list.length - partial.to, 0), Math.max(list.length - partial.from + 1, 0));
77
+ }
78
+
79
+ /**
80
+ * Registers the PARTIAL search return option, shared by the PARTIAL and CONTEXT=SEARCH plugins. The
81
+ * partial-range-last form (-1:-100) is only valid with the PARTIAL capability (RFC 9394 section 4)
82
+ *
83
+ * @param {Object} server IMAP server, with the ESEARCH plugin loaded
84
+ */
85
+ function registerPartialOption(server) {
86
+ if (server.searchReturnOptions.has('PARTIAL')) {
87
+ return;
88
+ }
89
+ server.searchReturnOptions.set('PARTIAL', { data: true, once: true, parse: item => parsePartialRange(item, !!server.partialRangeLast) });
90
+ // RFC 5267 section 4.4 and RFC 9394 section 3.1: a command MUST NOT contain more than one PARTIAL or ALL
91
+ server.searchReturnOptions.set('ALL', Object.assign({}, server.searchReturnOptions.get('ALL'), { once: true }));
92
+ server.searchReturnChecks.push(options => options.has('PARTIAL') && options.has('ALL') && 'PARTIAL and ALL can not be used together');
93
+ }
94
+
95
+ /**
96
+ * Picks the messages that the requested result options return. With COUNT or ALL, or without
97
+ * MIN, MAX and PARTIAL, these are all matching messages, otherwise the lowest and/or the highest
98
+ * matching message and the ones in the PARTIAL range. This is the set that the MODSEQ result
99
+ * option describes (RFC 4731 section 3.2) and that SAVE stores (RFC 5182 section 2.4, RFC 9394
100
+ * section 3.2)
101
+ *
102
+ * @param {Array} list Matching messages, in mailbox order
103
+ * @param {Map} options Upper case result option names to their arguments, an empty map stands for ALL
104
+ * @return {Array} returned messages, in mailbox order
105
+ */
106
+ function selectReturned(list, options) {
107
+ const partial = options.get('PARTIAL');
108
+ if (!list.length || options.has('ALL') || options.has('COUNT') || (!partial && !options.has('MIN') && !options.has('MAX'))) {
109
+ return list;
110
+ }
111
+ const returned = new Set(partial ? selectPartial(list, partial) : []);
112
+ if (options.has('MIN')) {
113
+ returned.add(list[0]);
114
+ }
115
+ if (options.has('MAX')) {
116
+ returned.add(list[list.length - 1]);
117
+ }
118
+ return list.filter(message => returned.has(message));
119
+ }
120
+
121
+ /**
122
+ * Builds the search correlator (RFC 4466 section 2.6.2), with the MAILBOX and UIDVALIDITY
123
+ * correlators of RFC 7377 section 4 if a mailbox is given
124
+ *
125
+ * @param {String} tag Tag of the command
126
+ * @param {Object} [mailbox] Mailbox the response is about
127
+ * @return {Array} correlator list
128
+ */
129
+ function buildCorrelator(tag, mailbox) {
130
+ const correlator = [
131
+ { type: 'ATOM', value: 'TAG' },
132
+ { type: 'STRING', value: tag }
133
+ ];
134
+ if (mailbox) {
135
+ correlator.push(
136
+ { type: 'ATOM', value: 'MAILBOX' },
137
+ // send() puts in the form of the name that the session uses
138
+ { type: 'MAILBOX', value: mailbox.path },
139
+ { type: 'ATOM', value: 'UIDVALIDITY' },
140
+ mailbox.uidvalidity
141
+ );
142
+ }
143
+ return correlator;
144
+ }
145
+
146
+ /**
147
+ * Builds an ESEARCH response (RFC 4466 section 2.6.2) with the RFC 4731 section 3.1 return data
148
+ * and PARTIAL (RFC 5267 section 4.4, RFC 9394 section 3.1)
149
+ *
150
+ * @param {String} tag Tag of the command, for the search correlator
151
+ * @param {Boolean} isUid If true, the response lists UIDs and has the UID indicator
152
+ * @param {Object} result Search result, `{ list, numbers }` (see lib/commands/handlers/search.js)
153
+ * @param {Map} options Upper case result option names to their arguments, an empty map stands for ALL (RFC 4731 section 3.1)
154
+ * @param {Object} [mailbox] Adds the MAILBOX and UIDVALIDITY correlators of RFC 7377 for this mailbox
155
+ * @param {Array} [sorted] The matching messages in sort order, for an extended SORT (RFC 5267 section 3.1): MIN and MAX are
156
+ * the first and the last sorted message, ALL and PARTIAL list the results in sort order
157
+ * @return {Object} response for connection.send()
158
+ */
159
+ function buildEsearchResponse(tag, isUid, result, options, mailbox, sorted) {
160
+ const values = (sorted || result.list).map(message => (isUid ? message.uid : result.numbers[message.uid]));
161
+ if (!sorted) {
162
+ values.sort((a, b) => a - b);
163
+ }
164
+ const toSet = sorted ? toOrderedSet : toSequenceSet;
165
+
166
+ const attributes = [buildCorrelator(tag, mailbox)];
167
+ if (isUid) {
168
+ attributes.push({ type: 'ATOM', value: 'UID' });
169
+ }
170
+
171
+ const add = (name, value) => attributes.push({ type: 'ATOM', value: name }, value);
172
+ // MIN, MAX and ALL are left out when nothing matched, COUNT is always included (RFC 4731 section 3.1)
173
+ if (values.length && options.has('MIN')) {
174
+ add('MIN', values[0]);
175
+ }
176
+ if (values.length && options.has('MAX')) {
177
+ add('MAX', values[values.length - 1]);
178
+ }
179
+ if (values.length && (options.has('ALL') || !options.size)) {
180
+ add('ALL', { type: 'SEQUENCE', value: toSet(values) });
181
+ }
182
+ if (options.has('PARTIAL')) {
183
+ // the requested range, and NIL when no results fall in it (RFC 9394 section 3.1)
184
+ const partial = options.get('PARTIAL');
185
+ const selected = selectPartial(values, partial);
186
+ add('PARTIAL', [{ type: 'ATOM', value: partial.range }, selected.length ? { type: 'SEQUENCE', value: toSet(selected) } : null]);
187
+ }
188
+ if (options.has('COUNT')) {
189
+ add('COUNT', values.length);
190
+ }
191
+
192
+ return {
193
+ tag: '*',
194
+ command: 'ESEARCH',
195
+ attributes
196
+ };
197
+ }
198
+
199
+ module.exports = {
200
+ toSequenceSet,
201
+ toOrderedSet,
202
+ selectReturned,
203
+ selectPartial,
204
+ parsePartialRange,
205
+ registerPartialOption,
206
+ buildCorrelator,
207
+ buildEsearchResponse
208
+ };