import { ImapFlow } from 'imapflow'; import { appendFileSync } from 'fs'; import type { EmailCredentials } from './multitenancy/credential-store.js'; function logImapError(label: string, err: unknown) { const msg = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? (err.stack ?? '') : ''; const line = `[${new Date().toISOString()}] [imap-error] ${label}: ${msg}\n${stack}\n---\n`; console.error(line); try { appendFileSync('/vaults/imap-errors.log', line); } catch { /* ignore */ } } export type Account = 'yahoo' | 'fetcherpay' | 'garfield' | 'sales' | 'leads' | 'founder' | 'gmail' | 'sqcp_garfield' | 'sqcp_info' | 'sqcp_sales' | 'sqcp_support' | 'sqcp_founder' | 'sqcp_contact' | 'sqcp_admin'; export type EmailCtx = Account | EmailCredentials; const FETCHERPAY_IMAP_HOST = process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com'; const FETCHERPAY_IMAP_PORT = parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993'); // squaremcp.com mail is on a separate server (mail.squaremcp.com / 104.190.60.129) // same custom port as fetcherpay but different host const SQCP_IMAP_HOST = process.env['SQCP_IMAP_HOST'] ?? 'mail.squaremcp.com'; const SQCP_IMAP_PORT = parseInt(process.env['SQCP_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 sqcpImapConfig(user: string, pass: string) { return { host: SQCP_IMAP_HOST, port: SQCP_IMAP_PORT, secure: true, auth: { user, pass }, tls: { rejectUnauthorized: false }, }; } function getEnvConfig(account: Account = 'yahoo') { switch (account) { case 'fetcherpay': return fetcherpayImapConfig(process.env['FETCHERPAY_EMAIL']!, process.env['FETCHERPAY_PASSWORD']!); case 'garfield': return fetcherpayImapConfig(process.env['GARFIELD_EMAIL']!, process.env['GARFIELD_PASSWORD']!); case 'sales': return fetcherpayImapConfig(process.env['SALES_EMAIL']!, process.env['SALES_PASSWORD']!); case 'leads': return fetcherpayImapConfig(process.env['LEADS_EMAIL']!, process.env['LEADS_PASSWORD']!); case 'founder': return fetcherpayImapConfig(process.env['FOUNDER_EMAIL']!, process.env['FOUNDER_PASSWORD']!); case 'sqcp_garfield': return sqcpImapConfig(process.env['SQCP_GARFIELD_EMAIL']!, process.env['SQCP_GARFIELD_PASSWORD']!); case 'sqcp_info': return sqcpImapConfig(process.env['SQCP_INFO_EMAIL']!, process.env['SQCP_INFO_PASSWORD']!); case 'sqcp_sales': return sqcpImapConfig(process.env['SQCP_SALES_EMAIL']!, process.env['SQCP_SALES_PASSWORD']!); case 'sqcp_support': return sqcpImapConfig(process.env['SQCP_SUPPORT_EMAIL']!, process.env['SQCP_SUPPORT_PASSWORD']!); case 'sqcp_founder': return sqcpImapConfig(process.env['SQCP_FOUNDER_EMAIL']!, process.env['SQCP_FOUNDER_PASSWORD']!); case 'sqcp_contact': return sqcpImapConfig(process.env['SQCP_CONTACT_EMAIL']!, process.env['SQCP_CONTACT_PASSWORD']!); case 'sqcp_admin': return sqcpImapConfig(process.env['SQCP_ADMIN_EMAIL']!, process.env['SQCP_ADMIN_PASSWORD']!); case 'gmail': return { host: 'imap.gmail.com', port: 993, secure: true, auth: { user: process.env['GMAIL_EMAIL']!, pass: process.env['GMAIL_APP_PASSWORD']!, }, }; default: return { host: 'imap.mail.yahoo.com', port: 993, secure: true, auth: { user: process.env['YAHOO_EMAIL']!, pass: process.env['YAHOO_APP_PASSWORD']!, }, }; } } function resolveImapConfig(ctx: EmailCtx) { if (typeof ctx === 'object') { return { host: ctx.host, port: ctx.port, secure: ctx.port === 993, auth: { user: ctx.user, pass: ctx.password }, tls: { rejectUnauthorized: false }, }; } return getEnvConfig(ctx); } function isGmail(ctx: EmailCtx): boolean { if (typeof ctx === 'string') return ctx === 'gmail'; return ctx.host === 'imap.gmail.com'; } async function withClient(ctx: EmailCtx, fn: (client: ImapFlow) => Promise): Promise { const client = new ImapFlow(resolveImapConfig(ctx)); client.on('error', (err) => logImapError('client-error-event', err)); try { await client.connect(); return await fn(client); } catch (err) { logImapError('withClient', err); throw err; } finally { try { await client.logout(); } catch { /* ignore logout errors */ } } } 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 }; 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, ctx: EmailCtx ): Promise { const mailbox = await client.mailboxOpen(folder); const total = mailbox.exists ?? 0; if (total === 0) return []; const buildSummary = (msg: { uid: number; envelope?: { messageId?: string; subject?: string; from?: Array<{ name?: string; address?: string }>; to?: Array<{ address?: string }>; date?: Date }; flags?: Set; size?: number }) => { const env = msg.envelope; return { 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, }; }; const criteria = isGmail(ctx) ? { gmailraw: query } : parseSearchCriteria(query); // Try SEARCH first; some Poste.io builds return malformed error responses that // crash ImapFlow's parser. Fall back to fetching the N most recent by sequence. let uidList: number[] = []; let searchWorked = false; try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const uids = await client.search(criteria as any, { uid: true }); uidList = Array.isArray(uids) ? uids : []; searchWorked = true; } catch (err) { console.warn(`[imap] SEARCH failed on ${folder}, using sequence fallback:`, (err as Error).message); } if (searchWorked) { 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 })) { messages.push(buildSummary(msg)); } return messages; } // Sequence-range fallback: fetch last N messages without SEARCH const start = Math.max(1, total - maxResults + 1); const messages: MessageSummary[] = []; for await (const msg of client.fetch(`${start}:${total}`, { envelope: true, flags: true, size: true })) { messages.push(buildSummary(msg)); } return messages.reverse(); } export async function searchMessages( query: string, maxResults = 20, ctx: EmailCtx = 'yahoo', folder?: string ): Promise { return withClient(ctx, async (client) => { const foldersToSearch: string[] = []; if (folder) { foldersToSearch.push(folder); } else { foldersToSearch.push('INBOX'); } for (const f of foldersToSearch) { const results = await searchInFolder(client, f, query, maxResults, ctx); if (results.length > 0) return results; } if (isGmail(ctx) && !folder) { const allMailResults = await searchInFolder(client, '[Gmail]/All Mail', query, maxResults, ctx); if (allMailResults.length > 0) return allMailResults; } return []; }); } export async function readMessage(uid: number, ctx: EmailCtx = 'yahoo', folder = 'INBOX'): Promise { return withClient(ctx, async (client) => { console.log(`[imap] readMessage uid=${uid} folder=${folder}`); await client.mailboxOpen(folder); 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; 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'); 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); 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`); console.log(`[imap] marking uid=${uid} as seen`); await client.messageFlagsAdd([uid], ['\\Seen'], { uid: true }); return result; }); } export async function getProfile(ctx: EmailCtx = 'yahoo'): Promise<{ email: string; name: string; account: string }> { if (typeof ctx === 'object') { return { email: ctx.user, name: ctx.user.split('@')[0], account: 'custom' }; } 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'] ?? '', sqcp_garfield: process.env['SQCP_GARFIELD_EMAIL'] ?? '', sqcp_info: process.env['SQCP_INFO_EMAIL'] ?? '', sqcp_sales: process.env['SQCP_SALES_EMAIL'] ?? '', sqcp_support: process.env['SQCP_SUPPORT_EMAIL'] ?? '', sqcp_founder: process.env['SQCP_FOUNDER_EMAIL'] ?? '', sqcp_contact: process.env['SQCP_CONTACT_EMAIL'] ?? '', sqcp_admin: process.env['SQCP_ADMIN_EMAIL'] ?? '', }; const email = emailMap[ctx] ?? ''; return { email, name: email.split('@')[0], account: ctx }; } export async function listFolders(ctx: EmailCtx = 'yahoo'): Promise { return withClient(ctx, async (client) => { const mailboxes = await client.list(); return mailboxes.map((m) => m.path); }); }