diff --git a/index.ts b/index.ts index f7cfa8d..eead508 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,9 @@ -import {withMailbox} from "./src/mail/read.ts"; +import {withClient} from "./src/mail/client.ts"; +import {collect, Collection} from "./src/bones/collection/Collection.ts"; +import {getMailboxesToSearch, withMailbox} from "./src/mail/read.ts"; -await withMailbox('e12a', async c => { - console.log((await c.collect()).all()) -}) +(await getMailboxesToSearch()) + .map(box => withMailbox(box, async c => { + console.log(await c.all()) + })) + .awaitAll() diff --git a/src/config.ts b/src/config.ts index 225c494..ead12f6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,7 +13,7 @@ const maybeConfig: any = { threads: { type: 'alias', template: process.env.CHORUS_THREAD_TEMPLATE, - idPrefix: 't.', + idPrefix: 'c.', }, }, } diff --git a/src/mail/client.ts b/src/mail/client.ts new file mode 100644 index 0000000..bb8f0b2 --- /dev/null +++ b/src/mail/client.ts @@ -0,0 +1,20 @@ +import {ImapFlow} from "imapflow"; +import type {Awaitable} from "../bones"; +import {config} from "../config.ts"; + +export async function withClient(cb: (client: ImapFlow) => Awaitable): Promise { + const client = new ImapFlow({ + ...config.mail.imap, + logger: false, + }) + + await client.connect() + + let result: T + try { + result = await cb(client) + } finally { + await client.logout() + } + return result +} diff --git a/src/mail/read.ts b/src/mail/read.ts index 9f8a37c..33aa4df 100644 --- a/src/mail/read.ts +++ b/src/mail/read.ts @@ -5,14 +5,31 @@ import {config} from "../config.ts"; import type {Awaitable, Maybe} from "../bones"; import {ReplyParser} from "./replies.ts"; import {extract} from "letterparser"; -import {Collection} from "../bones/collection/Collection.ts"; +import {collect, Collection} from "../bones/collection/Collection.ts"; import {AsyncCollection} from "../bones/collection/AsyncCollection.ts"; +import {withClient} from "./client.ts"; +import {buildThreadAddressMatcher} from "../threads/id.ts"; + +export async function getMailboxesToSearch(thread?: string): Promise> { + // There are 2 possibilities for where mail might end up. + // Either the mail-server is configured to forward the + extensions + // to their own folders automatically (e.g. the "c.1234" folder), or + // aliased mail just winds up in INBOX. + return collect(await withClient(c => c.list())) + .filter(box => + box.name === 'INBOX' || box.specialUse === '\\\\Inbox' + || (!thread && box.name.startsWith(config.mail.threads.idPrefix)) + || (thread && box.name === thread) + ) + .pluck('name') +} export async function withMailbox(mailbox: string, cb: (c: AsyncCollection) => Awaitable): Promise { return MailboxIterable.with(mailbox, i => cb(new AsyncCollection(i, 100))) } export class MailboxIterable extends Iterable { + private addressMatcher: RegExp private client?: Maybe private query: FetchQueryObject = { envelope: true, @@ -38,6 +55,7 @@ export class MailboxIterable extends Iterable { public readonly mailbox: string, ) { super() + this.addressMatcher = buildThreadAddressMatcher() } async at(i: number): Promise> { @@ -73,13 +91,22 @@ export class MailboxIterable extends Iterable { const source = message.source.toString('utf-8') const content = ReplyParser.parseReply(extract(source).text || '') + const recipients = message.envelope.to + .map(x => x.address || '') + .filter(Boolean) + + const thread = recipients.map(addr => this.addressMatcher.exec(addr)) + .map(result => result?.[1]) + .filter(Boolean)[0] + return { id: `${this.mailbox}.${message.uid}`, date: message.envelope.date, - recipients: message.envelope.to.map(x => x.address || '').filter(Boolean), from: message.envelope.from[0], subject: message.envelope.subject, + recipients, content, + thread, } } diff --git a/src/threads/id.ts b/src/threads/id.ts new file mode 100644 index 0000000..844fdc1 --- /dev/null +++ b/src/threads/id.ts @@ -0,0 +1,22 @@ +import {config} from "../config.ts"; + +function escapeRexString(rex: string): string { + const specials = ['.', '+', '(', ')', '[', ']', '^', '$'] + for ( const char of specials ) { + rex = rex.replaceAll(char, `\\${char}`) + } + return rex +} + +export function buildThreadAddressMatcher(): RegExp { + // We want a regular expression that only matches the thread address + // template specified by the config. We also want it to have a capture + // group for the thread ID. + // In the config, the thread ID is represented with the placeholder: %ID% + + const idPrefix = escapeRexString(config.mail.threads.idPrefix) + const idCapture = `(${idPrefix}[^@]+)` // first rule of email: don't be as strict as the RFC + + const template = escapeRexString(config.mail.threads.template).replace('%ID%', idCapture) + return new RegExp(`^${template}$`) +} diff --git a/src/types.ts b/src/types.ts index 33057e1..e1033bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,4 +35,5 @@ export type Message = { }, subject: string, content: string, + thread?: string, }