@cull/imap
Version:
A simple, configurable javascript interface exposing mailboxes and messages via IMAP.
414 lines (395 loc) • 12.2 kB
text/typescript
import { v4 as uuid } from 'uuid';
import { EventEmitter } from 'events';
import Connection, { Preferences as ConnectionPreferences } from './connection';
import Response, {
Status as ResponseStatus,
ServerStatus,
MailboxSizeUpdate,
MessageStatus,
FetchResponseData,
MessageDataItem
} from './response';
import { Code } from './code';
import { Command } from './command';
import Mailbox, { SelectedMailbox } from './mailbox';
import Envelope from './envelope';
import Message from './message';
import Header from './header';
/**
* A collection of server capabilities.
* @link https://tools.ietf.org/html/rfc3501#section-7.2.1
*/
type Capabilities = Set<string>;
/**
* A collection of mailboxes keyed by name.
*/
type Mailboxes = Map<string, Mailbox>;
/**
* A collection of envelopes keyed by message sequence number.
*/
type Envelopes = Map<number, Envelope>;
/**
* A collection of messages keyed by sequence number.
*/
type Messages = Map<number, Message>;
/**
* A collection of headrs keyed by message sequence number.
*/
type Headers = Map<number, Header>;
/**
* User-specified client options analogous to configuration.
*/
export interface Preferences extends ConnectionPreferences {
/**
* A unique identifier for the client instance.
*/
id?: string;
}
/**
* __An IMAP Client__
*/
export class Client extends EventEmitter {
/**
* A unique identifier.
*/
id: string;
/**
* An IMAP connection reference.
*/
connection: Connection;
/**
* Capabilities the server has communicated.
* @link https://tools.ietf.org/html/rfc3501#section-7.2.1
*/
capabilities: Capabilities = new Set();
/**
* Selected mailbox
* @link https://tools.ietf.org/html/rfc3501#section-3.3
*/
selected?: SelectedMailbox;
/**
* A cache of mailboxes the server has communicated.
*/
protected _mailboxes: Mailboxes = new Map();
/**
* A cache of envelopes the server has communicated.
*/
protected _envelopes: Envelopes = new Map();
/**
* A cache of messages the server has communicated.
*/
protected _messages: Messages = new Map();
constructor(preferences: Preferences) {
super();
this.id = preferences.id ?? uuid();
this.connection = new Connection(preferences);
this.initialize();
}
protected initialize(): void {
this.connection.on('error', error => {
this.emit('error', error);
});
this.connection.on('receive', response => {
this.analyzeResponse(response);
});
}
async connect(login: boolean = true): Promise<boolean> {
let response = await this.connection.connect(login);
if (response.status === ResponseStatus.OK) {
return Promise.resolve(true);
}
return Promise.reject(response);
}
async disconnect(): Promise<boolean> {
let response = await this.connection.disconnect();
if (response.status === ResponseStatus.OK) {
this.selected = undefined;
this.capabilities = new Set();
return Promise.resolve(true);
}
return Promise.reject(response);
}
/**
* Analyze a response and update connection data as applicable.
*/
protected analyzeResponse(response: Response): void {
// Response Codes
response.codes.forEach(c => {
switch (c.code) {
case Code.CAPABILITY:
c.data.forEach(capability => this.capabilities.add(capability));
break;
case Code.PERMANENTFLAGS:
if (this.selected) {
c.data.forEach(flag => this.selected?.flags.set(flag as string, true));
}
break;
case Code.READONLY:
if (this.selected) {
this.selected.writeable = false;
}
break;
case Code.READWRITE:
if (this.selected) {
this.selected.writeable = true;
}
break;
case Code.UIDNEXT:
if (this.selected) {
this.selected.uid.next = c.data;
}
break;
case Code.UIDVALIDITY:
if (this.selected) {
this.selected.uid.validity = c.data;
}
break;
default:
this.emit('debug', ['unhandled response code', c, response]);
break;
}
});
// Response Data
Object.keys(response.data).forEach(key => {
switch (key) {
case ServerStatus.CAPABILITY:
(response.data[key] as Capabilities).forEach(capability =>
this.capabilities.add(capability)
);
break;
case ServerStatus.LIST:
(response.data[key] as Mailboxes).forEach(mailbox =>
this._mailboxes.set(mailbox.name, mailbox)
);
break;
case ServerStatus.FLAGS:
if (this.selected) {
response.data[key].forEach(flag => this.selected?.flags.set(flag, false));
}
break;
case MailboxSizeUpdate.EXISTS:
if (this.selected) {
this.selected.exists = response.data[key] ?? 0;
}
break;
case MailboxSizeUpdate.RECENT:
if (this.selected) {
this.selected.recent = response.data[key] ?? 0;
}
break;
case MessageStatus.FETCH:
let data = response.data[key];
if (data !== undefined) {
this.analyzeFetchResponseData(data);
}
break;
default:
this.emit('debug', ['unhandled response data', key, response]);
break;
}
});
}
protected analyzeFetchResponseData(data: FetchResponseData): void {
data.forEach((datum, sequence) => {
let message = new Message();
Object.keys(datum).forEach(item => {
switch (item) {
case MessageDataItem.ENVELOPE:
message.envelope = datum[item];
break;
case MessageDataItem.UID:
message.uid = datum[item];
break;
case MessageDataItem.FLAGS:
message.flags = datum[item];
break;
case MessageDataItem.BODY:
message.body = datum[item];
break;
default:
this.emit('debug', `Unhandled message data item: ${item}`);
break;
}
});
if (message.envelope !== undefined) {
this._envelopes.set(sequence, message.envelope);
}
this._messages.set(sequence, message);
});
}
/**
* Get Mailboxes
* @param path (`string`, default: empty) The name of a mailbox or level of hierarchy
* @param children (`boolean`, default: `true`) Return children under this hierarchy
* @param flatten (`boolean`, default: `false`) Return a flat array vs a nested array (tree)
* @link https://tools.ietf.org/html/rfc3501#section-6.3.8
*/
async mailboxes(
path: string = '',
children: boolean = true,
flatten: boolean = false
): Promise<Mailboxes> {
return new Promise(async (resolve, reject) => {
try {
let wildcard = children ? '*' : '%';
let command = new Command('list', `"${path}" ${wildcard}`);
let response = await this.connection.exchange(command);
if (response.status === ResponseStatus.OK) {
let mailboxes = flatten ? this._mailboxes : mailboxTree(this._mailboxes);
return resolve(mailboxes);
}
throw response;
} catch (error) {
return reject(error);
}
});
}
/**
* Get Mailbox
* @link https://tools.ietf.org/html/rfc3501#section-6.3.8
* @param name (`string`) The name of a mailbox or level of hierarchy
* @param path (`string`, default: `/`)
* @param stale (`boolean`, default: `true`) allow possibly stale (cached) result
*/
async mailbox(name: string, path: string = '/', stale: boolean = true): Promise<Mailbox> {
let mailbox: Mailbox | undefined;
return new Promise(async (resolve, reject) => {
if (stale) {
mailbox = this._mailboxes.get(path);
if (mailbox !== undefined) return resolve(mailbox);
}
try {
let command = new Command('list', `"${path}" "${name}"`);
let response = await this.connection.exchange(command);
if (response.status === ResponseStatus.OK) {
mailbox = this._mailboxes.get(name);
return mailbox !== undefined
? resolve(mailbox)
: reject(new Error('Mailbox could not be found.'));
}
throw response;
} catch (error) {
return reject(error);
}
});
}
/**
* Select a Mailbox for subsequent command context.
* @link https://tools.ietf.org/html/rfc3501#section-6.3.1
* @param name (`string`)
*/
async select(name: string): Promise<SelectedMailbox> {
return new Promise(async (resolve, reject) => {
if (this.selected !== undefined && this.selected.name === name) {
return resolve(this.selected);
}
try {
this.selected = new SelectedMailbox(name);
let select = new Command('select', name);
let response = await this.connection.exchange(select);
if (response.status === ResponseStatus.OK) {
return resolve(this.selected);
}
throw response;
} catch (error) {
this.selected = undefined;
return reject(error);
}
});
}
/**
* Fetch Envelopes for a given mailbox and sequence
* @link https://tools.ietf.org/html/rfc3501#section-6.4.5
* @param name (`string`) The mailbox name/path
* @param sequence (`string) sequence set
* @param timeout (`number`) timeout in seconds
*/
async envelopes(
name: string = 'INBOX',
sequence: string = '1:10',
timeout?: number
): Promise<Envelopes> {
this._envelopes = new Map();
return new Promise(async (resolve, reject) => {
try {
await this.select(name);
let command = new Command('fetch', `${sequence} envelope`);
let response = await this.connection.exchange(command, timeout);
if (response.status === ResponseStatus.OK) {
return resolve(this._envelopes);
}
throw response;
} catch (error) {
return reject(error);
}
});
}
async messages(
name: string = 'INBOX',
sequence: string = '1:10',
items: string[] = ['UID', 'FLAGS', 'BODY.PEEK[]'],
timeout?: number
): Promise<Messages> {
this._messages = new Map();
return new Promise(async (resolve, reject) => {
try {
await this.select(name);
let command = new Command('fetch', `${sequence} (${items.join(' ')})`);
let response = await this.connection.exchange(command, timeout);
if (response.status === ResponseStatus.OK) {
return resolve(this._messages);
}
throw response;
} catch (error) {
return reject(error);
}
});
}
async headers(
name: string = 'INBOX',
sequence: string = '1:10',
timeout?: number
): Promise<Headers> {
try {
let headers: Headers = new Map();
let messages = await this.messages(
name,
sequence,
['UID', 'FLAGS', 'BODY.PEEK[HEADER]'],
timeout
);
messages.forEach((message, key) => {
if (message.body !== undefined && message.body.HEADER !== undefined) {
headers.set(key, message.body.HEADER);
}
});
return Promise.resolve(headers);
} catch (error) {
return Promise.reject(error);
}
}
}
export default Client;
export let mailboxTree = (map: Mailboxes): Mailboxes => {
let mailboxes = [...map.values()];
mailboxes.forEach(mailbox => {
// reset for idempotency
delete mailbox.children;
});
let tree: Mailboxes = new Map();
mailboxes.forEach(mailbox => {
let components = mailbox.name.split(mailbox.delimiter);
if (components.length > 1) {
components.pop();
let parent = map.get(components.join(mailbox.delimiter));
if (parent) {
parent.children ? parent.children.push(mailbox) : (parent.children = [mailbox]);
} else {
throw new Error(`Dangling mailbox: ${mailbox}`);
}
} else {
tree.set(mailbox.name, mailbox);
}
});
return tree;
};