/** * Lead e-mail history — live IMAP lookup, nothing is stored (v1). * * For a contact's e-mail address every configured mailbox is searched for mails * FROM / TO / CC that address; the lead page shows them merged as a timeline and * loads a single message's content on click. Each call opens its own short-lived * read-only IMAP connection (login ≈ 200 ms, a full account scan ≈ 1–3 s). * * ─── Later: stored credentials ──────────────────────────────────────────────── * MAIL_ACCOUNTS below is the ONLY place that knows the mailboxes. To move away * from hard-coded accounts, replace `getMailAccounts()` with a lookup — e.g. a * `data/mail-accounts.db` SQLite store (like data/contract-signing.db) or a * CUST_ table — and encrypt the password with CREDENTIAL_ENCRYPTION_KEY (already * in .env, same idea as the marketplace credentials). Natural next steps: one * mailbox per staff user (AD_User) plus shared ones, a settings page to add and * test an account, and returning only the accounts the logged-in user may read. * Callers only ever see `{ index, label }`, never host / user / password, so the * routes and the modal stay unchanged. (`index` should then become a stable id.) * * ─── Later: performance / caching ───────────────────────────────────────────── * 1. Result cache: keep `searchAccount()` results per (account, address) for a * few minutes in memory, so re-opening the modal is instant; a "refresh" * button bypasses it. * 2. Body cache: a parsed message never changes — cache `fetchMessage()` by * (account, folder, uidValidity, uid) in memory or SQLite. * 3. Folder list cache: `client.list()` rarely changes; caching it per account * for an hour saves a round trip. Scanning ~60 folders is most of the cost * today — remembering which folders ever had hits lets those be scanned first. * 4. Connection reuse: keep one idle ImapFlow connection per account (IDLE) with * a queue instead of logging in per request. * 5. Real index: a background job syncing envelopes (Message-ID, date, from, to, * subject, folder, uid) into SQLite / Elasticsearch makes the timeline a local * query, enables full-text search and lets other pages (partners, tickets) * show mail without touching IMAP. Sync incrementally by UIDNEXT / UIDVALIDITY. * 6. Streaming: the modal already requests one account per call in parallel; an * SSE route (…/stream) could push hits folder by folder for very large boxes. */ import { ImapFlow } from 'imapflow' interface MailAccount { label: string; host: string; port: number; user: string; pass: string } // v1: hard-coded on purpose (see "Later: stored credentials" above). const MAIL_ACCOUNTS: MailAccount[] = [ { label: 'info@logyou.de', host: 'imap.younex.de', port: 993, user: 'infologyou@ad.younex.de', pass: 'bq4J89nR9SMN' }, { label: 'younes', host: 'imap.younex.de', port: 993, user: 'youneslogyou', pass: 'V+6BDL3A^pW' }, { label: 'dennis', host: 'imap.younex.de', port: 993, user: 'dennis', pass: '986324264746Ab!x' } ] const getMailAccounts = (): MailAccount[] => MAIL_ACCOUNTS /** What the client may know about the mailboxes: position + display label only. */ export const listMailAccounts = () => getMailAccounts().map((a, index) => ({ index, label: a.label })) export const isValidEmail = (v: any): boolean => /^[^\s@"<>()\\,;:]+@[^\s@"<>()\\,;:]+\.[^\s@"<>()\\,;:]+$/.test(String(v || '').trim()) const norm = (v: any): string => String(v || '').trim().toLowerCase() // Folders that never hold correspondence: drafts, junk and the Exchange // non-mail folders (calendar, contacts, tasks, sync issues, …). const SKIP_SPECIAL = new Set(['\\Drafts', '\\Junk']) const SKIP_NAMES = /^(Aufgaben|Tasks|Geplant|Journal|Kalender|Calendar|Kontakte|Contacts|Notizen|Notes|Postausgang|Outbox|RSS-Feeds|RSS-Abonnements|RSS Feeds|Synchronisierungsprobleme|Sync Issues|Verlauf der Unterhaltung|Conversation History)(\/|$)/i const MAX_HITS_PER_FOLDER = 200 const CONNECT_TIMEOUT_MS = 15_000 export interface LeadMailRow { account: number accountLabel: string folder: string folderKind: string // inbox | sent | trash | archive | other uid: number messageId: string date: string | null subject: string from: { name: string; address: string } to: { name: string; address: string }[] cc: { name: string; address: string }[] direction: 'in' | 'out' // in = written by the contact, out = sent to the contact size: number seen: boolean answered: boolean hasAttachments: boolean } const withClient = async (index: number, fn: (client: ImapFlow, account: MailAccount) => Promise): Promise => { const account = getMailAccounts()[index] if (!account) throw new Error('Unknown mail account') const client = new ImapFlow({ host: account.host, port: account.port, secure: true, auth: { user: account.user, pass: account.pass }, logger: false, connectionTimeout: CONNECT_TIMEOUT_MS, greetingTimeout: CONNECT_TIMEOUT_MS, socketTimeout: 60_000 }) // An unhandled 'error' event on the socket would crash the Nitro worker. client.on('error', () => {}) await client.connect() try { return await fn(client, account) } finally { try { await client.logout() } catch { try { client.close() } catch { /* already gone */ } } } } const addrList = (list: any): { name: string; address: string }[] => (Array.isArray(list) ? list : []).map((a: any) => ({ name: String(a?.name || ''), address: String(a?.address || '') })) const involves = (envelope: any, email: string): boolean => [...addrList(envelope?.from), ...addrList(envelope?.sender), ...addrList(envelope?.to), ...addrList(envelope?.cc), ...addrList(envelope?.bcc), ...addrList(envelope?.replyTo)] .some((a) => norm(a.address) === email) const folderKind = (box: any): string => { const s = String(box?.specialUse || '') if (s === '\\Inbox' || /^INBOX(\/|$)/i.test(String(box?.path || ''))) return 'inbox' if (s === '\\Sent') return 'sent' if (s === '\\Trash') return 'trash' if (s === '\\Archive') return 'archive' return 'other' } /** Every mail from / to / cc `email` in one mailbox (all mail folders), newest first. */ export const searchAccount = async (index: number, email: string): Promise<{ rows: LeadMailRow[]; folders: number; truncated: boolean }> => { const needle = norm(email) if (!isValidEmail(needle)) throw new Error('Invalid e-mail address') return await withClient(index, async (client, account) => { const rows: LeadMailRow[] = [] let truncated = false let scanned = 0 const boxes = (await client.list()).filter((b: any) => !b.flags?.has('\\Noselect') && !SKIP_SPECIAL.has(String(b.specialUse || '')) && !SKIP_NAMES.test(String(b.path || ''))) for (const box of boxes) { let lock: any try { lock = await client.getMailboxLock(box.path, { readOnly: true }) } catch { continue } try { scanned++ if (!(client.mailbox as any)?.exists) continue let uids: number[] = (await client.search({ or: [{ from: needle }, { to: needle }, { cc: needle }] }, { uid: true })) || [] if (!uids.length) continue if (uids.length > MAX_HITS_PER_FOLDER) { truncated = true; uids = uids.slice(-MAX_HITS_PER_FOLDER) } const kind = folderKind(box) for await (const m of client.fetch(uids, { uid: true, envelope: true, flags: true, size: true, internalDate: true, bodyStructure: true }, { uid: true })) { const env: any = m.envelope || {} // The server matches substrings of the header — keep exact address hits only. if (!involves(env, needle)) continue const from = addrList(env.from)[0] || { name: '', address: '' } const d: any = env.date || m.internalDate rows.push({ account: index, accountLabel: account.label, folder: box.path, folderKind: kind, uid: Number(m.uid), messageId: String(env.messageId || ''), date: d ? new Date(d).toISOString() : null, subject: String(env.subject || ''), from, to: addrList(env.to), cc: addrList(env.cc), direction: norm(from.address) === needle ? 'in' : 'out', size: Number(m.size || 0), seen: !!m.flags?.has('\\Seen'), answered: !!m.flags?.has('\\Answered'), hasAttachments: classifyParts(m.bodyStructure).attachments.length > 0 }) } } catch (err: any) { console.warn('[lead-emails] folder failed:', box.path, err?.message || err) } finally { lock.release() } } rows.sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))) return { rows, folders: scanned, truncated } }) } const streamToBuffer = async (stream: any): Promise => { const chunks: Buffer[] = [] for await (const c of stream) chunks.push(Buffer.from(c)) return Buffer.concat(chunks) } const IMAGE_EXT: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml' } const partFilename = (n: any): string => String(n?.dispositionParameters?.filename || n?.parameters?.name || '') const partImageType = (n: any): string => { const type = norm(n?.type) if (type.startsWith('image/')) return type return IMAGE_EXT[partFilename(n).split('.').pop()?.toLowerCase() || ''] || '' } /** Flatten the MIME tree into the parts the reader needs. */ const classifyParts = (root: any) => { const out = { html: null as any, text: null as any, inline: [] as any[], attachments: [] as any[] } const walk = (n: any) => { if (!n) return if (Array.isArray(n.childNodes) && n.childNodes.length) { n.childNodes.forEach(walk); return } const type = norm(n.type) const disposition = norm(n.disposition) const part = n.part || '1' // a non-multipart message is part "1" if (disposition !== 'attachment' && !partFilename(n) && type === 'text/html') { if (!out.html) out.html = { ...n, part }; return } if (disposition !== 'attachment' && !partFilename(n) && type === 'text/plain') { if (!out.text) out.text = { ...n, part }; return } if (n.id && disposition !== 'attachment' && partImageType(n)) { out.inline.push({ ...n, part }); return } if (type.startsWith('multipart/')) return out.attachments.push({ ...n, part }) } walk(root) return out } const MAX_TEXT_PART_BYTES = 3 * 1024 * 1024 const MAX_INLINE_IMAGE_BYTES = 1024 * 1024 const MAX_INLINE_IMAGES = 15 /** * One message for display. Only the text parts and small inline images are * downloaded — never the attachments (a 24 MB mail with two PDFs must open as * fast as a plain one); attachments are listed from the MIME structure. * `email` is the contact's address: a message that does not involve it is * refused, so the route cannot be used to read arbitrary mail of the mailbox by * guessing folder + uid. */ export const fetchMessage = async (index: number, folder: string, uid: number, email: string) => { const needle = norm(email) if (!isValidEmail(needle)) throw new Error('Invalid e-mail address') return await withClient(index, async (client, account) => { const lock = await client.getMailboxLock(folder, { readOnly: true }) try { const head: any = await client.fetchOne(String(uid), { uid: true, envelope: true, size: true, bodyStructure: true, internalDate: true }, { uid: true }) if (!head || !involves(head.envelope, needle)) return null const env: any = head.envelope || {} const parts = classifyParts(head.bodyStructure) // imapflow decodes the transfer encoding and converts text parts to UTF-8. const readPart = async (node: any, maxBytes: number): Promise => { const dl = await client.download(String(uid), node.part, { uid: true, maxBytes }) return await streamToBuffer(dl.content) } let html = parts.html ? (await readPart(parts.html, MAX_TEXT_PART_BYTES)).toString('utf8') : '' const text = (!html && parts.text) ? (await readPart(parts.text, MAX_TEXT_PART_BYTES)).toString('utf8') : '' // Inline images (cid:…) → data URIs, small ones only. if (html) { for (const img of parts.inline.slice(0, MAX_INLINE_IMAGES)) { const cid = String(img.id || '').replace(/^<|>$/g, '') if (!cid || Number(img.size || 0) > MAX_INLINE_IMAGE_BYTES * 1.4 || !html.includes('cid:' + cid)) continue try { const data = await readPart(img, MAX_INLINE_IMAGE_BYTES) html = html.split('cid:' + cid).join(`data:${partImageType(img)};base64,${data.toString('base64')}`) } catch { /* image stays broken, the text is what matters */ } } } const d: any = env.date || head.internalDate return { account: index, accountLabel: account.label, folder, uid, messageId: String(env.messageId || ''), date: d ? new Date(d).toISOString() : null, subject: String(env.subject || ''), from: addrList(env.from), to: addrList(env.to), cc: addrList(env.cc), html, text, truncated: Number((parts.html || parts.text)?.size || 0) > MAX_TEXT_PART_BYTES * 1.4, // v1 lists attachments only; a download route can stream one later with // client.download(uid, part) — `part` is already known here. attachments: parts.attachments.map((a: any) => ({ filename: partFilename(a) || 'attachment', contentType: String(a.type || ''), // bodyStructure reports the encoded size; base64 inflates by ~4/3 size: norm(a.encoding) === 'base64' ? Math.round(Number(a.size || 0) * 0.73) : Number(a.size || 0) })) } } finally { lock.release() } }) }