UNPKG

trash-cleaner

Version:

Finds and deletes trash email in the mailbox

385 lines (349 loc) 12.3 kB
import readline from 'readline'; import { ImapFlow } from 'imapflow'; import { simpleParser } from 'mailparser'; import { Email, EmailClient, EmailClientFactory } from './email-client.js'; import { retry } from '../utils/retry.js'; import type { ConfigStore } from '../store/config-store.js'; const DEFAULT_ARCHIVE_FOLDER = 'Archive'; interface ImapFileNames { credentialsFile: string; } interface ImapConnectionAuth { user: string; pass: string; } interface ImapConnectionConfig { host: string; port: number; auth: ImapConnectionAuth; secure: boolean; } interface ImapCredentials { host: string; port?: number; secure?: boolean; user: string; password: string; archiveFolder?: string; } interface ImapMessage { uid: number; flags: Set<string>; source: Buffer; envelope?: unknown; } interface ImapMailbox { path: string; flags?: Set<string>; specialUse?: string; } /** * Returns the credential file name for an account. * Default account uses the original file name for backward compatibility. */ function getImapFileNames(account: string): ImapFileNames { if (!account || account === 'default') { return { credentialsFile: 'imap.credentials.json' }; } return { credentialsFile: `imap.credentials.${account}.json` }; } /** * An IMAP client to get unread emails from mailbox. */ class ImapClient extends EmailClient { private _connectionConfig: ImapConnectionConfig; private _archiveFolder: string; /** * Constructs the {ImapClient} instance. */ constructor(connectionConfig: ImapConnectionConfig, archiveFolder?: string) { super(); this._connectionConfig = connectionConfig; this._archiveFolder = archiveFolder || DEFAULT_ARCHIVE_FOLDER; } /** * Gets the unread emails from the mailbox. */ async getUnreadEmails(since?: Date): Promise<Email[]> { const client = this._createClient(); await retry(() => client.connect()); try { const folders = await this._listFolders(client); const allEmails: Email[] = []; for (const folder of folders) { const emails = await this._getUnreadFromFolder(client, folder, since); allEmails.push(...emails); } return allEmails; } finally { await client.logout(); } } /** * Lists all scannable mailbox folders. */ private async _listFolders(client: ImapFlow): Promise<string[]> { const list: ImapMailbox[] = await client.list() as ImapMailbox[]; const folders: string[] = []; for (const mailbox of list) { // Skip folders that can't be selected (e.g. [Gmail] parent) if (mailbox.flags?.has('\\Noselect')) { continue; } // Skip Sent, Drafts, and All Mail to avoid noise if (mailbox.specialUse === '\\Sent' || mailbox.specialUse === '\\Drafts' || mailbox.specialUse === '\\All') { continue; } folders.push(mailbox.path); } return folders; } /** * Gets unread emails from a specific folder. */ private async _getUnreadFromFolder(client: ImapFlow, folder: string, since?: Date): Promise<Email[]> { let lock: { release: () => void }; try { lock = await client.getMailboxLock(folder); } catch { return []; } try { const searchCriteria: { seen: boolean; since?: Date } = { seen: false }; if (since) { searchCriteria.since = since; } const uids = await client.search(searchCriteria, { uid: true }); if (!uids || uids.length === 0) { return []; } const emails: Email[] = []; const fetchIterator = client.fetch({ uid: uids } as unknown as string, { envelope: true, source: true, uid: true }); for await (const msg of fetchIterator as AsyncIterable<ImapMessage>) { const parsed = await simpleParser(msg.source); const email = new Email(); email.id = String(msg.uid); email.subject = parsed.subject || ''; email.from = parsed.from ? parsed.from.text : ''; email.body = parsed.text || ''; email.snippet = (parsed.text || '').substring(0, 200); email.date = parsed.date || null; email.labels = this._extractLabels(msg, folder); email._folder = folder; emails.push(email); } return emails; } finally { lock.release(); } } /** * Deletes the emails. */ async deleteEmails(emails: Email[]): Promise<void> { const byFolder = this._groupByFolder(emails); const client = this._createClient(); await retry(() => client.connect()); try { for (const [folder, folderEmails] of byFolder) { const uids = folderEmails.map(e => Number(e.id)); const lock = await client.getMailboxLock(folder); try { await retry(() => client.messageDelete(uids as unknown as string, { uid: true })); } finally { lock.release(); } } } catch (err) { throw new Error(`Failed to delete messages: ${err}`); } finally { await client.logout(); } } /** * Archives emails by moving them to the archive folder. */ async archiveEmails(emails: Email[]): Promise<void> { const byFolder = this._groupByFolder(emails); const client = this._createClient(); await retry(() => client.connect()); try { for (const [folder, folderEmails] of byFolder) { const uids = folderEmails.map(e => Number(e.id)); const lock = await client.getMailboxLock(folder); try { await retry(() => client.messageMove(uids as unknown as string, this._archiveFolder, { uid: true })); } finally { lock.release(); } } } catch (err) { throw new Error(`Failed to archive messages: ${err}`); } finally { await client.logout(); } } /** * Marks emails as read by adding the \\Seen flag. */ async markAsReadEmails(emails: Email[]): Promise<void> { const byFolder = this._groupByFolder(emails); const client = this._createClient(); await retry(() => client.connect()); try { for (const [folder, folderEmails] of byFolder) { const uids = folderEmails.map(e => Number(e.id)); const lock = await client.getMailboxLock(folder); try { await retry(() => client.messageFlagsAdd(uids as unknown as string, ['\\Seen'], { uid: true })); } finally { lock.release(); } } } catch (err) { throw new Error(`Failed to mark messages as read: ${err}`); } finally { await client.logout(); } } /** * Restores previously processed emails. Not supported in IMAP mode. */ async restoreEmails(_emailIds: string[]): Promise<void> { throw new Error( 'Undo is not supported in IMAP mode. Deleted messages cannot be restored via IMAP. ' + 'Use --service gmail or --service outlook for undo support.' ); } /** * Extracts labels from IMAP message flags and folder name. */ private _extractLabels(msg: ImapMessage, folder: string): string[] { const labels: string[] = []; if (msg.flags) { for (const flag of msg.flags) { if (flag === '\\Flagged') { labels.push('flagged'); } else if (flag === '\\Seen') { labels.push('read'); } else if (flag === '\\Answered') { labels.push('answered'); } else if (flag === '\\Draft') { labels.push('draft'); } } } // Map folder path to a label const folderLower = folder.toLowerCase(); if (folderLower === 'inbox') { labels.push('inbox'); } else if (folderLower.includes('spam') || folderLower.includes('junk')) { labels.push('spam'); } else if (folderLower.includes('trash') || folderLower.includes('deleted')) { labels.push('trash'); } else if (folderLower.includes('sent')) { labels.push('sent'); } else { labels.push(folderLower); } return labels; } /** * Groups emails by their folder. */ private _groupByFolder(emails: Email[]): Map<string, Email[]> { const map = new Map<string, Email[]>(); for (const email of emails) { const folder = email._folder || 'INBOX'; if (!map.has(folder)) { map.set(folder, []); } map.get(folder)!.push(email); } return map; } /** * Creates a new ImapFlow client instance. */ private _createClient(): ImapFlow { return new ImapFlow({ ...this._connectionConfig, logger: false }); } } /** * Factory for ImapClient objects. */ class ImapClientFactory extends EmailClientFactory { configStore: ConfigStore; private _credentialsFile: string; /** * Creates an instance of ImapClientFactory. */ constructor(configStore: ConfigStore, account: string) { super(); this.configStore = configStore; const fileNames = getImapFileNames(account); this._credentialsFile = fileNames.credentialsFile; } /** * Creates an instance of ImapClient. */ async getInstance(reconfig: boolean, _launch: boolean): Promise<ImapClient> { let credentials: ImapCredentials | null; try { credentials = await this.configStore.getJson(this._credentialsFile) as ImapCredentials | null; } catch { credentials = null; } if (reconfig || !credentials) { credentials = await this._promptCredentials(); await this.configStore.putJson(this._credentialsFile, credentials); } const connectionConfig: ImapConnectionConfig = { host: credentials.host, port: credentials.port || 993, secure: credentials.secure !== false, auth: { user: credentials.user, pass: credentials.password } }; return new ImapClient(connectionConfig, credentials.archiveFolder); } /** * Prompts the user interactively for IMAP credentials. */ private async _promptCredentials(): Promise<ImapCredentials> { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const ask = (question: string): Promise<string> => new Promise(resolve => rl.question(question, resolve)); try { const host = await ask('IMAP host (e.g., imap.gmail.com): '); const port = await ask('IMAP port (default: 993): '); const user = await ask('Email address: '); const password = await ask('App password: '); const archiveFolder = await ask('Archive folder (default: Archive): '); return { host, port: parseInt(port) || 993, user, password, archiveFolder: archiveFolder || undefined }; } finally { rl.close(); } } } export { ImapClient, ImapClientFactory };