import md5 from 'md5'; import RawImap, { MailBoxes as RawBoxes } from 'imap'; /** Credentials and properties used to establish an IMAP connection. */ export interface ConnectionProperties { username: string; password: string; host: string; port: number; tls: boolean; } /** * Mailbox types (attributes). * Only the attributes relevant to Aether are included, * and some are named differently to better match interface language. */ export enum MailboxType { Box = 'NORMAL_BOX', All = '\\All', Archive = '\\Archive', Drafts = '\\Drafts', Starred = '\\Flagged', Important = '\\Important', Inbox = '\\Inbox', Spam = '\\Junk', Sent = '\\Sent', Trash = '\\Trash' } /** * Message flags (keywords). * Only the flags relevant to Aether are included, * and some are named differently to better match interface language. */ export enum MessageFlag { Read = '\\Seen', Important = '\\Flagged', Deleted = '\\Deleted', Draft = '\\Draft', Active = '$ACTIVE' } /** An IMAP Box. */ export interface Mailbox { name: string; path: string; delimiter: string; type: MailboxType; treeTypes: Set; parent?: string; children: string[]; } /** A simple key-value set of raw message headers. */ export type RawMessageHeaders = Record; /** Raw message attributes. */ export type MessageAttrs = { date: Date; flags: Set; uid: number; }; /** * A parsed conversation participant (sender / receiver). * Some participants don't have names included. */ export interface Participant { name: string | undefined; address: string }; /** Headers for an email message, identified by a persistant ID string. */ export interface Message { to: Participant[]; from: Participant; date: Date; subject: string; messageId: string; active: boolean; id: number; boxId: number; replyTo: string; references: string[]; } /** * A wrapper on a node-imap connection that provides * a promise-based wrapper to an IMAP connection. * Returns more friendly interfaces than the raw connection, * and keeps track of the logged in state and current box. */ export default class Imap { private raw: RawImap; private connected: boolean = false; private boxes: Mailbox[] = []; private box: Mailbox | undefined = undefined; /** * Constructs an Imap instance, but does not connect. * connect() must be called before accessing the server. * * @param props - Connection properties to initialize with. */ constructor(props: ConnectionProperties) { this.raw = new RawImap({ user: props.username, password: props.password, host: props.host, port: props.port, tls: props.tls }); } /** * Connects to the remote IMAP server. * * @returns a promise resolving upon connection or rejecting with a connection error. */ connect(): Promise { return new Promise((resolve, reject) => { if (this.connected) { reject('Already connected.'); return; } this.raw.once('end', () => this.connected = false); this.raw.once('error', (error: any) => reject(error)); this.raw.once('ready', async () => { this.connected = true; this.boxes = await this.listBoxes(); resolve(); }); this.raw.connect(); }); } /** * Gets the current connection state. * * @returns a boolean indicating the connection state of the instance. */ isConnected(): boolean { return this.connected; } /** * Gets a flat list of all the mailboxes mailboxes. * * @returns a promise resolving to an array of mailboxes, or rejecting with an error. */ listBoxes(): Promise { return new Promise((resolve, reject) => { if (!this.connected) reject('Cannot get box when the connection is closed.'); this.raw.getBoxes((err, boxes) => { if (err) { reject(err); return; } const foundBoxes: Mailbox[] = []; function traverseBoxes(boxes: RawBoxes, path: string, parent: string | undefined, treeTypes: Set) { for (let boxName in boxes) { if ({}.hasOwnProperty.call(boxes, boxName)) { const box = boxes[boxName]; const thisPath = path + boxName + box.delimiter; const type = (box as any).special_use_attrib as MailboxType ?? (boxName === 'INBOX' ? MailboxType.Inbox : MailboxType.Box); const thisTreeTypes = new Set([ ...treeTypes, type ]); foundBoxes.push({ name: boxName, path: path + boxName, delimiter: box.delimiter, type, treeTypes: thisTreeTypes, parent, children: Object.keys(box.children ?? []).map(name => thisPath + name) }); traverseBoxes(box.children ?? [], thisPath, path + boxName, thisTreeTypes); } } } traverseBoxes(boxes, '', undefined, new Set()); resolve(foundBoxes); }); }); } /** * Attempts to open a box with the path provided, and returns that box. * * @param box - The path of the box to open. * @returns a raw node-imap box instance. */ openBox(box: string): Promise { return new Promise((resolve, reject) => { if (!this.connected) reject('Cannot get box when the connection is closed.'); this.raw.openBox(box, false, (err, box) => { if (err) { reject(err); return; } this.box = this.boxes.filter(b => b.path === box.name)[0]; resolve(this.box); }); }); } /** * Gets the name of the currently opened box. * * @returns the name of the currently opened box, or undefined if none are open. */ getCurrentBox(): string | undefined { return this.box?.name; } /** * Fetches a set of messages from the server matching the query provided. * This query should be a string matching the IMAP query selector format. * * @param query - A query to send to the server. * @param seq - Whether or not the query is a sequence query or an ID query. * @returns a promise resolving to a record of messages, indexed by persistant Message ID, or rejecting with an error. */ async fetchMessages(query: string | number | number[]): Promise> { const bodies = await this.fetchMessageHeaders(query, false, 'HEADER.FIELDS (FROM TO SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)'); const messages: Record = {}; Object.keys(bodies).forEach(idStr => { const id = parseInt(idStr, 10); const headers = bodies[id].headers; const attrs = bodies[id].attrs; messages[headers['MESSAGE-ID']] = { to: this.parseParticipants(headers.TO ?? ''), from: this.parseParticipants(headers.FROM ?? '')[0], subject: headers.SUBJECT, date: attrs.date, id: id, boxId: attrs.uid, messageId: headers['MESSAGE-ID'] || `HASH:${md5(attrs.date.toString())}:${md5(headers.SUBJECT)}`, active: attrs.flags.has(MessageFlag.Active) || this.box!.type === MailboxType.Inbox, replyTo: headers['IN-REPLY-TO'], references: (headers.REFERENCES ?? '').split(' ').map(s => s.trim()).filter(r => r) }; }); return messages; } /** * Fetches a set of message headers from the server matching the query provided. * This query should match the node-imap query selector format. * The bodies should be presented in the node-imap format, e.g. * 'HEADER.FIELDS (FROM TO SUBJECT DATE)' * Attributes are also returned. * * @param query - A query to send to the server. * @param seq - Whether or not the query is a sequence query or an ID query. * @param bodies - The string identifying the bodies to fetch. * @returns a promise resolving to a record of message bodies, indexed by persistant Message ID, or rejecting with an error. */ fetchMessageHeaders(query: string | number | number[], seq: boolean, bodies: string): Promise> { return new Promise((resolve, reject) => { if (!this.connected) reject('Cannot fetch messages when the connection is closed.'); if (!this.box) reject('Cannot fetch messages when there is no current box.'); let fetchRoot = seq ? this.raw.seq : this.raw; const rawAttrs: Record = {}; const rawHeaders: Record = {}; const fetch = fetchRoot.fetch(query, { bodies: bodies, struct: false }); fetch.on('error', err => reject(err)); fetch.on('message', (msg, id) => { rawHeaders[id] = ''; msg.on('body', stream => stream.on('data', (chunk) => rawHeaders[id] += chunk.toString('utf8'))); msg.once('attributes', (attrs) => rawAttrs[id] = attrs); }); fetch.on('end', () => { const messages: Record = {}; Object.keys(rawHeaders).forEach(idStr => { const id = parseInt(idStr, 10); const finalHeaders: Record = {}; rawHeaders[id].split(/\r?\n(?=[A-z:\-_]+)/g).map(h => h.trim()).filter(h => h).forEach(header => { const delimiter = header.indexOf(':'); const name = header.substr(0, delimiter).trim(); const value = header.substr(delimiter + 1).trim(); finalHeaders[name.toUpperCase()] = value; }); const myAttrs = rawAttrs[id]; messages[id] = { headers: finalHeaders, attrs: { uid: myAttrs.uid, date: myAttrs.date, flags: new Set(myAttrs.flags) } }; }); resolve(messages); }); }); } /** * Parses a participant list, * e.g 'Auri Collings , nicole@aurailus.design' * into a parsed array of Participant objects. * This function WILL return invalid results if a name contains a comma. * * @returns an array of Participants. */ private parseParticipants(header: string): Participant[] { return header.split(',').map(raw => { const delimiter = raw.indexOf('<'); if (delimiter === -1) return { name: undefined, address: raw.trim() }; const name = raw.substr(0, delimiter).replace(/^[\s'"]+/g, '').trim().replace(/[\s'"]+$/g, ''); const address = raw.substr(delimiter + 1).replace(/[<>]/g, '').replace(/^[\s'"]+/g, '').trim().replace(/[\s'"]+$/g, ''); return { name: name ? name : undefined, address }; }); } }