8217 lines
262 KiB
JavaScript
8217 lines
262 KiB
JavaScript
'use strict';
|
|
|
|
/* eslint-disable new-cap */
|
|
// BigInt() is a standard JS function but triggers new-cap rule
|
|
|
|
// ============================================
|
|
// Mock Connection Factory
|
|
// ============================================
|
|
|
|
const createMockConnection = (overrides = {}) => {
|
|
const states = {
|
|
NOT_AUTHENTICATED: 1,
|
|
AUTHENTICATED: 2,
|
|
SELECTED: 3,
|
|
LOGOUT: 4
|
|
};
|
|
|
|
const defaultMailbox = {
|
|
path: 'INBOX',
|
|
flags: new Set(['\\Seen', '\\Answered', '\\Flagged', '\\Deleted', '\\Draft']),
|
|
permanentFlags: new Set(['\\*']),
|
|
exists: 100,
|
|
recent: 5,
|
|
uidNext: 1000,
|
|
uidValidity: BigInt(12345),
|
|
noModseq: false
|
|
};
|
|
|
|
return {
|
|
states,
|
|
state: overrides.state || states.SELECTED,
|
|
id: 'test-connection-id',
|
|
capabilities: new Map(overrides.capabilities || [['IMAP4rev1', true]]),
|
|
enabled: new Set(overrides.enabled || []),
|
|
authCapabilities: new Map(),
|
|
mailbox: overrides.mailbox || { ...defaultMailbox },
|
|
namespace: overrides.namespace || { delimiter: '/', prefix: '' },
|
|
expectCapabilityUpdate: overrides.expectCapabilityUpdate || false,
|
|
log: {
|
|
warn: () => {},
|
|
info: () => {},
|
|
error: () => {},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
},
|
|
close: overrides.close || (() => {}),
|
|
emit: overrides.emit || (() => {}),
|
|
currentSelectCommand: false,
|
|
messageFlagsAdd: overrides.messageFlagsAdd || (async () => {}),
|
|
run: overrides.run || (async () => {}),
|
|
exec:
|
|
overrides.exec ||
|
|
(async () => ({
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
})),
|
|
...overrides
|
|
};
|
|
};
|
|
|
|
// ============================================
|
|
// CAPABILITY Command Tests
|
|
// ============================================
|
|
|
|
const capabilityCommand = require('../lib/commands/capability');
|
|
|
|
module.exports['Commands: capability returns cached when available'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([
|
|
['IMAP4rev1', true],
|
|
['IDLE', true]
|
|
]),
|
|
expectCapabilityUpdate: false
|
|
});
|
|
|
|
const result = await capabilityCommand(connection);
|
|
test.ok(result instanceof Map);
|
|
test.equal(result.get('IDLE'), true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: capability fetches when empty'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map(),
|
|
exec: async () => ({ next: () => {} })
|
|
});
|
|
|
|
const result = await capabilityCommand(connection);
|
|
test.ok(result instanceof Map);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: capability fetches when update expected'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['IMAP4rev1', true]]),
|
|
expectCapabilityUpdate: true,
|
|
exec: async () => {
|
|
execCalled = true;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await capabilityCommand(connection);
|
|
test.equal(execCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: capability handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map(),
|
|
exec: async () => {
|
|
throw new Error('Command failed');
|
|
}
|
|
});
|
|
|
|
const result = await capabilityCommand(connection);
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// NOOP Command Tests
|
|
// ============================================
|
|
|
|
const noopCommand = require('../lib/commands/noop');
|
|
|
|
module.exports['Commands: noop success'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
exec: async cmd => {
|
|
test.equal(cmd, 'NOOP');
|
|
execCalled = true;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await noopCommand(connection);
|
|
test.equal(result, true);
|
|
test.equal(execCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: noop handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
exec: async () => {
|
|
throw new Error('Command failed');
|
|
}
|
|
});
|
|
|
|
const result = await noopCommand(connection);
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// LOGIN Command Tests
|
|
// ============================================
|
|
|
|
const loginCommand = require('../lib/commands/login');
|
|
|
|
module.exports['Commands: login success'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 1, // NOT_AUTHENTICATED
|
|
exec: async (cmd, attrs) => {
|
|
execArgs = { cmd, attrs };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await loginCommand(connection, 'testuser', 'testpass');
|
|
test.equal(result, 'testuser');
|
|
test.equal(execArgs.cmd, 'LOGIN');
|
|
test.equal(execArgs.attrs[0].value, 'testuser');
|
|
test.equal(execArgs.attrs[1].value, 'testpass');
|
|
test.equal(execArgs.attrs[1].sensitive, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: login skips when already authenticated'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2 // AUTHENTICATED
|
|
});
|
|
|
|
const result = await loginCommand(connection, 'testuser', 'testpass');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: login handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
exec: async () => {
|
|
const err = new Error('Auth failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await loginCommand(connection, 'testuser', 'wrongpass');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.authenticationFailed, true);
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: login error includes serverResponseCode'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
exec: async () => {
|
|
const err = new Error('Auth failed');
|
|
err.response = {
|
|
tag: 'A1',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'AUTHENTICATIONFAILED' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Authentication failed' }
|
|
]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await loginCommand(connection, 'testuser', 'wrongpass');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.authenticationFailed, true);
|
|
test.equal(err.serverResponseCode, 'AUTHENTICATIONFAILED');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// LOGOUT Command Tests
|
|
// ============================================
|
|
|
|
const logoutCommand = require('../lib/commands/logout');
|
|
|
|
module.exports['Commands: logout success'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
exec: async cmd => {
|
|
test.equal(cmd, 'LOGOUT');
|
|
execCalled = true;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await logoutCommand(connection);
|
|
test.equal(result, true);
|
|
test.equal(execCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: logout handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
exec: async () => {
|
|
throw new Error('Command failed');
|
|
}
|
|
});
|
|
|
|
const result = await logoutCommand(connection);
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: logout returns early when already in LOGOUT state'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 4, // LOGOUT
|
|
exec: async () => {
|
|
execCalled = true;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await logoutCommand(connection);
|
|
test.equal(result, false);
|
|
test.equal(execCalled, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: logout handles NOT_AUTHENTICATED state'] = async test => {
|
|
let closeCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 1, // NOT_AUTHENTICATED (mock states: 1=NOT_AUTH, 2=AUTH, 3=SELECTED, 4=LOGOUT)
|
|
exec: async () => ({ next: () => {} }),
|
|
close: () => {
|
|
closeCalled = true;
|
|
}
|
|
});
|
|
|
|
const result = await logoutCommand(connection);
|
|
test.equal(result, false);
|
|
test.equal(connection.state, connection.states.LOGOUT);
|
|
test.equal(closeCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: logout handles NoConnection error'] = async test => {
|
|
const connection = createMockConnection({
|
|
exec: async () => {
|
|
const err = new Error('No connection');
|
|
err.code = 'NoConnection';
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const result = await logoutCommand(connection);
|
|
test.equal(result, true);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// CLOSE Command Tests
|
|
// ============================================
|
|
|
|
const closeCommand = require('../lib/commands/close');
|
|
|
|
module.exports['Commands: close success'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
exec: async cmd => {
|
|
test.equal(cmd, 'CLOSE');
|
|
execCalled = true;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await closeCommand(connection);
|
|
test.equal(result, true);
|
|
test.equal(execCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: close skips when not selected'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2 // AUTHENTICATED, not SELECTED
|
|
});
|
|
|
|
const result = await closeCommand(connection);
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: close handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
throw new Error('Command failed');
|
|
}
|
|
});
|
|
|
|
const result = await closeCommand(connection);
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: close emits mailboxClose event'] = async test => {
|
|
let emittedMailbox = null;
|
|
const testMailbox = { path: 'INBOX', uidValidity: 12345n };
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: testMailbox,
|
|
currentSelectCommand: { command: 'SELECT', arguments: [{ value: 'INBOX' }] },
|
|
exec: async () => ({ next: () => {} }),
|
|
emit: (event, data) => {
|
|
if (event === 'mailboxClose') {
|
|
emittedMailbox = data;
|
|
}
|
|
}
|
|
});
|
|
|
|
const result = await closeCommand(connection);
|
|
test.equal(result, true);
|
|
test.ok(emittedMailbox);
|
|
test.equal(emittedMailbox.path, 'INBOX');
|
|
test.equal(connection.mailbox, false);
|
|
test.equal(connection.currentSelectCommand, false);
|
|
test.equal(connection.state, 2); // AUTHENTICATED
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: close without mailbox does not emit event'] = async test => {
|
|
let eventEmitted = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: false, // No mailbox
|
|
exec: async () => ({ next: () => {} }),
|
|
emit: event => {
|
|
if (event === 'mailboxClose') {
|
|
eventEmitted = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
const result = await closeCommand(connection);
|
|
test.equal(result, true);
|
|
test.equal(eventEmitted, false);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// SEARCH Command Tests
|
|
// ============================================
|
|
|
|
const searchCommand = require('../lib/commands/search');
|
|
|
|
module.exports['Commands: search with ALL'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
execArgs = { cmd, attrs };
|
|
// Simulate SEARCH response
|
|
if (opts && opts.untagged && opts.untagged.SEARCH) {
|
|
await opts.untagged.SEARCH({
|
|
attributes: [{ value: '1' }, { value: '2' }, { value: '3' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await searchCommand(connection, true, {});
|
|
test.deepEqual(result, [1, 2, 3]);
|
|
test.equal(execArgs.cmd, 'SEARCH');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: search with UID option'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
execCmd = cmd;
|
|
if (opts && opts.untagged && opts.untagged.SEARCH) {
|
|
await opts.untagged.SEARCH({ attributes: [{ value: '100' }] });
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await searchCommand(connection, { all: true }, { uid: true });
|
|
test.deepEqual(result, [100]);
|
|
test.equal(execCmd, 'UID SEARCH');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: search with query object'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
// Check that search compiler was used
|
|
test.ok(attrs.some(a => a.value === 'FROM'));
|
|
if (opts && opts.untagged && opts.untagged.SEARCH) {
|
|
await opts.untagged.SEARCH({ attributes: [] });
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await searchCommand(connection, { from: 'test@example.com' }, {});
|
|
test.ok(Array.isArray(result));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: search skips when not selected'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2 // AUTHENTICATED
|
|
});
|
|
|
|
const result = await searchCommand(connection, { all: true }, {});
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: search handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
const err = new Error('Search failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const result = await searchCommand(connection, { all: true }, {});
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: search returns false for invalid query'] = async test => {
|
|
const connection = createMockConnection({ state: 3 });
|
|
|
|
const result = await searchCommand(connection, 'invalid-query', {});
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: search error with serverResponseCode'] = async test => {
|
|
let capturedErr = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
const err = new Error('Search failed');
|
|
err.response = {
|
|
tag: 'A1',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'CANNOT' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Search not allowed' }
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
log: {
|
|
warn: data => {
|
|
capturedErr = data.err;
|
|
},
|
|
info: () => {},
|
|
debug: () => {},
|
|
trace: () => {},
|
|
error: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await searchCommand(connection, { all: true }, {});
|
|
test.equal(result, false);
|
|
test.ok(capturedErr);
|
|
test.equal(capturedErr.serverResponseCode, 'CANNOT');
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// STORE Command Tests
|
|
// ============================================
|
|
|
|
const storeCommand = require('../lib/commands/store');
|
|
|
|
module.exports['Commands: store add flags'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
execArgs = { cmd, attrs };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await storeCommand(connection, '1:10', ['\\Seen'], { operation: 'add' });
|
|
test.equal(result, true);
|
|
test.equal(execArgs.cmd, 'STORE');
|
|
test.ok(execArgs.attrs[1].value.startsWith('+'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store remove flags'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
execArgs = { cmd, attrs };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await storeCommand(connection, '1:10', ['\\Seen'], { operation: 'remove' });
|
|
test.equal(result, true);
|
|
test.ok(execArgs.attrs[1].value.startsWith('-'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store set flags'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
execArgs = { cmd, attrs };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await storeCommand(connection, '1:10', ['\\Seen'], { operation: 'set' });
|
|
test.equal(result, true);
|
|
test.ok(!execArgs.attrs[1].value.startsWith('+'));
|
|
test.ok(!execArgs.attrs[1].value.startsWith('-'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store with UID'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await storeCommand(connection, '100', ['\\Flagged'], { uid: true });
|
|
test.equal(execCmd, 'UID STORE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store with silent'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
execArgs = { cmd, attrs };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await storeCommand(connection, '1', ['\\Seen'], { silent: true });
|
|
test.ok(execArgs.attrs[1].value.includes('.SILENT'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store with Gmail labels'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['X-GM-EXT-1', true]]),
|
|
exec: async (cmd, attrs) => {
|
|
execArgs = { cmd, attrs };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await storeCommand(connection, '1', ['Important'], { useLabels: true });
|
|
test.ok(execArgs.attrs[1].value.includes('X-GM-LABELS'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store skips when labels not supported'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map() // No X-GM-EXT-1
|
|
});
|
|
|
|
const result = await storeCommand(connection, '1', ['Label'], { useLabels: true });
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store skips when not selected'] = async test => {
|
|
const connection = createMockConnection({ state: 2 });
|
|
|
|
const result = await storeCommand(connection, '1:10', ['\\Seen'], {});
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store skips when no range'] = async test => {
|
|
const connection = createMockConnection({ state: 3 });
|
|
|
|
const result = await storeCommand(connection, null, ['\\Seen'], {});
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store with CONDSTORE'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
enabled: new Set(['CONDSTORE']),
|
|
exec: async (cmd, attrs) => {
|
|
execArgs = { cmd, attrs };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await storeCommand(connection, '1', ['\\Seen'], { unchangedSince: 12345 });
|
|
test.ok(execArgs.attrs.some(a => Array.isArray(a) && a.some(x => x.value === 'UNCHANGEDSINCE')));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
const err = new Error('Store failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const result = await storeCommand(connection, '1', ['\\Seen'], {});
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store error with serverResponseCode'] = async test => {
|
|
let capturedErr = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
const err = new Error('Store failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'CANNOT' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Cannot modify flags' }
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
log: {
|
|
warn: data => {
|
|
capturedErr = data.err;
|
|
}
|
|
}
|
|
});
|
|
|
|
const result = await storeCommand(connection, '1', ['\\Seen'], {});
|
|
test.equal(result, false);
|
|
test.ok(capturedErr);
|
|
test.equal(capturedErr.serverResponseCode, 'CANNOT');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store filters flags that cannot be used'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: {
|
|
permanentFlags: new Set(['\\Seen']) // Only \\Seen is allowed
|
|
},
|
|
exec: async (cmd, attrs) => {
|
|
execAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
// Try to add \\Deleted which is not in permanentFlags
|
|
const result = await storeCommand(connection, '1', ['\\Seen', '\\Deleted'], { operation: 'add' });
|
|
test.equal(result, true);
|
|
test.ok(execAttrs);
|
|
// Flags list should only contain \\Seen
|
|
const flagsList = execAttrs[2];
|
|
test.equal(flagsList.length, 1);
|
|
test.equal(flagsList[0].value, '\\Seen');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store remove operation uses minus prefix'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
execAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await storeCommand(connection, '1', ['\\Seen', '\\Deleted'], { operation: 'remove' });
|
|
test.equal(result, true);
|
|
test.ok(execAttrs);
|
|
// Remove operation should use -FLAGS prefix
|
|
test.equal(execAttrs[1].value, '-FLAGS');
|
|
const flagsList = execAttrs[2];
|
|
test.equal(flagsList.length, 2);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store returns false when no valid flags for add'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: {
|
|
permanentFlags: new Set() // No flags allowed
|
|
}
|
|
});
|
|
|
|
// All flags get filtered out
|
|
const result = await storeCommand(connection, '1', ['\\Seen', '\\Deleted'], { operation: 'add' });
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store allows empty flags for set operation'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: {
|
|
permanentFlags: new Set() // No flags allowed, all get filtered
|
|
},
|
|
exec: async () => {
|
|
execCalled = true;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
// Set operation with empty flags should still proceed (to clear flags)
|
|
const result = await storeCommand(connection, '1', ['\\Seen'], { operation: 'set' });
|
|
test.equal(result, true);
|
|
test.equal(execCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store returns false with empty flags for remove'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: {
|
|
permanentFlags: new Set()
|
|
}
|
|
});
|
|
|
|
// Remove with no valid flags should return false (nothing to remove)
|
|
const result = await storeCommand(connection, '1', [], { operation: 'remove' });
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store default operation is add'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
execAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await storeCommand(connection, '1', ['\\Seen'], {}); // No operation specified
|
|
test.ok(execAttrs);
|
|
test.equal(execAttrs[1].value, '+FLAGS');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store with labels uses X-GM-LABELS'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['X-GM-EXT-1', true]]),
|
|
exec: async (cmd, attrs) => {
|
|
execAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await storeCommand(connection, '1', ['Important'], { useLabels: true, operation: 'add' });
|
|
test.ok(execAttrs);
|
|
test.equal(execAttrs[1].value, '+X-GM-LABELS');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: store silent does not apply to labels'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['X-GM-EXT-1', true]]),
|
|
exec: async (cmd, attrs) => {
|
|
execAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
// When using labels, silent flag should not add .SILENT suffix
|
|
await storeCommand(connection, '1', ['Important'], { useLabels: true, silent: true, operation: 'set' });
|
|
test.ok(execAttrs);
|
|
test.equal(execAttrs[1].value, 'X-GM-LABELS'); // Not X-GM-LABELS.SILENT
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// COPY Command Tests
|
|
// ============================================
|
|
|
|
const copyCommand = require('../lib/commands/copy');
|
|
|
|
module.exports['Commands: copy success'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
execArgs = { cmd, attrs };
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
const result = await copyCommand(connection, '1:10', 'Archive', {});
|
|
test.ok(result);
|
|
test.equal(result.destination, 'Archive');
|
|
test.equal(execArgs.cmd, 'COPY');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy with UID'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {}, response: { attributes: [] } };
|
|
}
|
|
});
|
|
|
|
await copyCommand(connection, '100', 'Archive', { uid: true });
|
|
test.equal(execCmd, 'UID COPY');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy with COPYUID response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
section: [{ value: 'COPYUID' }, { value: '12345' }, { value: '1:3' }, { value: '100:102' }]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await copyCommand(connection, '1:3', 'Archive', {});
|
|
test.ok(result.uidValidity);
|
|
test.ok(result.uidMap instanceof Map);
|
|
test.equal(result.uidMap.get(1), 100);
|
|
test.equal(result.uidMap.get(2), 101);
|
|
test.equal(result.uidMap.get(3), 102);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy skips when not selected'] = async test => {
|
|
const connection = createMockConnection({ state: 2 });
|
|
|
|
const result = await copyCommand(connection, '1:10', 'Archive', {});
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy skips when no range'] = async test => {
|
|
const connection = createMockConnection({ state: 3 });
|
|
|
|
const result = await copyCommand(connection, null, 'Archive', {});
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy skips when no destination'] = async test => {
|
|
const connection = createMockConnection({ state: 3 });
|
|
|
|
const result = await copyCommand(connection, '1:10', null, {});
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
const err = new Error('Copy failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const result = await copyCommand(connection, '1:10', 'Archive', {});
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy error with serverResponseCode'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
const err = new Error('Copy failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'TRYCREATE' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Mailbox does not exist' }
|
|
]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const result = await copyCommand(connection, '1:10', 'NonExistent', {});
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy with partial COPYUID response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { path: 'INBOX' },
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [
|
|
{ type: 'ATOM', value: 'COPYUID' },
|
|
{ type: 'ATOM', value: '12345' }
|
|
// Missing source and destination UIDs
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await copyCommand(connection, '1:10', 'Archive', {});
|
|
test.ok(result);
|
|
test.equal(result.path, 'INBOX');
|
|
test.equal(result.destination, 'Archive');
|
|
test.equal(result.uidValidity, 12345n);
|
|
test.equal(result.uidMap, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy with invalid uidValidity'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { path: 'INBOX' },
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [
|
|
{ type: 'ATOM', value: 'COPYUID' },
|
|
{ type: 'ATOM', value: 'invalid' } // Non-numeric uidValidity
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await copyCommand(connection, '1:10', 'Archive', {});
|
|
test.ok(result);
|
|
test.equal(result.uidValidity, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy with mismatched UID counts'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { path: 'INBOX' },
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [
|
|
{ type: 'ATOM', value: 'COPYUID' },
|
|
{ type: 'ATOM', value: '12345' },
|
|
{ type: 'ATOM', value: '1:3' }, // 3 source UIDs
|
|
{ type: 'ATOM', value: '100:101' } // 2 destination UIDs
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await copyCommand(connection, '1:3', 'Archive', {});
|
|
test.ok(result);
|
|
test.equal(result.uidValidity, 12345n);
|
|
test.equal(result.uidMap, undefined); // Not set due to mismatch
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: copy with non-COPYUID response code'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { path: 'INBOX' },
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [{ type: 'ATOM', value: 'APPENDUID' }] // Not COPYUID
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await copyCommand(connection, '1:10', 'Archive', {});
|
|
test.ok(result);
|
|
test.equal(result.path, 'INBOX');
|
|
test.equal(result.destination, 'Archive');
|
|
test.equal(result.uidValidity, undefined);
|
|
test.equal(result.uidMap, undefined);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// MOVE Command Tests
|
|
// ============================================
|
|
|
|
const moveCommand = require('../lib/commands/move');
|
|
|
|
module.exports['Commands: move with MOVE capability'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {}, response: { attributes: [] } };
|
|
}
|
|
});
|
|
|
|
await moveCommand(connection, '1:10', 'Archive', {});
|
|
test.equal(execCmd, 'MOVE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move with UID and MOVE capability'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {}, response: { attributes: [] } };
|
|
}
|
|
});
|
|
|
|
await moveCommand(connection, '100', 'Archive', { uid: true });
|
|
test.equal(execCmd, 'UID MOVE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move skips when not selected'] = async test => {
|
|
const connection = createMockConnection({ state: 2 });
|
|
|
|
const result = await moveCommand(connection, '1:10', 'Archive', {});
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move skips when no range'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]])
|
|
});
|
|
|
|
const result = await moveCommand(connection, null, 'Archive', {});
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move skips when no destination'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]])
|
|
});
|
|
|
|
const result = await moveCommand(connection, '1:10', null, {});
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move fallback without MOVE capability'] = async test => {
|
|
let copyCalled = false;
|
|
let deleteCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(), // No MOVE capability
|
|
messageCopy: async (range, dest) => {
|
|
copyCalled = true;
|
|
test.equal(range, '1:10');
|
|
test.equal(dest, 'Archive');
|
|
return { path: 'INBOX', destination: 'Archive' };
|
|
},
|
|
messageDelete: async (range, opts) => {
|
|
deleteCalled = true;
|
|
test.equal(range, '1:10');
|
|
test.equal(opts.silent, true);
|
|
return true;
|
|
}
|
|
});
|
|
|
|
const result = await moveCommand(connection, '1:10', 'Archive', {});
|
|
test.ok(copyCalled);
|
|
test.ok(deleteCalled);
|
|
test.equal(result.destination, 'Archive');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move fallback passes options'] = async test => {
|
|
let copyOpts = null;
|
|
let deleteOpts = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(), // No MOVE capability
|
|
messageCopy: async (range, dest, opts) => {
|
|
copyOpts = opts;
|
|
return { path: 'INBOX', destination: dest };
|
|
},
|
|
messageDelete: async (range, opts) => {
|
|
deleteOpts = opts;
|
|
return true;
|
|
}
|
|
});
|
|
|
|
await moveCommand(connection, '1:10', 'Archive', { uid: true });
|
|
test.equal(copyOpts.uid, true);
|
|
test.equal(deleteOpts.uid, true);
|
|
test.equal(deleteOpts.silent, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move with COPYUID response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
section: [{ value: 'COPYUID' }, { value: '12345' }, { value: '1:3' }, { value: '100:102' }]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await moveCommand(connection, '1:3', 'Archive', {});
|
|
test.ok(result.uidValidity);
|
|
test.equal(result.uidValidity, BigInt(12345));
|
|
test.ok(result.uidMap instanceof Map);
|
|
test.equal(result.uidMap.get(1), 100);
|
|
test.equal(result.uidMap.get(2), 101);
|
|
test.equal(result.uidMap.get(3), 102);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move handles COPYUID in untagged response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
// Simulate untagged OK with COPYUID
|
|
if (opts && opts.untagged && opts.untagged.OK) {
|
|
await opts.untagged.OK({
|
|
attributes: [
|
|
{
|
|
section: [{ value: 'COPYUID' }, { value: '99999' }, { value: '5:7' }, { value: '200:202' }]
|
|
}
|
|
]
|
|
});
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
const result = await moveCommand(connection, '5:7', 'Archive', {});
|
|
test.ok(result.uidMap instanceof Map);
|
|
test.equal(result.uidMap.get(5), 200);
|
|
test.equal(result.uidMap.get(6), 201);
|
|
test.equal(result.uidMap.get(7), 202);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move returns correct map structure'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
})
|
|
});
|
|
|
|
const result = await moveCommand(connection, '1:10', 'Archive', {});
|
|
test.equal(result.path, 'INBOX');
|
|
test.equal(result.destination, 'Archive');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move handles error'] = async test => {
|
|
let warnLogged = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
exec: async () => {
|
|
const err = new Error('Move failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
},
|
|
log: {
|
|
warn: () => {
|
|
warnLogged = true;
|
|
},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await moveCommand(connection, '1:10', 'Archive', {});
|
|
test.equal(result, false);
|
|
test.ok(warnLogged);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move handles error with status code'] = async test => {
|
|
let capturedErr = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
exec: async () => {
|
|
const err = new Error('Move failed');
|
|
// Provide response with TRYCREATE status code
|
|
err.response = {
|
|
tag: 'A1',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
value: '',
|
|
section: [{ type: 'ATOM', value: 'TRYCREATE' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Mailbox does not exist' }
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
log: {
|
|
warn: msg => {
|
|
capturedErr = msg;
|
|
},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await moveCommand(connection, '1:10', 'NonExistent', {});
|
|
test.equal(result, false);
|
|
test.ok(capturedErr);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move normalizes destination path'] = async test => {
|
|
let capturedAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
namespace: { delimiter: '/', prefix: 'INBOX/' },
|
|
exec: async (cmd, attrs) => {
|
|
capturedAttrs = attrs;
|
|
return { next: () => {}, response: { attributes: [] } };
|
|
}
|
|
});
|
|
|
|
await moveCommand(connection, '1:10', 'Archive', {});
|
|
// The destination should be normalized
|
|
test.ok(capturedAttrs);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move handles COPYUID with invalid uidValidity'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
section: [
|
|
{ type: 'ATOM', value: 'COPYUID' },
|
|
{ value: 'invalid' }, // Invalid uidValidity (NaN)
|
|
{ value: '1:5' },
|
|
{ value: '100:104' }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await moveCommand(connection, '1:5', 'Archive', {});
|
|
test.ok(result);
|
|
test.equal(result.uidValidity, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move handles COPYUID with mismatched UID counts'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
section: [
|
|
{ type: 'ATOM', value: 'COPYUID' },
|
|
{ value: '12345' },
|
|
{ value: '1:5' }, // 5 source UIDs
|
|
{ value: '100:102' } // Only 3 destination UIDs - mismatch
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await moveCommand(connection, '1:5', 'Archive', {});
|
|
test.ok(result);
|
|
test.equal(result.uidValidity, BigInt(12345));
|
|
test.equal(result.uidMap, undefined); // Not set due to mismatch
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: move handles COPYUID with missing source/destination UIDs'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['MOVE', true]]),
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
section: [
|
|
{ type: 'ATOM', value: 'COPYUID' },
|
|
{ value: '12345' },
|
|
{ value: null }, // Missing source UIDs
|
|
{ value: '100:104' }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await moveCommand(connection, '1:5', 'Archive', {});
|
|
test.ok(result);
|
|
test.equal(result.uidMap, undefined);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// EXPUNGE Command Tests
|
|
// ============================================
|
|
|
|
const expungeCommand = require('../lib/commands/expunge');
|
|
|
|
module.exports['Commands: expunge success'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async cmd => {
|
|
test.equal(cmd, 'EXPUNGE');
|
|
execCalled = true;
|
|
return { next: () => {}, response: { attributes: [] } };
|
|
}
|
|
});
|
|
|
|
const result = await expungeCommand(connection, '1:*', {});
|
|
test.equal(result, true);
|
|
test.equal(execCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge with UID range'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['UIDPLUS', true]]),
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {}, response: { attributes: [] } };
|
|
}
|
|
});
|
|
|
|
await expungeCommand(connection, '1:100', { uid: true });
|
|
test.equal(execCmd, 'UID EXPUNGE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge skips when not selected'] = async test => {
|
|
const connection = createMockConnection({ state: 2 });
|
|
|
|
const result = await expungeCommand(connection, '1:*', {});
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge skips when no range'] = async test => {
|
|
const connection = createMockConnection({ state: 3 });
|
|
|
|
const result = await expungeCommand(connection, null, {});
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
const err = new Error('Expunge failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const result = await expungeCommand(connection, '1:*', {});
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge parses HIGHESTMODSEQ response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { highestModseq: 100n },
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [
|
|
{ type: 'ATOM', value: 'HIGHESTMODSEQ' },
|
|
{ type: 'ATOM', value: '9122' }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await expungeCommand(connection, '1:*', {});
|
|
test.equal(result, true);
|
|
test.equal(connection.mailbox.highestModseq, 9122n);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge does not update lower HIGHESTMODSEQ'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { highestModseq: 10000n },
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [
|
|
{ type: 'ATOM', value: 'HIGHESTMODSEQ' },
|
|
{ type: 'ATOM', value: '5000' }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await expungeCommand(connection, '1:*', {});
|
|
test.equal(result, true);
|
|
test.equal(connection.mailbox.highestModseq, 10000n); // Should not be updated
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge handles invalid HIGHESTMODSEQ value'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { highestModseq: 100n },
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [
|
|
{ type: 'ATOM', value: 'HIGHESTMODSEQ' },
|
|
{ type: 'ATOM', value: 'invalid' }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await expungeCommand(connection, '1:*', {});
|
|
test.equal(result, true);
|
|
test.equal(connection.mailbox.highestModseq, 100n); // Should not be updated
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge updates HIGHESTMODSEQ when mailbox has none'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: {}, // No highestModseq
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [
|
|
{ type: 'ATOM', value: 'HIGHESTMODSEQ' },
|
|
{ type: 'ATOM', value: '500' }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await expungeCommand(connection, '1:*', {});
|
|
test.equal(result, true);
|
|
test.equal(connection.mailbox.highestModseq, 500n);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge error with serverResponseCode'] = async test => {
|
|
let capturedErr = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
const err = new Error('Expunge failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'CANNOT' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Cannot expunge' }
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
log: {
|
|
warn: data => {
|
|
capturedErr = data.err;
|
|
}
|
|
}
|
|
});
|
|
|
|
const result = await expungeCommand(connection, '1:*', {});
|
|
test.equal(result, false);
|
|
test.ok(capturedErr);
|
|
test.equal(capturedErr.serverResponseCode, 'CANNOT');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge without UID when UIDPLUS not available'] = async test => {
|
|
let execCmd = null;
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(), // No UIDPLUS
|
|
exec: async (cmd, attrs) => {
|
|
execCmd = cmd;
|
|
execAttrs = attrs;
|
|
return { next: () => {}, response: { attributes: [] } };
|
|
}
|
|
});
|
|
|
|
await expungeCommand(connection, '1:100', { uid: true });
|
|
test.equal(execCmd, 'EXPUNGE'); // Falls back to EXPUNGE
|
|
test.equal(execAttrs, false); // No attributes for regular EXPUNGE
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge with UID EXPUNGE includes range'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['UIDPLUS', true]]),
|
|
exec: async (cmd, attrs) => {
|
|
execAttrs = attrs;
|
|
return { next: () => {}, response: { attributes: [] } };
|
|
}
|
|
});
|
|
|
|
await expungeCommand(connection, '1:50', { uid: true });
|
|
test.ok(execAttrs);
|
|
test.equal(execAttrs[0].type, 'SEQUENCE');
|
|
test.equal(execAttrs[0].value, '1:50');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: expunge with non-HIGHESTMODSEQ response code'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { highestModseq: 100n },
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [{ type: 'ATOM', value: 'OTHERCODE' }]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await expungeCommand(connection, '1:*', {});
|
|
test.equal(result, true);
|
|
test.equal(connection.mailbox.highestModseq, 100n); // Should not be updated
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// CREATE Command Tests
|
|
// ============================================
|
|
|
|
const createCommand = require('../lib/commands/create');
|
|
|
|
module.exports['Commands: create success'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs) => {
|
|
execArgs = { cmd, attrs };
|
|
return { next: () => {}, response: { attributes: [] } };
|
|
}
|
|
});
|
|
|
|
const result = await createCommand(connection, 'NewFolder');
|
|
test.ok(result);
|
|
test.equal(result.created, true);
|
|
test.equal(execArgs.cmd, 'CREATE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 });
|
|
|
|
const result = await createCommand(connection, 'NewFolder');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create handles ALREADYEXISTS'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Mailbox already exists');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'ALREADYEXISTS' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Mailbox already exists' }
|
|
]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const result = await createCommand(connection, 'ExistingFolder');
|
|
test.ok(result);
|
|
test.equal(result.created, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create throws on other errors'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Create failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [{ type: 'TEXT', value: 'Create failed' }]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await createCommand(connection, 'NewFolder');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.message.includes('Create failed'));
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// DELETE Command Tests
|
|
// ============================================
|
|
|
|
const deleteCommand = require('../lib/commands/delete');
|
|
|
|
module.exports['Commands: delete success'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await deleteCommand(connection, 'OldFolder');
|
|
test.ok(result);
|
|
test.equal(result.path, 'OldFolder');
|
|
test.equal(execCmd, 'DELETE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: delete skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 });
|
|
|
|
const result = await deleteCommand(connection, 'OldFolder');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: delete throws on error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Delete failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [{ type: 'TEXT', value: 'Delete failed' }]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await deleteCommand(connection, 'OldFolder');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.message.includes('Delete failed'));
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: delete closes mailbox when deleting current mailbox'] = async test => {
|
|
let closeCalled = false;
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'FolderToDelete' },
|
|
run: async cmd => {
|
|
if (cmd === 'CLOSE') {
|
|
closeCalled = true;
|
|
}
|
|
},
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await deleteCommand(connection, 'FolderToDelete');
|
|
test.ok(closeCalled, 'CLOSE should be called');
|
|
test.equal(execCmd, 'DELETE');
|
|
test.ok(result);
|
|
test.equal(result.path, 'FolderToDelete');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: delete does not close when deleting different mailbox'] = async test => {
|
|
let closeCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'INBOX' },
|
|
run: async cmd => {
|
|
if (cmd === 'CLOSE') {
|
|
closeCalled = true;
|
|
}
|
|
},
|
|
exec: async () => ({ next: () => {} })
|
|
});
|
|
|
|
const result = await deleteCommand(connection, 'OtherFolder');
|
|
test.ok(!closeCalled, 'CLOSE should not be called');
|
|
test.ok(result);
|
|
test.equal(result.path, 'OtherFolder');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: delete works in SELECTED state'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'INBOX' },
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await deleteCommand(connection, 'SomeFolder');
|
|
test.ok(result);
|
|
test.equal(execCmd, 'DELETE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: delete error with serverResponseCode'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Delete failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'NONEXISTENT' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Mailbox does not exist' }
|
|
]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await deleteCommand(connection, 'NonExistent');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.serverResponseCode, 'NONEXISTENT');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: delete normalizes path'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, args) => {
|
|
execArgs = args;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await deleteCommand(connection, 'INBOX/Subfolder');
|
|
test.ok(result);
|
|
test.equal(result.path, 'INBOX/Subfolder');
|
|
test.ok(execArgs);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// RENAME Command Tests
|
|
// ============================================
|
|
|
|
const renameCommand = require('../lib/commands/rename');
|
|
|
|
module.exports['Commands: rename success'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs) => {
|
|
execArgs = { cmd, attrs };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await renameCommand(connection, 'OldName', 'NewName');
|
|
test.ok(result);
|
|
test.equal(result.path, 'OldName');
|
|
test.equal(result.newPath, 'NewName');
|
|
test.equal(execArgs.cmd, 'RENAME');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: rename skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 });
|
|
|
|
const result = await renameCommand(connection, 'OldName', 'NewName');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: rename throws on error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Rename failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [{ type: 'TEXT', value: 'Rename failed' }]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await renameCommand(connection, 'OldName', 'NewName');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.message.includes('Rename failed'));
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: rename closes mailbox when renaming current mailbox'] = async test => {
|
|
let closeCalled = false;
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'OldFolder' },
|
|
run: async cmd => {
|
|
if (cmd === 'CLOSE') {
|
|
closeCalled = true;
|
|
}
|
|
},
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await renameCommand(connection, 'OldFolder', 'NewFolder');
|
|
test.ok(closeCalled, 'CLOSE should be called');
|
|
test.equal(execCmd, 'RENAME');
|
|
test.ok(result);
|
|
test.equal(result.path, 'OldFolder');
|
|
test.equal(result.newPath, 'NewFolder');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: rename does not close when renaming different mailbox'] = async test => {
|
|
let closeCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'INBOX' },
|
|
run: async cmd => {
|
|
if (cmd === 'CLOSE') {
|
|
closeCalled = true;
|
|
}
|
|
},
|
|
exec: async () => ({ next: () => {} })
|
|
});
|
|
|
|
const result = await renameCommand(connection, 'OtherFolder', 'NewName');
|
|
test.ok(!closeCalled, 'CLOSE should not be called');
|
|
test.ok(result);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: rename works in SELECTED state'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'INBOX' },
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await renameCommand(connection, 'SomeFolder', 'NewName');
|
|
test.ok(result);
|
|
test.equal(execCmd, 'RENAME');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: rename error with serverResponseCode'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Rename failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'NONEXISTENT' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Mailbox does not exist' }
|
|
]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await renameCommand(connection, 'NonExistent', 'NewName');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.serverResponseCode, 'NONEXISTENT');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: rename normalizes paths'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, args) => {
|
|
execArgs = args;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await renameCommand(connection, 'INBOX/Old', 'INBOX/New');
|
|
test.ok(result);
|
|
test.equal(result.path, 'INBOX/Old');
|
|
test.equal(result.newPath, 'INBOX/New');
|
|
test.ok(execArgs);
|
|
test.equal(execArgs.length, 2);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// SUBSCRIBE/UNSUBSCRIBE Command Tests
|
|
// ============================================
|
|
|
|
const subscribeCommand = require('../lib/commands/subscribe');
|
|
const unsubscribeCommand = require('../lib/commands/unsubscribe');
|
|
|
|
module.exports['Commands: subscribe success'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await subscribeCommand(connection, 'Folder');
|
|
test.equal(result, true);
|
|
test.equal(execCmd, 'SUBSCRIBE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: subscribe skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 });
|
|
|
|
const result = await subscribeCommand(connection, 'Folder');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: unsubscribe success'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await unsubscribeCommand(connection, 'Folder');
|
|
test.equal(result, true);
|
|
test.equal(execCmd, 'UNSUBSCRIBE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: unsubscribe skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 });
|
|
|
|
const result = await unsubscribeCommand(connection, 'Folder');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: subscribe works in SELECTED state'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await subscribeCommand(connection, 'Folder');
|
|
test.equal(result, true);
|
|
test.equal(execCmd, 'SUBSCRIBE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: subscribe returns false on error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Subscribe failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [{ type: 'TEXT', value: 'Subscribe failed' }]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const result = await subscribeCommand(connection, 'Folder');
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: subscribe error with serverResponseCode'] = async test => {
|
|
let capturedErr = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Subscribe failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'NONEXISTENT' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Mailbox does not exist' }
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
log: {
|
|
warn: data => {
|
|
capturedErr = data.err;
|
|
}
|
|
}
|
|
});
|
|
|
|
const result = await subscribeCommand(connection, 'NonExistent');
|
|
test.equal(result, false);
|
|
test.ok(capturedErr);
|
|
test.equal(capturedErr.serverResponseCode, 'NONEXISTENT');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: subscribe normalizes path'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, args) => {
|
|
execArgs = args;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await subscribeCommand(connection, 'INBOX/Subfolder');
|
|
test.equal(result, true);
|
|
test.ok(execArgs);
|
|
test.equal(execArgs.length, 1);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: unsubscribe works in SELECTED state'] = async test => {
|
|
let execCmd = null;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
exec: async cmd => {
|
|
execCmd = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await unsubscribeCommand(connection, 'Folder');
|
|
test.equal(result, true);
|
|
test.equal(execCmd, 'UNSUBSCRIBE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: unsubscribe returns false on error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Unsubscribe failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [{ type: 'TEXT', value: 'Unsubscribe failed' }]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const result = await unsubscribeCommand(connection, 'Folder');
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: unsubscribe error with serverResponseCode'] = async test => {
|
|
let capturedErr = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Unsubscribe failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'NONEXISTENT' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Mailbox does not exist' }
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
log: {
|
|
warn: data => {
|
|
capturedErr = data.err;
|
|
}
|
|
}
|
|
});
|
|
|
|
const result = await unsubscribeCommand(connection, 'NonExistent');
|
|
test.equal(result, false);
|
|
test.ok(capturedErr);
|
|
test.equal(capturedErr.serverResponseCode, 'NONEXISTENT');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: unsubscribe normalizes path'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, args) => {
|
|
execArgs = args;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await unsubscribeCommand(connection, 'INBOX/Subfolder');
|
|
test.equal(result, true);
|
|
test.ok(execArgs);
|
|
test.equal(execArgs.length, 1);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// ENABLE Command Tests
|
|
// ============================================
|
|
|
|
const enableCommand = require('../lib/commands/enable');
|
|
|
|
module.exports['Commands: enable success'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
exec: async () => ({ next: () => {} })
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
// Returns Set of enabled extensions
|
|
test.ok(result instanceof Set);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 });
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable skips when ENABLE not supported'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map() // No ENABLE capability
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
// Need to include CONDSTORE so the filter doesn't skip it
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
exec: async () => {
|
|
throw new Error('Enable failed');
|
|
}
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// COMPRESS Command Tests
|
|
// ============================================
|
|
|
|
const compressCommand = require('../lib/commands/compress');
|
|
|
|
module.exports['Commands: compress success'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['COMPRESS=DEFLATE', true]]),
|
|
exec: async () => {
|
|
execCalled = true;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await compressCommand(connection);
|
|
test.equal(result, true);
|
|
test.equal(execCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: compress skips when not supported'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map() // No COMPRESS=DEFLATE
|
|
});
|
|
|
|
const result = await compressCommand(connection);
|
|
// Returns false when not supported (not undefined)
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: compress handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['COMPRESS=DEFLATE', true]]),
|
|
exec: async () => {
|
|
throw new Error('Compress failed');
|
|
}
|
|
});
|
|
|
|
const result = await compressCommand(connection);
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// STARTTLS Command Tests
|
|
// ============================================
|
|
|
|
const starttlsCommand = require('../lib/commands/starttls');
|
|
|
|
module.exports['Commands: starttls success'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['STARTTLS', true]]),
|
|
exec: async () => {
|
|
execCalled = true;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await starttlsCommand(connection);
|
|
test.equal(result, true);
|
|
test.equal(execCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: starttls skips when not supported'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map() // No STARTTLS
|
|
});
|
|
|
|
const result = await starttlsCommand(connection);
|
|
// Returns false when not supported (not undefined)
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: starttls handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['STARTTLS', true]]),
|
|
exec: async () => {
|
|
throw new Error('STARTTLS failed');
|
|
}
|
|
});
|
|
|
|
const result = await starttlsCommand(connection);
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// FETCH Command Tests
|
|
// ============================================
|
|
|
|
const fetchCommand = require('../lib/commands/fetch');
|
|
|
|
module.exports['Commands: fetch basic query'] = async test => {
|
|
let execCalled = false;
|
|
let execCommand = '';
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
exec: async (cmd, attrs, opts) => {
|
|
execCalled = true;
|
|
execCommand = cmd;
|
|
// Simulate a FETCH response
|
|
if (opts && opts.untagged && opts.untagged.FETCH) {
|
|
await opts.untagged.FETCH({
|
|
command: '1',
|
|
attributes: [
|
|
{ value: '1' },
|
|
[{ type: 'ATOM', value: 'UID' }, { type: 'ATOM', value: '100' }, { type: 'ATOM', value: 'FLAGS' }, [{ value: '\\Seen' }]]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await fetchCommand(connection, '1:*', { uid: true, flags: true });
|
|
test.equal(execCalled, true);
|
|
test.equal(execCommand, 'FETCH');
|
|
test.ok(result);
|
|
test.equal(result.count, 1);
|
|
test.ok(Array.isArray(result.list));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with UID option'] = async test => {
|
|
let execCommand = '';
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async cmd => {
|
|
execCommand = cmd;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1:*', { uid: true }, { uid: true });
|
|
test.equal(execCommand, 'UID FETCH');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch skips when not selected'] = async test => {
|
|
const connection = createMockConnection({ state: 2 }); // AUTHENTICATED, not SELECTED
|
|
|
|
const result = await fetchCommand(connection, '1:*', { uid: true });
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch skips when no range'] = async test => {
|
|
const connection = createMockConnection({ state: 3 });
|
|
|
|
const result = await fetchCommand(connection, null, { uid: true });
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with envelope query'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { envelope: true });
|
|
test.ok(queryAttrs);
|
|
// Check that ENVELOPE is in the query
|
|
const hasEnvelope = JSON.stringify(queryAttrs).includes('ENVELOPE');
|
|
test.ok(hasEnvelope);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with bodyStructure query'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { bodyStructure: true });
|
|
test.ok(queryAttrs);
|
|
const hasBODYSTRUCTURE = JSON.stringify(queryAttrs).includes('BODYSTRUCTURE');
|
|
test.ok(hasBODYSTRUCTURE);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with size query'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { size: true });
|
|
test.ok(queryAttrs);
|
|
const hasRFC822SIZE = JSON.stringify(queryAttrs).includes('RFC822.SIZE');
|
|
test.ok(hasRFC822SIZE);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with source query'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { source: true });
|
|
test.ok(queryAttrs);
|
|
const hasBODYPEEK = JSON.stringify(queryAttrs).includes('BODY.PEEK');
|
|
test.ok(hasBODYPEEK);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with source partial'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { source: { start: 0, maxLength: 1024 } });
|
|
test.ok(queryAttrs);
|
|
// Partial should be set
|
|
const queryStr = JSON.stringify(queryAttrs);
|
|
test.ok(queryStr.includes('BODY.PEEK'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with BINARY capability'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['BINARY', true]]),
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { source: true }, { binary: true });
|
|
test.ok(queryAttrs);
|
|
const hasBINARYPEEK = JSON.stringify(queryAttrs).includes('BINARY.PEEK');
|
|
test.ok(hasBINARYPEEK);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with OBJECTID capability'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['OBJECTID', true]]),
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { flags: true });
|
|
test.ok(queryAttrs);
|
|
const hasEMAILID = JSON.stringify(queryAttrs).includes('EMAILID');
|
|
test.ok(hasEMAILID);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with X-GM-EXT-1 capability'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['X-GM-EXT-1', true]]),
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { flags: true });
|
|
test.ok(queryAttrs);
|
|
const hasXGMMSGID = JSON.stringify(queryAttrs).includes('X-GM-MSGID');
|
|
test.ok(hasXGMMSGID);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with threadId query'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['OBJECTID', true]]),
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { threadId: true });
|
|
test.ok(queryAttrs);
|
|
const hasTHREADID = JSON.stringify(queryAttrs).includes('THREADID');
|
|
test.ok(hasTHREADID);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with threadId and X-GM-EXT-1 fallback'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['X-GM-EXT-1', true]]), // No OBJECTID, but has X-GM-EXT-1
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { threadId: true });
|
|
test.ok(queryAttrs);
|
|
const hasXGMTHRID = JSON.stringify(queryAttrs).includes('X-GM-THRID');
|
|
test.ok(hasXGMTHRID, 'Should use X-GM-THRID as fallback for threadId');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with labels query'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['X-GM-EXT-1', true]]),
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { labels: true });
|
|
test.ok(queryAttrs);
|
|
const hasXGMLABELS = JSON.stringify(queryAttrs).includes('X-GM-LABELS');
|
|
test.ok(hasXGMLABELS);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with CONDSTORE enabled'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
enabled: new Set(['CONDSTORE']),
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { flags: true });
|
|
test.ok(queryAttrs);
|
|
const hasMODSEQ = JSON.stringify(queryAttrs).includes('MODSEQ');
|
|
test.ok(hasMODSEQ);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with headers array'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { headers: ['Subject', 'From', 'To'] });
|
|
test.ok(queryAttrs);
|
|
const queryStr = JSON.stringify(queryAttrs);
|
|
test.ok(queryStr.includes('HEADER.FIELDS'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with headers true'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { headers: true });
|
|
test.ok(queryAttrs);
|
|
const queryStr = JSON.stringify(queryAttrs);
|
|
test.ok(queryStr.includes('HEADER'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with bodyParts'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { bodyParts: ['1', '2'] });
|
|
test.ok(queryAttrs);
|
|
const queryStr = JSON.stringify(queryAttrs);
|
|
test.ok(queryStr.includes('BODY.PEEK'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with bodyParts object'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { bodyParts: [{ key: '1', start: 0, maxLength: 100 }] });
|
|
test.ok(queryAttrs);
|
|
const queryStr = JSON.stringify(queryAttrs);
|
|
test.ok(queryStr.includes('BODY.PEEK'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with bodyParts skips invalid'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
// Invalid entries: null, object without key, number
|
|
await fetchCommand(connection, '1', { bodyParts: [null, { noKey: true }, 123, '1'] });
|
|
test.ok(queryAttrs);
|
|
// Should still work - just skips invalid entries
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with changedSince'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
enabled: new Set(['CONDSTORE']),
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { flags: true }, { changedSince: '12345' });
|
|
test.ok(queryAttrs);
|
|
const queryStr = JSON.stringify(queryAttrs);
|
|
test.ok(queryStr.includes('CHANGEDSINCE'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with changedSince and QRESYNC'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
enabled: new Set(['CONDSTORE', 'QRESYNC']),
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { flags: true }, { changedSince: '12345', uid: true });
|
|
test.ok(queryAttrs);
|
|
const queryStr = JSON.stringify(queryAttrs);
|
|
test.ok(queryStr.includes('VANISHED'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with onUntaggedFetch callback'] = async test => {
|
|
let callbackCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.FETCH) {
|
|
await opts.untagged.FETCH({
|
|
command: '1',
|
|
attributes: [
|
|
{ value: '1' },
|
|
[
|
|
{ type: 'ATOM', value: 'UID' },
|
|
{ type: 'ATOM', value: '100' }
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(
|
|
connection,
|
|
'1',
|
|
{ uid: true },
|
|
{
|
|
onUntaggedFetch: (msg, done) => {
|
|
callbackCalled = true;
|
|
done();
|
|
}
|
|
}
|
|
);
|
|
test.equal(callbackCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch callback error propagates'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.FETCH) {
|
|
await opts.untagged.FETCH({
|
|
command: '1',
|
|
attributes: [
|
|
{ value: '1' },
|
|
[
|
|
{ type: 'ATOM', value: 'UID' },
|
|
{ type: 'ATOM', value: '100' }
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
try {
|
|
await fetchCommand(
|
|
connection,
|
|
'1',
|
|
{ uid: true },
|
|
{
|
|
onUntaggedFetch: (msg, done) => {
|
|
done(new Error('Callback error'));
|
|
}
|
|
}
|
|
);
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.message, 'Callback error');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
throw new Error('Fetch failed');
|
|
}
|
|
});
|
|
|
|
try {
|
|
await fetchCommand(connection, '1', { uid: true });
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.message, 'Fetch failed');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch retries on throttle error'] = async test => {
|
|
let attempts = 0;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
attempts++;
|
|
if (attempts < 3) {
|
|
const err = new Error('Throttled');
|
|
err.code = 'ETHROTTLE';
|
|
err.throttleReset = 10; // 10ms for testing
|
|
throw err;
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await fetchCommand(connection, '1', { uid: true });
|
|
test.ok(result);
|
|
test.equal(attempts, 3);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: fetch with all/fast/full query'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await fetchCommand(connection, '1', { all: true, fast: true, full: true, internalDate: true });
|
|
test.ok(queryAttrs);
|
|
const queryStr = JSON.stringify(queryAttrs);
|
|
test.ok(queryStr.includes('ALL'));
|
|
test.ok(queryStr.includes('FAST'));
|
|
test.ok(queryStr.includes('FULL'));
|
|
test.ok(queryStr.includes('INTERNALDATE'));
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// LIST Command Tests
|
|
// ============================================
|
|
|
|
const listCommand = require('../lib/commands/list');
|
|
|
|
module.exports['Commands: list basic'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
execCalled = true;
|
|
// Simulate LIST response
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
test.equal(execCalled, true);
|
|
test.ok(Array.isArray(result));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list with XLIST capability'] = async test => {
|
|
let usedListCommand = '';
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['XLIST', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
// Capture the first LIST/XLIST command, not LSUB
|
|
if ((cmd === 'LIST' || cmd === 'XLIST') && !usedListCommand) {
|
|
usedListCommand = cmd;
|
|
}
|
|
if (opts && opts.untagged && opts.untagged[cmd]) {
|
|
await opts.untagged[cmd]({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await listCommand(connection, '', '*');
|
|
test.equal(usedListCommand, 'XLIST');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list prefers LIST over XLIST when SPECIAL-USE available'] = async test => {
|
|
let usedListCommand = '';
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([
|
|
['XLIST', true],
|
|
['SPECIAL-USE', true]
|
|
]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
// Capture the first LIST/XLIST command, not LSUB
|
|
if ((cmd === 'LIST' || cmd === 'XLIST') && !usedListCommand) {
|
|
usedListCommand = cmd;
|
|
}
|
|
if (opts && opts.untagged && opts.untagged[cmd]) {
|
|
await opts.untagged[cmd]({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await listCommand(connection, '', '*');
|
|
test.equal(usedListCommand, 'LIST');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list with statusQuery'] = async test => {
|
|
let listAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([
|
|
['LIST-STATUS', true],
|
|
['SPECIAL-USE', true]
|
|
]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST') {
|
|
listAttrs = attrs;
|
|
if (opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '10' }, { value: 'UNSEEN' }, { value: '5' }]]
|
|
});
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*', {
|
|
statusQuery: { messages: true, unseen: true }
|
|
});
|
|
test.ok(listAttrs);
|
|
const attrsStr = JSON.stringify(listAttrs);
|
|
test.ok(attrsStr.includes('RETURN'));
|
|
test.ok(attrsStr.includes('STATUS'));
|
|
test.ok(Array.isArray(result));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list with CONDSTORE status query'] = async test => {
|
|
let listAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([
|
|
['LIST-STATUS', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST') {
|
|
listAttrs = attrs;
|
|
if (opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await listCommand(connection, '', '*', {
|
|
statusQuery: { highestModseq: true }
|
|
});
|
|
test.ok(listAttrs);
|
|
const attrsStr = JSON.stringify(listAttrs);
|
|
test.ok(attrsStr.includes('HIGHESTMODSEQ'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list with listOnly option'] = async test => {
|
|
let lsubCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LSUB') {
|
|
lsubCalled = true;
|
|
}
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*', { listOnly: true });
|
|
test.equal(lsubCalled, false);
|
|
test.ok(Array.isArray(result));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list with specialUseHints'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Sent Items' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*', {
|
|
specialUseHints: { sent: 'Sent Items' }
|
|
});
|
|
test.ok(Array.isArray(result));
|
|
// The Sent Items folder should have specialUse set
|
|
const sentFolder = result.find(e => e.path === 'Sent Items');
|
|
test.ok(sentFolder);
|
|
test.equal(sentFolder.specialUse, '\\Sent');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list handles INBOX specially'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if ((cmd === 'LIST' || cmd === 'LSUB') && opts && opts.untagged) {
|
|
const handler = opts.untagged[cmd];
|
|
if (handler) {
|
|
await handler({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
const inbox = result.find(e => e.path === 'INBOX');
|
|
test.ok(inbox);
|
|
test.equal(inbox.specialUse, '\\Inbox');
|
|
// INBOX should always be subscribed
|
|
test.equal(inbox.subscribed, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list runs separate INBOX query when using namespace'] = async test => {
|
|
let listCalls = 0;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST') {
|
|
listCalls++;
|
|
if (opts && opts.untagged && opts.untagged.LIST) {
|
|
// First call is for the namespace, second for INBOX
|
|
if (listCalls === 1) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX/Subfolder' }]
|
|
});
|
|
} else {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await listCommand(connection, 'INBOX/', '*');
|
|
// Should have called LIST twice - once for namespace, once for INBOX
|
|
test.equal(listCalls, 2);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list handles LSUB merging'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
|
|
});
|
|
}
|
|
if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
|
|
await opts.untagged.LSUB({
|
|
attributes: [[{ value: '\\Subscribed' }], { value: '/' }, { value: 'Folder1' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
const folder = result.find(e => e.path === 'Folder1');
|
|
test.ok(folder);
|
|
test.equal(folder.subscribed, true);
|
|
test.equal(folder.listed, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async () => {
|
|
throw new Error('List failed');
|
|
}
|
|
});
|
|
|
|
try {
|
|
await listCommand(connection, '', '*');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.message, 'List failed');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list handles empty attributes'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
// Empty attributes - should be skipped
|
|
await opts.untagged.LIST({ attributes: [] });
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
test.ok(Array.isArray(result));
|
|
test.equal(result.length, 0);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list status fallback when LIST-STATUS not supported'] = async test => {
|
|
let statusCalls = 0;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(), // No LIST-STATUS
|
|
// eslint-disable-next-line no-unused-vars
|
|
run: async (cmd, path, query) => {
|
|
if (cmd === 'STATUS') {
|
|
statusCalls++;
|
|
return { messages: 10, unseen: 5, path };
|
|
}
|
|
},
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*', {
|
|
statusQuery: { messages: true, unseen: true }
|
|
});
|
|
test.ok(Array.isArray(result));
|
|
// STATUS should have been called for each folder
|
|
test.equal(statusCalls, 1);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list handles STATUS errors gracefully'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(),
|
|
run: async cmd => {
|
|
if (cmd === 'STATUS') {
|
|
throw new Error('Status failed');
|
|
}
|
|
},
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*', {
|
|
statusQuery: { messages: true }
|
|
});
|
|
const inbox = result.find(e => e.path === 'INBOX');
|
|
test.ok(inbox);
|
|
// Status should have error property
|
|
test.ok(inbox.status);
|
|
test.ok(inbox.status.error);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list sorts by special use'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['SPECIAL-USE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
// Add folders out of order
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\Trash' }], { value: '/' }, { value: 'Trash' }]
|
|
});
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\Sent' }], { value: '/' }, { value: 'Sent' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
// INBOX should be first (has \\Inbox special use)
|
|
test.equal(result[0].specialUse, '\\Inbox');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list handles delimiter in path'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [
|
|
[{ value: '\\HasNoChildren' }],
|
|
{ value: '/' },
|
|
{ value: '/Leading/Slash' } // Path starts with delimiter
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
const folder = result.find(e => e.name === 'Slash');
|
|
test.ok(folder);
|
|
// Leading delimiter should be removed
|
|
test.equal(folder.path, 'Leading/Slash');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list skips Noselect folders for status'] = async test => {
|
|
let statusCalls = 0;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(),
|
|
run: async cmd => {
|
|
if (cmd === 'STATUS') {
|
|
statusCalls++;
|
|
return { messages: 10 };
|
|
}
|
|
},
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\Noselect' }], { value: '/' }, { value: 'Parent' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await listCommand(connection, '', '*', { statusQuery: { messages: true } });
|
|
// STATUS should not be called for Noselect folders
|
|
test.equal(statusCalls, 0);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list XLIST removes Inbox flag from non-INBOX'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['XLIST', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'XLIST' && opts && opts.untagged && opts.untagged.XLIST) {
|
|
// XLIST may have localised inbox name with \\Inbox flag
|
|
await opts.untagged.XLIST({
|
|
attributes: [
|
|
[{ value: '\\Inbox' }, { value: '\\HasNoChildren' }],
|
|
{ value: '/' },
|
|
{ value: 'Posteingang' } // German for inbox
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
const folder = result.find(e => e.path === 'Posteingang');
|
|
test.ok(folder);
|
|
// \\Inbox flag should be removed from flags set
|
|
test.equal(folder.flags.has('\\Inbox'), false);
|
|
// But it should have \\Inbox special use
|
|
test.equal(folder.specialUse, '\\Inbox');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list LSUB path with leading delimiter'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['SPECIAL-USE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Folder1' }]
|
|
});
|
|
}
|
|
if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
|
|
// LSUB returns path with leading delimiter
|
|
await opts.untagged.LSUB({
|
|
attributes: [
|
|
[{ value: '\\Subscribed' }],
|
|
{ value: '/' },
|
|
{ value: '/Folder1' } // Leading delimiter
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
const folder = result.find(e => e.path === 'Folder1');
|
|
test.ok(folder);
|
|
test.equal(folder.subscribed, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list sorts non-special-use after special-use'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['SPECIAL-USE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
// Regular folder first
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'ZFolder' }]
|
|
});
|
|
// Then INBOX (special use)
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
// INBOX (special use) should come before ZFolder (no special use)
|
|
const inboxIndex = result.findIndex(e => e.path === 'INBOX');
|
|
const zFolderIndex = result.findIndex(e => e.path === 'ZFolder');
|
|
test.ok(inboxIndex < zFolderIndex, 'Special use folders should sort before non-special-use');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list sorts alphabetically when no special use'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['SPECIAL-USE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
// Folders without special use in reverse alphabetical order
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Zebra' }]
|
|
});
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Alpha' }]
|
|
});
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Middle' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
// Should be sorted alphabetically
|
|
const alphaIndex = result.findIndex(e => e.path === 'Alpha');
|
|
const middleIndex = result.findIndex(e => e.path === 'Middle');
|
|
const zebraIndex = result.findIndex(e => e.path === 'Zebra');
|
|
test.ok(alphaIndex < middleIndex, 'Alpha should come before Middle');
|
|
test.ok(middleIndex < zebraIndex, 'Middle should come before Zebra');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list sorts nested folders by parent path'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['SPECIAL-USE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
// Nested folders
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasChildren' }], { value: '/' }, { value: 'B' }]
|
|
});
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'B/Nested' }]
|
|
});
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasChildren' }], { value: '/' }, { value: 'A' }]
|
|
});
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'A/Nested' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
// A folders should come before B folders
|
|
const aIndex = result.findIndex(e => e.path === 'A');
|
|
const aNestedIndex = result.findIndex(e => e.path === 'A/Nested');
|
|
const bIndex = result.findIndex(e => e.path === 'B');
|
|
const bNestedIndex = result.findIndex(e => e.path === 'B/Nested');
|
|
test.ok(aIndex < bIndex, 'A should come before B');
|
|
test.ok(aNestedIndex < bIndex, 'A/Nested should come before B');
|
|
test.ok(bIndex < bNestedIndex || aNestedIndex < bNestedIndex, 'Parent folders sort correctly');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list handles LSUB with empty attributes'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['SPECIAL-USE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'TestFolder' }]
|
|
});
|
|
}
|
|
if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
|
|
// Empty attributes
|
|
await opts.untagged.LSUB({
|
|
attributes: []
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
test.ok(result.length >= 1);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list handles STATUS NaN values in LSUB response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([
|
|
['SPECIAL-USE', true],
|
|
['LIST-STATUS', true]
|
|
]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged) {
|
|
if (opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'TestFolder' }]
|
|
});
|
|
}
|
|
if (opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [
|
|
{ value: 'TestFolder' },
|
|
[
|
|
{ value: 'MESSAGES' },
|
|
{ value: 'NaN' }, // Invalid number
|
|
{ value: 'RECENT' },
|
|
{ value: 'invalid' } // Invalid value
|
|
]
|
|
]
|
|
});
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*', { statusQuery: { messages: true, recent: true } });
|
|
const folder = result.find(e => e.path === 'TestFolder');
|
|
test.ok(folder);
|
|
// NaN values should be filtered out (value === false check)
|
|
test.equal(folder.status.messages, undefined);
|
|
test.equal(folder.status.recent, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list STATUS parses UIDVALIDITY UNSEEN HIGHESTMODSEQ'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([
|
|
['LIST-STATUS', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST') {
|
|
if (opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'TestFolder' }]
|
|
});
|
|
}
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [
|
|
{ value: 'TestFolder' },
|
|
[
|
|
{ value: 'UIDVALIDITY' },
|
|
{ value: '123456789' },
|
|
{ value: 'UNSEEN' },
|
|
{ value: '42' },
|
|
{ value: 'HIGHESTMODSEQ' },
|
|
{ value: '999999999' }
|
|
]
|
|
]
|
|
});
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*', {
|
|
statusQuery: { uidValidity: true, unseen: true, highestModseq: true }
|
|
});
|
|
const folder = result.find(e => e.path === 'TestFolder');
|
|
test.ok(folder);
|
|
test.ok(folder.status);
|
|
test.equal(folder.status.uidValidity, BigInt(123456789));
|
|
test.equal(folder.status.unseen, 42);
|
|
test.equal(folder.status.highestModseq, BigInt(999999999));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list LSUB folder not in LIST entries'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['SPECIAL-USE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
// Only return INBOX in LIST
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
if (cmd === 'LSUB' && opts && opts.untagged && opts.untagged.LSUB) {
|
|
// Return a folder in LSUB that wasn't in LIST (hits else branch)
|
|
await opts.untagged.LSUB({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'SubscribedOnly' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
// The subscribed-only folder should not be in results (else branch ignores it)
|
|
const subscribedOnly = result.find(e => e.path === 'SubscribedOnly');
|
|
test.equal(subscribedOnly, undefined);
|
|
// INBOX should still be there
|
|
const inbox = result.find(e => e.path === 'INBOX');
|
|
test.ok(inbox);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list sort b has specialUse a does not'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['SPECIAL-USE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
// First add a folder without special use
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'AAA_Regular' }]
|
|
});
|
|
// Then add INBOX which gets special use
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
// When sorting, INBOX has specialUse, AAA_Regular does not
|
|
// So the comparison should hit: !a.specialUse && b.specialUse returns 1
|
|
// This means INBOX should come first even though AAA_Regular is alphabetically first
|
|
test.ok(result.length >= 2);
|
|
test.equal(result[0].path, 'INBOX');
|
|
test.equal(result[0].specialUse, '\\Inbox');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: list sort fallback path comparison'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['SPECIAL-USE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
// Create folders where parent parts match but paths differ at the end
|
|
// A/B/C and A/B will have matching parts up to a point
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Parent/Child/Deep' }]
|
|
});
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'Parent/Child' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await listCommand(connection, '', '*');
|
|
// Parent/Child should come before Parent/Child/Deep
|
|
const childIndex = result.findIndex(e => e.path === 'Parent/Child');
|
|
const deepIndex = result.findIndex(e => e.path === 'Parent/Child/Deep');
|
|
test.ok(childIndex < deepIndex, 'Shorter path should sort before longer when parent matches');
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// SELECT Command Tests
|
|
// ============================================
|
|
|
|
const selectCommand = require('../lib/commands/select');
|
|
|
|
module.exports['Commands: select basic'] = async test => {
|
|
let execCalled = false;
|
|
let execCommand = '';
|
|
const connection = createMockConnection({
|
|
state: 2, // AUTHENTICATED
|
|
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
run: async () => [],
|
|
exec: async (cmd, attrs, opts) => {
|
|
execCalled = true;
|
|
execCommand = cmd;
|
|
// Simulate SELECT response
|
|
if (opts && opts.untagged) {
|
|
if (opts.untagged.FLAGS) {
|
|
await opts.untagged.FLAGS({
|
|
attributes: [[{ value: '\\Seen' }, { value: '\\Answered' }, { value: '\\Flagged' }]]
|
|
});
|
|
}
|
|
if (opts.untagged.EXISTS) {
|
|
await opts.untagged.EXISTS({ command: '100' });
|
|
}
|
|
if (opts.untagged.OK) {
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'UIDVALIDITY' }, { value: '12345' }] }]
|
|
});
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'UIDNEXT' }, { value: '1000' }] }]
|
|
});
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'PERMANENTFLAGS' }, [{ value: '\\*' }]] }]
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX');
|
|
test.equal(execCalled, true);
|
|
test.equal(execCommand, 'SELECT');
|
|
test.ok(result);
|
|
test.equal(result.path, 'INBOX');
|
|
test.equal(result.exists, 100);
|
|
test.equal(result.readOnly, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select with readOnly option uses EXAMINE'] = async test => {
|
|
let execCommand = '';
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async cmd => {
|
|
execCommand = cmd;
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-ONLY' }] }] }
|
|
};
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX', { readOnly: true });
|
|
test.equal(execCommand, 'EXAMINE');
|
|
test.equal(result.readOnly, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 }); // NOT_AUTHENTICATED
|
|
|
|
const result = await selectCommand(connection, 'INBOX');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select fetches folder list if not cached'] = async test => {
|
|
let listCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map(), // Empty - will trigger LIST
|
|
run: async cmd => {
|
|
if (cmd === 'LIST') {
|
|
listCalled = true;
|
|
return [{ path: 'INBOX', delimiter: '/' }];
|
|
}
|
|
},
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
}),
|
|
emit: () => {}
|
|
});
|
|
|
|
await selectCommand(connection, 'INBOX');
|
|
test.equal(listCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select throws when LIST fails'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map(),
|
|
run: async () => null // LIST returns null
|
|
});
|
|
|
|
try {
|
|
await selectCommand(connection, 'INBOX');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.message, 'Failed to fetch folders');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select with QRESYNC'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
enabled: new Set(['QRESYNC']),
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async (cmd, attrs, opts) => {
|
|
execAttrs = attrs;
|
|
// Must return matching UIDVALIDITY and HIGHESTMODSEQ for QRESYNC to remain valid
|
|
if (opts && opts.untagged && opts.untagged.OK) {
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'UIDVALIDITY' }, { value: '67890' }] }]
|
|
});
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'HIGHESTMODSEQ' }, { value: '100' }] }]
|
|
});
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {},
|
|
untaggedVanished: async () => {},
|
|
untaggedFetch: async () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX', {
|
|
changedSince: '12345',
|
|
uidValidity: BigInt(67890)
|
|
});
|
|
test.ok(execAttrs);
|
|
const attrsStr = JSON.stringify(execAttrs);
|
|
test.ok(attrsStr.includes('QRESYNC'));
|
|
test.equal(result.qresync, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select QRESYNC invalidated when UIDVALIDITY mismatch'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
enabled: new Set(['QRESYNC']),
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async (cmd, attrs, opts) => {
|
|
// Return different UIDVALIDITY
|
|
if (opts && opts.untagged && opts.untagged.OK) {
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'UIDVALIDITY' }, { value: '99999' }] }]
|
|
});
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'HIGHESTMODSEQ' }, { value: '100' }] }]
|
|
});
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX', {
|
|
changedSince: '12345',
|
|
uidValidity: BigInt(67890) // Different from server's 99999
|
|
});
|
|
// QRESYNC should be invalidated due to UIDVALIDITY mismatch
|
|
test.equal(result.qresync, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select QRESYNC invalidated when NOMODSEQ'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
enabled: new Set(['QRESYNC']),
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.OK) {
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'UIDVALIDITY' }, { value: '67890' }] }]
|
|
});
|
|
// NOMODSEQ present
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'NOMODSEQ' }] }]
|
|
});
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX', {
|
|
changedSince: '12345',
|
|
uidValidity: BigInt(67890)
|
|
});
|
|
test.equal(result.noModseq, true);
|
|
test.equal(result.qresync, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select parses HIGHESTMODSEQ'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.OK) {
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'HIGHESTMODSEQ' }, { value: '9876543210' }] }]
|
|
});
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX');
|
|
test.equal(result.highestModseq, BigInt('9876543210'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select parses MAILBOXID'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.OK) {
|
|
await opts.untagged.OK({
|
|
attributes: [{ section: [{ type: 'ATOM', value: 'MAILBOXID' }, [{ value: 'abc123' }]] }]
|
|
});
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX');
|
|
test.equal(result.mailboxId, 'abc123');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select emits mailboxOpen event'] = async test => {
|
|
let emittedEvents = [];
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: false, // No current mailbox
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
}),
|
|
emit: event => {
|
|
emittedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await selectCommand(connection, 'INBOX');
|
|
test.ok(emittedEvents.includes('mailboxOpen'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select emits mailboxClose when switching'] = async test => {
|
|
let emittedEvents = [];
|
|
const connection = createMockConnection({
|
|
state: 3, // Already SELECTED
|
|
mailbox: { path: 'OldFolder' },
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
}),
|
|
emit: event => {
|
|
emittedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await selectCommand(connection, 'INBOX');
|
|
test.ok(emittedEvents.includes('mailboxClose'));
|
|
test.ok(emittedEvents.includes('mailboxOpen'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async () => {
|
|
const err = new Error('Select failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
try {
|
|
await selectCommand(connection, 'INBOX');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.message, 'Select failed');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select resets state on error when SELECTED'] = async test => {
|
|
let emittedEvent = '';
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'CurrentFolder' },
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async () => {
|
|
const err = new Error('Select failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
},
|
|
emit: event => {
|
|
emittedEvent = event;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await selectCommand(connection, 'INBOX');
|
|
} catch (err) {
|
|
// Expected - error is intentionally ignored
|
|
err.expected = true;
|
|
}
|
|
test.equal(connection.state, 2); // Reset to AUTHENTICATED
|
|
test.equal(connection.mailbox, false);
|
|
test.equal(emittedEvent, 'mailboxClose');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select copies folder metadata'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map([
|
|
[
|
|
'INBOX',
|
|
{
|
|
path: 'INBOX',
|
|
delimiter: '/',
|
|
specialUse: '\\Inbox',
|
|
subscribed: true,
|
|
listed: true
|
|
}
|
|
]
|
|
]),
|
|
run: async () => [],
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
}),
|
|
emit: () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX');
|
|
test.equal(result.delimiter, '/');
|
|
test.equal(result.specialUse, '\\Inbox');
|
|
test.equal(result.subscribed, true);
|
|
test.equal(result.listed, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select handles VANISHED untagged'] = async test => {
|
|
let vanishedCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
enabled: new Set(['QRESYNC']),
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.VANISHED) {
|
|
await opts.untagged.VANISHED({ attributes: [] });
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {},
|
|
untaggedVanished: async () => {
|
|
vanishedCalled = true;
|
|
}
|
|
});
|
|
|
|
await selectCommand(connection, 'INBOX', { changedSince: '100', uidValidity: BigInt(123) });
|
|
test.equal(vanishedCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select handles FETCH untagged'] = async test => {
|
|
let fetchCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
enabled: new Set(['QRESYNC']),
|
|
folders: new Map([['INBOX', { path: 'INBOX' }]]),
|
|
run: async () => [],
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.FETCH) {
|
|
await opts.untagged.FETCH({ command: '1', attributes: [] });
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {},
|
|
untaggedFetch: async () => {
|
|
fetchCalled = true;
|
|
}
|
|
});
|
|
|
|
await selectCommand(connection, 'INBOX', { changedSince: '100', uidValidity: BigInt(123) });
|
|
test.equal(fetchCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select encodes path with special characters'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map([['Test&Folder', { path: 'Test&Folder' }]]),
|
|
run: async () => [],
|
|
exec: async (cmd, attrs) => {
|
|
execAttrs = attrs;
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
await selectCommand(connection, 'Test&Folder');
|
|
// Path with & should use STRING type instead of ATOM
|
|
test.ok(execAttrs);
|
|
test.equal(execAttrs[0].type, 'STRING');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select handles empty OK attributes'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.OK) {
|
|
// Empty attributes - should return early
|
|
await opts.untagged.OK({
|
|
attributes: []
|
|
});
|
|
}
|
|
if (opts && opts.untagged && opts.untagged.EXISTS) {
|
|
await opts.untagged.EXISTS({ command: '100' });
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX');
|
|
test.ok(result);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select handles null FLAGS attributes'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.FLAGS) {
|
|
// Null/undefined attributes - should return early
|
|
await opts.untagged.FLAGS({
|
|
attributes: null
|
|
});
|
|
}
|
|
if (opts && opts.untagged && opts.untagged.EXISTS) {
|
|
await opts.untagged.EXISTS({ command: '100' });
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX');
|
|
test.ok(result);
|
|
test.equal(result.flags, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select handles NaN EXISTS'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.EXISTS) {
|
|
// NaN command value - should return false
|
|
await opts.untagged.EXISTS({ command: 'invalid' });
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [{ section: [{ type: 'ATOM', value: 'READ-WRITE' }] }] }
|
|
};
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
const result = await selectCommand(connection, 'INBOX');
|
|
test.ok(result);
|
|
test.equal(result.exists, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: select error with serverResponseCode'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
folders: new Map([['INBOX', { path: 'INBOX', delimiter: '/' }]]),
|
|
exec: async () => {
|
|
const err = new Error('Select failed');
|
|
err.response = {
|
|
tag: 'A1',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'NONEXISTENT' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Mailbox does not exist' }
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
emit: () => {}
|
|
});
|
|
|
|
try {
|
|
await selectCommand(connection, 'INBOX');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.serverResponseCode, 'NONEXISTENT');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// STATUS Command Tests
|
|
// ============================================
|
|
|
|
const statusCommand = require('../lib/commands/status');
|
|
|
|
module.exports['Commands: status basic'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 2, // AUTHENTICATED
|
|
exec: async (cmd, attrs, opts) => {
|
|
execCalled = true;
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '100' }, { value: 'UNSEEN' }, { value: '10' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'INBOX', { messages: true, unseen: true });
|
|
test.equal(execCalled, true);
|
|
test.ok(result);
|
|
test.equal(result.path, 'INBOX');
|
|
test.equal(result.messages, 100);
|
|
test.equal(result.unseen, 10);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 }); // NOT_AUTHENTICATED
|
|
|
|
const result = await statusCommand(connection, 'INBOX', { messages: true });
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status skips when no path'] = async test => {
|
|
const connection = createMockConnection({ state: 2 });
|
|
|
|
const result = await statusCommand(connection, '', { messages: true });
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status skips when no query attributes'] = async test => {
|
|
const connection = createMockConnection({ state: 2 });
|
|
|
|
const result = await statusCommand(connection, 'INBOX', {});
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status skips when all query values are false'] = async test => {
|
|
const connection = createMockConnection({ state: 2 });
|
|
|
|
const result = await statusCommand(connection, 'INBOX', { messages: false, unseen: false });
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status with all standard query attributes'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs, opts) => {
|
|
queryAttrs = attrs;
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [
|
|
{ value: 'INBOX' },
|
|
[
|
|
{ value: 'MESSAGES' },
|
|
{ value: '100' },
|
|
{ value: 'RECENT' },
|
|
{ value: '5' },
|
|
{ value: 'UIDNEXT' },
|
|
{ value: '1000' },
|
|
{ value: 'UIDVALIDITY' },
|
|
{ value: '12345' },
|
|
{ value: 'UNSEEN' },
|
|
{ value: '10' }
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'INBOX', {
|
|
messages: true,
|
|
recent: true,
|
|
uidNext: true,
|
|
uidValidity: true,
|
|
unseen: true
|
|
});
|
|
test.ok(queryAttrs);
|
|
const queryStr = JSON.stringify(queryAttrs);
|
|
test.ok(queryStr.includes('MESSAGES'));
|
|
test.ok(queryStr.includes('RECENT'));
|
|
test.ok(queryStr.includes('UIDNEXT'));
|
|
test.ok(queryStr.includes('UIDVALIDITY'));
|
|
test.ok(queryStr.includes('UNSEEN'));
|
|
|
|
test.equal(result.messages, 100);
|
|
test.equal(result.recent, 5);
|
|
test.equal(result.uidNext, 1000);
|
|
test.equal(result.uidValidity, BigInt(12345));
|
|
test.equal(result.unseen, 10);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status with HIGHESTMODSEQ and CONDSTORE'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['CONDSTORE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
queryAttrs = attrs;
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [{ value: 'INBOX' }, [{ value: 'HIGHESTMODSEQ' }, { value: '9876543210' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'INBOX', { highestModseq: true });
|
|
test.ok(queryAttrs);
|
|
const queryStr = JSON.stringify(queryAttrs);
|
|
test.ok(queryStr.includes('HIGHESTMODSEQ'));
|
|
test.equal(result.highestModseq, BigInt('9876543210'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status ignores HIGHESTMODSEQ without CONDSTORE'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map(), // No CONDSTORE
|
|
exec: async () => ({ next: () => {} })
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'INBOX', { highestModseq: true });
|
|
// Should return false since no valid query attributes
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status updates current mailbox when SELECTED'] = async test => {
|
|
let existsEmitted = false;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'INBOX', exists: 50, uidNext: 500 },
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '100' }, { value: 'UIDNEXT' }, { value: '1000' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
emit: event => {
|
|
if (event === 'exists') existsEmitted = true;
|
|
}
|
|
});
|
|
|
|
await statusCommand(connection, 'INBOX', { messages: true, uidNext: true });
|
|
// Mailbox should be updated
|
|
test.equal(connection.mailbox.exists, 100);
|
|
test.equal(connection.mailbox.uidNext, 1000);
|
|
// exists event should be emitted since count changed
|
|
test.equal(existsEmitted, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status does not emit exists when count unchanged'] = async test => {
|
|
let existsEmitted = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { path: 'INBOX', exists: 100 }, // Same as response
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '100' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
emit: event => {
|
|
if (event === 'exists') existsEmitted = true;
|
|
}
|
|
});
|
|
|
|
await statusCommand(connection, 'INBOX', { messages: true });
|
|
test.equal(existsEmitted, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status handles error with NO response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
run: async () => [], // LIST returns empty - folder doesn't exist
|
|
exec: async () => {
|
|
const err = new Error('Mailbox not found');
|
|
err.responseStatus = 'NO';
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await statusCommand(connection, 'NonExistent', { messages: true });
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.code, 'NotFound');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status returns false on other errors'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Some error');
|
|
err.responseStatus = 'BAD';
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'INBOX', { messages: true });
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status handles empty STATUS response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
// Empty list - should be ignored
|
|
await opts.untagged.STATUS({
|
|
attributes: [{ value: 'INBOX' }, false]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'INBOX', { messages: true });
|
|
test.ok(result);
|
|
test.equal(result.path, 'INBOX');
|
|
// No messages property since response was empty
|
|
test.equal(result.messages, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status handles invalid entry values'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [
|
|
{ value: 'INBOX' },
|
|
[
|
|
{ value: 'MESSAGES' },
|
|
{ value: 'not-a-number' },
|
|
{ value: 'UNSEEN' },
|
|
{ value: '10' },
|
|
null,
|
|
{ value: '5' }, // Invalid key
|
|
{ value: 'RECENT' },
|
|
null // Invalid value
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'INBOX', { messages: true, unseen: true, recent: true });
|
|
test.ok(result);
|
|
// MESSAGES with invalid value should be skipped (isNaN check fails)
|
|
test.equal(result.messages, undefined);
|
|
// UNSEEN should work
|
|
test.equal(result.unseen, 10);
|
|
// RECENT with null value should be skipped
|
|
test.equal(result.recent, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status encodes path with special characters'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs) => {
|
|
execAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await statusCommand(connection, 'Test&Folder', { messages: true });
|
|
// Path with & should use STRING type instead of ATOM
|
|
test.ok(execAttrs);
|
|
test.equal(execAttrs[0].type, 'STRING');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status works from SELECTED state'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'OtherFolder' }, // Different folder
|
|
exec: async (cmd, attrs, opts) => {
|
|
execCalled = true;
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [{ value: 'INBOX' }, [{ value: 'MESSAGES' }, { value: '50' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'INBOX', { messages: true });
|
|
test.equal(execCalled, true);
|
|
test.equal(result.messages, 50);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status updates HIGHESTMODSEQ for current mailbox'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['CONDSTORE', true]]),
|
|
mailbox: { path: 'INBOX', highestModseq: BigInt(100) },
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [{ value: 'INBOX' }, [{ value: 'HIGHESTMODSEQ' }, { value: '200' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await statusCommand(connection, 'INBOX', { highestModseq: true });
|
|
test.equal(connection.mailbox.highestModseq, BigInt(200));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status handles NaN values in response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [
|
|
{ value: 'TestFolder' },
|
|
[
|
|
{ value: 'MESSAGES' },
|
|
{ value: 'invalid' }, // NaN
|
|
{ value: 'RECENT' },
|
|
{ value: 'notanumber' }, // NaN
|
|
{ value: 'UIDNEXT' },
|
|
{ value: 'abc' }, // NaN
|
|
{ value: 'UIDVALIDITY' },
|
|
{ value: 'xyz' }, // NaN
|
|
{ value: 'UNSEEN' },
|
|
{ value: 'bad' } // NaN
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'TestFolder', {
|
|
messages: true,
|
|
recent: true,
|
|
uidNext: true,
|
|
uidValidity: true,
|
|
unseen: true
|
|
});
|
|
test.ok(result);
|
|
test.equal(result.path, 'TestFolder');
|
|
// NaN values should not be set
|
|
test.equal(result.messages, undefined);
|
|
test.equal(result.recent, undefined);
|
|
test.equal(result.uidNext, undefined);
|
|
test.equal(result.uidValidity, undefined);
|
|
test.equal(result.unseen, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status handles NaN HIGHESTMODSEQ'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['CONDSTORE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [{ value: 'TestFolder' }, [{ value: 'HIGHESTMODSEQ' }, { value: 'notvalid' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'TestFolder', { highestModseq: true });
|
|
test.ok(result);
|
|
test.equal(result.highestModseq, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status filters falsy query values'] = async test => {
|
|
let queryAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs) => {
|
|
queryAttrs = attrs;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
// Mix of truthy and falsy values
|
|
const result = await statusCommand(connection, 'TestFolder', {
|
|
messages: true,
|
|
recent: false, // Should be filtered
|
|
uidNext: 0, // Falsy, should be filtered
|
|
uidValidity: true,
|
|
unseen: null // Falsy, should be filtered
|
|
});
|
|
test.ok(result);
|
|
// Query should only include messages and uidValidity
|
|
test.ok(queryAttrs);
|
|
const queryList = queryAttrs[1];
|
|
test.equal(queryList.length, 2);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status handles missing entry value'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [
|
|
{ value: 'TestFolder' },
|
|
[
|
|
{ value: 'MESSAGES' },
|
|
null, // Missing value
|
|
{ value: 'RECENT' },
|
|
{ value: '5' }
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'TestFolder', {
|
|
messages: true,
|
|
recent: true
|
|
});
|
|
test.ok(result);
|
|
test.equal(result.messages, undefined); // Skipped due to null value
|
|
test.equal(result.recent, 5);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status handles missing key in response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [
|
|
{ value: 'TestFolder' },
|
|
[
|
|
null, // Missing key
|
|
{ value: '10' },
|
|
{ value: 'MESSAGES' },
|
|
{ value: '20' }
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'TestFolder', { messages: true });
|
|
test.ok(result);
|
|
test.equal(result.messages, 20);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: status handles unknown key in response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.STATUS) {
|
|
await opts.untagged.STATUS({
|
|
attributes: [{ value: 'TestFolder' }, [{ value: 'UNKNOWNKEY' }, { value: '999' }, { value: 'MESSAGES' }, { value: '10' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await statusCommand(connection, 'TestFolder', { messages: true });
|
|
test.ok(result);
|
|
test.equal(result.messages, 10);
|
|
test.equal(result.UNKNOWNKEY, undefined); // Unknown keys ignored
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// APPEND Command Tests
|
|
// ============================================
|
|
|
|
const appendCommand = require('../lib/commands/append');
|
|
|
|
module.exports['Commands: append basic'] = async test => {
|
|
let appendCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 2, // AUTHENTICATED
|
|
mailbox: { path: 'OtherFolder' }, // Different folder to avoid EXISTS handling
|
|
exec: async cmd => {
|
|
if (cmd === 'APPEND') {
|
|
appendCalled = true;
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
const result = await appendCommand(connection, 'INBOX', 'Test message content');
|
|
test.equal(appendCalled, true);
|
|
test.ok(result);
|
|
test.equal(result.destination, 'INBOX');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append with Buffer content'] = async test => {
|
|
let contentAttr = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: { path: 'OtherFolder' },
|
|
exec: async (cmd, attrs) => {
|
|
if (cmd === 'APPEND' && Array.isArray(attrs)) {
|
|
contentAttr = attrs.find(a => a && a.type === 'LITERAL');
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
const buffer = Buffer.from('Test message');
|
|
await appendCommand(connection, 'INBOX', buffer);
|
|
test.ok(contentAttr);
|
|
test.ok(Buffer.isBuffer(contentAttr.value));
|
|
test.equal(contentAttr.value.toString(), 'Test message');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 }); // NOT_AUTHENTICATED
|
|
|
|
const result = await appendCommand(connection, 'INBOX', 'content');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append skips when no destination'] = async test => {
|
|
const connection = createMockConnection({ state: 2 });
|
|
|
|
const result = await appendCommand(connection, '', 'content');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append with flags'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: { path: 'OtherFolder', permanentFlags: new Set(['\\*']) },
|
|
exec: async (cmd, attrs) => {
|
|
if (cmd === 'APPEND' && Array.isArray(attrs)) {
|
|
execAttrs = attrs;
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
await appendCommand(connection, 'INBOX', 'content', ['\\Seen', '\\Flagged']);
|
|
test.ok(execAttrs);
|
|
// Should have flags array between path and content
|
|
const flagsAttr = execAttrs.find(a => Array.isArray(a));
|
|
test.ok(flagsAttr);
|
|
test.ok(flagsAttr.some(f => f.value === '\\Seen'));
|
|
test.ok(flagsAttr.some(f => f.value === '\\Flagged'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append with internal date'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: { path: 'OtherFolder' },
|
|
exec: async (cmd, attrs) => {
|
|
if (cmd === 'APPEND' && Array.isArray(attrs)) {
|
|
execAttrs = attrs;
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
const date = new Date('2024-01-15T10:30:00Z');
|
|
await appendCommand(connection, 'INBOX', 'content', [], date);
|
|
test.ok(execAttrs);
|
|
// Should have date string
|
|
const dateAttr = execAttrs.find(a => a && a.type === 'STRING');
|
|
test.ok(dateAttr);
|
|
test.ok(dateAttr.value.includes('2024'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append checks APPENDLIMIT'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['APPENDLIMIT', 100]]), // 100 byte limit
|
|
mailbox: { path: 'INBOX' }
|
|
});
|
|
|
|
const largeContent = Buffer.alloc(200, 'x'); // 200 bytes, exceeds limit
|
|
|
|
try {
|
|
await appendCommand(connection, 'INBOX', largeContent);
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.serverResponseCode, 'APPENDLIMIT');
|
|
test.ok(err.message.includes('APPENDLIMIT'));
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append allows content within APPENDLIMIT'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['APPENDLIMIT', 1000]]),
|
|
mailbox: { path: 'INBOX' },
|
|
exec: async () => {
|
|
execCalled = true;
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
const content = Buffer.alloc(500, 'x'); // Within limit
|
|
await appendCommand(connection, 'INBOX', content);
|
|
test.equal(execCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append with APPENDUID response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: { path: 'INBOX' },
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
section: [
|
|
{ value: 'APPENDUID' },
|
|
{ value: '12345' }, // uidValidity
|
|
{ value: '100' } // uid
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await appendCommand(connection, 'INBOX', 'content');
|
|
test.equal(result.uidValidity, BigInt(12345));
|
|
test.equal(result.uid, 100);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append to current mailbox triggers EXISTS'] = async test => {
|
|
let existsEmitted = false;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'INBOX', exists: 10 },
|
|
exec: async (cmd, attrs, opts) => {
|
|
// Simulate EXISTS untagged response
|
|
if (cmd === 'APPEND' && opts && opts.untagged && opts.untagged.EXISTS) {
|
|
await opts.untagged.EXISTS({ command: '11' });
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
},
|
|
emit: event => {
|
|
if (event === 'exists') existsEmitted = true;
|
|
},
|
|
search: async () => [100] // Return UID
|
|
});
|
|
|
|
await appendCommand(connection, 'INBOX', 'content');
|
|
test.equal(existsEmitted, true);
|
|
test.equal(connection.mailbox.exists, 11);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append runs NOOP to get sequence if not in EXISTS'] = async test => {
|
|
let noopCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { path: 'INBOX', exists: 10 },
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'NOOP') {
|
|
noopCalled = true;
|
|
if (opts && opts.untagged && opts.untagged.EXISTS) {
|
|
await opts.untagged.EXISTS({ command: '11' });
|
|
}
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
},
|
|
emit: () => {},
|
|
search: async () => [100] // Return UID
|
|
});
|
|
|
|
const result = await appendCommand(connection, 'INBOX', 'content');
|
|
test.equal(noopCalled, true);
|
|
test.equal(result.seq, 11);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append searches for UID if seq but no uid'] = async test => {
|
|
let searchCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { path: 'INBOX', exists: 10 },
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.EXISTS) {
|
|
await opts.untagged.EXISTS({ command: '11' });
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
},
|
|
emit: () => {},
|
|
search: async () => {
|
|
searchCalled = true;
|
|
return [100];
|
|
}
|
|
});
|
|
|
|
const result = await appendCommand(connection, 'INBOX', 'content');
|
|
test.equal(searchCalled, true);
|
|
test.equal(result.uid, 100);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append with BINARY and NULL bytes'] = async test => {
|
|
let literalAttr = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['BINARY', true]]),
|
|
mailbox: { path: 'INBOX' },
|
|
exec: async (cmd, attrs) => {
|
|
literalAttr = attrs.find(a => a.type === 'LITERAL');
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
// Content with NULL byte
|
|
const content = Buffer.concat([Buffer.from('test'), Buffer.from([0]), Buffer.from('data')]);
|
|
await appendCommand(connection, 'INBOX', content);
|
|
test.ok(literalAttr);
|
|
test.equal(literalAttr.isLiteral8, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append without BINARY uses regular literal'] = async test => {
|
|
let literalAttr = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map(), // No BINARY
|
|
mailbox: { path: 'INBOX' },
|
|
exec: async (cmd, attrs) => {
|
|
literalAttr = attrs.find(a => a.type === 'LITERAL');
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
const content = Buffer.concat([Buffer.from('test'), Buffer.from([0]), Buffer.from('data')]);
|
|
await appendCommand(connection, 'INBOX', content);
|
|
test.ok(literalAttr);
|
|
test.equal(literalAttr.isLiteral8, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: { path: 'OtherFolder' },
|
|
exec: async () => {
|
|
const err = new Error('Append failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await appendCommand(connection, 'INBOX', 'content');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.message, 'Append failed');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append filters invalid flags'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: {
|
|
path: 'OtherFolder',
|
|
permanentFlags: new Set(['\\Seen', '\\Flagged']) // Only allow these
|
|
},
|
|
exec: async (cmd, attrs) => {
|
|
if (cmd === 'APPEND' && Array.isArray(attrs)) {
|
|
execAttrs = attrs;
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
// Mix of valid and invalid flags
|
|
await appendCommand(connection, 'INBOX', 'content', ['\\Seen', '\\CustomFlag', null, '\\Flagged']);
|
|
test.ok(execAttrs);
|
|
const flagsAttr = execAttrs.find(a => Array.isArray(a));
|
|
test.ok(flagsAttr);
|
|
// Should only contain allowed flags
|
|
test.equal(flagsAttr.length, 2);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append works from SELECTED state'] = async test => {
|
|
let execCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
mailbox: { path: 'OtherFolder', exists: 10 },
|
|
exec: async () => {
|
|
execCalled = true;
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
// Append to different folder than current
|
|
const result = await appendCommand(connection, 'INBOX', 'content');
|
|
test.equal(execCalled, true);
|
|
test.equal(result.destination, 'INBOX');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append error with serverResponseCode'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: { path: 'OtherFolder' },
|
|
exec: async () => {
|
|
const err = new Error('Append failed');
|
|
err.response = {
|
|
tag: '*',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'TRYCREATE' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Mailbox does not exist' }
|
|
]
|
|
};
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await appendCommand(connection, 'NonExistent', 'content');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.serverResponseCode, 'TRYCREATE');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append with invalid APPENDUID values'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: { path: 'OtherFolder' },
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [
|
|
{ type: 'ATOM', value: 'APPENDUID' },
|
|
{ type: 'ATOM', value: 'invalid' }, // Invalid uidValidity
|
|
{ type: 'ATOM', value: 'notanumber' } // Invalid uid
|
|
]
|
|
}
|
|
]
|
|
}
|
|
})
|
|
});
|
|
|
|
const result = await appendCommand(connection, 'INBOX', 'content');
|
|
test.ok(result);
|
|
test.equal(result.uidValidity, undefined);
|
|
test.equal(result.uid, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append NOOP error is caught'] = async test => {
|
|
let noopCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { path: 'INBOX', exists: 10 },
|
|
exec: async cmd => {
|
|
if (cmd === 'APPEND') {
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
if (cmd === 'NOOP') {
|
|
noopCalled = true;
|
|
const err = new Error('NOOP failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Append to current mailbox, expectExists = true
|
|
const result = await appendCommand(connection, 'INBOX', 'content');
|
|
test.ok(result);
|
|
test.equal(noopCalled, true);
|
|
// Should not throw, NOOP error is caught
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append EXISTS updates mailbox count'] = async test => {
|
|
let emittedEvent = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { path: 'INBOX', exists: 10 },
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'APPEND' && opts && opts.untagged && opts.untagged.EXISTS) {
|
|
await opts.untagged.EXISTS({ command: '11' }); // New count
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [
|
|
{ type: 'ATOM', value: 'APPENDUID' },
|
|
{ type: 'ATOM', value: '12345' },
|
|
{ type: 'ATOM', value: '100' }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
};
|
|
},
|
|
emit: (event, data) => {
|
|
if (event === 'exists') {
|
|
emittedEvent = data;
|
|
}
|
|
}
|
|
});
|
|
|
|
const result = await appendCommand(connection, 'INBOX', 'content');
|
|
test.ok(result);
|
|
test.equal(result.seq, 11);
|
|
test.equal(connection.mailbox.exists, 11);
|
|
test.ok(emittedEvent);
|
|
test.equal(emittedEvent.count, 11);
|
|
test.equal(emittedEvent.prevCount, 10);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append does not emit exists when count unchanged'] = async test => {
|
|
let emittedEvent = null;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
mailbox: { path: 'INBOX', exists: 10 },
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (cmd === 'APPEND' && opts && opts.untagged && opts.untagged.EXISTS) {
|
|
await opts.untagged.EXISTS({ command: '10' }); // Same count
|
|
}
|
|
return {
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
section: [
|
|
{ type: 'ATOM', value: 'APPENDUID' },
|
|
{ type: 'ATOM', value: '12345' },
|
|
{ type: 'ATOM', value: '100' }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
};
|
|
},
|
|
emit: (event, data) => {
|
|
if (event === 'exists') {
|
|
emittedEvent = data;
|
|
}
|
|
}
|
|
});
|
|
|
|
await appendCommand(connection, 'INBOX', 'content');
|
|
test.equal(emittedEvent, null); // No event emitted
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append with both flags and date'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: { path: 'OtherFolder' },
|
|
exec: async (cmd, attrs) => {
|
|
execAttrs = attrs;
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
const testDate = new Date('2024-01-15T10:30:00Z');
|
|
await appendCommand(connection, 'INBOX', 'content', ['\\Seen'], testDate);
|
|
test.ok(execAttrs);
|
|
// Should have: path, flags array, date string, literal
|
|
test.equal(execAttrs.length, 4);
|
|
// Flags array
|
|
test.ok(Array.isArray(execAttrs[1]));
|
|
// Date string
|
|
test.equal(execAttrs[2].type, 'STRING');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: append with disableBinary does not use literal8'] = async test => {
|
|
let execAttrs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
mailbox: { path: 'OtherFolder' },
|
|
capabilities: new Map([['BINARY', true]]),
|
|
disableBinary: true,
|
|
exec: async (cmd, attrs) => {
|
|
execAttrs = attrs;
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
}
|
|
});
|
|
|
|
// Content with NULL byte
|
|
const content = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x57, 0x6f, 0x72, 0x6c, 0x64]);
|
|
await appendCommand(connection, 'INBOX', content);
|
|
test.ok(execAttrs);
|
|
const literalAttr = execAttrs.find(a => a && a.type === 'LITERAL');
|
|
test.ok(literalAttr);
|
|
test.equal(literalAttr.isLiteral8, false); // Not literal8 due to disableBinary
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// IDLE Command Tests
|
|
// ============================================
|
|
|
|
const idleCommand = require('../lib/commands/idle');
|
|
|
|
module.exports['Commands: idle with IDLE capability'] = async test => {
|
|
let execCommand = '';
|
|
let idlingSet = false;
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
execCommand = cmd;
|
|
idlingSet = connection.idling;
|
|
// Simulate continuation response
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
await idleCommand(connection);
|
|
test.equal(execCommand, 'IDLE');
|
|
test.equal(idlingSet, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle skips when not selected'] = async test => {
|
|
const connection = createMockConnection({ state: 2 }); // AUTHENTICATED
|
|
|
|
const result = await idleCommand(connection);
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle falls back to NOOP without IDLE capability'] = async test => {
|
|
let noopCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(), // No IDLE
|
|
currentSelectCommand: { command: 'SELECT', arguments: [{ value: 'INBOX' }] },
|
|
exec: async cmd => {
|
|
if (cmd === 'NOOP') {
|
|
noopCalled = true;
|
|
// Break out of the loop by calling preCheck
|
|
if (connection.preCheck) {
|
|
await connection.preCheck();
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
// Start idle - it will loop with NOOP
|
|
const idlePromise = idleCommand(connection);
|
|
|
|
// Give it a moment to start, then break the loop
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
if (connection.preCheck) {
|
|
await connection.preCheck();
|
|
}
|
|
|
|
await idlePromise;
|
|
test.equal(noopCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle preCheck breaks IDLE'] = async test => {
|
|
let doneSent = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
// After IDLE is initiated, trigger preCheck
|
|
if (connection.preCheck) {
|
|
await connection.preCheck();
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: data => {
|
|
if (data === 'DONE') {
|
|
doneSent = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
await idleCommand(connection);
|
|
test.equal(doneSent, true);
|
|
test.equal(connection.idling, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async () => {
|
|
throw new Error('IDLE failed');
|
|
}
|
|
});
|
|
|
|
const result = await idleCommand(connection);
|
|
test.equal(result, false);
|
|
test.equal(connection.idling, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle with maxIdleTime restarts loop'] = async test => {
|
|
let idleCount = 0;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
idleCount++;
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
// Break after second iteration
|
|
if (idleCount >= 2 && connection.preCheck) {
|
|
await connection.preCheck();
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
// Very short maxIdleTime to trigger restart
|
|
await idleCommand(connection, 5);
|
|
test.ok(idleCount >= 1);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle without currentSelectCommand returns immediately'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(), // No IDLE
|
|
currentSelectCommand: false // No select command
|
|
});
|
|
|
|
// Should resolve immediately
|
|
await idleCommand(connection);
|
|
test.ok(true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle NOOP fallback uses STATUS when configured'] = async test => {
|
|
let statusCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(),
|
|
currentSelectCommand: { command: 'SELECT', arguments: [{ value: 'INBOX' }] },
|
|
missingIdleCommand: 'STATUS',
|
|
exec: async cmd => {
|
|
if (cmd === 'STATUS') {
|
|
statusCalled = true;
|
|
if (connection.preCheck) {
|
|
await connection.preCheck();
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await idleCommand(connection);
|
|
test.equal(statusCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle NOOP fallback uses SELECT when configured'] = async test => {
|
|
let selectCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(),
|
|
currentSelectCommand: { command: 'SELECT', arguments: [{ value: 'INBOX' }] },
|
|
missingIdleCommand: 'SELECT',
|
|
exec: async cmd => {
|
|
if (cmd === 'SELECT') {
|
|
selectCalled = true;
|
|
if (connection.preCheck) {
|
|
await connection.preCheck();
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await idleCommand(connection);
|
|
test.equal(selectCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle sets preCheck function'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
// Check that preCheck is set
|
|
test.equal(typeof connection.preCheck, 'function');
|
|
// Break the IDLE
|
|
if (connection.preCheck) {
|
|
await connection.preCheck();
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
await idleCommand(connection);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle clears preCheck on completion'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
if (connection.preCheck) {
|
|
await connection.preCheck();
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
await idleCommand(connection);
|
|
test.equal(connection.preCheck, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle NOOP fallback handles error'] = async test => {
|
|
let errorLogged = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map(),
|
|
currentSelectCommand: { command: 'SELECT', arguments: [{ value: 'INBOX' }] },
|
|
exec: async () => {
|
|
throw new Error('NOOP failed');
|
|
},
|
|
log: {
|
|
warn: () => {
|
|
errorLogged = true;
|
|
},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
// Should resolve even on error
|
|
await idleCommand(connection);
|
|
test.equal(errorLogged, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle clears wait queue on normal completion'] = async test => {
|
|
let preCheckResolved = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
// Simulate waiting preCheck request before completion
|
|
if (connection.preCheck) {
|
|
// Queue a preCheck request
|
|
connection.preCheck().then(() => {
|
|
preCheckResolved = true;
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
await idleCommand(connection);
|
|
// Wait a tick for the promise to resolve
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
test.equal(preCheckResolved, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle rejects wait queue on error'] = async test => {
|
|
let preCheckRejected = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
// Queue a preCheck request then throw
|
|
if (connection.preCheck) {
|
|
connection.preCheck().catch(() => {
|
|
preCheckRejected = true;
|
|
});
|
|
}
|
|
throw new Error('IDLE failed');
|
|
}
|
|
});
|
|
|
|
const result = await idleCommand(connection);
|
|
test.equal(result, false);
|
|
// Wait a tick for the promise to reject
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
test.equal(preCheckRejected, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle onPlusTag calls preCheck if doneRequested'] = async test => {
|
|
let doneSent = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
// Request done before onPlusTag is called
|
|
if (connection.preCheck) {
|
|
connection.preCheck().catch(() => {});
|
|
}
|
|
// Then call onPlusTag which should send DONE
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: data => {
|
|
if (data === 'DONE') {
|
|
doneSent = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
await idleCommand(connection);
|
|
test.equal(doneSent, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle calls onSend callback'] = async test => {
|
|
let onSendCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
// Call onSend callback
|
|
if (opts && opts.onSend) {
|
|
opts.onSend();
|
|
onSendCalled = true;
|
|
}
|
|
// Then call onPlusTag to enable IDLE
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
// Break IDLE via preCheck
|
|
if (connection.preCheck) {
|
|
await connection.preCheck();
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
await idleCommand(connection);
|
|
test.equal(onSendCalled, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle clears preCheck and queue on normal completion'] = async test => {
|
|
let waitQueueResolved = false;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
exec: async (cmd, attrs, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
// After onPlusTag, queue a preCheck but don't resolve yet
|
|
if (connection.preCheck) {
|
|
connection.preCheck().then(() => {
|
|
waitQueueResolved = true;
|
|
});
|
|
}
|
|
// Return to complete IDLE - this should clear the queue
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
await idleCommand(connection);
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
// preCheck should be cleared after completion
|
|
test.equal(connection.preCheck, false);
|
|
test.equal(waitQueueResolved, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle with maxIdleTime triggers preCheck after timeout'] = async test => {
|
|
let preCheckCalled = false;
|
|
let loopCount = 0;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
idling: false,
|
|
exec: async (cmd, attrs, opts) => {
|
|
loopCount++;
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
// Simulate time passing - on first loop, wait for timer
|
|
if (loopCount === 1) {
|
|
// Wait a bit for the timer to fire
|
|
await new Promise(resolve => setTimeout(resolve, 25));
|
|
}
|
|
// Check if preCheck was called by the timer
|
|
if (connection.preCheck && loopCount === 1) {
|
|
preCheckCalled = true;
|
|
await connection.preCheck();
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
// Use very short maxIdleTime
|
|
await idleCommand(connection, 10);
|
|
test.ok(preCheckCalled || loopCount > 1, 'preCheck should be called by timer or loop should restart');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: idle stillIdling triggers loop restart'] = async test => {
|
|
let loopCount = 0;
|
|
const connection = createMockConnection({
|
|
state: 3,
|
|
capabilities: new Map([['IDLE', true]]),
|
|
idling: false,
|
|
exec: async (cmd, attrs, opts) => {
|
|
loopCount++;
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag();
|
|
}
|
|
// First iteration: let the timer set stillIdling and trigger preCheck
|
|
if (loopCount === 1) {
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
// Timer should have called preCheck which sets stillIdling
|
|
}
|
|
// Second iteration: just complete
|
|
if (connection.preCheck) {
|
|
await connection.preCheck();
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
await idleCommand(connection, 5);
|
|
// Loop should have run at least once (could run twice if timer works)
|
|
test.ok(loopCount >= 1, 'IDLE loop should have run');
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// ID Command Tests
|
|
// ============================================
|
|
|
|
const idCommand = require('../lib/commands/id');
|
|
|
|
module.exports['Commands: id skips when no ID capability'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map() // No ID capability
|
|
});
|
|
|
|
const result = await idCommand(connection, { name: 'TestClient' });
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id sends client info'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await idCommand(connection, { name: 'TestClient', version: '1.0' });
|
|
test.equal(execArgs.cmd, 'ID');
|
|
test.ok(Array.isArray(execArgs.args));
|
|
test.ok(execArgs.args[0].includes('name'));
|
|
test.ok(execArgs.args[0].includes('TestClient'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id sends null when no clientInfo'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await idCommand(connection, null);
|
|
test.equal(execArgs.cmd, 'ID');
|
|
test.equal(execArgs.args[0], null);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id sends null for empty clientInfo'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await idCommand(connection, {});
|
|
test.equal(execArgs.cmd, 'ID');
|
|
test.equal(execArgs.args[0], null);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id parses server response'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.ID) {
|
|
await opts.untagged.ID({
|
|
attributes: [[{ value: 'name' }, { value: 'TestServer' }, { value: 'version' }, { value: '2.0' }, { value: 'vendor' }, { value: 'ACME' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await idCommand(connection, { name: 'TestClient' });
|
|
test.equal(result.name, 'TestServer');
|
|
test.equal(result.version, '2.0');
|
|
test.equal(result.vendor, 'ACME');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id updates serverInfo'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
serverInfo: {},
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.ID) {
|
|
await opts.untagged.ID({
|
|
attributes: [[{ value: 'name' }, { value: 'ImapServer' }, { value: 'support-url' }, { value: 'https://example.com' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await idCommand(connection, { name: 'TestClient' });
|
|
test.equal(connection.serverInfo.name, 'ImapServer');
|
|
test.equal(connection.serverInfo['support-url'], 'https://example.com');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id handles non-array server response'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.ID) {
|
|
// Some servers might send NIL or a single value
|
|
await opts.untagged.ID({
|
|
attributes: [null]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await idCommand(connection, { name: 'TestClient' });
|
|
test.ok(result);
|
|
test.deepEqual(result, {});
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id formats date value'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const testDate = new Date('2024-06-15T10:30:00Z');
|
|
await idCommand(connection, { date: testDate });
|
|
|
|
test.equal(execArgs.cmd, 'ID');
|
|
// Date should be formatted, not passed as Date object
|
|
test.ok(execArgs.args[0].includes('date'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id normalizes key names to lowercase'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.ID) {
|
|
await opts.untagged.ID({
|
|
attributes: [[{ value: 'NAME' }, { value: 'TestServer' }, { value: 'VERSION' }, { value: '1.0' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await idCommand(connection, { name: 'TestClient' });
|
|
test.equal(result.name, 'TestServer');
|
|
test.equal(result.version, '1.0');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id trims key names'] = async test => {
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.ID) {
|
|
await opts.untagged.ID({
|
|
attributes: [[{ value: ' name ' }, { value: 'TestServer' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await idCommand(connection, { name: 'TestClient' });
|
|
test.equal(result.name, 'TestServer');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id handles error'] = async test => {
|
|
let warnLogged = false;
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async () => {
|
|
throw new Error('ID command failed');
|
|
},
|
|
log: {
|
|
warn: () => {
|
|
warnLogged = true;
|
|
},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await idCommand(connection, { name: 'TestClient' });
|
|
test.equal(result, false);
|
|
test.ok(warnLogged);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id filters empty values'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await idCommand(connection, { name: 'TestClient', empty: '', valid: 'value' });
|
|
test.equal(execArgs.cmd, 'ID');
|
|
// Empty values should be filtered out
|
|
test.ok(execArgs.args[0].includes('name'));
|
|
test.ok(execArgs.args[0].includes('valid'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: id replaces whitespace in values'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
capabilities: new Map([['ID', true]]),
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await idCommand(connection, { name: 'Test\nClient\tApp' });
|
|
test.equal(execArgs.cmd, 'ID');
|
|
// Whitespace should be normalized to single spaces
|
|
const nameIndex = execArgs.args[0].indexOf('name');
|
|
test.ok(nameIndex >= 0);
|
|
const nameValue = execArgs.args[0][nameIndex + 1];
|
|
test.ok(!nameValue.includes('\n'));
|
|
test.ok(!nameValue.includes('\t'));
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// NAMESPACE Command Tests
|
|
// ============================================
|
|
|
|
const namespaceCommand = require('../lib/commands/namespace');
|
|
|
|
module.exports['Commands: namespace skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 }); // NOT_AUTHENTICATED
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace with NAMESPACE capability'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2, // AUTHENTICATED
|
|
capabilities: new Map([['NAMESPACE', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
test.equal(cmd, 'NAMESPACE');
|
|
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
await opts.untagged.NAMESPACE({
|
|
attributes: [
|
|
// personal namespaces
|
|
[[{ value: 'INBOX.' }, { value: '.' }]],
|
|
// other users
|
|
[[{ value: 'Users.' }, { value: '.' }]],
|
|
// shared
|
|
[[{ value: 'Shared.' }, { value: '.' }]]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.ok(result);
|
|
test.equal(result.prefix, 'INBOX.');
|
|
test.equal(result.delimiter, '.');
|
|
test.equal(connection.namespaces.personal[0].prefix, 'INBOX.');
|
|
test.equal(connection.namespaces.other[0].prefix, 'Users.');
|
|
test.equal(connection.namespaces.shared[0].prefix, 'Shared.');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace fallback without capability'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2, // AUTHENTICATED
|
|
capabilities: new Map(), // No NAMESPACE capability
|
|
exec: async (cmd, args, opts) => {
|
|
test.equal(cmd, 'LIST');
|
|
if (opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '/' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.ok(result);
|
|
test.equal(result.delimiter, '/');
|
|
test.equal(connection.namespaces.other, false);
|
|
test.equal(connection.namespaces.shared, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace fallback adds delimiter to prefix'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [[{ value: '\\HasNoChildren' }], { value: '.' }, { value: 'INBOX' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.ok(result);
|
|
test.equal(result.delimiter, '.');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace handles empty response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['NAMESPACE', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
// Provide minimal valid namespace even in "empty" case
|
|
await opts.untagged.NAMESPACE({
|
|
attributes: [
|
|
[[{ value: '' }, { value: '.' }]], // minimal personal namespace
|
|
null,
|
|
null
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.ok(result);
|
|
test.equal(result.prefix, '');
|
|
test.equal(result.delimiter, '.');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace handles NIL namespaces'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['NAMESPACE', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
await opts.untagged.NAMESPACE({
|
|
attributes: [
|
|
[[{ value: '' }, { value: '/' }]], // personal
|
|
null, // other (NIL)
|
|
null // shared (NIL)
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.ok(result);
|
|
test.equal(result.delimiter, '/');
|
|
test.equal(connection.namespaces.other, false);
|
|
test.equal(connection.namespaces.shared, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace handles multiple personal namespaces'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['NAMESPACE', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
await opts.untagged.NAMESPACE({
|
|
attributes: [
|
|
[
|
|
[{ value: 'INBOX' }, { value: '.' }],
|
|
[{ value: 'Mail' }, { value: '/' }]
|
|
],
|
|
null,
|
|
null
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.ok(result);
|
|
test.equal(connection.namespaces.personal.length, 2);
|
|
test.equal(connection.namespaces.personal[0].prefix, 'INBOX.');
|
|
test.equal(connection.namespaces.personal[1].prefix, 'Mail/');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace works in SELECTED state'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
capabilities: new Map([['NAMESPACE', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
await opts.untagged.NAMESPACE({
|
|
attributes: [[[{ value: '' }, { value: '/' }]], null, null]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.ok(result);
|
|
test.equal(result.delimiter, '/');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace handles error'] = async test => {
|
|
let warnLogged = false;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['NAMESPACE', true]]),
|
|
exec: async () => {
|
|
const err = new Error('Namespace failed');
|
|
err.responseStatus = 'NO';
|
|
err.responseText = 'Command not supported';
|
|
throw err;
|
|
},
|
|
log: {
|
|
warn: () => {
|
|
warnLogged = true;
|
|
},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.ok(result.error);
|
|
test.equal(result.status, 'NO');
|
|
test.equal(result.text, 'Command not supported');
|
|
test.ok(warnLogged);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace fallback handles LIST error'] = async test => {
|
|
let warnLogged = false;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map(), // No NAMESPACE capability
|
|
exec: async () => {
|
|
throw new Error('LIST failed');
|
|
},
|
|
log: {
|
|
warn: () => {
|
|
warnLogged = true;
|
|
},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.ok(result);
|
|
// Should return default namespace even on error
|
|
test.equal(result.prefix, '');
|
|
test.ok(warnLogged);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace appends delimiter to prefix if missing'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['NAMESPACE', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
await opts.untagged.NAMESPACE({
|
|
attributes: [
|
|
// prefix without trailing delimiter
|
|
[[{ value: 'INBOX' }, { value: '.' }]],
|
|
null,
|
|
null
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.equal(result.prefix, 'INBOX.');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace fallback strips leading delimiter from prefix'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.LIST) {
|
|
await opts.untagged.LIST({
|
|
attributes: [
|
|
[{ value: '\\HasNoChildren' }],
|
|
{ value: '/' },
|
|
{ value: '/INBOX' } // Leading delimiter
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.equal(result.prefix, 'INBOX/');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace ignores empty NAMESPACE response attributes'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['NAMESPACE', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (cmd === 'NAMESPACE' && opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
// Empty attributes - the callback should return early
|
|
await opts.untagged.NAMESPACE({
|
|
attributes: []
|
|
});
|
|
// Also provide a valid NAMESPACE to avoid error
|
|
await opts.untagged.NAMESPACE({
|
|
attributes: [[[{ value: 'INBOX.' }, { value: '.' }]], null, null]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
// Should return namespace from the second call
|
|
test.equal(result.prefix, 'INBOX.');
|
|
test.equal(result.delimiter, '.');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace sets default when personal namespace is empty array'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['NAMESPACE', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (cmd === 'NAMESPACE' && opts && opts.untagged && opts.untagged.NAMESPACE) {
|
|
// Provide an array where entries don't pass the filter
|
|
// (entry.length < 2), so getNamsepaceInfo returns []
|
|
await opts.untagged.NAMESPACE({
|
|
attributes: [
|
|
[[]], // array with one empty entry - filter removes it, returns []
|
|
null, // other
|
|
null // shared
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
// Should set default personal namespace when personal[0] is falsy
|
|
test.equal(result.prefix, '');
|
|
test.equal(result.delimiter, '.');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: namespace fallback ignores empty LIST attributes'] = async test => {
|
|
let listCallCount = 0;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map(), // No NAMESPACE capability
|
|
exec: async (cmd, args, opts) => {
|
|
if (cmd === 'LIST' && opts && opts.untagged && opts.untagged.LIST) {
|
|
listCallCount++;
|
|
// Empty attributes - the callback should return early
|
|
await opts.untagged.LIST({
|
|
attributes: []
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await namespaceCommand(connection);
|
|
test.ok(result);
|
|
test.equal(listCallCount, 1);
|
|
// With empty LIST, prefix and delimiter are undefined
|
|
test.equal(result.prefix, '');
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// QUOTA Command Tests
|
|
// ============================================
|
|
|
|
const quotaCommand = require('../lib/commands/quota');
|
|
|
|
module.exports['Commands: quota skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 }); // NOT_AUTHENTICATED
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota skips when no path'] = async test => {
|
|
const connection = createMockConnection({ state: 2 });
|
|
|
|
const result = await quotaCommand(connection, null);
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota returns false without capability'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map() // No QUOTA capability
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota with storage quota'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
test.equal(cmd, 'GETQUOTAROOT');
|
|
if (opts && opts.untagged) {
|
|
if (opts.untagged.QUOTAROOT) {
|
|
await opts.untagged.QUOTAROOT({
|
|
attributes: [
|
|
{ value: 'INBOX' },
|
|
{ value: 'user.root' } // quota root
|
|
]
|
|
});
|
|
}
|
|
if (opts.untagged.QUOTA) {
|
|
await opts.untagged.QUOTA({
|
|
attributes: [
|
|
{ value: 'user.root' },
|
|
[
|
|
{ value: 'STORAGE' },
|
|
{ value: '500' }, // 500 KB used
|
|
{ value: '1000' } // 1000 KB limit
|
|
]
|
|
]
|
|
});
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.ok(result);
|
|
test.equal(result.path, 'INBOX');
|
|
test.equal(result.quotaRoot, 'user.root');
|
|
test.equal(result.storage.usage, 500 * 1024); // Converted to bytes
|
|
test.equal(result.storage.limit, 1000 * 1024);
|
|
test.equal(result.storage.status, '50%');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota with message quota'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
await opts.untagged.QUOTA({
|
|
attributes: [{ value: 'root' }, [{ value: 'MESSAGE' }, { value: '100' }, { value: '1000' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.ok(result);
|
|
// MESSAGE quota is not multiplied by 1024
|
|
test.equal(result.message.usage, 100);
|
|
test.equal(result.message.limit, 1000);
|
|
test.equal(result.message.status, '10%');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota with multiple quota types'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
await opts.untagged.QUOTA({
|
|
attributes: [
|
|
{ value: '' },
|
|
[{ value: 'STORAGE' }, { value: '250' }, { value: '500' }, { value: 'MESSAGE' }, { value: '50' }, { value: '100' }]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.ok(result.storage);
|
|
test.ok(result.message);
|
|
test.equal(result.storage.usage, 250 * 1024);
|
|
test.equal(result.message.usage, 50);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota fetches GETQUOTA when quotaRoot but no QUOTA response'] = async test => {
|
|
let getQuotaCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (cmd === 'GETQUOTAROOT') {
|
|
if (opts && opts.untagged && opts.untagged.QUOTAROOT) {
|
|
await opts.untagged.QUOTAROOT({
|
|
attributes: [{ value: 'INBOX' }, { value: 'user.root' }]
|
|
});
|
|
}
|
|
// No QUOTA response
|
|
} else if (cmd === 'GETQUOTA') {
|
|
getQuotaCalled = true;
|
|
test.deepEqual(args, [{ type: 'ATOM', value: 'user.root' }]);
|
|
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
await opts.untagged.QUOTA({
|
|
attributes: [{ value: 'user.root' }, [{ value: 'STORAGE' }, { value: '100' }, { value: '200' }]]
|
|
});
|
|
}
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.ok(getQuotaCalled);
|
|
test.equal(result.quotaRoot, 'user.root');
|
|
test.equal(result.storage.usage, 100 * 1024);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota handles zero limit'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
await opts.untagged.QUOTA({
|
|
attributes: [
|
|
{ value: '' },
|
|
[
|
|
{ value: 'STORAGE' },
|
|
{ value: '0' },
|
|
{ value: '0' } // Zero limit
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.ok(result.storage);
|
|
test.equal(result.storage.usage, 0);
|
|
test.equal(result.storage.limit, 0);
|
|
// No status when limit is 0
|
|
test.equal(result.storage.status, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota handles empty attributes'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
await opts.untagged.QUOTA({
|
|
attributes: [
|
|
{ value: '' },
|
|
[] // Empty quota list
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.ok(result);
|
|
test.equal(result.path, 'INBOX');
|
|
test.equal(result.storage, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota works in SELECTED state'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
await opts.untagged.QUOTA({
|
|
attributes: [{ value: '' }, [{ value: 'STORAGE' }, { value: '10' }, { value: '100' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.ok(result);
|
|
test.equal(result.storage.status, '10%');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota handles error'] = async test => {
|
|
let warnLogged = false;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async () => {
|
|
const err = new Error('Quota failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
},
|
|
log: {
|
|
warn: () => {
|
|
warnLogged = true;
|
|
},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.equal(result, false);
|
|
test.ok(warnLogged);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota handles error with status code'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async () => {
|
|
const err = new Error('Quota failed');
|
|
err.response = {
|
|
tag: 'A1',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
value: '',
|
|
section: [{ type: 'ATOM', value: 'NOQUOTA' }]
|
|
}
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
log: {
|
|
warn: () => {},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.equal(result, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota normalizes path'] = async test => {
|
|
let capturedArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
namespace: { delimiter: '/', prefix: 'INBOX/' },
|
|
exec: async (cmd, args) => {
|
|
capturedArgs = args;
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await quotaCommand(connection, 'Subfolder');
|
|
test.ok(capturedArgs);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota handles non-numeric values'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
await opts.untagged.QUOTA({
|
|
attributes: [
|
|
{ value: '' },
|
|
[
|
|
{ value: 'STORAGE' },
|
|
{ value: 'invalid' }, // Non-numeric usage
|
|
{ value: 'also-invalid' } // Non-numeric limit
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.ok(result);
|
|
// Non-numeric values should be skipped - no storage data set
|
|
test.equal(result.storage, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota calculates percentage correctly'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
await opts.untagged.QUOTA({
|
|
attributes: [{ value: '' }, [{ value: 'MESSAGE' }, { value: '333' }, { value: '1000' }]]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.equal(result.message.status, '33%'); // Rounded
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota handles falsy key in attributes'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
// First attribute (i=0) has invalid key (null value), so key becomes false
|
|
// Then i=1 and i=2 should be skipped due to !key check
|
|
await opts.untagged.QUOTA({
|
|
attributes: [
|
|
{ value: '' },
|
|
[
|
|
{ value: null }, // Invalid key at i=0 -> key = false
|
|
{ value: '100' }, // i=1, skipped because !key
|
|
{ value: '1000' } // i=2, skipped because !key
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.ok(result);
|
|
// No quota data should be set since key was falsy
|
|
test.equal(Object.keys(result).filter(k => k !== 'path').length, 0);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: quota sets limit without prior usage'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['QUOTA', true]]),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.QUOTA) {
|
|
// Provide only the limit (i=2) without usage (i=1) being valid
|
|
await opts.untagged.QUOTA({
|
|
attributes: [
|
|
{ value: '' },
|
|
[
|
|
{ value: 'STORAGE' }, // i=0, key = 'storage'
|
|
{ value: 'invalid' }, // i=1, usage - invalid number, skipped
|
|
{ value: '1000' } // i=2, limit - should create map[key] first
|
|
]
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await quotaCommand(connection, 'INBOX');
|
|
test.ok(result);
|
|
test.ok(result.storage);
|
|
test.equal(result.storage.limit, 1024000); // 1000 * 1024 for storage
|
|
test.equal(result.storage.usage, undefined);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// AUTHENTICATE Command Tests
|
|
// ============================================
|
|
|
|
const authenticateCommand = require('../lib/commands/authenticate');
|
|
|
|
module.exports['Commands: authenticate skips when already authenticated'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2 // AUTHENTICATED
|
|
});
|
|
|
|
const result = await authenticateCommand(connection, 'user', { password: 'pass' });
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate with OAUTHBEARER'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 1, // NOT_AUTHENTICATED
|
|
capabilities: new Map([['AUTH=OAUTHBEARER', true]]),
|
|
servername: 'imap.example.com',
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
const result = await authenticateCommand(connection, 'user@example.com', { accessToken: 'token123' });
|
|
test.equal(result, 'user@example.com');
|
|
test.equal(execArgs.cmd, 'AUTHENTICATE');
|
|
test.equal(execArgs.args[0].value, 'OAUTHBEARER');
|
|
test.ok(connection.authCapabilities.has('AUTH=OAUTHBEARER'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate with XOAUTH2'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=XOAUTH2', true]]),
|
|
servername: 'imap.example.com',
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
const result = await authenticateCommand(connection, 'user@example.com', { accessToken: 'token123' });
|
|
test.equal(result, 'user@example.com');
|
|
test.equal(execArgs.args[0].value, 'XOAUTH2');
|
|
test.ok(connection.authCapabilities.has('AUTH=XOAUTH2'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate with XOAUTH (legacy)'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=XOAUTH', true]]),
|
|
servername: 'imap.example.com',
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {}
|
|
});
|
|
|
|
const result = await authenticateCommand(connection, 'user@example.com', { accessToken: 'token123' });
|
|
test.equal(result, 'user@example.com');
|
|
test.equal(execArgs.args[0].value, 'XOAUTH2');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate OAuth handles error response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=OAUTHBEARER', true]]),
|
|
servername: 'imap.example.com',
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
// Simulate server sending error in plus tag
|
|
if (opts && opts.onPlusTag) {
|
|
const errorJson = Buffer.from(JSON.stringify({ status: '401', error: 'invalid_token' })).toString('base64');
|
|
await opts.onPlusTag({
|
|
attributes: [{ type: 'TEXT', value: errorJson }]
|
|
});
|
|
}
|
|
const err = new Error('Authentication failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
},
|
|
write: () => {},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
try {
|
|
await authenticateCommand(connection, 'user@example.com', { accessToken: 'bad_token' });
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.authenticationFailed);
|
|
test.ok(err.oauthError);
|
|
test.equal(err.oauthError.status, '401');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate OAuth handles malformed error response'] = async test => {
|
|
let debugLogged = false;
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=OAUTHBEARER', true]]),
|
|
servername: 'imap.example.com',
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
// Malformed base64/JSON
|
|
await opts.onPlusTag({
|
|
attributes: [{ type: 'TEXT', value: 'not-valid-base64!' }]
|
|
});
|
|
}
|
|
const err = new Error('Authentication failed');
|
|
err.response = { attributes: [] };
|
|
throw err;
|
|
},
|
|
write: () => {},
|
|
log: {
|
|
debug: () => {
|
|
debugLogged = true;
|
|
},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
try {
|
|
await authenticateCommand(connection, 'user@example.com', { accessToken: 'token' });
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.authenticationFailed);
|
|
test.ok(debugLogged); // Should log the parse error
|
|
test.equal(err.oauthError, undefined); // No oauthError since parse failed
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate OAuth error with serverResponseCode'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=OAUTHBEARER', true]]),
|
|
servername: 'imap.example.com',
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag({});
|
|
}
|
|
const err = new Error('Authentication failed');
|
|
err.response = {
|
|
tag: 'A1',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'AUTHORIZATIONFAILED' }]
|
|
},
|
|
{ type: 'TEXT', value: 'OAuth token expired' }
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
write: () => {},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
try {
|
|
await authenticateCommand(connection, 'user@example.com', { accessToken: 'expired_token' });
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.authenticationFailed);
|
|
test.equal(err.serverResponseCode, 'AUTHORIZATIONFAILED');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate with PLAIN'] = async test => {
|
|
let execArgs = null;
|
|
let writtenData = null;
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=PLAIN', true]]),
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
execArgs = { cmd, args };
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag({});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: data => {
|
|
writtenData = data;
|
|
},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await authenticateCommand(connection, 'testuser', { password: 'testpass' });
|
|
test.equal(result, 'testuser');
|
|
test.equal(execArgs.cmd, 'AUTHENTICATE');
|
|
test.equal(execArgs.args[0].value, 'PLAIN');
|
|
// Verify PLAIN format: \x00username\x00password
|
|
const decoded = Buffer.from(writtenData, 'base64').toString();
|
|
test.equal(decoded, '\x00testuser\x00testpass');
|
|
test.ok(connection.authCapabilities.has('AUTH=PLAIN'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate with PLAIN and authzid'] = async test => {
|
|
let writtenData = null;
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=PLAIN', true]]),
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag({});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: data => {
|
|
writtenData = data;
|
|
},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await authenticateCommand(connection, 'admin', {
|
|
password: 'adminpass',
|
|
authzid: 'impersonated_user'
|
|
});
|
|
test.equal(result, 'impersonated_user'); // Returns authzid when provided
|
|
// Verify PLAIN format with authzid: authzid\x00username\x00password
|
|
const decoded = Buffer.from(writtenData, 'base64').toString();
|
|
test.equal(decoded, 'impersonated_user\x00admin\x00adminpass');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate with PLAIN forced via loginMethod'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([
|
|
['AUTH=LOGIN', true],
|
|
['AUTH=PLAIN', true]
|
|
]),
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
execArgs = { cmd, args };
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag({});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
await authenticateCommand(connection, 'user', { password: 'pass', loginMethod: 'AUTH=PLAIN' });
|
|
test.equal(execArgs.args[0].value, 'PLAIN');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate with LOGIN'] = async test => {
|
|
let execArgs = null;
|
|
let writeCount = 0;
|
|
let writtenValues = [];
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=LOGIN', true]]),
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
execArgs = { cmd, args };
|
|
if (opts && opts.onPlusTag) {
|
|
// Simulate server prompts
|
|
await opts.onPlusTag({
|
|
attributes: [{ type: 'TEXT', value: Buffer.from('Username:').toString('base64') }]
|
|
});
|
|
await opts.onPlusTag({
|
|
attributes: [{ type: 'TEXT', value: Buffer.from('Password:').toString('base64') }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: data => {
|
|
writeCount++;
|
|
writtenValues.push(Buffer.from(data, 'base64').toString());
|
|
},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await authenticateCommand(connection, 'loginuser', { password: 'loginpass' });
|
|
test.equal(result, 'loginuser');
|
|
test.equal(execArgs.args[0].value, 'LOGIN');
|
|
test.equal(writeCount, 2);
|
|
test.equal(writtenValues[0], 'loginuser');
|
|
test.equal(writtenValues[1], 'loginpass');
|
|
test.ok(connection.authCapabilities.has('AUTH=LOGIN'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate with LOGIN handles user name prompt'] = async test => {
|
|
let writtenValues = [];
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=LOGIN', true]]),
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
// Some servers use "User Name" instead of "Username"
|
|
await opts.onPlusTag({
|
|
attributes: [{ type: 'TEXT', value: Buffer.from('User Name:').toString('base64') }]
|
|
});
|
|
await opts.onPlusTag({
|
|
attributes: [{ type: 'TEXT', value: Buffer.from('Password').toString('base64') }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: data => {
|
|
writtenValues.push(Buffer.from(data, 'base64').toString());
|
|
},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
await authenticateCommand(connection, 'testuser', { password: 'testpass' });
|
|
test.equal(writtenValues[0], 'testuser');
|
|
test.equal(writtenValues[1], 'testpass');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate with LOGIN throws on unknown question'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=LOGIN', true]]),
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag({
|
|
attributes: [{ type: 'TEXT', value: Buffer.from('Unknown Question:').toString('base64') }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
try {
|
|
await authenticateCommand(connection, 'user', { password: 'pass' });
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.message.includes('Unknown LOGIN question'));
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate with LOGIN forced via loginMethod'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([
|
|
['AUTH=PLAIN', true],
|
|
['AUTH=LOGIN', true]
|
|
]),
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
execArgs = { cmd, args };
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag({
|
|
attributes: [{ type: 'TEXT', value: Buffer.from('Username:').toString('base64') }]
|
|
});
|
|
await opts.onPlusTag({
|
|
attributes: [{ type: 'TEXT', value: Buffer.from('Password:').toString('base64') }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
await authenticateCommand(connection, 'user', { password: 'pass', loginMethod: 'AUTH=LOGIN' });
|
|
test.equal(execArgs.args[0].value, 'LOGIN');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate PLAIN handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=PLAIN', true]]),
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag({});
|
|
}
|
|
const err = new Error('Authentication failed');
|
|
err.response = {
|
|
tag: 'A1',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
value: '',
|
|
section: [{ type: 'ATOM', value: 'AUTHENTICATIONFAILED' }]
|
|
}
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
write: () => {},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
try {
|
|
await authenticateCommand(connection, 'user', { password: 'wrongpass' });
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.authenticationFailed);
|
|
test.equal(err.serverResponseCode, 'AUTHENTICATIONFAILED');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate LOGIN handles error'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=LOGIN', true]]),
|
|
authCapabilities: new Map(),
|
|
exec: async () => {
|
|
const err = new Error('Login failed');
|
|
err.response = {
|
|
tag: 'A1',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'SECTION',
|
|
section: [{ type: 'ATOM', value: 'AUTHENTICATIONFAILED' }]
|
|
},
|
|
{ type: 'TEXT', value: 'Invalid credentials' }
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
write: () => {},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
try {
|
|
await authenticateCommand(connection, 'user', { password: 'pass' });
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.authenticationFailed);
|
|
test.equal(err.serverResponseCode, 'AUTHENTICATIONFAILED');
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate throws unsupported mechanism'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map() // No auth capabilities
|
|
});
|
|
|
|
try {
|
|
await authenticateCommand(connection, 'user', { password: 'pass' });
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.message.includes('Unsupported authentication mechanism'));
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate throws unsupported for accessToken without OAuth capability'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([['AUTH=PLAIN', true]]) // No OAuth capability
|
|
});
|
|
|
|
try {
|
|
await authenticateCommand(connection, 'user', { accessToken: 'token123' });
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.ok(err.message.includes('Unsupported authentication mechanism'));
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate prefers PLAIN over LOGIN by default'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([
|
|
['AUTH=LOGIN', true],
|
|
['AUTH=PLAIN', true]
|
|
]),
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args, opts) => {
|
|
execArgs = { cmd, args };
|
|
if (opts && opts.onPlusTag) {
|
|
await opts.onPlusTag({});
|
|
}
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
await authenticateCommand(connection, 'user', { password: 'pass' });
|
|
test.equal(execArgs.args[0].value, 'PLAIN'); // PLAIN should be preferred
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: authenticate prefers OAuth when accessToken provided'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 1,
|
|
capabilities: new Map([
|
|
['AUTH=PLAIN', true],
|
|
['AUTH=OAUTHBEARER', true]
|
|
]),
|
|
servername: 'imap.example.com',
|
|
authCapabilities: new Map(),
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return { next: () => {} };
|
|
},
|
|
write: () => {},
|
|
log: {
|
|
debug: () => {},
|
|
warn: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
await authenticateCommand(connection, 'user', { accessToken: 'token', password: 'pass' });
|
|
test.equal(execArgs.args[0].value, 'OAUTHBEARER'); // OAuth preferred when token provided
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// CREATE Command Tests
|
|
// ============================================
|
|
|
|
module.exports['Commands: create skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({ state: 1 }); // NOT_AUTHENTICATED
|
|
|
|
const result = await createCommand(connection, 'NewFolder');
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create mailbox success'] = async test => {
|
|
let execArgs = null;
|
|
let subscribeCalled = false;
|
|
const connection = createMockConnection({
|
|
state: 2, // AUTHENTICATED
|
|
exec: async (cmd, args) => {
|
|
execArgs = { cmd, args };
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
},
|
|
run: async (cmd, path) => {
|
|
if (cmd === 'SUBSCRIBE') {
|
|
subscribeCalled = true;
|
|
test.equal(path, 'NewFolder');
|
|
}
|
|
}
|
|
});
|
|
|
|
const result = await createCommand(connection, 'NewFolder');
|
|
test.ok(result);
|
|
test.equal(result.path, 'NewFolder');
|
|
test.equal(result.created, true);
|
|
test.equal(execArgs.cmd, 'CREATE');
|
|
test.ok(subscribeCalled);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create works in SELECTED state'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
}),
|
|
run: async () => {}
|
|
});
|
|
|
|
const result = await createCommand(connection, 'NewFolder');
|
|
test.ok(result);
|
|
test.equal(result.created, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create with MAILBOXID response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
section: [{ value: 'MAILBOXID' }, [{ value: 'F12345' }]]
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
run: async () => {}
|
|
});
|
|
|
|
const result = await createCommand(connection, 'NewFolder');
|
|
test.ok(result);
|
|
test.equal(result.mailboxId, 'F12345');
|
|
test.equal(result.created, true);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create normalizes path'] = async test => {
|
|
let capturedArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
namespace: { delimiter: '/', prefix: 'INBOX/' },
|
|
exec: async (cmd, args) => {
|
|
capturedArgs = args;
|
|
return {
|
|
next: () => {},
|
|
response: { attributes: [] }
|
|
};
|
|
},
|
|
run: async () => {}
|
|
});
|
|
|
|
await createCommand(connection, 'Subfolder');
|
|
test.ok(capturedArgs);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create handles ALREADYEXISTS'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Mailbox already exists');
|
|
err.response = {
|
|
tag: 'A1',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
value: '',
|
|
section: [{ type: 'ATOM', value: 'ALREADYEXISTS' }]
|
|
}
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
run: async () => {},
|
|
log: {
|
|
warn: () => {},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await createCommand(connection, 'ExistingFolder');
|
|
test.ok(result);
|
|
test.equal(result.path, 'ExistingFolder');
|
|
test.equal(result.created, false);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create throws on other errors'] = async test => {
|
|
let warnLogged = false;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => {
|
|
const err = new Error('Permission denied');
|
|
err.response = {
|
|
tag: 'A1',
|
|
command: 'NO',
|
|
attributes: [
|
|
{
|
|
type: 'ATOM',
|
|
value: '',
|
|
section: [{ type: 'ATOM', value: 'NOPERM' }]
|
|
}
|
|
]
|
|
};
|
|
throw err;
|
|
},
|
|
run: async () => {},
|
|
log: {
|
|
warn: () => {
|
|
warnLogged = true;
|
|
},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
try {
|
|
await createCommand(connection, 'RestrictedFolder');
|
|
test.ok(false, 'Should have thrown');
|
|
} catch (err) {
|
|
test.equal(err.serverResponseCode, 'NOPERM');
|
|
test.ok(warnLogged);
|
|
}
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create handles empty section'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
section: [] // Empty section
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
run: async () => {}
|
|
});
|
|
|
|
const result = await createCommand(connection, 'NewFolder');
|
|
test.ok(result);
|
|
test.equal(result.created, true);
|
|
test.equal(result.mailboxId, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create handles invalid MAILBOXID format'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
section: [
|
|
{ value: 'MAILBOXID' },
|
|
{ value: 'not-an-array' } // Should be array
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
run: async () => {}
|
|
});
|
|
|
|
const result = await createCommand(connection, 'NewFolder');
|
|
test.ok(result);
|
|
test.equal(result.created, true);
|
|
// mailboxId should not be set due to invalid format
|
|
test.equal(result.mailboxId, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: create handles null key in section'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
exec: async () => ({
|
|
next: () => {},
|
|
response: {
|
|
attributes: [
|
|
{
|
|
section: [
|
|
null, // null key
|
|
[{ value: 'F12345' }]
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
run: async () => {}
|
|
});
|
|
|
|
const result = await createCommand(connection, 'NewFolder');
|
|
test.ok(result);
|
|
test.equal(result.created, true);
|
|
test.done();
|
|
};
|
|
|
|
// ============================================
|
|
// ENABLE Command Tests
|
|
// ============================================
|
|
|
|
module.exports['Commands: enable skips without ENABLE capability'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map() // No ENABLE capability
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable skips when not authenticated'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 3, // SELECTED - not AUTHENTICATED
|
|
capabilities: new Map([['ENABLE', true]])
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable skips when no supported extensions'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([['ENABLE', true]]) // Has ENABLE but not CONDSTORE
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.equal(result, undefined);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable single extension'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
enabled: new Set(),
|
|
exec: async (cmd, args, opts) => {
|
|
execArgs = { cmd, args };
|
|
if (opts && opts.untagged && opts.untagged.ENABLED) {
|
|
await opts.untagged.ENABLED({
|
|
attributes: [{ value: 'CONDSTORE' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.ok(result instanceof Set);
|
|
test.ok(result.has('CONDSTORE'));
|
|
test.equal(execArgs.cmd, 'ENABLE');
|
|
test.equal(execArgs.args[0].value, 'CONDSTORE');
|
|
test.ok(connection.enabled.has('CONDSTORE'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable multiple extensions'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true],
|
|
['QRESYNC', true]
|
|
]),
|
|
enabled: new Set(),
|
|
exec: async (cmd, args, opts) => {
|
|
execArgs = { cmd, args };
|
|
if (opts && opts.untagged && opts.untagged.ENABLED) {
|
|
await opts.untagged.ENABLED({
|
|
attributes: [{ value: 'CONDSTORE' }, { value: 'QRESYNC' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE', 'QRESYNC']);
|
|
test.ok(result instanceof Set);
|
|
test.ok(result.has('CONDSTORE'));
|
|
test.ok(result.has('QRESYNC'));
|
|
test.equal(execArgs.args.length, 2);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable filters unsupported extensions'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true]
|
|
// QRESYNC not supported
|
|
]),
|
|
enabled: new Set(),
|
|
exec: async (cmd, args, opts) => {
|
|
execArgs = { cmd, args };
|
|
if (opts && opts.untagged && opts.untagged.ENABLED) {
|
|
await opts.untagged.ENABLED({
|
|
attributes: [{ value: 'CONDSTORE' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE', 'QRESYNC']);
|
|
test.ok(result instanceof Set);
|
|
test.ok(result.has('CONDSTORE'));
|
|
test.ok(!result.has('QRESYNC'));
|
|
// Only CONDSTORE should be in the request
|
|
test.equal(execArgs.args.length, 1);
|
|
test.equal(execArgs.args[0].value, 'CONDSTORE');
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable converts to uppercase'] = async test => {
|
|
let execArgs = null;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
enabled: new Set(),
|
|
exec: async (cmd, args, opts) => {
|
|
execArgs = { cmd, args };
|
|
if (opts && opts.untagged && opts.untagged.ENABLED) {
|
|
await opts.untagged.ENABLED({
|
|
attributes: [{ value: 'condstore' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['condstore']); // lowercase
|
|
test.ok(result instanceof Set);
|
|
test.ok(result.has('CONDSTORE')); // Stored as uppercase
|
|
test.equal(execArgs.args[0].value, 'CONDSTORE'); // Sent as uppercase
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable handles empty ENABLED response'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
enabled: new Set(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.ENABLED) {
|
|
await opts.untagged.ENABLED({
|
|
attributes: [] // Empty
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.ok(result instanceof Set);
|
|
test.equal(result.size, 0);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable handles null attributes'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
enabled: new Set(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.ENABLED) {
|
|
await opts.untagged.ENABLED({
|
|
attributes: null
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.ok(result instanceof Set);
|
|
test.equal(result.size, 0);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable trims response values'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
enabled: new Set(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.ENABLED) {
|
|
await opts.untagged.ENABLED({
|
|
attributes: [{ value: ' CONDSTORE ' }] // With whitespace
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.ok(result.has('CONDSTORE'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable handles error'] = async test => {
|
|
let warnLogged = false;
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
enabled: new Set(),
|
|
exec: async () => {
|
|
throw new Error('Enable failed');
|
|
},
|
|
log: {
|
|
warn: () => {
|
|
warnLogged = true;
|
|
},
|
|
debug: () => {},
|
|
trace: () => {}
|
|
}
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.equal(result, false);
|
|
test.ok(warnLogged);
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable skips non-string attribute values'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true]
|
|
]),
|
|
enabled: new Set(),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.ENABLED) {
|
|
await opts.untagged.ENABLED({
|
|
attributes: [
|
|
{ value: 'CONDSTORE' },
|
|
{ value: null }, // null value
|
|
{ value: 123 }, // number value
|
|
{ notValue: 'test' } // missing value property
|
|
]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
const result = await enableCommand(connection, ['CONDSTORE']);
|
|
test.ok(result instanceof Set);
|
|
test.equal(result.size, 1);
|
|
test.ok(result.has('CONDSTORE'));
|
|
test.done();
|
|
};
|
|
|
|
module.exports['Commands: enable updates connection.enabled'] = async test => {
|
|
const connection = createMockConnection({
|
|
state: 2,
|
|
capabilities: new Map([
|
|
['ENABLE', true],
|
|
['CONDSTORE', true],
|
|
['UTF8=ACCEPT', true]
|
|
]),
|
|
enabled: new Set(['EXISTING']),
|
|
exec: async (cmd, args, opts) => {
|
|
if (opts && opts.untagged && opts.untagged.ENABLED) {
|
|
await opts.untagged.ENABLED({
|
|
attributes: [{ value: 'CONDSTORE' }, { value: 'UTF8=ACCEPT' }]
|
|
});
|
|
}
|
|
return { next: () => {} };
|
|
}
|
|
});
|
|
|
|
await enableCommand(connection, ['CONDSTORE', 'UTF8=ACCEPT']);
|
|
// connection.enabled should be replaced with new set
|
|
test.ok(connection.enabled.has('CONDSTORE'));
|
|
test.ok(connection.enabled.has('UTF8=ACCEPT'));
|
|
test.ok(!connection.enabled.has('EXISTING')); // Old value should be gone
|
|
test.done();
|
|
};
|