376 lines
9.9 KiB
TypeScript
376 lines
9.9 KiB
TypeScript
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<MailboxType>;
|
|
parent?: string;
|
|
children: string[];
|
|
}
|
|
|
|
|
|
/** A simple key-value set of raw message headers. */
|
|
|
|
export type RawMessageHeaders = Record<string, string>;
|
|
|
|
/** Raw message attributes. */
|
|
|
|
export type MessageAttrs = {
|
|
date: Date;
|
|
flags: Set<MessageFlag>;
|
|
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<void> {
|
|
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<Mailbox[]> {
|
|
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<MailboxType>) {
|
|
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<Mailbox> {
|
|
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<Record<string, Message>> {
|
|
const bodies = await this.fetchMessageHeaders(query, false,
|
|
'HEADER.FIELDS (FROM TO SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)');
|
|
const messages: Record<string, Message> = {};
|
|
|
|
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<Record<number, { headers: RawMessageHeaders; attrs: MessageAttrs }>> {
|
|
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<number, any> = {};
|
|
const rawHeaders: Record<number, string> = {};
|
|
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<number, { headers: RawMessageHeaders; attrs: MessageAttrs }> = {};
|
|
Object.keys(rawHeaders).forEach(idStr => {
|
|
const id = parseInt(idStr, 10);
|
|
const finalHeaders: Record<string, string> = {};
|
|
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 <me@auri.xyz>, 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 };
|
|
});
|
|
}
|
|
}
|