imapflow 1.2.7 → 1.2.9
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/.ncurc.js +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +3 -3
- package/lib/charsets.js +15 -0
- package/lib/commands/append.js +62 -54
- package/lib/commands/authenticate.js +99 -52
- package/lib/commands/capability.js +12 -2
- package/lib/commands/close.js +11 -1
- package/lib/commands/compress.js +10 -1
- package/lib/commands/copy.js +18 -1
- package/lib/commands/create.js +15 -2
- package/lib/commands/delete.js +10 -1
- package/lib/commands/enable.js +12 -1
- package/lib/commands/expunge.js +18 -2
- package/lib/commands/fetch.js +39 -4
- package/lib/commands/id.js +22 -3
- package/lib/commands/idle.js +39 -4
- package/lib/commands/list.js +86 -48
- package/lib/commands/login.js +12 -1
- package/lib/commands/logout.js +11 -2
- package/lib/commands/move.js +17 -1
- package/lib/commands/namespace.js +32 -2
- package/lib/commands/noop.js +6 -1
- package/lib/commands/quota.js +33 -14
- package/lib/commands/rename.js +13 -1
- package/lib/commands/search.js +16 -1
- package/lib/commands/select.js +76 -33
- package/lib/commands/starttls.js +6 -1
- package/lib/commands/status.js +64 -52
- package/lib/commands/store.js +27 -4
- package/lib/commands/subscribe.js +7 -1
- package/lib/commands/unsubscribe.js +7 -1
- package/lib/handler/imap-compiler.js +44 -2
- package/lib/handler/imap-formal-syntax.js +51 -3
- package/lib/handler/imap-handler.js +8 -0
- package/lib/handler/imap-parser.js +23 -2
- package/lib/handler/imap-stream.js +84 -31
- package/lib/handler/parser-instance.js +61 -1
- package/lib/handler/token-parser.js +66 -9
- package/lib/imap-commands.js +11 -0
- package/lib/imap-flow.d.ts +6 -0
- package/lib/imap-flow.js +164 -42
- package/lib/jp-decoder.js +10 -0
- package/lib/limited-passthrough.js +12 -5
- package/lib/proxy-connection.js +18 -12
- package/lib/search-compiler.js +3 -11
- package/lib/special-use.js +23 -16
- package/lib/tools.js +218 -13
- package/package.json +4 -11
- package/test/commands-integration-test.js +33 -0
- package/test/special-use-test.js +32 -0
- package/assets/favicon.ico +0 -0
- package/jsdoc.json +0 -28
package/lib/commands/list.js
CHANGED
|
@@ -3,23 +3,45 @@
|
|
|
3
3
|
const { decodePath, encodePath, normalizePath } = require('../tools.js');
|
|
4
4
|
const { specialUse } = require('../special-use');
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Lists mailboxes from the server, including subscription status and special-use flags.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} connection - IMAP connection instance
|
|
10
|
+
* @param {string} reference - Reference name (namespace prefix)
|
|
11
|
+
* @param {string} mailbox - Mailbox name pattern with possible wildcards
|
|
12
|
+
* @param {Object} [options] - List options
|
|
13
|
+
* @param {boolean} [options.listOnly] - If true, return entries after LIST without LSUB or status queries
|
|
14
|
+
* @param {Object} [options.statusQuery] - Status data items to query for each listed mailbox
|
|
15
|
+
* @param {Object} [options.specialUseHints] - Hints mapping mailbox paths to special-use types (sent, junk, trash, drafts, archive)
|
|
16
|
+
* @returns {Promise<Object[]>} Array of mailbox entries sorted by special-use flags and name
|
|
17
|
+
* @throws {Error} If the LIST command fails
|
|
18
|
+
*/
|
|
7
19
|
module.exports = async (connection, reference, mailbox, options) => {
|
|
8
20
|
options = options || {};
|
|
9
21
|
|
|
22
|
+
// Special-use flags sorted by display priority (INBOX first, Trash last).
|
|
23
|
+
// Used in the final sort to group special-use mailboxes at the top of the list.
|
|
10
24
|
const FLAG_SORT_ORDER = ['\\Inbox', '\\Flagged', '\\Sent', '\\Drafts', '\\All', '\\Archive', '\\Junk', '\\Trash'];
|
|
25
|
+
// Priority for how a special-use flag was determined: explicit user hint > server
|
|
26
|
+
// extension flag (SPECIAL-USE/XLIST) > name-based guess. When multiple mailboxes
|
|
27
|
+
// claim the same special-use type, the highest-priority source wins.
|
|
11
28
|
const SOURCE_SORT_ORDER = ['user', 'extension', 'name'];
|
|
12
29
|
|
|
30
|
+
// Prefer XLIST (legacy Gmail extension) only if SPECIAL-USE (RFC 6154) is unavailable.
|
|
31
|
+
// Both provide special-use flags, but SPECIAL-USE is the standardized approach.
|
|
13
32
|
let listCommand = connection.capabilities.has('XLIST') && !connection.capabilities.has('SPECIAL-USE') ? 'XLIST' : 'LIST';
|
|
14
33
|
|
|
15
34
|
let response;
|
|
16
35
|
try {
|
|
17
36
|
let entries = [];
|
|
18
37
|
|
|
38
|
+
// statusMap caches STATUS responses received inline via LIST-STATUS extension,
|
|
39
|
+
// keyed by normalized mailbox path. This avoids separate STATUS commands per mailbox.
|
|
19
40
|
let statusMap = new Map();
|
|
20
41
|
let returnArgs = [];
|
|
21
42
|
let statusQueryAttributes = [];
|
|
22
43
|
|
|
44
|
+
// Build the list of STATUS data items to request (MESSAGES, UIDNEXT, etc.)
|
|
23
45
|
if (options.statusQuery) {
|
|
24
46
|
Object.keys(options.statusQuery || {}).forEach(key => {
|
|
25
47
|
if (!options.statusQuery[key]) {
|
|
@@ -44,6 +66,9 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
44
66
|
});
|
|
45
67
|
}
|
|
46
68
|
|
|
69
|
+
// LIST-STATUS (RFC 5819): allows requesting STATUS data inline with LIST,
|
|
70
|
+
// avoiding a separate STATUS command for each mailbox. Adds RETURN (STATUS (...))
|
|
71
|
+
// and optionally SPECIAL-USE to the LIST command arguments.
|
|
47
72
|
if (listCommand === 'LIST' && connection.capabilities.has('LIST-STATUS') && statusQueryAttributes.length) {
|
|
48
73
|
returnArgs.push({ type: 'ATOM', value: 'STATUS' }, statusQueryAttributes);
|
|
49
74
|
if (connection.capabilities.has('SPECIAL-USE')) {
|
|
@@ -51,6 +76,9 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
51
76
|
}
|
|
52
77
|
}
|
|
53
78
|
|
|
79
|
+
// Tracks all candidate mailboxes for each special-use type (e.g., \\Sent).
|
|
80
|
+
// Multiple mailboxes may claim the same type via different sources (user hint,
|
|
81
|
+
// server extension, name match). After listing, the best match wins.
|
|
54
82
|
let specialUseMatches = {};
|
|
55
83
|
let addSpecialUseMatch = (entry, type, source) => {
|
|
56
84
|
if (!specialUseMatches[type]) {
|
|
@@ -59,6 +87,9 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
59
87
|
specialUseMatches[type].push({ entry, source });
|
|
60
88
|
};
|
|
61
89
|
|
|
90
|
+
// User-provided hints map mailbox paths to special-use types (e.g., {sent: "Sent Items"}).
|
|
91
|
+
// These override server-reported flags and name-based guesses. Converted to a
|
|
92
|
+
// path-keyed lookup: { "Sent Items" => "\\Sent" }
|
|
62
93
|
let specialUseHints = {};
|
|
63
94
|
if (options.specialUseHints && typeof options.specialUseHints === 'object') {
|
|
64
95
|
for (let type of Object.keys(options.specialUseHints)) {
|
|
@@ -67,11 +98,15 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
67
98
|
options.specialUseHints[type] &&
|
|
68
99
|
typeof options.specialUseHints[type] === 'string'
|
|
69
100
|
) {
|
|
101
|
+
// Capitalize first letter: "sent" -> "\\Sent"
|
|
70
102
|
specialUseHints[normalizePath(connection, options.specialUseHints[type])] = `\\${type.replace(/^./, c => c.toUpperCase())}`;
|
|
71
103
|
}
|
|
72
104
|
}
|
|
73
105
|
}
|
|
74
106
|
|
|
107
|
+
// Executes a LIST (or XLIST) command and collects mailbox entries.
|
|
108
|
+
// Called once for the main listing and optionally again for INBOX if a
|
|
109
|
+
// namespace prefix was used (INBOX may live outside the namespace).
|
|
75
110
|
let runList = async (reference, mailbox) => {
|
|
76
111
|
const cmdArgs = [encodePath(connection, reference), encodePath(connection, mailbox)];
|
|
77
112
|
|
|
@@ -81,12 +116,15 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
81
116
|
|
|
82
117
|
response = await connection.exec(listCommand, cmdArgs, {
|
|
83
118
|
untagged: {
|
|
119
|
+
// Each untagged LIST response: * LIST (<flags>) "<delimiter>" "<mailbox name>"
|
|
120
|
+
// attributes[0] = flags array, attributes[1] = delimiter, attributes[2] = mailbox name
|
|
84
121
|
[listCommand]: async untagged => {
|
|
85
122
|
if (!untagged.attributes || !untagged.attributes.length) {
|
|
86
123
|
return;
|
|
87
124
|
}
|
|
88
125
|
|
|
89
126
|
let entry = {
|
|
127
|
+
// Decode from modified UTF-7 wire format and normalize the path
|
|
90
128
|
path: normalizePath(connection, decodePath(connection, (untagged.attributes[2] && untagged.attributes[2].value) || '')),
|
|
91
129
|
pathAsListed: (untagged.attributes[2] && untagged.attributes[2].value) || '',
|
|
92
130
|
flags: new Set(untagged.attributes[0].map(entry => entry.value)),
|
|
@@ -94,31 +132,39 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
94
132
|
listed: true
|
|
95
133
|
};
|
|
96
134
|
|
|
135
|
+
// Check user-provided hints first (highest priority)
|
|
97
136
|
if (specialUseHints[entry.path]) {
|
|
98
137
|
addSpecialUseMatch(entry, specialUseHints[entry.path], 'user');
|
|
99
138
|
}
|
|
100
139
|
|
|
140
|
+
// XLIST marks INBOX with a \\Inbox flag. Remove it from flags
|
|
141
|
+
// (it's not a standard flag) and register as special-use match.
|
|
142
|
+
// XLIST may also use a localised name (e.g., "Posteingang" for German INBOX).
|
|
101
143
|
if (listCommand === 'XLIST' && entry.flags.has('\\Inbox')) {
|
|
102
|
-
// XLIST specific flag, ignore
|
|
103
144
|
entry.flags.delete('\\Inbox');
|
|
104
145
|
if (entry.path !== 'INBOX') {
|
|
105
|
-
// XLIST may use localised inbox name
|
|
106
146
|
addSpecialUseMatch(entry, '\\Inbox', 'extension');
|
|
107
147
|
}
|
|
108
148
|
}
|
|
109
149
|
|
|
150
|
+
// Name-based INBOX detection: any mailbox named "INBOX" (case-insensitive)
|
|
151
|
+
// is the inbox per RFC 3501.
|
|
110
152
|
if (entry.path.toUpperCase() === 'INBOX') {
|
|
111
153
|
addSpecialUseMatch(entry, '\\Inbox', 'name');
|
|
112
154
|
}
|
|
113
155
|
|
|
156
|
+
// Strip leading delimiter (some servers prepend it to paths)
|
|
114
157
|
if (entry.delimiter && entry.path.charAt(0) === entry.delimiter) {
|
|
115
158
|
entry.path = entry.path.slice(1);
|
|
116
159
|
}
|
|
117
160
|
|
|
161
|
+
// Build parent path hierarchy for tree construction and sorting
|
|
118
162
|
entry.parentPath = entry.delimiter && entry.path ? entry.path.substr(0, entry.path.lastIndexOf(entry.delimiter)) : '';
|
|
119
163
|
entry.parent = entry.delimiter ? entry.path.split(entry.delimiter) : [entry.path];
|
|
120
164
|
entry.name = entry.parent.pop();
|
|
121
165
|
|
|
166
|
+
// Try to detect special-use from server flags or well-known names
|
|
167
|
+
// (e.g., "Sent", "Drafts", "Junk", "Trash")
|
|
122
168
|
let { flag: specialUseFlag, source: flagSource } = specialUse(
|
|
123
169
|
connection.capabilities.has('XLIST') || connection.capabilities.has('SPECIAL-USE'),
|
|
124
170
|
entry
|
|
@@ -131,6 +177,8 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
131
177
|
entries.push(entry);
|
|
132
178
|
},
|
|
133
179
|
|
|
180
|
+
// Inline STATUS response from LIST-STATUS extension (RFC 5819).
|
|
181
|
+
// Parses alternating key-value pairs (i % 2 pattern).
|
|
134
182
|
STATUS: async untagged => {
|
|
135
183
|
let statusPath = normalizePath(connection, decodePath(connection, (untagged.attributes[0] && untagged.attributes[0].value) || ''));
|
|
136
184
|
let statusList = untagged.attributes && Array.isArray(untagged.attributes[1]) ? untagged.attributes[1] : false;
|
|
@@ -138,8 +186,16 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
138
186
|
return;
|
|
139
187
|
}
|
|
140
188
|
|
|
141
|
-
|
|
189
|
+
const STATUS_FIELD_MAP = {
|
|
190
|
+
MESSAGES: { key: 'messages', parser: Number },
|
|
191
|
+
RECENT: { key: 'recent', parser: Number },
|
|
192
|
+
UIDNEXT: { key: 'uidNext', parser: Number },
|
|
193
|
+
UIDVALIDITY: { key: 'uidValidity', parser: BigInt },
|
|
194
|
+
UNSEEN: { key: 'unseen', parser: Number },
|
|
195
|
+
HIGHESTMODSEQ: { key: 'highestModseq', parser: BigInt }
|
|
196
|
+
};
|
|
142
197
|
|
|
198
|
+
let key;
|
|
143
199
|
let map = { path: statusPath };
|
|
144
200
|
|
|
145
201
|
statusList.forEach((entry, i) => {
|
|
@@ -150,43 +206,18 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
150
206
|
if (!key || !entry || typeof entry.value !== 'string') {
|
|
151
207
|
return;
|
|
152
208
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
value = !isNaN(entry.value) ? Number(entry.value) : false;
|
|
158
|
-
break;
|
|
159
|
-
|
|
160
|
-
case 'RECENT':
|
|
161
|
-
key = 'recent';
|
|
162
|
-
value = !isNaN(entry.value) ? Number(entry.value) : false;
|
|
163
|
-
break;
|
|
164
|
-
|
|
165
|
-
case 'UIDNEXT':
|
|
166
|
-
key = 'uidNext';
|
|
167
|
-
value = !isNaN(entry.value) ? Number(entry.value) : false;
|
|
168
|
-
break;
|
|
169
|
-
|
|
170
|
-
case 'UIDVALIDITY':
|
|
171
|
-
key = 'uidValidity';
|
|
172
|
-
value = !isNaN(entry.value) ? BigInt(entry.value) : false;
|
|
173
|
-
break;
|
|
174
|
-
|
|
175
|
-
case 'UNSEEN':
|
|
176
|
-
key = 'unseen';
|
|
177
|
-
value = !isNaN(entry.value) ? Number(entry.value) : false;
|
|
178
|
-
break;
|
|
179
|
-
|
|
180
|
-
case 'HIGHESTMODSEQ':
|
|
181
|
-
key = 'highestModseq';
|
|
182
|
-
value = !isNaN(entry.value) ? BigInt(entry.value) : false;
|
|
183
|
-
break;
|
|
209
|
+
|
|
210
|
+
const fieldConfig = STATUS_FIELD_MAP[key.toUpperCase()];
|
|
211
|
+
if (!fieldConfig) {
|
|
212
|
+
return;
|
|
184
213
|
}
|
|
214
|
+
|
|
215
|
+
const value = !isNaN(entry.value) ? fieldConfig.parser(entry.value) : false;
|
|
185
216
|
if (value === false) {
|
|
186
217
|
return;
|
|
187
218
|
}
|
|
188
219
|
|
|
189
|
-
map[key] = value;
|
|
220
|
+
map[fieldConfig.key] = value;
|
|
190
221
|
});
|
|
191
222
|
|
|
192
223
|
statusMap.set(statusPath, map);
|
|
@@ -203,18 +234,22 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
203
234
|
return entries;
|
|
204
235
|
}
|
|
205
236
|
|
|
237
|
+
// When listing with a namespace prefix (e.g., "INBOX."), INBOX itself may
|
|
238
|
+
// not appear in results. Run a separate LIST for INBOX to ensure it's included.
|
|
206
239
|
if (normalizedReference && !specialUseMatches['\\Inbox']) {
|
|
207
|
-
// INBOX was most probably not included in the listing if namespace was used
|
|
208
240
|
await runList('', 'INBOX');
|
|
209
241
|
}
|
|
210
242
|
|
|
243
|
+
// Attach STATUS data to each selectable mailbox. If LIST-STATUS was used,
|
|
244
|
+
// data is already in statusMap; otherwise, fall back to individual STATUS commands.
|
|
211
245
|
if (options.statusQuery) {
|
|
212
246
|
for (let entry of entries) {
|
|
247
|
+
// \\Noselect and \\NonExistent mailboxes cannot hold messages
|
|
213
248
|
if (!entry.flags.has('\\Noselect') && !entry.flags.has('\\NonExistent')) {
|
|
214
249
|
if (statusMap.has(entry.path)) {
|
|
215
250
|
entry.status = statusMap.get(entry.path);
|
|
216
251
|
} else if (!statusMap.size) {
|
|
217
|
-
//
|
|
252
|
+
// Server didn't support LIST-STATUS; fall back to per-mailbox STATUS
|
|
218
253
|
try {
|
|
219
254
|
entry.status = await connection.run('STATUS', entry.path, options.statusQuery);
|
|
220
255
|
} catch (err) {
|
|
@@ -225,6 +260,10 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
225
260
|
}
|
|
226
261
|
}
|
|
227
262
|
|
|
263
|
+
// LSUB (RFC 3501 6.3.9): queries which mailboxes the user is subscribed to.
|
|
264
|
+
// We merge subscription info into the entries already collected from LIST.
|
|
265
|
+
// Subscribed-only mailboxes that weren't in LIST are intentionally ignored
|
|
266
|
+
// (they may be phantom entries from old subscriptions to deleted mailboxes).
|
|
228
267
|
response = await connection.exec(
|
|
229
268
|
'LSUB',
|
|
230
269
|
[encodePath(connection, normalizePath(connection, reference || '')), encodePath(connection, normalizePath(connection, mailbox || '', true))],
|
|
@@ -255,26 +294,23 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
255
294
|
entry.parent = entry.delimiter ? entry.path.split(entry.delimiter) : [entry.path];
|
|
256
295
|
entry.name = entry.parent.pop();
|
|
257
296
|
|
|
297
|
+
// Merge LSUB data into existing LIST entry if found
|
|
258
298
|
let existing = entries.find(existing => existing.path === entry.path);
|
|
259
299
|
if (existing) {
|
|
260
300
|
existing.subscribed = true;
|
|
301
|
+
// Merge any additional flags from LSUB into the LIST entry
|
|
261
302
|
entry.flags.forEach(flag => existing.flags.add(flag));
|
|
262
|
-
} else {
|
|
263
|
-
// ignore non-listed folders
|
|
264
|
-
/*
|
|
265
|
-
let specialUseFlag = specialUse(connection.capabilities.has('XLIST') || connection.capabilities.has('SPECIAL-USE'), entry);
|
|
266
|
-
if (specialUseFlag && !flagsSeen.has(specialUseFlag)) {
|
|
267
|
-
entry.specialUse = specialUseFlag;
|
|
268
|
-
}
|
|
269
|
-
entries.push(entry);
|
|
270
|
-
*/
|
|
271
303
|
}
|
|
304
|
+
// Non-listed subscribed folders are intentionally ignored
|
|
272
305
|
}
|
|
273
306
|
}
|
|
274
307
|
}
|
|
275
308
|
);
|
|
276
309
|
response.next();
|
|
277
310
|
|
|
311
|
+
// Resolve special-use conflicts: for each type, pick the best candidate
|
|
312
|
+
// based on source priority (user > extension > name), then alphabetically.
|
|
313
|
+
// Only the winning entry gets the specialUse property set.
|
|
278
314
|
for (let type of Object.keys(specialUseMatches)) {
|
|
279
315
|
let sortedEntries = specialUseMatches[type].sort((a, b) => {
|
|
280
316
|
let aSource = SOURCE_SORT_ORDER.indexOf(a.source);
|
|
@@ -291,12 +327,14 @@ module.exports = async (connection, reference, mailbox, options) => {
|
|
|
291
327
|
}
|
|
292
328
|
}
|
|
293
329
|
|
|
330
|
+
// INBOX should always appear as subscribed regardless of LSUB results
|
|
294
331
|
let inboxEntry = entries.find(entry => entry.specialUse === '\\Inbox');
|
|
295
332
|
if (inboxEntry && !inboxEntry.subscribed) {
|
|
296
|
-
// override server settings and make INBOX always as subscribed
|
|
297
333
|
inboxEntry.subscribed = true;
|
|
298
334
|
}
|
|
299
335
|
|
|
336
|
+
// Sort: special-use mailboxes first (in FLAG_SORT_ORDER), then alphabetically
|
|
337
|
+
// by path segments for a natural folder hierarchy ordering.
|
|
300
338
|
return entries.sort((a, b) => {
|
|
301
339
|
if (a.specialUse && !b.specialUse) {
|
|
302
340
|
return -1;
|
package/lib/commands/login.js
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
const { getStatusCode, getErrorText } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Authenticates user using the IMAP LOGIN command.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} username - The username to authenticate with
|
|
10
|
+
* @param {string} password - The password to authenticate with
|
|
11
|
+
* @returns {Promise<string|undefined>} The authenticated username, or undefined if already authenticated
|
|
12
|
+
* @throws {Error} If authentication fails, with authenticationFailed and serverResponseCode properties set
|
|
13
|
+
*/
|
|
6
14
|
module.exports = async (connection, username, password) => {
|
|
7
15
|
if (connection.state !== connection.states.NOT_AUTHENTICATED) {
|
|
8
16
|
// nothing to do here
|
|
@@ -12,10 +20,13 @@ module.exports = async (connection, username, password) => {
|
|
|
12
20
|
try {
|
|
13
21
|
let response = await connection.exec('LOGIN', [
|
|
14
22
|
{ type: 'STRING', value: username },
|
|
23
|
+
// sensitive: true prevents the password from appearing in debug logs
|
|
15
24
|
{ type: 'STRING', value: password, sensitive: true }
|
|
16
25
|
]);
|
|
17
26
|
response.next();
|
|
18
27
|
|
|
28
|
+
// Record that LOGIN was the method used, so the connection knows which
|
|
29
|
+
// auth mechanism succeeded (used for reconnection and diagnostics).
|
|
19
30
|
connection.authCapabilities.set('LOGIN', true);
|
|
20
31
|
|
|
21
32
|
return username;
|
package/lib/commands/logout.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Logs out the user and closes the connection.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} connection - IMAP connection instance
|
|
7
|
+
* @returns {Promise<boolean>} True if logout command succeeded, false otherwise
|
|
8
|
+
*/
|
|
4
9
|
module.exports = async connection => {
|
|
5
10
|
if (connection.state === connection.states.LOGOUT) {
|
|
6
11
|
// nothing to do here
|
|
@@ -8,6 +13,7 @@ module.exports = async connection => {
|
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
if (connection.state === connection.states.NOT_AUTHENTICATED) {
|
|
16
|
+
// Not yet authenticated -- no LOGOUT command needed; just close the socket.
|
|
11
17
|
connection.state = connection.states.LOGOUT;
|
|
12
18
|
connection.close();
|
|
13
19
|
return false;
|
|
@@ -18,13 +24,16 @@ module.exports = async connection => {
|
|
|
18
24
|
response = await connection.exec('LOGOUT');
|
|
19
25
|
return true;
|
|
20
26
|
} catch (err) {
|
|
27
|
+
// If the connection is already gone, treat as successful logout
|
|
21
28
|
if (err.code === 'NoConnection') {
|
|
22
29
|
return true;
|
|
23
30
|
}
|
|
24
31
|
connection.log.warn({ err, cid: connection.id });
|
|
25
32
|
return false;
|
|
26
33
|
} finally {
|
|
27
|
-
//
|
|
34
|
+
// Set state to LOGOUT before closing to prevent any further commands from
|
|
35
|
+
// being queued. The socket is closed unconditionally in this finally block
|
|
36
|
+
// regardless of whether the LOGOUT command succeeded or failed.
|
|
28
37
|
connection.state = connection.states.LOGOUT;
|
|
29
38
|
if (response && typeof response.next === 'function') {
|
|
30
39
|
response.next();
|
package/lib/commands/move.js
CHANGED
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
const { normalizePath, encodePath, expandRange, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Moves messages from the current mailbox to another mailbox.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} range - Message sequence number or UID range
|
|
10
|
+
* @param {string} destination - Destination mailbox path
|
|
11
|
+
* @param {Object} [options] - Move options
|
|
12
|
+
* @param {boolean} [options.uid] - If true, use UID MOVE instead of MOVE
|
|
13
|
+
* @returns {Promise<{path: string, destination: string, uidValidity?: BigInt, uidMap?: Map}|boolean|undefined>} Move result with UID mapping if available, false on failure, or undefined if preconditions not met
|
|
14
|
+
*/
|
|
6
15
|
module.exports = async (connection, range, destination, options) => {
|
|
7
16
|
if (connection.state !== connection.states.SELECTED || !range || !destination) {
|
|
8
17
|
// nothing to do here
|
|
@@ -19,12 +28,17 @@ module.exports = async (connection, range, destination, options) => {
|
|
|
19
28
|
|
|
20
29
|
let map = { path: connection.mailbox.path, destination };
|
|
21
30
|
|
|
31
|
+
// Fallback for servers without the MOVE extension (RFC 6851):
|
|
32
|
+
// emulate MOVE using COPY + flag as \Deleted + EXPUNGE.
|
|
22
33
|
if (!connection.capabilities.has('MOVE')) {
|
|
23
34
|
let result = await connection.messageCopy(range, destination, options);
|
|
24
35
|
await connection.messageDelete(range, Object.assign({ silent: true }, options));
|
|
25
36
|
return result;
|
|
26
37
|
}
|
|
27
38
|
|
|
39
|
+
// Extract COPYUID response code (UIDPLUS, RFC 4315) from either an untagged
|
|
40
|
+
// OK response or the final tagged OK. MOVE uses the same COPYUID format as COPY
|
|
41
|
+
// to report the source-to-destination UID mapping.
|
|
28
42
|
let checkMoveInfo = response => {
|
|
29
43
|
let section = response.attributes && response.attributes[0] && response.attributes[0].section;
|
|
30
44
|
let responseCode = section && section.length && section[0] && typeof section[0].value === 'string' ? section[0].value : '';
|
|
@@ -48,6 +62,8 @@ module.exports = async (connection, range, destination, options) => {
|
|
|
48
62
|
|
|
49
63
|
let response;
|
|
50
64
|
try {
|
|
65
|
+
// Some servers send COPYUID in an untagged OK before the tagged response,
|
|
66
|
+
// others include it in the tagged OK. We check both to be safe.
|
|
51
67
|
response = await connection.exec(options.uid ? 'UID MOVE' : 'MOVE', attributes, {
|
|
52
68
|
untagged: {
|
|
53
69
|
OK: async untagged => {
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Requests NAMESPACE info from the server.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} connection - IMAP connection instance
|
|
7
|
+
* @returns {Promise<{prefix: string, delimiter: string}|{error: boolean, status: string, text: string}>} The primary personal namespace, or an error object on failure
|
|
8
|
+
*/
|
|
4
9
|
module.exports = async connection => {
|
|
5
10
|
if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
|
|
6
11
|
// nothing to do here
|
|
@@ -8,8 +13,12 @@ module.exports = async connection => {
|
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
if (!connection.capabilities.has('NAMESPACE')) {
|
|
11
|
-
//
|
|
16
|
+
// Fallback: when the server does not support the NAMESPACE extension (RFC 2342),
|
|
17
|
+
// derive the prefix and delimiter from a LIST "" "" command, which returns
|
|
18
|
+
// the hierarchy delimiter and root name for the default mailbox hierarchy.
|
|
12
19
|
let { prefix, delimiter } = await getListPrefix(connection);
|
|
20
|
+
// Ensure the prefix ends with the delimiter so that appending a mailbox name
|
|
21
|
+
// produces a valid path (e.g., "INBOX." + "Sent" = "INBOX.Sent").
|
|
13
22
|
if (delimiter && prefix && prefix.charAt(prefix.length - 1) !== delimiter) {
|
|
14
23
|
prefix += delimiter;
|
|
15
24
|
}
|
|
@@ -28,6 +37,11 @@ module.exports = async connection => {
|
|
|
28
37
|
let map = {};
|
|
29
38
|
response = await connection.exec('NAMESPACE', false, {
|
|
30
39
|
untagged: {
|
|
40
|
+
// The NAMESPACE response (RFC 2342) contains exactly three sections:
|
|
41
|
+
// [0] = personal namespaces (user's own mailboxes)
|
|
42
|
+
// [1] = other users' namespaces (shared by other users)
|
|
43
|
+
// [2] = shared namespaces (public/organizational folders)
|
|
44
|
+
// Each section is either NIL or a list of (prefix, delimiter) pairs.
|
|
31
45
|
NAMESPACE: async untagged => {
|
|
32
46
|
if (!untagged.attributes || !untagged.attributes.length) {
|
|
33
47
|
return;
|
|
@@ -60,10 +74,18 @@ module.exports = async connection => {
|
|
|
60
74
|
}
|
|
61
75
|
};
|
|
62
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Derives namespace prefix and delimiter from a LIST command when NAMESPACE is not supported.
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} connection - IMAP connection instance
|
|
81
|
+
* @returns {Promise<{prefix?: string, delimiter?: string, flags?: Set}>} Object with prefix, delimiter, and flags, or empty object on failure
|
|
82
|
+
*/
|
|
63
83
|
async function getListPrefix(connection) {
|
|
64
84
|
let response;
|
|
65
85
|
try {
|
|
66
86
|
let map = {};
|
|
87
|
+
// LIST "" "" is a special form that returns only the hierarchy delimiter
|
|
88
|
+
// and the root name, without listing any actual mailboxes.
|
|
67
89
|
response = await connection.exec('LIST', ['', ''], {
|
|
68
90
|
untagged: {
|
|
69
91
|
LIST: async untagged => {
|
|
@@ -88,6 +110,12 @@ async function getListPrefix(connection) {
|
|
|
88
110
|
}
|
|
89
111
|
}
|
|
90
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Parses namespace information from an IMAP NAMESPACE response attribute.
|
|
115
|
+
*
|
|
116
|
+
* @param {Array} attribute - Namespace attribute array from the server response
|
|
117
|
+
* @returns {Array<{prefix: string, delimiter: string}>|boolean} Array of namespace entries, or false if empty
|
|
118
|
+
*/
|
|
91
119
|
function getNamsepaceInfo(attribute) {
|
|
92
120
|
if (!attribute || !attribute.length) {
|
|
93
121
|
return false;
|
|
@@ -99,6 +127,8 @@ function getNamsepaceInfo(attribute) {
|
|
|
99
127
|
let prefix = entry[0].value;
|
|
100
128
|
let delimiter = entry[1].value;
|
|
101
129
|
|
|
130
|
+
// Append the delimiter to the prefix if it doesn't already end with one,
|
|
131
|
+
// so callers can construct full paths by simply concatenating prefix + name.
|
|
102
132
|
if (delimiter && prefix && prefix.charAt(prefix.length - 1) !== delimiter) {
|
|
103
133
|
prefix += delimiter;
|
|
104
134
|
}
|
package/lib/commands/noop.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Sends a NOOP command to the server.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} connection - IMAP connection instance
|
|
7
|
+
* @returns {Promise<boolean>} True on success, false on failure
|
|
8
|
+
*/
|
|
4
9
|
module.exports = async connection => {
|
|
5
10
|
try {
|
|
6
11
|
let response = await connection.exec('NOOP', false, { comment: 'Requested by command' });
|
package/lib/commands/quota.js
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Requests quota information for a mailbox.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} path - Mailbox path to query quota for
|
|
10
|
+
* @returns {Promise<{path: string, quotaRoot?: string, storage?: {usage: number, limit: number, status: string}, message?: {usage: number, limit: number, status: string}}|boolean|undefined>} Quota information object, false if QUOTA not supported or on failure, or undefined if preconditions not met
|
|
11
|
+
*/
|
|
6
12
|
module.exports = async (connection, path) => {
|
|
7
13
|
if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state) || !path) {
|
|
8
14
|
// nothing to do here
|
|
@@ -17,6 +23,11 @@ module.exports = async (connection, path) => {
|
|
|
17
23
|
|
|
18
24
|
let map = { path };
|
|
19
25
|
|
|
26
|
+
// Parse a QUOTA response. The resource list uses a repeating triplet pattern (i % 3):
|
|
27
|
+
// position 0: resource name (e.g., "STORAGE", "MESSAGE")
|
|
28
|
+
// position 1: current usage
|
|
29
|
+
// position 2: limit
|
|
30
|
+
// Storage values are in KB on the wire; multiply by 1024 to report bytes.
|
|
20
31
|
let processQuotaResponse = untagged => {
|
|
21
32
|
let attributes = untagged.attributes && untagged.attributes[1];
|
|
22
33
|
if (!attributes || !attributes.length) {
|
|
@@ -25,10 +36,13 @@ module.exports = async (connection, path) => {
|
|
|
25
36
|
|
|
26
37
|
let key = false;
|
|
27
38
|
attributes.forEach((attribute, i) => {
|
|
28
|
-
|
|
39
|
+
const position = i % 3;
|
|
40
|
+
|
|
41
|
+
if (position === 0) {
|
|
29
42
|
key = attribute && typeof attribute.value === 'string' ? attribute.value.toLowerCase() : false;
|
|
30
43
|
return;
|
|
31
44
|
}
|
|
45
|
+
|
|
32
46
|
if (!key) {
|
|
33
47
|
return;
|
|
34
48
|
}
|
|
@@ -38,21 +52,18 @@ module.exports = async (connection, path) => {
|
|
|
38
52
|
return;
|
|
39
53
|
}
|
|
40
54
|
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
if (!map[key]) {
|
|
44
|
-
map[key] = {};
|
|
45
|
-
}
|
|
46
|
-
map[key].usage = value * (key === 'storage' ? 1024 : 1);
|
|
55
|
+
if (!map[key]) {
|
|
56
|
+
map[key] = {};
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (!map[key]) {
|
|
52
|
-
map[key] = {};
|
|
53
|
-
}
|
|
54
|
-
map[key].limit = value * (key === 'storage' ? 1024 : 1);
|
|
59
|
+
// Storage quota is reported in KB by IMAP; convert to bytes for consistency
|
|
60
|
+
const multiplier = key === 'storage' ? 1024 : 1;
|
|
55
61
|
|
|
62
|
+
if (position === 1) {
|
|
63
|
+
map[key].usage = value * multiplier;
|
|
64
|
+
} else if (position === 2) {
|
|
65
|
+
map[key].limit = value * multiplier;
|
|
66
|
+
// Calculate usage percentage for convenient display
|
|
56
67
|
if (map[key].limit) {
|
|
57
68
|
map[key].status = Math.round(((map[key].usage || 0) / map[key].limit) * 100) + '%';
|
|
58
69
|
}
|
|
@@ -63,8 +74,13 @@ module.exports = async (connection, path) => {
|
|
|
63
74
|
let quotaFound = false;
|
|
64
75
|
let response;
|
|
65
76
|
try {
|
|
77
|
+
// Two-step quota lookup: GETQUOTAROOT identifies the quota root for a mailbox,
|
|
78
|
+
// and the server usually sends the QUOTA response inline. Some servers only
|
|
79
|
+
// send the root name and require a separate GETQUOTA command.
|
|
66
80
|
response = await connection.exec('GETQUOTAROOT', [{ type: 'ATOM', value: encodePath(connection, path) }], {
|
|
67
81
|
untagged: {
|
|
82
|
+
// QUOTAROOT response tells us which quota root applies to this mailbox.
|
|
83
|
+
// A mailbox may have zero or one quota root.
|
|
68
84
|
QUOTAROOT: async untagged => {
|
|
69
85
|
let quotaRoot =
|
|
70
86
|
untagged.attributes && untagged.attributes[1] && typeof untagged.attributes[1].value === 'string'
|
|
@@ -74,6 +90,7 @@ module.exports = async (connection, path) => {
|
|
|
74
90
|
map.quotaRoot = quotaRoot;
|
|
75
91
|
}
|
|
76
92
|
},
|
|
93
|
+
// QUOTA response provides the actual resource usage and limits
|
|
77
94
|
QUOTA: async untagged => {
|
|
78
95
|
quotaFound = true;
|
|
79
96
|
processQuotaResponse(untagged);
|
|
@@ -83,6 +100,8 @@ module.exports = async (connection, path) => {
|
|
|
83
100
|
|
|
84
101
|
response.next();
|
|
85
102
|
|
|
103
|
+
// Fallback: if we got a quota root but no QUOTA response inline,
|
|
104
|
+
// explicitly request quota for that root.
|
|
86
105
|
if (map.quotaRoot && !quotaFound) {
|
|
87
106
|
response = await connection.exec('GETQUOTA', [{ type: 'ATOM', value: map.quotaRoot }], {
|
|
88
107
|
untagged: {
|
package/lib/commands/rename.js
CHANGED
|
@@ -2,16 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
const { encodePath, normalizePath, enhanceCommandError } = require('../tools.js');
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Renames an existing mailbox.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} connection - IMAP connection instance
|
|
9
|
+
* @param {string} path - Current mailbox path
|
|
10
|
+
* @param {string} newPath - New mailbox path
|
|
11
|
+
* @returns {Promise<{path: string, newPath: string}|undefined>} Object with old and new paths, or undefined if preconditions not met
|
|
12
|
+
* @throws {Error} If the RENAME command fails
|
|
13
|
+
*/
|
|
6
14
|
module.exports = async (connection, path, newPath) => {
|
|
7
15
|
if (![connection.states.AUTHENTICATED, connection.states.SELECTED].includes(connection.state)) {
|
|
8
16
|
// nothing to do here
|
|
9
17
|
return;
|
|
10
18
|
}
|
|
11
19
|
|
|
20
|
+
// Normalize both paths (resolve special names, apply namespace prefix) and encode
|
|
21
|
+
// them for the IMAP wire format (modified UTF-7 for non-ASCII characters).
|
|
12
22
|
path = normalizePath(connection, path);
|
|
13
23
|
newPath = normalizePath(connection, newPath);
|
|
14
24
|
|
|
25
|
+
// Must close/deselect the mailbox before renaming if it's currently selected,
|
|
26
|
+
// as IMAP servers will not rename an active mailbox.
|
|
15
27
|
if (connection.state === connection.states.SELECTED && connection.mailbox.path === path) {
|
|
16
28
|
await connection.run('CLOSE');
|
|
17
29
|
}
|