imapflow 1.2.13 → 1.2.14
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/lib/charsets.js +1 -4
- package/lib/commands/append.js +1 -1
- package/lib/commands/copy.js +3 -24
- package/lib/commands/copyuid-parser.js +33 -0
- package/lib/commands/idle.js +5 -7
- package/lib/commands/list.js +1 -1
- package/lib/commands/move.js +4 -27
- package/lib/commands/store.js +1 -3
- package/lib/handler/imap-compiler.js +1 -3
- package/lib/handler/imap-formal-syntax.js +1 -7
- package/lib/handler/imap-parser.js +1 -1
- package/lib/handler/imap-stream.js +7 -13
- package/lib/handler/parser-instance.js +2 -2
- package/lib/handler/token-parser.js +9 -9
- package/lib/imap-flow.js +80 -117
- package/lib/search-compiler.js +4 -10
- package/lib/tools.js +29 -36
- package/package.json +2 -2
- package/test/charsets-test.js +161 -0
- package/test/commands-integration-test.js +61 -0
- package/test/connection-edge-cases-test.js +94 -0
- package/test/copyuid-parser-test.js +153 -0
- package/test/imap-compiler-test.js +66 -0
- package/test/imap-formal-syntax-test.js +275 -0
- package/test/imap-stream-edge-cases-test.js +268 -0
- package/test/imapflow-test.js +212 -0
- package/test/search-compiler-test.js +74 -0
- package/test/token-parser-test.js +374 -0
- package/test/tools-test.js +149 -2
package/test/imapflow-test.js
CHANGED
|
@@ -42,3 +42,215 @@ module.exports['Create imapflow instance with custom logger'] = async test => {
|
|
|
42
42
|
}
|
|
43
43
|
test.done();
|
|
44
44
|
};
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Helpers for testing prototype methods on a mock context
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
const createFetchContext = runOverride => ({
|
|
50
|
+
mailbox: { path: 'INBOX', exists: 10 },
|
|
51
|
+
isClosed: false,
|
|
52
|
+
socket: { destroyed: false },
|
|
53
|
+
resolveRange: async range => range,
|
|
54
|
+
run: runOverride
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function callFetch(ctx, range, query, options) {
|
|
58
|
+
return ImapFlow.prototype.fetch.call(ctx, range, query, options);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// setFlagColor tests
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
module.exports['setFlagColor with valid color calls STORE for add and remove'] = async test => {
|
|
66
|
+
let storeCalls = [];
|
|
67
|
+
const ctx = {
|
|
68
|
+
resolveRange: async range => range,
|
|
69
|
+
run: async (command, range, flags, opts) => {
|
|
70
|
+
storeCalls.push({ command, range, flags, operation: opts.operation });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let result = await ImapFlow.prototype.setFlagColor.call(ctx, '1:*', 'orange');
|
|
76
|
+
|
|
77
|
+
test.equal(storeCalls.length, 2, 'run should be called twice');
|
|
78
|
+
|
|
79
|
+
test.equal(storeCalls[0].command, 'STORE');
|
|
80
|
+
test.equal(storeCalls[0].operation, 'add');
|
|
81
|
+
test.ok(storeCalls[0].flags.indexOf('\\Flagged') >= 0, 'add flags should include \\Flagged');
|
|
82
|
+
test.ok(storeCalls[0].flags.indexOf('$MailFlagBit0') >= 0, 'add flags should include $MailFlagBit0');
|
|
83
|
+
|
|
84
|
+
test.equal(storeCalls[1].command, 'STORE');
|
|
85
|
+
test.equal(storeCalls[1].operation, 'remove');
|
|
86
|
+
test.ok(storeCalls[1].flags.indexOf('$MailFlagBit1') >= 0, 'remove flags should include $MailFlagBit1');
|
|
87
|
+
test.ok(storeCalls[1].flags.indexOf('$MailFlagBit2') >= 0, 'remove flags should include $MailFlagBit2');
|
|
88
|
+
|
|
89
|
+
test.ok(result, 'should return truthy result');
|
|
90
|
+
test.done();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
module.exports['setFlagColor with invalid color returns false'] = async test => {
|
|
94
|
+
let runCalled = false;
|
|
95
|
+
const ctx = {
|
|
96
|
+
resolveRange: async range => range,
|
|
97
|
+
run: async () => {
|
|
98
|
+
runCalled = true;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let result = await ImapFlow.prototype.setFlagColor.call(ctx, '1:*', 'invalid');
|
|
103
|
+
|
|
104
|
+
test.strictEqual(result, false, 'should return false for invalid color');
|
|
105
|
+
test.ok(!runCalled, 'run should not be called');
|
|
106
|
+
test.done();
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
module.exports['setFlagColor with empty range returns false'] = async test => {
|
|
110
|
+
let runCalled = false;
|
|
111
|
+
const ctx = {
|
|
112
|
+
resolveRange: async () => null,
|
|
113
|
+
run: async () => {
|
|
114
|
+
runCalled = true;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
let result = await ImapFlow.prototype.setFlagColor.call(ctx, '1:*', 'orange');
|
|
119
|
+
|
|
120
|
+
test.strictEqual(result, false, 'should return false for empty range');
|
|
121
|
+
test.ok(!runCalled, 'run should not be called');
|
|
122
|
+
test.done();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
module.exports['setFlagColor with red calls STORE with Flagged in add'] = async test => {
|
|
126
|
+
let storeCalls = [];
|
|
127
|
+
const ctx = {
|
|
128
|
+
resolveRange: async range => range,
|
|
129
|
+
run: async (command, range, flags, opts) => {
|
|
130
|
+
storeCalls.push({ command, range, flags, operation: opts.operation });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
let result = await ImapFlow.prototype.setFlagColor.call(ctx, '1:*', 'red');
|
|
136
|
+
|
|
137
|
+
// red is index 0 (all bits zero), so add should contain only \Flagged,
|
|
138
|
+
// and remove should contain all three MailFlagBit flags
|
|
139
|
+
test.ok(storeCalls.length >= 1, 'run should be called at least once');
|
|
140
|
+
|
|
141
|
+
let addCall = storeCalls.find(c => c.operation === 'add');
|
|
142
|
+
test.ok(addCall, 'should have an add operation');
|
|
143
|
+
test.ok(addCall.flags.indexOf('\\Flagged') >= 0, 'add flags should include \\Flagged for red');
|
|
144
|
+
|
|
145
|
+
let removeCall = storeCalls.find(c => c.operation === 'remove');
|
|
146
|
+
test.ok(removeCall, 'should have a remove operation');
|
|
147
|
+
test.ok(removeCall.flags.indexOf('$MailFlagBit0') >= 0, 'remove flags should include $MailFlagBit0');
|
|
148
|
+
test.ok(removeCall.flags.indexOf('$MailFlagBit1') >= 0, 'remove flags should include $MailFlagBit1');
|
|
149
|
+
test.ok(removeCall.flags.indexOf('$MailFlagBit2') >= 0, 'remove flags should include $MailFlagBit2');
|
|
150
|
+
|
|
151
|
+
test.ok(result, 'should return truthy result');
|
|
152
|
+
test.done();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// status test
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
module.exports['status delegates to run with STATUS command'] = async test => {
|
|
160
|
+
let runArgs = null;
|
|
161
|
+
const ctx = {
|
|
162
|
+
run: async (...args) => {
|
|
163
|
+
runArgs = args;
|
|
164
|
+
return { messages: 10, unseen: 3 };
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
let result = await ImapFlow.prototype.status.call(ctx, 'INBOX', { unseen: true });
|
|
169
|
+
|
|
170
|
+
test.deepEqual(runArgs, ['STATUS', 'INBOX', { unseen: true }], 'run should be called with correct arguments');
|
|
171
|
+
test.deepEqual(result, { messages: 10, unseen: 3 }, 'should return the result from run');
|
|
172
|
+
test.done();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// getQuota tests
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
module.exports['getQuota delegates to run with QUOTA command'] = async test => {
|
|
180
|
+
let runArgs = null;
|
|
181
|
+
const ctx = {
|
|
182
|
+
run: async (...args) => {
|
|
183
|
+
runArgs = args;
|
|
184
|
+
return { storage: { usage: 1024, limit: 10240 } };
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
let result = await ImapFlow.prototype.getQuota.call(ctx, 'Sent');
|
|
189
|
+
|
|
190
|
+
test.deepEqual(runArgs, ['QUOTA', 'Sent'], 'run should be called with correct arguments');
|
|
191
|
+
test.ok(result.storage, 'should return quota result');
|
|
192
|
+
test.done();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
module.exports['getQuota defaults path to INBOX'] = async test => {
|
|
196
|
+
let runArgs = null;
|
|
197
|
+
const ctx = {
|
|
198
|
+
run: async (...args) => {
|
|
199
|
+
runArgs = args;
|
|
200
|
+
return { storage: { usage: 512, limit: 10240 } };
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
let result = await ImapFlow.prototype.getQuota.call(ctx);
|
|
205
|
+
|
|
206
|
+
test.deepEqual(runArgs, ['QUOTA', 'INBOX'], 'run should be called with INBOX as default path');
|
|
207
|
+
test.ok(result.storage, 'should return quota result');
|
|
208
|
+
test.done();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// fetch generator: error propagation tests
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
module.exports['fetch generator: run error propagates to consumer'] = async test => {
|
|
216
|
+
const run = async () => {
|
|
217
|
+
throw new Error('FETCH failed');
|
|
218
|
+
};
|
|
219
|
+
const ctx = createFetchContext(run);
|
|
220
|
+
let caught = false;
|
|
221
|
+
try {
|
|
222
|
+
// eslint-disable-next-line no-unused-vars
|
|
223
|
+
for await (let msg of callFetch(ctx, '1:5', { uid: true })) {
|
|
224
|
+
// should not reach here
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
caught = true;
|
|
228
|
+
test.equal(err.message, 'FETCH failed');
|
|
229
|
+
}
|
|
230
|
+
test.ok(caught, 'error was caught');
|
|
231
|
+
test.done();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
module.exports['fetch generator: socket destroyed mid-iteration throws EConnectionClosed'] = async test => {
|
|
235
|
+
const run = async (cmd, range, query, opts) => {
|
|
236
|
+
for (let i = 0; i < 3; i++) {
|
|
237
|
+
await new Promise(resolve => {
|
|
238
|
+
opts.onUntaggedFetch({ seq: i + 1, uid: 400 + i }, () => resolve());
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const ctx = createFetchContext(run);
|
|
243
|
+
let caught = false;
|
|
244
|
+
try {
|
|
245
|
+
for await (let msg of callFetch(ctx, '1:3', { uid: true })) {
|
|
246
|
+
if (msg.seq === 1) {
|
|
247
|
+
ctx.socket.destroyed = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
caught = true;
|
|
252
|
+
test.equal(err.code, 'EConnectionClosed');
|
|
253
|
+
}
|
|
254
|
+
test.ok(caught, 'error was caught');
|
|
255
|
+
test.done();
|
|
256
|
+
};
|
|
@@ -861,3 +861,77 @@ module.exports['Search Compiler: OR combined with other criteria'] = test => {
|
|
|
861
861
|
test.ok(hasAttr(compiled, 'OR'));
|
|
862
862
|
test.done();
|
|
863
863
|
};
|
|
864
|
+
|
|
865
|
+
// ============================================
|
|
866
|
+
// OR tree structure tests
|
|
867
|
+
// ============================================
|
|
868
|
+
|
|
869
|
+
module.exports['Search Compiler: OR with 3 conditions builds binary tree'] = test => {
|
|
870
|
+
let connection = createMockConnection();
|
|
871
|
+
let compiled = searchCompiler(connection, {
|
|
872
|
+
or: [{ from: 'alice' }, { to: 'bob' }, { subject: 'test' }]
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test.ok(hasAttr(compiled, 'OR'), 'should contain OR atom');
|
|
876
|
+
test.ok(hasAttr(compiled, 'alice'), 'should contain alice');
|
|
877
|
+
test.ok(hasAttr(compiled, 'bob'), 'should contain bob');
|
|
878
|
+
test.ok(hasAttr(compiled, 'test'), 'should contain test');
|
|
879
|
+
test.ok(hasAttr(compiled, 'FROM'), 'should contain FROM');
|
|
880
|
+
test.ok(hasAttr(compiled, 'TO'), 'should contain TO');
|
|
881
|
+
test.ok(hasAttr(compiled, 'SUBJECT'), 'should contain SUBJECT');
|
|
882
|
+
test.done();
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
module.exports['Search Compiler: OR with 5 conditions produces 4 OR atoms'] = test => {
|
|
886
|
+
let connection = createMockConnection();
|
|
887
|
+
let compiled = searchCompiler(connection, {
|
|
888
|
+
or: [{ from: 'a' }, { from: 'b' }, { from: 'c' }, { from: 'd' }, { from: 'e' }]
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// 5 conditions need 4 OR atoms in a binary tree structure
|
|
892
|
+
let orCount = compiled.filter(a => a.value === 'OR').length;
|
|
893
|
+
test.equal(orCount, 4, 'should have exactly 4 OR atoms for 5 conditions');
|
|
894
|
+
test.ok(hasAttr(compiled, 'a'), 'should contain a');
|
|
895
|
+
test.ok(hasAttr(compiled, 'b'), 'should contain b');
|
|
896
|
+
test.ok(hasAttr(compiled, 'c'), 'should contain c');
|
|
897
|
+
test.ok(hasAttr(compiled, 'd'), 'should contain d');
|
|
898
|
+
test.ok(hasAttr(compiled, 'e'), 'should contain e');
|
|
899
|
+
test.done();
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
// ============================================
|
|
903
|
+
// Unicode CHARSET in HEADER searches
|
|
904
|
+
// ============================================
|
|
905
|
+
|
|
906
|
+
module.exports['Search Compiler: Unicode header search without UTF8=ACCEPT adds CHARSET'] = test => {
|
|
907
|
+
let connection = createMockConnection({
|
|
908
|
+
enabled: new Set()
|
|
909
|
+
});
|
|
910
|
+
let compiled = searchCompiler(connection, {
|
|
911
|
+
header: { subject: 'caf\u00e9' }
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test.ok(hasAttr(compiled, 'CHARSET'), 'should have CHARSET prefix');
|
|
915
|
+
test.ok(hasAttr(compiled, 'UTF-8'), 'should have UTF-8 value');
|
|
916
|
+
// CHARSET and UTF-8 should be the first two entries
|
|
917
|
+
test.equal(compiled[0].value, 'CHARSET', 'CHARSET should be first');
|
|
918
|
+
test.equal(compiled[1].value, 'UTF-8', 'UTF-8 should be second');
|
|
919
|
+
test.ok(hasAttr(compiled, 'HEADER'), 'should contain HEADER');
|
|
920
|
+
test.ok(hasAttr(compiled, 'caf\u00e9'), 'should contain the unicode value');
|
|
921
|
+
test.done();
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
module.exports['Search Compiler: Unicode header search with UTF8=ACCEPT skips CHARSET'] = test => {
|
|
925
|
+
let connection = createMockConnection({
|
|
926
|
+
enabled: new Set(['UTF8=ACCEPT'])
|
|
927
|
+
});
|
|
928
|
+
let compiled = searchCompiler(connection, {
|
|
929
|
+
header: { subject: 'caf\u00e9' }
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
test.ok(!hasAttr(compiled, 'CHARSET'), 'should NOT have CHARSET prefix');
|
|
933
|
+
test.ok(!hasAttr(compiled, 'UTF-8'), 'should NOT have UTF-8 value');
|
|
934
|
+
test.ok(hasAttr(compiled, 'HEADER'), 'should contain HEADER');
|
|
935
|
+
test.ok(hasAttr(compiled, 'caf\u00e9'), 'should contain the unicode value');
|
|
936
|
+
test.done();
|
|
937
|
+
};
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/*eslint no-unused-expressions: 0, prefer-arrow-callback: 0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { parser } = require('../lib/handler/imap-handler');
|
|
6
|
+
|
|
7
|
+
let asyncWrapper = (test, handler) => {
|
|
8
|
+
handler(test)
|
|
9
|
+
.then(() => test.done())
|
|
10
|
+
.catch(err => {
|
|
11
|
+
test.ifError(err);
|
|
12
|
+
test.done();
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Error path tests
|
|
17
|
+
//
|
|
18
|
+
// NOTE: All error inputs use a non-status command (FETCH) rather than OK/NO/BAD/BYE.
|
|
19
|
+
// Status commands consume their entire remainder as human-readable text and never
|
|
20
|
+
// invoke the TokenParser, so errors like E9/E10/E13 cannot be triggered through
|
|
21
|
+
// the public parser() API when the command is a status response.
|
|
22
|
+
//
|
|
23
|
+
// Error-path tests use asyncWrapper with an internal try/catch so that err.code
|
|
24
|
+
// can be asserted. The handler re-throws when no error was caught so that a missing
|
|
25
|
+
// rejection is still surfaced as a test failure.
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* E9: Unclosed quoted string. The node's isClosed flag remains false when
|
|
29
|
+
* getAttributes() walks the tree, triggering ParserError9.
|
|
30
|
+
*/
|
|
31
|
+
module.exports['Token Parser: E9: unclosed quoted string throws ParserError9'] = test =>
|
|
32
|
+
asyncWrapper(test, async test => {
|
|
33
|
+
let err;
|
|
34
|
+
try {
|
|
35
|
+
await parser('* FETCH "unterminated');
|
|
36
|
+
} catch (e) {
|
|
37
|
+
err = e;
|
|
38
|
+
}
|
|
39
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
40
|
+
test.ok(err, 'expected an error to be thrown');
|
|
41
|
+
test.equal(err.code, 'ParserError9');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* E10: Unexpected list terminator. A ')' is encountered when the current node
|
|
46
|
+
* is not a LIST node, triggering ParserError10.
|
|
47
|
+
*/
|
|
48
|
+
module.exports['Token Parser: E10: unexpected ) throws ParserError10'] = test =>
|
|
49
|
+
asyncWrapper(test, async test => {
|
|
50
|
+
let err;
|
|
51
|
+
try {
|
|
52
|
+
await parser('* FETCH )');
|
|
53
|
+
} catch (e) {
|
|
54
|
+
err = e;
|
|
55
|
+
}
|
|
56
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
57
|
+
test.ok(err, 'expected an error to be thrown');
|
|
58
|
+
test.equal(err.code, 'ParserError10');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* E13: Unexpected control character. A character below 0x80 that is not in
|
|
63
|
+
* ATOM-CHAR and not one of the explicitly allowed exceptions (\, %) triggers
|
|
64
|
+
* ParserError13 in the STATE_NORMAL default branch.
|
|
65
|
+
* \x01 is a control character excluded from ATOM-CHAR.
|
|
66
|
+
*/
|
|
67
|
+
module.exports['Token Parser: E13: control character in attribute position throws ParserError13'] = test =>
|
|
68
|
+
asyncWrapper(test, async test => {
|
|
69
|
+
let err;
|
|
70
|
+
try {
|
|
71
|
+
await parser('* FETCH \x01rest');
|
|
72
|
+
} catch (e) {
|
|
73
|
+
err = e;
|
|
74
|
+
}
|
|
75
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
76
|
+
test.ok(err, 'expected an error to be thrown');
|
|
77
|
+
test.equal(err.code, 'ParserError13');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* E18: Backslash escape at end of quoted string input. When a backslash is
|
|
82
|
+
* encountered in STATE_STRING and there is no following character (i >= len),
|
|
83
|
+
* ParserError18 is thrown.
|
|
84
|
+
* The JS string '* FETCH "test\\' represents the IMAP input: * FETCH "test\
|
|
85
|
+
*/
|
|
86
|
+
module.exports['Token Parser: E18: escape at end of quoted string throws ParserError18'] = test =>
|
|
87
|
+
asyncWrapper(test, async test => {
|
|
88
|
+
let err;
|
|
89
|
+
try {
|
|
90
|
+
await parser('* FETCH "test\\');
|
|
91
|
+
} catch (e) {
|
|
92
|
+
err = e;
|
|
93
|
+
}
|
|
94
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
95
|
+
test.ok(err, 'expected an error to be thrown');
|
|
96
|
+
test.equal(err.code, 'ParserError18');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* E23: Empty literal braces. When '}' is encountered in STATE_LITERAL before
|
|
101
|
+
* any digit has been stored in literalLength, ParserError23 is thrown because
|
|
102
|
+
* the check is: if (!('literalLength' in this.currentNode)) throw E23.
|
|
103
|
+
*/
|
|
104
|
+
module.exports['Token Parser: E23: empty literal braces throws ParserError23'] = test =>
|
|
105
|
+
asyncWrapper(test, async test => {
|
|
106
|
+
let err;
|
|
107
|
+
try {
|
|
108
|
+
await parser('* FETCH {}', { literals: [] });
|
|
109
|
+
} catch (e) {
|
|
110
|
+
err = e;
|
|
111
|
+
}
|
|
112
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
113
|
+
test.ok(err, 'expected an error to be thrown');
|
|
114
|
+
test.equal(err.code, 'ParserError23');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* E25: Non-digit character inside literal size braces. Any character inside
|
|
119
|
+
* {…} that is not a digit (and not '}' or '+' with literalPlus) triggers
|
|
120
|
+
* ParserError25.
|
|
121
|
+
*/
|
|
122
|
+
module.exports['Token Parser: E25: non-digit in literal size throws ParserError25'] = test =>
|
|
123
|
+
asyncWrapper(test, async test => {
|
|
124
|
+
let err;
|
|
125
|
+
try {
|
|
126
|
+
await parser('* FETCH {abc}', { literals: [] });
|
|
127
|
+
} catch (e) {
|
|
128
|
+
err = e;
|
|
129
|
+
}
|
|
130
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
131
|
+
test.ok(err, 'expected an error to be thrown');
|
|
132
|
+
test.equal(err.code, 'ParserError25');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* E26: Leading zero in literal size. After '0' is stored as literalLength,
|
|
137
|
+
* any following digit triggers ParserError26 because a leading zero is invalid.
|
|
138
|
+
*/
|
|
139
|
+
module.exports['Token Parser: E26: leading zero in literal size throws ParserError26'] = test =>
|
|
140
|
+
asyncWrapper(test, async test => {
|
|
141
|
+
let err;
|
|
142
|
+
try {
|
|
143
|
+
await parser('* FETCH {01}', { literals: [Buffer.from('x')] });
|
|
144
|
+
} catch (e) {
|
|
145
|
+
err = e;
|
|
146
|
+
}
|
|
147
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
148
|
+
test.ok(err, 'expected an error to be thrown');
|
|
149
|
+
test.equal(err.code, 'ParserError26');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* MAX_IMAP_NESTING_REACHED: Nesting depth exceeds MAX_NODE_DEPTH (25).
|
|
154
|
+
* createNode() throws when node.depth > 25. The root TREE node starts at
|
|
155
|
+
* depth 0, so depth 1 is the first LIST ('('), and 26 open parens reaches
|
|
156
|
+
* depth 26 which exceeds the limit.
|
|
157
|
+
*/
|
|
158
|
+
module.exports['Token Parser: MAX_IMAP_NESTING_REACHED: deep nesting throws MAX_IMAP_NESTING_REACHED'] = test =>
|
|
159
|
+
asyncWrapper(test, async test => {
|
|
160
|
+
let err;
|
|
161
|
+
try {
|
|
162
|
+
const depth = 26;
|
|
163
|
+
const input = '* FETCH ' + '('.repeat(depth) + 'x' + ')'.repeat(depth);
|
|
164
|
+
await parser(input);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
err = e;
|
|
167
|
+
}
|
|
168
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
169
|
+
test.ok(err, 'expected an error to be thrown');
|
|
170
|
+
test.equal(err.code, 'MAX_IMAP_NESTING_REACHED');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Happy-path tests
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* NIL atom is returned as null in the attributes array. The ATOM node whose
|
|
177
|
+
* value is "NIL" (case-insensitive) is converted to null in getAttributes().
|
|
178
|
+
*/
|
|
179
|
+
module.exports['Token Parser: NIL atom is parsed as null'] = test =>
|
|
180
|
+
asyncWrapper(test, async test => {
|
|
181
|
+
const result = await parser('* FETCH NIL');
|
|
182
|
+
const attrs = result.attributes;
|
|
183
|
+
test.ok(Array.isArray(attrs), 'attributes should be an array');
|
|
184
|
+
test.equal(attrs.length, 1, 'should have exactly one attribute');
|
|
185
|
+
test.strictEqual(attrs[0], null, 'NIL should be parsed as null');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Nested lists produce nested arrays in the attributes output.
|
|
190
|
+
* Input ((a b)) produces: [ [ [ {a}, {b} ] ] ] — the attributes array contains
|
|
191
|
+
* one outer list which contains one inner list with two atom elements.
|
|
192
|
+
*/
|
|
193
|
+
module.exports['Token Parser: nested lists produce nested arrays'] = test =>
|
|
194
|
+
asyncWrapper(test, async test => {
|
|
195
|
+
const result = await parser('* FETCH ((a b))');
|
|
196
|
+
const attrs = result.attributes;
|
|
197
|
+
test.ok(Array.isArray(attrs), 'attributes should be an array');
|
|
198
|
+
const outer = attrs[0];
|
|
199
|
+
test.ok(Array.isArray(outer), 'outer list should be an array');
|
|
200
|
+
const inner = outer[0];
|
|
201
|
+
test.ok(Array.isArray(inner), 'inner list should be an array');
|
|
202
|
+
test.equal(inner.length, 2, 'inner list should have 2 elements');
|
|
203
|
+
test.equal(inner[0].value, 'a', 'first inner element value should be a');
|
|
204
|
+
test.equal(inner[1].value, 'b', 'second inner element value should be b');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Quoted string with escaped double-quote characters. The backslash-escape
|
|
209
|
+
* mechanism converts \" to " in the parsed value. The JS literal
|
|
210
|
+
* '* FETCH "hello \\"world\\""' represents: * FETCH "hello \"world\""
|
|
211
|
+
*/
|
|
212
|
+
module.exports['Token Parser: quoted string with escaped quotes is parsed correctly'] = test =>
|
|
213
|
+
asyncWrapper(test, async test => {
|
|
214
|
+
const result = await parser('* FETCH "hello \\"world\\""');
|
|
215
|
+
const attrs = result.attributes;
|
|
216
|
+
test.ok(Array.isArray(attrs), 'attributes should be an array');
|
|
217
|
+
test.equal(attrs.length, 1, 'should have exactly one attribute');
|
|
218
|
+
test.equal(attrs[0].type, 'STRING', 'type should be STRING');
|
|
219
|
+
test.equal(attrs[0].value, 'hello "world"', 'escaped quotes should be unescaped');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Empty quoted string results in a STRING attribute with an empty value.
|
|
224
|
+
*/
|
|
225
|
+
module.exports['Token Parser: empty quoted string produces STRING with empty value'] = test =>
|
|
226
|
+
asyncWrapper(test, async test => {
|
|
227
|
+
const result = await parser('* FETCH ""');
|
|
228
|
+
const attrs = result.attributes;
|
|
229
|
+
test.ok(Array.isArray(attrs), 'attributes should be an array');
|
|
230
|
+
test.equal(attrs.length, 1, 'should have exactly one attribute');
|
|
231
|
+
test.equal(attrs[0].type, 'STRING', 'type should be STRING');
|
|
232
|
+
test.equal(attrs[0].value, '', 'value should be empty string');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Literal data provided via the literals option is parsed into a LITERAL attribute.
|
|
237
|
+
* When options.literals is set and '}' is encountered with a valid size, the next
|
|
238
|
+
* Buffer in the literals array is used as the literal value directly.
|
|
239
|
+
* The literal size in braces must be followed by \r\n or \n per IMAP protocol.
|
|
240
|
+
*/
|
|
241
|
+
module.exports['Token Parser: literal with pre-parsed data produces LITERAL attribute'] = test =>
|
|
242
|
+
asyncWrapper(test, async test => {
|
|
243
|
+
const data = Buffer.from('hello');
|
|
244
|
+
const result = await parser('* FETCH {5}\r\n', { literals: [data] });
|
|
245
|
+
const attrs = result.attributes;
|
|
246
|
+
test.ok(Array.isArray(attrs), 'attributes should be an array');
|
|
247
|
+
test.equal(attrs.length, 1, 'should have exactly one attribute');
|
|
248
|
+
test.equal(attrs[0].type, 'LITERAL', 'type should be LITERAL');
|
|
249
|
+
test.ok(Buffer.isBuffer(attrs[0].value), 'literal value should be a Buffer');
|
|
250
|
+
test.equal(attrs[0].value.toString(), 'hello', 'literal value should match provided buffer');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A plain ATOM token produces an ATOM attribute with the correct string value.
|
|
255
|
+
*/
|
|
256
|
+
module.exports['Token Parser: atom value produces ATOM attribute'] = test =>
|
|
257
|
+
asyncWrapper(test, async test => {
|
|
258
|
+
const result = await parser('* FETCH INBOX');
|
|
259
|
+
const attrs = result.attributes;
|
|
260
|
+
test.ok(Array.isArray(attrs), 'attributes should be an array');
|
|
261
|
+
test.equal(attrs.length, 1, 'should have exactly one attribute');
|
|
262
|
+
test.equal(attrs[0].type, 'ATOM', 'type should be ATOM');
|
|
263
|
+
test.equal(attrs[0].value, 'INBOX', 'value should be INBOX');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* E29: Range separator ':' after a character that is not a digit or '*'.
|
|
268
|
+
* Input "1,:5" starts as ATOM "1", ',' triggers SEQUENCE reclassification and
|
|
269
|
+
* appends ',' (value becomes "1,"). Then ':' fires E29 because last char ','
|
|
270
|
+
* is not a digit or '*'.
|
|
271
|
+
*/
|
|
272
|
+
module.exports['Token Parser: E29: range separator after non-digit/non-star throws ParserError29'] = test =>
|
|
273
|
+
asyncWrapper(test, async test => {
|
|
274
|
+
let err;
|
|
275
|
+
try {
|
|
276
|
+
await parser('* FETCH 1,:5');
|
|
277
|
+
} catch (e) {
|
|
278
|
+
err = e;
|
|
279
|
+
}
|
|
280
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
281
|
+
test.ok(err, 'expected an error to be thrown');
|
|
282
|
+
test.equal(err.code, 'ParserError29');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* E30: Wildcard '*' when last char is not ',' or ':'.
|
|
287
|
+
* Input "1:2*" enters SEQUENCE after "1:" and appends "2" (value "1:2").
|
|
288
|
+
* Then '*' fires E30 because last char '2' is not ',' or ':'.
|
|
289
|
+
*/
|
|
290
|
+
module.exports['Token Parser: E30: wildcard after digit throws ParserError30'] = test =>
|
|
291
|
+
asyncWrapper(test, async test => {
|
|
292
|
+
let err;
|
|
293
|
+
try {
|
|
294
|
+
await parser('* FETCH 1:2*');
|
|
295
|
+
} catch (e) {
|
|
296
|
+
err = e;
|
|
297
|
+
}
|
|
298
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
299
|
+
test.ok(err, 'expected an error to be thrown');
|
|
300
|
+
test.equal(err.code, 'ParserError30');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* E31: Separator ',' after a character that is not a digit or '*'.
|
|
305
|
+
* Input "1:,5" enters SEQUENCE after "1" and appends ':' (value "1:").
|
|
306
|
+
* Then ',' fires E31 because last char ':' is not a digit or '*'.
|
|
307
|
+
*/
|
|
308
|
+
module.exports['Token Parser: E31: comma after colon throws ParserError31'] = test =>
|
|
309
|
+
asyncWrapper(test, async test => {
|
|
310
|
+
let err;
|
|
311
|
+
try {
|
|
312
|
+
await parser('* FETCH 1:,5');
|
|
313
|
+
} catch (e) {
|
|
314
|
+
err = e;
|
|
315
|
+
}
|
|
316
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
317
|
+
test.ok(err, 'expected an error to be thrown');
|
|
318
|
+
test.equal(err.code, 'ParserError31');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* E32: Separator ',' after bare '*' (not in a range).
|
|
323
|
+
* Input "*,5" starts SEQUENCE with value "*". Then ',' passes E31
|
|
324
|
+
* (last char '*' satisfies the check) but fires E32 because last char
|
|
325
|
+
* is '*' and the char before it (at(-2)) is not ':'.
|
|
326
|
+
*/
|
|
327
|
+
module.exports['Token Parser: E32: comma after bare star throws ParserError32'] = test =>
|
|
328
|
+
asyncWrapper(test, async test => {
|
|
329
|
+
let err;
|
|
330
|
+
try {
|
|
331
|
+
await parser('* FETCH *,5');
|
|
332
|
+
} catch (e) {
|
|
333
|
+
err = e;
|
|
334
|
+
}
|
|
335
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
336
|
+
test.ok(err, 'expected an error to be thrown');
|
|
337
|
+
test.equal(err.code, 'ParserError32');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* E33: Non-digit, non-special character in sequence position.
|
|
342
|
+
* Input "1:a" enters SEQUENCE after "1" and appends ':' (value "1:").
|
|
343
|
+
* Then 'a' is not a digit, not ':', not '*', not ',' so E33 fires.
|
|
344
|
+
*/
|
|
345
|
+
module.exports['Token Parser: E33: non-digit non-special char in sequence throws ParserError33'] = test =>
|
|
346
|
+
asyncWrapper(test, async test => {
|
|
347
|
+
let err;
|
|
348
|
+
try {
|
|
349
|
+
await parser('* FETCH 1:a');
|
|
350
|
+
} catch (e) {
|
|
351
|
+
err = e;
|
|
352
|
+
}
|
|
353
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
354
|
+
test.ok(err, 'expected an error to be thrown');
|
|
355
|
+
test.equal(err.code, 'ParserError33');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* E34: Digit immediately after '*'.
|
|
360
|
+
* Input "*1" starts SEQUENCE with value "*". Then '1' is a digit but
|
|
361
|
+
* last char is '*', so E34 fires (digits cannot follow '*').
|
|
362
|
+
*/
|
|
363
|
+
module.exports['Token Parser: E34: digit after star throws ParserError34'] = test =>
|
|
364
|
+
asyncWrapper(test, async test => {
|
|
365
|
+
let err;
|
|
366
|
+
try {
|
|
367
|
+
await parser('* FETCH *1');
|
|
368
|
+
} catch (e) {
|
|
369
|
+
err = e;
|
|
370
|
+
}
|
|
371
|
+
if (!err) throw new Error('Expected parser to throw but it did not');
|
|
372
|
+
test.ok(err, 'expected an error to be thrown');
|
|
373
|
+
test.equal(err.code, 'ParserError34');
|
|
374
|
+
});
|