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.
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
@@ -0,0 +1,654 @@
1
+ 'use strict';
2
+
3
+ const { states } = require('../command-states');
4
+ const { fetchResponse } = require('../commands/fetch');
5
+ const builtinFetchHandlers = require('../commands/handlers/fetch');
6
+ const { statusResponse } = require('../commands/handlers/status');
7
+ const { badError } = require('../commands/handlers/search');
8
+ const { isAtom, isAstring } = require('../arguments');
9
+
10
+ /**
11
+ * @help Adds NOTIFY [RFC5465] capability. Events: MessageNew (with
12
+ * @help fetch attributes for the selected mailbox), MessageExpunge,
13
+ * @help FlagChange, MailboxName, SubscriptionChange, and with METADATA
14
+ * @help MailboxMetadataChange and ServerMetadataChange.
15
+ * @help server.notifyOverflow([connection]) sends
16
+ * @help "* OK [NOTIFICATIONOVERFLOW]" and turns NOTIFY off
17
+ *
18
+ * NOTIFY: https://www.rfc-editor.org/rfc/rfc5465
19
+ *
20
+ * Additional commands:
21
+ * - NOTIFY SET [STATUS] event-groups, NOTIFY NONE
22
+ *
23
+ * After the first NOTIFY command the session gets only the events it asked for (RFC 5465 section 3.1),
24
+ * also between commands. Events caused by the session itself are not reported (section 5), the
25
+ * responses of its own commands are sent as usual. Fetch attributes of MessageNew never set \Seen,
26
+ * clients SHOULD NOT use such attributes anyway (section 5.2).
27
+ */
28
+
29
+ // RFC 5465 section 8, canonical names by upper case name
30
+ const MESSAGE_EVENTS = ['MessageNew', 'MessageExpunge', 'FlagChange', 'AnnotationChange'];
31
+ const EVENT_NAMES = new Map(
32
+ MESSAGE_EVENTS.concat(['MailboxName', 'SubscriptionChange', 'MailboxMetadataChange', 'ServerMetadataChange']).map(name => [name.toUpperCase(), name])
33
+ );
34
+ const SELECTED_FILTERS = ['SELECTED', 'SELECTED-DELAYED'];
35
+ const OTHER_FILTERS = ['INBOXES', 'PERSONAL', 'SUBSCRIBED', 'SUBTREE', 'MAILBOXES'];
36
+ // fetch items whose arguments are checked with a dry run when NOTIFY SET is parsed
37
+ const SECTION_ITEMS = ['BODY', 'BODY.PEEK', 'BINARY', 'BINARY.PEEK', 'BINARY.SIZE'];
38
+
39
+ module.exports = function (server) {
40
+ server.registerCapability('NOTIFY');
41
+
42
+ // NOTIFY needs the authenticated state, UNAUTHENTICATE clears the settings
43
+ const isActive = connection => !!connection.notifyState;
44
+
45
+ // RFC 5465 sections 5.6 and 5.7: the metadata events are supported (and REQUIRED) with METADATA or METADATA-SERVER
46
+ const getSupportedEvents = () => {
47
+ const supported = ['MessageNew', 'MessageExpunge', 'FlagChange', 'MailboxName', 'SubscriptionChange'];
48
+ if (server.capabilities.METADATA) {
49
+ supported.push('MailboxMetadataChange');
50
+ }
51
+ if (server.capabilities.METADATA || server.capabilities['METADATA-SERVER']) {
52
+ supported.push('ServerMetadataChange');
53
+ }
54
+ return supported;
55
+ };
56
+
57
+ // Access checks, RFC 5465 section 3.1 and section 5: every event needs the "l" and "r" rights
58
+ const hasRights = (connection, mailbox, letters, acl) => {
59
+ const rights = server.acl && server.acl.getRights(connection, mailbox, acl);
60
+ return !rights || [...letters].every(letter => rights.has(letter));
61
+ };
62
+ const canList = (connection, mailbox, acl) => hasRights(connection, mailbox, 'l', acl);
63
+ const canRead = (connection, mailbox, acl) => hasRights(connection, mailbox, 'lr', acl);
64
+
65
+ const normalizePath = path => (path.toUpperCase() === 'INBOX' ? 'INBOX' : path);
66
+
67
+ /**
68
+ * Checks if an event group of NOTIFY SET covers a mailbox name (RFC 5465 section 6)
69
+ *
70
+ * @param {Object} group Parsed event group
71
+ * @param {String} path Storage name
72
+ * @param {Object} [mailbox] Mailbox object, if the mailbox exists
73
+ * @param {Boolean} [subscription] true for a subscription change, the subscribed filter covers both states
74
+ */
75
+ const covers = (group, path, mailbox, subscription) => {
76
+ switch (group.filter) {
77
+ case 'INBOXES':
78
+ // RFC 5465 section 6.3: a server that can not tell which mailboxes get mail treats it as "personal"
79
+ // falls through
80
+ case 'PERSONAL':
81
+ return server.isPersonal(path);
82
+ case 'SUBSCRIBED':
83
+ // RFC 5465 section 6.4: the list is reevaluated when subscriptions change
84
+ return !!subscription || !!(mailbox && mailbox.subscribed);
85
+ case 'SUBTREE':
86
+ return group.paths.indexOf(path) >= 0 || group.prefixes.some(prefix => path.substr(0, prefix.length) === prefix);
87
+ case 'MAILBOXES':
88
+ // RFC 5465 section 6.6: no wildcard expansion
89
+ return group.paths.indexOf(path) >= 0;
90
+ }
91
+ return false;
92
+ };
93
+
94
+ /**
95
+ * Lists the events a session asked for on a mailbox other than the selected one. Several groups
96
+ * can cover the same mailbox (RFC 5465 section 6), message events for the selected mailbox only
97
+ * come from SELECTED or SELECTED-DELAYED (section 3.1)
98
+ *
99
+ * @return {Set} event names
100
+ */
101
+ const getEvents = (connection, path, mailbox, subscription) => {
102
+ const events = new Set();
103
+ connection.notifyState.groups.forEach(group => {
104
+ if (covers(group, path, mailbox, subscription)) {
105
+ group.events.forEach(event => events.add(event));
106
+ }
107
+ });
108
+ if (mailbox && mailbox === connection.selectedMailbox) {
109
+ MESSAGE_EVENTS.forEach(event => events.delete(event));
110
+ }
111
+ return events;
112
+ };
113
+
114
+ // RFC 5465 section 6.1.2: with SELECTED-DELAYED an expunge waits for a command that allows it
115
+ const isHeld = connection =>
116
+ !!(connection.notifyState.selected && connection.notifyState.selected.delayed) && !connection.directNotifications && connection.hasPendingExpunge();
117
+
118
+ /**
119
+ * Sends the queued notifications of a session that is between commands (RFC 5465 section 3.1: the
120
+ * client listens all the time). During a command they wait for its tagged response
121
+ */
122
+ const flush = connection => {
123
+ if (!connection.socket || !connection.notificationQueue.length || connection._runningCommand || !isActive(connection) || isHeld(connection)) {
124
+ return;
125
+ }
126
+ connection.processNotifications();
127
+ };
128
+
129
+ // the events of one command go out together, e.g. several expunges as one VANISHED response (QRESYNC)
130
+ const scheduleFlush = connection => {
131
+ if (!connection.notifyFlushPending) {
132
+ connection.notifyFlushPending = true;
133
+ setImmediate(() => {
134
+ connection.notifyFlushPending = false;
135
+ flush(connection);
136
+ });
137
+ }
138
+ };
139
+
140
+ /**
141
+ * Sends an event response about another mailbox or about the server
142
+ *
143
+ * @param {Object} connection IMAP connection
144
+ * @param {Object} response Untagged response
145
+ */
146
+ const deliver = (connection, response) => {
147
+ response.tag = '*';
148
+ response.notification = true;
149
+ if (!connection._runningCommand && isHeld(connection)) {
150
+ // this does not change message sequence numbers, so it does not have to wait for the expunge
151
+ connection.send(response, 'NOTIFY EVENT');
152
+ return;
153
+ }
154
+ connection.notificationQueue.push(response);
155
+ scheduleFlush(connection);
156
+ };
157
+
158
+ // the sessions that use NOTIFY, other than the one that caused a change (RFC 5465 section 5: SHOULD omit)
159
+ const listeners = origin => [...server.connections].filter(connection => connection !== origin && isActive(connection));
160
+
161
+ /**
162
+ * Builds an unsolicited STATUS response for a mailbox. With FlagChange the UNSEEN count is added
163
+ * when it changed since the session last heard of it (RFC 5465 section 5.1), with CONDSTORE (or
164
+ * QRESYNC) enabled HIGHESTMODSEQ as well (sections 5.1, 5.2 and 5.3)
165
+ *
166
+ * @return {Object|Boolean} response, or false if there is nothing to tell
167
+ */
168
+ const eventStatus = (connection, mailbox, items, events) => {
169
+ items = items.slice();
170
+ if (events.has('FlagChange')) {
171
+ const unseen = server.getStatus(mailbox).unseen || 0;
172
+ if (connection.notifyState.unseen.get(mailbox) !== unseen) {
173
+ connection.notifyState.unseen.set(mailbox, unseen);
174
+ items.push('UNSEEN');
175
+ }
176
+ }
177
+ if (server.condstore && server.condstore.isEnabled(connection)) {
178
+ items.push('HIGHESTMODSEQ');
179
+ }
180
+ if (!items.length) {
181
+ return false;
182
+ }
183
+ return statusResponse(connection, mailbox.path, mailbox, items);
184
+ };
185
+
186
+ // Reports a change of the messages of a mailbox that is not selected (RFC 5465 sections 5.1, 5.2 and 5.3)
187
+ const messageEvent = (mailbox, event, items, origin) => {
188
+ listeners(origin).forEach(connection => {
189
+ if (mailbox === connection.selectedMailbox || !canRead(connection, mailbox)) {
190
+ return;
191
+ }
192
+ const events = getEvents(connection, mailbox.path, mailbox);
193
+ if (!events.has(event)) {
194
+ return;
195
+ }
196
+ const response = eventStatus(
197
+ connection,
198
+ mailbox,
199
+ event === 'FlagChange' && server.condstore && server.condstore.isEnabled(connection) ? ['UIDVALIDITY'] : items,
200
+ events
201
+ );
202
+ if (response) {
203
+ deliver(connection, response);
204
+ }
205
+ });
206
+ };
207
+
208
+ // Checks if any mailbox below a mailbox is visible to the session (RFC 3348 section 4)
209
+ const hasVisibleChildren = (connection, path) =>
210
+ server.hasDescendant(path, child => canList(connection, child) && child.flags.indexOf('\\NonExistent') < 0);
211
+
212
+ /**
213
+ * Builds an unsolicited LIST response with accurate attributes (RFC 5465 sections 5.4 and 5.5)
214
+ *
215
+ * @param {Object} connection IMAP connection
216
+ * @param {String} path Storage name
217
+ * @param {Object} [options] `{ oldPath, noAccess, exists }`, `exists: false` reports the name as \NonExistent
218
+ */
219
+ const listResponse = (connection, path, options) => {
220
+ options = options || {};
221
+ const mailbox = server.getMailbox(path);
222
+ const exists = options.exists !== false && !!mailbox && canList(connection, mailbox);
223
+ const flags = server.listAttributes(exists ? mailbox : null, {
224
+ exists,
225
+ subscribed: exists && mailbox.subscribed,
226
+ extra: options.noAccess ? ['\\NoAccess'] : [],
227
+ hasChildren: hasVisibleChildren(connection, path)
228
+ });
229
+ const attributes = [flags.map(flag => ({ type: 'ATOM', value: flag })), server.getSeparator(path), { type: 'MAILBOX', value: path }];
230
+ if (options.oldPath) {
231
+ // RFC 5465 section 5.4, the OLDNAME extended data item (mbox-list-extended)
232
+ attributes.push([{ type: 'STRING', value: 'OLDNAME' }, [{ type: 'STRING', value: connection.exportMailboxName(options.oldPath) }]]);
233
+ }
234
+ return { tag: '*', command: 'LIST', attributes };
235
+ };
236
+
237
+ // RFC 5465 section 5.4: a created or deleted mailbox and its direct parent are affected
238
+ const nameEvent = (connection, path, mailbox, exists) => {
239
+ [path, server.getParentPath(path)].forEach(name => {
240
+ if (!name) {
241
+ return;
242
+ }
243
+ const target = name === path ? mailbox : server.getMailbox(name);
244
+ if (getEvents(connection, name, target).has('MailboxName')) {
245
+ deliver(connection, listResponse(connection, name, name === path ? { exists } : {}));
246
+ }
247
+ });
248
+ };
249
+
250
+ // RFC 5465 section 5.1: with FlagChange the server MUST tell about UIDVALIDITY changes, a mailbox that
251
+ // gets a name that is watched has a new UIDVALIDITY
252
+ const uidvalidityEvent = (connection, mailbox) => {
253
+ if (mailbox !== connection.selectedMailbox && canRead(connection, mailbox) && getEvents(connection, mailbox.path, mailbox).has('FlagChange')) {
254
+ deliver(connection, eventStatus(connection, mailbox, ['MESSAGES', 'UIDNEXT', 'UIDVALIDITY'], new Set(['FlagChange'])));
255
+ }
256
+ };
257
+
258
+ server.on('mailbox', change => {
259
+ const mailbox = server.getMailbox(change.path);
260
+ listeners(change.origin).forEach(connection => {
261
+ switch (change.type) {
262
+ case 'create':
263
+ if (mailbox && canList(connection, mailbox)) {
264
+ nameEvent(connection, change.path, mailbox, true);
265
+ uidvalidityEvent(connection, mailbox);
266
+ }
267
+ break;
268
+ case 'delete':
269
+ if (change.mailbox && canList(connection, change.mailbox)) {
270
+ nameEvent(connection, change.path, mailbox, !!mailbox);
271
+ }
272
+ break;
273
+ case 'rename':
274
+ // RFC 5465 section 5.4: one LIST response for the new name, none for the children
275
+ if (mailbox && canList(connection, mailbox)) {
276
+ const events = getEvents(connection, change.path, mailbox);
277
+ getEvents(connection, change.oldPath).forEach(event => events.add(event));
278
+ if (events.has('MailboxName')) {
279
+ deliver(connection, listResponse(connection, change.path, { oldPath: change.oldPath }));
280
+ }
281
+ uidvalidityEvent(connection, mailbox);
282
+ }
283
+ break;
284
+ case 'subscribe':
285
+ case 'unsubscribe':
286
+ // RFC 5465 section 5.5
287
+ if (mailbox && canList(connection, mailbox) && getEvents(connection, change.path, mailbox, true).has('SubscriptionChange')) {
288
+ deliver(connection, listResponse(connection, change.path));
289
+ }
290
+ break;
291
+ }
292
+ });
293
+ });
294
+
295
+ // RFC 5465 section 5.4: granting or revoking the "l" right counts as creating or deleting the mailbox. Section 5.9:
296
+ // monitoring stops without the rights and starts again when they are granted, \NoAccess tells about the "r" right
297
+ server.on('acl', (mailbox, previous) => {
298
+ listeners(null).forEach(connection => {
299
+ const before = { list: canList(connection, mailbox, previous), read: canRead(connection, mailbox, previous) };
300
+ const after = { list: canList(connection, mailbox), read: canRead(connection, mailbox) };
301
+ if (before.list !== after.list) {
302
+ nameEvent(connection, mailbox.path, mailbox, after.list);
303
+ } else if (after.list && before.read !== after.read) {
304
+ const events = getEvents(connection, mailbox.path, mailbox);
305
+ if (MESSAGE_EVENTS.some(event => events.has(event))) {
306
+ deliver(connection, listResponse(connection, mailbox.path, { noAccess: !after.read }));
307
+ }
308
+ }
309
+ });
310
+ });
311
+
312
+ server.on('expunge', (mailbox, messages, origin) => messageEvent(mailbox, 'MessageExpunge', ['MESSAGES', 'UIDNEXT'], origin));
313
+
314
+ server.on('notify', notification => {
315
+ const command = notification.command || {};
316
+ const mailbox = typeof notification.mailbox === 'string' ? server.getMailbox(notification.mailbox) : notification.mailbox;
317
+
318
+ if (mailbox && command.flagUpdate) {
319
+ messageEvent(mailbox, 'FlagChange', [], notification.origin);
320
+ } else if (mailbox && command.message && isAtom(command.attributes && command.attributes[1], 'EXISTS')) {
321
+ messageEvent(mailbox, 'MessageNew', ['MESSAGES', 'UIDNEXT'], notification.origin);
322
+ } else if (command.command === 'METADATA' && command.attributes && command.attributes[0]) {
323
+ // RFC 5465 sections 5.6 and 5.7: METADATA responses without ENABLE METADATA
324
+ const path = command.attributes[0].value;
325
+ const target = path === '' ? null : server.getMailbox(path);
326
+ listeners(notification.origin).forEach(connection => {
327
+ let wanted;
328
+ if (path === '') {
329
+ wanted = connection.notifyState.groups.some(group => group.events.has('ServerMetadataChange'));
330
+ } else {
331
+ wanted = !!target && canRead(connection, target) && getEvents(connection, path, target).has('MailboxMetadataChange');
332
+ }
333
+ if (wanted) {
334
+ notification.notifyHandled = notification.notifyHandled || new Set();
335
+ notification.notifyHandled.add(connection);
336
+ deliver(connection, Object.assign({}, command));
337
+ }
338
+ });
339
+ }
340
+ });
341
+
342
+ // a METADATA response that NOTIFY sent already does not go out again for ENABLE METADATA
343
+ server.notifyFilters.push((connection, notification) => !(notification.notifyHandled && notification.notifyHandled.has(connection)));
344
+
345
+ // Which message event a notification for the selected mailbox is
346
+ const selectedEvent = command => {
347
+ if (command.flagUpdate) {
348
+ return 'FlagChange';
349
+ }
350
+ const type = command.attributes && command.attributes[1];
351
+ if (isAtom(type, 'EXPUNGE')) {
352
+ return 'MessageExpunge';
353
+ }
354
+ if (isAtom(type, 'EXISTS')) {
355
+ // the EXISTS after expunges carries a snapshot, the one of a new message the message
356
+ return command.message ? 'MessageNew' : 'MessageExpunge';
357
+ }
358
+ return false;
359
+ };
360
+
361
+ // Builds the FETCH response for a new message in the selected mailbox (RFC 5465 section 5.2)
362
+ const newMessageFetch = (connection, items, sequence, message) => {
363
+ const params = items.map(item => Object.assign({}, item));
364
+ let data;
365
+ try {
366
+ data = fetchResponse(connection, message, params);
367
+ } catch {
368
+ // the items were checked by NOTIFY SET, a failure here leaves out the FETCH response
369
+ return false;
370
+ }
371
+ // the message goes with the response for output handlers (UIDONLY reports its UID). Not as `message`, that
372
+ // marks an EXPUNGE notification
373
+ return { tag: '*', notification: true, attributes: [sequence, { type: 'ATOM', value: 'FETCH' }, data], fetchedMessage: message };
374
+ };
375
+
376
+ server.connectionHandlers.push(connection => {
377
+ connection.notifyState = null;
378
+
379
+ const queueNotification = connection.queueNotification;
380
+ connection.queueNotification = function (command, notification) {
381
+ const event = notification && notification.mailbox && selectedEvent(command);
382
+ if (!isActive(this) || !event || notification.origin === this) {
383
+ // responses to the own commands of the session are not NOTIFY events
384
+ return queueNotification.call(this, command, notification);
385
+ }
386
+ const selected = this.notifyState.selected;
387
+ if (!selected || !selected.events.has(event) || !canRead(this, this.selectedMailbox)) {
388
+ // RFC 5465 section 3.1: without SELECTED or SELECTED-DELAYED (or without the event) the client
389
+ // does not want to hear about it
390
+ return;
391
+ }
392
+ this.notificationQueue.push(command);
393
+ if (event === 'MessageNew' && selected.fetch) {
394
+ // the EXISTS response gives the sequence number of the new message
395
+ const fetch = newMessageFetch(this, selected.fetch, command.attributes[0], command.message);
396
+ if (fetch) {
397
+ this.notificationQueue.push(fetch);
398
+ }
399
+ }
400
+ scheduleFlush(this);
401
+ };
402
+ });
403
+
404
+ // RFC 8437 section 4.1: UNAUTHENTICATE forgets the NOTIFY settings
405
+ server.resetHandlers.push(connection => {
406
+ connection.notifyState = null;
407
+ });
408
+
409
+ // notifications held back during a command (FETCH, STORE, SEARCH) go out once it completed
410
+ server.outputHandlers.push((connection, response) => {
411
+ if (response.tag !== '*' && !response.notification && isActive(connection) && connection.notificationQueue.length) {
412
+ scheduleFlush(connection);
413
+ }
414
+ });
415
+
416
+ /**
417
+ * RFC 5465 section 5.8: disables notifications for a session (or all sessions that use NOTIFY),
418
+ * which get an untagged OK [NOTIFICATIONOVERFLOW] and behave as after NOTIFY NONE
419
+ *
420
+ * @param {Object} [target] IMAP connection, all sessions if not set
421
+ */
422
+ server.notifyOverflow = target => {
423
+ listeners(null)
424
+ .filter(connection => !target || connection === target)
425
+ .forEach(connection => {
426
+ connection.notifyState = { selected: null, groups: [], unseen: new WeakMap() };
427
+ deliver(connection, {
428
+ command: 'OK',
429
+ attributes: [
430
+ { type: 'SECTION', section: [{ type: 'ATOM', value: 'NOTIFICATIONOVERFLOW' }] },
431
+ { type: 'TEXT', value: 'Too many notifications, NOTIFY is turned off' }
432
+ ]
433
+ });
434
+ });
435
+ };
436
+
437
+ /**
438
+ * Parses the fetch attributes of MessageNew (RFC 5465 section 8: "(" fetch-att *(SP fetch-att) ")")
439
+ *
440
+ * @param {Object} connection IMAP connection
441
+ * @param {Array} list Parsed list
442
+ * @return {Array} fetch items
443
+ */
444
+ const parseFetchAtts = (connection, list) => {
445
+ if (!list.length) {
446
+ throw badError('MessageNew expects a list of fetch attributes');
447
+ }
448
+ return list.map(item => {
449
+ const key = isAtom(item) && item.value.toUpperCase();
450
+ // macros like ALL are not fetch-att values
451
+ if (!key || !(server.fetchHandlers[key] || builtinFetchHandlers[key])) {
452
+ throw badError('Invalid fetch attribute for MessageNew');
453
+ }
454
+ if ((item.section || item.partial) && SECTION_ITEMS.indexOf(key) >= 0) {
455
+ // check the section and the partial range against a message
456
+ const params = [Object.assign({}, item)];
457
+ try {
458
+ fetchResponse(connection, { raw: 'Subject: test\r\n\r\nTest\r\n', flags: [], uid: 1 }, params);
459
+ } catch (err) {
460
+ throw badError(err.message);
461
+ }
462
+ }
463
+ return item;
464
+ });
465
+ };
466
+
467
+ /**
468
+ * Parses the events of an event group (RFC 5465 section 8: events = ( "(" event *(SP event) ")" ) / "NONE")
469
+ *
470
+ * @param {Object} connection IMAP connection
471
+ * @param {Object|Array} value Parsed events
472
+ * @param {Boolean} selected true for SELECTED and SELECTED-DELAYED
473
+ * @param {Array} unknown Collects the unknown event names
474
+ * @return {Object} `{ events, fetch }`
475
+ */
476
+ const parseEvents = (connection, value, selected, unknown) => {
477
+ const events = new Set();
478
+ let fetch = null;
479
+ if (isAtom(value, 'NONE')) {
480
+ return { events, fetch };
481
+ }
482
+ if (!Array.isArray(value) || !value.length) {
483
+ throw badError('Expecting a list of events or NONE');
484
+ }
485
+ for (let i = 0; i < value.length; i++) {
486
+ const item = value[i];
487
+ if (!isAtom(item) || item.section || item.partial) {
488
+ throw badError('Invalid event');
489
+ }
490
+ const name = EVENT_NAMES.get(item.value.toUpperCase());
491
+ if (Array.isArray(value[i + 1]) && name === 'MessageNew') {
492
+ // the fetch-att list is only allowed for the selected mailbox (RFC 5465 section 8)
493
+ if (!selected) {
494
+ throw badError('Fetch attributes for MessageNew are only allowed with SELECTED or SELECTED-DELAYED');
495
+ }
496
+ fetch = parseFetchAtts(connection, value[++i]);
497
+ }
498
+ if (!name) {
499
+ // event-ext: an event this server does not know, NO [BADEVENT] once the syntax is checked
500
+ unknown.push(item.value);
501
+ continue;
502
+ }
503
+ if (selected && MESSAGE_EVENTS.indexOf(name) < 0) {
504
+ // RFC 5465 section 6.1
505
+ throw badError(name + ' can not be used with SELECTED or SELECTED-DELAYED');
506
+ }
507
+ events.add(name);
508
+ }
509
+ // RFC 5465 section 5
510
+ if (events.has('MessageNew') !== events.has('MessageExpunge')) {
511
+ throw badError('MessageNew and MessageExpunge must be used together');
512
+ }
513
+ if ((events.has('FlagChange') || events.has('AnnotationChange')) && !events.has('MessageNew')) {
514
+ throw badError('FlagChange and AnnotationChange require MessageNew and MessageExpunge');
515
+ }
516
+ return { events, fetch };
517
+ };
518
+
519
+ // one-or-more-mailbox = mailbox / many-mailboxes, the names are converted to storage names
520
+ const parseMailboxes = (connection, value) => {
521
+ const list = Array.isArray(value) ? value : [value];
522
+ if (!list.length || !list.every(isAstring)) {
523
+ throw badError('Expecting a mailbox name or a list of mailbox names');
524
+ }
525
+ return list.map(item => normalizePath(connection.importMailboxName(item.value)));
526
+ };
527
+
528
+ /**
529
+ * Parses the arguments of NOTIFY (RFC 5465 section 8)
530
+ *
531
+ * @return {Object} `{ state, status, unknown }`
532
+ */
533
+ const parseNotify = (connection, args) => {
534
+ // `unseen` holds the UNSEEN count each mailbox had in the last STATUS response the session got
535
+ const state = { selected: null, groups: [], unseen: new WeakMap() };
536
+ const unknown = [];
537
+ if (isAtom(args[0], 'NONE')) {
538
+ if (args.length !== 1) {
539
+ throw badError('NOTIFY NONE does not take any arguments');
540
+ }
541
+ return { state, status: false, unknown };
542
+ }
543
+ if (!isAtom(args[0], 'SET')) {
544
+ throw badError('NOTIFY expects SET or NONE');
545
+ }
546
+ const status = isAtom(args[1], 'STATUS');
547
+ const groups = args.slice(status ? 2 : 1);
548
+ if (!groups.length) {
549
+ throw badError('NOTIFY SET expects event groups');
550
+ }
551
+ groups.forEach(group => {
552
+ if (!Array.isArray(group) || !isAtom(group[0])) {
553
+ throw badError('Invalid event group');
554
+ }
555
+ const filter = group[0].value.toUpperCase();
556
+ if (SELECTED_FILTERS.indexOf(filter) >= 0) {
557
+ if (group.length !== 2) {
558
+ throw badError('Invalid event group');
559
+ }
560
+ if (state.selected) {
561
+ // RFC 5465 section 6.1
562
+ throw badError('Only one of SELECTED and SELECTED-DELAYED can be used');
563
+ }
564
+ state.selected = Object.assign({ delayed: filter === 'SELECTED-DELAYED' }, parseEvents(connection, group[1], true, unknown));
565
+ } else if (OTHER_FILTERS.indexOf(filter) >= 0) {
566
+ const withNames = filter === 'SUBTREE' || filter === 'MAILBOXES';
567
+ if (group.length !== (withNames ? 3 : 2)) {
568
+ throw badError('Invalid event group');
569
+ }
570
+ const paths = withNames ? parseMailboxes(connection, group[1]) : [];
571
+ const { events } = parseEvents(connection, group[group.length - 1], false, unknown);
572
+ // the names below a SUBTREE root start with these
573
+ const prefixes = filter === 'SUBTREE' ? paths.map(root => root + server.getSeparator(root)) : [];
574
+ state.groups.push({ filter, paths, prefixes, events });
575
+ } else {
576
+ throw badError('Unknown mailbox filter ' + group[0].value);
577
+ }
578
+ });
579
+ return { state, status, unknown };
580
+ };
581
+
582
+ // RFC 5465 section 3.1: the initial STATUS responses and the \NoAccess LIST responses of NOTIFY SET
583
+ const sendInitialResponses = (connection, status, parsed, data) => {
584
+ const paths = Object.keys(server.folderCache).sort((a, b) => (a === 'INBOX' ? -1 : b === 'INBOX' ? 1 : a.localeCompare(b)));
585
+ paths.forEach(path => {
586
+ const mailbox = server.folderCache[path];
587
+ if (mailbox === connection.selectedMailbox || mailbox.flags.some(flag => /^\\(Noselect|NonExistent)$/i.test(flag))) {
588
+ return;
589
+ }
590
+ const events = getEvents(connection, path, mailbox);
591
+ if (!events.size || !canList(connection, mailbox)) {
592
+ return;
593
+ }
594
+ if (!canRead(connection, mailbox)) {
595
+ connection.send(listResponse(connection, path, { noAccess: true }), 'NOTIFY LIST', parsed, data);
596
+ return;
597
+ }
598
+ if (!status || !events.has('MessageNew')) {
599
+ return;
600
+ }
601
+ const items = ['MESSAGES', 'UIDNEXT', 'UIDVALIDITY'];
602
+ if (events.has('FlagChange')) {
603
+ items.push('UNSEEN');
604
+ connection.notifyState.unseen.set(mailbox, server.getStatus(mailbox).unseen || 0);
605
+ if (server.condstore) {
606
+ items.push('HIGHESTMODSEQ');
607
+ }
608
+ }
609
+ connection.send(statusResponse(connection, path, mailbox, items), 'NOTIFY STATUS', parsed, data);
610
+ });
611
+ };
612
+
613
+ server.setCommandHandler(
614
+ 'NOTIFY',
615
+ (connection, parsed, data, callback) => {
616
+ let result;
617
+ try {
618
+ result = parseNotify(connection, parsed.attributes || []);
619
+ } catch (err) {
620
+ connection.sendStatus(parsed, data, 'BAD', err.imapResponse === 'BAD' ? err.message : 'Invalid NOTIFY arguments');
621
+ return callback();
622
+ }
623
+
624
+ const supported = getSupportedEvents();
625
+ const used = [result.state.selected].concat(result.state.groups).filter(group => group);
626
+ const unsupported = result.unknown.concat(...used.map(group => [...group.events].filter(event => supported.indexOf(event) < 0)));
627
+ if (unsupported.length) {
628
+ // RFC 5465 section 3.1: NO with BADEVENT, which MUST list all supported events
629
+ connection.send(
630
+ {
631
+ tag: parsed.tag,
632
+ command: 'NO',
633
+ attributes: [
634
+ { type: 'SECTION', section: [{ type: 'ATOM', value: 'BADEVENT' }, supported.map(event => ({ type: 'ATOM', value: event }))] },
635
+ { type: 'TEXT', value: 'Unsupported NOTIFY events' }
636
+ ]
637
+ },
638
+ 'NOTIFY FAILED',
639
+ parsed,
640
+ data
641
+ );
642
+ return callback();
643
+ }
644
+
645
+ connection.notifyState = result.state;
646
+ sendInitialResponses(connection, result.status, parsed, data);
647
+
648
+ // the tagged response sends the changes of the selected mailbox, NOTIFY SET implies NOOP (section 3.1)
649
+ connection.sendStatus(parsed, data, 'OK', 'NOTIFY completed');
650
+ return callback();
651
+ },
652
+ { states: states.AUTHENTICATED }
653
+ );
654
+ };