import { ImapFlow } from 'imapflow'; export type Account = 'yahoo' | 'fetcherpay' | 'garfield' | 'sales' | 'leads' | 'founder' | 'gmail'; const FETCHERPAY_IMAP_HOST = process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com'; const FETCHERPAY_IMAP_PORT = parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'); function fetcherpayImapConfig(user: string, pass: string) { return { host: FETCHERPAY_IMAP_HOST, port: FETCHERPAY_IMAP_PORT, secure: true, auth: { user, pass }, tls: { rejectUnauthorized: false }, }; } function getConfig(account: Account = 'yahoo') { switch (account) { case 'fetcherpay': return fetcherpayImapConfig( process.env['FETCHERPAY_EMAIL'] as string, process.env['FETCHERPAY_PASSWORD'] as string, ); case 'garfield': return fetcherpayImapConfig( process.env['GARFIELD_EMAIL'] as string, process.env['GARFIELD_PASSWORD'] as string, ); case 'sales': return fetcherpayImapConfig( process.env['SALES_EMAIL'] as string, process.env['SALES_PASSWORD'] as string, ); case 'leads': return fetcherpayImapConfig( process.env['LEADS_EMAIL'] as string, process.env['LEADS_PASSWORD'] as string, ); case 'founder': return fetcherpayImapConfig( process.env['FOUNDER_EMAIL'] as string, process.env['FOUNDER_PASSWORD'] as string, ); case 'gmail': return { host: 'imap.gmail.com', port: 993, secure: true, auth: { user: process.env['GMAIL_EMAIL'] as string, pass: process.env['GMAIL_APP_PASSWORD'] as string, }, }; default: return { host: 'imap.mail.yahoo.com', port: 993, secure: true, auth: { user: process.env['YAHOO_EMAIL'] as string, pass: process.env['YAHOO_APP_PASSWORD'] as string, }, }; } } async function withClient(account: Account, fn: (client: ImapFlow) => Promise): Promise { const client = new ImapFlow(getConfig(account)); await client.connect(); try { return await fn(client); } finally { await client.logout(); } } export interface MessageSummary { uid: number; messageId: string; subject: string; from: string; date: string; seen: boolean; size: number; folder: string; } export interface FullMessage { uid: number; messageId: string; subject: string; from: string; to: string; date: string; body: string; seen: boolean; } function parseSearchCriteria(query: string): object { const trimmed = query.trim(); if (!trimmed) return { all: true }; // Parse quoted and unquoted tokens like from:x, subject:y, to:z const tokens: { key: string; value: string }[] = []; const regex = /(\w+):("([^"]*)"|([^\s]+))/g; let m: RegExpExecArray | null; let lastIndex = 0; while ((m = regex.exec(trimmed)) !== null) { tokens.push({ key: m[1].toLowerCase(), value: m[3] ?? m[4] }); lastIndex = regex.lastIndex; } const remaining = trimmed.slice(lastIndex).trim(); if (remaining) { tokens.push({ key: 'keyword', value: remaining }); } if (tokens.length === 0) { return { or: [{ subject: trimmed }, { from: trimmed }] }; } const parts: object[] = []; for (const t of tokens) { switch (t.key) { case 'from': parts.push({ from: t.value }); break; case 'subject': parts.push({ subject: t.value }); break; case 'to': parts.push({ to: t.value }); break; case 'after': case 'since': { const d = new Date(t.value); if (!isNaN(d.getTime())) parts.push({ since: d }); break; } case 'before': { const d = new Date(t.value); if (!isNaN(d.getTime())) parts.push({ before: d }); break; } case 'keyword': default: parts.push({ or: [{ subject: t.value }, { from: t.value }] }); break; } } if (parts.length === 1) return parts[0]; return { and: parts }; } async function searchInFolder( client: ImapFlow, folder: string, query: string, maxResults: number, account: Account ): Promise { await client.mailboxOpen(folder); const criteria = account === 'gmail' ? { gmailraw: query } : parseSearchCriteria(query); // eslint-disable-next-line @typescript-eslint/no-explicit-any const uids = await client.search(criteria as any, { uid: true }); const uidList: number[] = Array.isArray(uids) ? uids : []; const recentUids = uidList.slice(-maxResults).reverse(); if (recentUids.length === 0) return []; const messages: MessageSummary[] = []; for await (const msg of client.fetch(recentUids, { envelope: true, flags: true, size: true, }, { uid: true })) { const env = msg.envelope; messages.push({ uid: msg.uid, messageId: env?.messageId ?? '', subject: env?.subject ?? '(no subject)', from: env?.from?.[0] ? `${env.from[0].name ?? ''} <${env.from[0].address ?? ''}>`.trim() : '', date: env?.date?.toISOString() ?? '', seen: msg.flags?.has('\\Seen') ?? false, size: msg.size ?? 0, folder, }); } return messages; } export async function searchMessages( query: string, maxResults = 20, account: Account = 'yahoo', folder?: string ): Promise { return withClient(account, async (client) => { const foldersToSearch: string[] = []; if (folder) { foldersToSearch.push(folder); } else if (account === 'gmail') { foldersToSearch.push('INBOX'); } else { foldersToSearch.push('INBOX'); } for (const f of foldersToSearch) { const results = await searchInFolder(client, f, query, maxResults, account); if (results.length > 0) return results; } // Fallback for Gmail: search All Mail if INBOX was empty if (account === 'gmail' && !folder) { const allMailResults = await searchInFolder(client, '[Gmail]/All Mail', query, maxResults, account); if (allMailResults.length > 0) return allMailResults; } return []; }); } export async function readMessage(uid: number, account: Account = 'yahoo', folder = 'INBOX'): Promise { return withClient(account, async (client) => { console.log(`[imap] readMessage uid=${uid} account=${account} folder=${folder}`); await client.mailboxOpen(folder); console.log(`[imap] mailbox opened, fetching uid=${uid}`); let result: FullMessage | null = null; for await (const msg of client.fetch([uid], { envelope: true, flags: true, bodyParts: ['TEXT'], }, { uid: true })) { const env = msg.envelope; console.log(`[imap] got msg uid=${msg.uid} subject="${env?.subject}"`); const bpKeys = msg.bodyParts ? [...msg.bodyParts.keys()] : []; console.log(`[imap] bodyParts keys:`, JSON.stringify(bpKeys)); const textBuf = msg.bodyParts?.get('text') ?? msg.bodyParts?.get('TEXT') ?? msg.bodyParts?.get('1'); console.log(`[imap] textBuf length=${textBuf ? textBuf.length : 'null'}`); const rawBody = textBuf ? textBuf.toString('utf-8') : ''; const body = rawBody .replace(/]*>[\s\S]*?<\/style>/gi, '') .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 10000); console.log(`[imap] body length after strip=${body.length}`); result = { uid: msg.uid, messageId: env?.messageId ?? '', subject: env?.subject ?? '(no subject)', from: env?.from?.[0] ? `${env.from[0].name ?? ''} <${env.from[0].address ?? ''}>`.trim() : '', to: env?.to?.[0]?.address ?? '', date: env?.date?.toISOString() ?? '', body, seen: true, }; } if (!result) throw new Error(`Message UID ${uid} not found`); // Mark as seen AFTER the fetch loop fully completes — calling messageFlagsAdd // inside the for-await loop deadlocks because the FETCH command is still active. console.log(`[imap] marking uid=${uid} as seen`); await client.messageFlagsAdd([uid], ['\\Seen'], { uid: true }); console.log(`[imap] readMessage done uid=${uid}`); return result; }); } export async function getProfile(account: Account = 'yahoo'): Promise<{ email: string; name: string; account: string }> { const emailMap: Record = { yahoo: process.env['YAHOO_EMAIL'] ?? '', fetcherpay: process.env['FETCHERPAY_EMAIL'] ?? '', garfield: process.env['GARFIELD_EMAIL'] ?? '', sales: process.env['SALES_EMAIL'] ?? '', leads: process.env['LEADS_EMAIL'] ?? '', founder: process.env['FOUNDER_EMAIL'] ?? '', gmail: process.env['GMAIL_EMAIL'] ?? '', }; const email = emailMap[account] ?? ''; return { email, name: email.split('@')[0], account }; } export async function listFolders(account: Account = 'yahoo'): Promise { return withClient(account, async (client) => { const mailboxes = await client.list(); return mailboxes.map((m) => m.path); }); }