/** * lib/imap.ts * IMAP client using imapflow — server-side only. * Connects with user's mailbox credentials (from session), never stored in DB. */ import { ImapFlow } from "imapflow"; import { simpleParser, ParsedMail } from "mailparser"; const IMAP_HOST = process.env.MAILCOW_API_URL ? new URL(process.env.MAILCOW_API_URL).hostname : "localhost"; const IMAP_PORT = parseInt(process.env.IMAP_PORT ?? "993"); export interface MailCredentials { email: string; password: string; } export interface MailFolder { name: string; path: string; specialUse?: string; messages: number; unseen: number; } export interface MailEnvelope { uid: number; seq: number; subject: string; from: { name: string; address: string }[]; to: { name: string; address: string }[]; date: string; seen: boolean; flagged: boolean; hasAttachments: boolean; size: number; preview: string; } export interface MailMessage { uid: number; subject: string; from: { name: string; address: string }[]; to: { name: string; address: string }[]; cc: { name: string; address: string }[]; date: string; html: string; text: string; seen: boolean; flagged: boolean; attachments: { filename: string; contentType: string; size: number; cid?: string; }[]; } /** Create an IMAP connection for the given credentials */ async function createConnection(creds: MailCredentials): Promise { const client = new ImapFlow({ host: IMAP_HOST, port: IMAP_PORT, secure: true, auth: { user: creds.email, pass: creds.password, }, logger: false, tls: { rejectUnauthorized: false, }, }); await client.connect(); return client; } /** List all mailbox folders */ export async function listFolders(creds: MailCredentials): Promise { const client = await createConnection(creds); try { const mailboxes = await client.list(); const folders: MailFolder[] = []; for (const mb of mailboxes) { try { const status = await client.status(mb.path, { messages: true, unseen: true, }); folders.push({ name: mb.name, path: mb.path, specialUse: mb.specialUse || undefined, messages: status.messages ?? 0, unseen: status.unseen ?? 0, }); } catch { folders.push({ name: mb.name, path: mb.path, specialUse: mb.specialUse || undefined, messages: 0, unseen: 0, }); } } return folders; } finally { await client.logout(); } } /** Map imapflow address to our format */ function mapAddr( addrs: { name?: string; address?: string }[] | undefined ): { name: string; address: string }[] { if (!addrs) return []; return addrs.map((a) => ({ name: a.name ?? "", address: a.address ?? "", })); } /** List messages in a folder (paginated) */ export async function listMessages( creds: MailCredentials, folder: string, page: number = 1, pageSize: number = 50 ): Promise<{ messages: MailEnvelope[]; total: number }> { const client = await createConnection(creds); try { const lock = await client.getMailboxLock(folder); try { const total = (client.mailbox && typeof client.mailbox !== "boolean" && client.mailbox.exists) ? client.mailbox.exists : 0; if (total === 0) return { messages: [], total: 0 }; // Newest first: fetch from end const end = total; const start = Math.max(1, end - (page * pageSize) + 1); const fetchStart = Math.max(1, end - ((page - 1) * pageSize)); const fetchEnd = Math.max(1, end - (page * pageSize) + 1); const range = `${fetchEnd}:${fetchStart}`; const messages: MailEnvelope[] = []; for await (const msg of client.fetch(range, { uid: true, envelope: true, flags: true, bodyStructure: true, size: true, })) { const env = msg.envelope; if (!env) continue; messages.push({ uid: msg.uid, seq: msg.seq, subject: env.subject ?? "(Konu yok)", from: mapAddr(env.from), to: mapAddr(env.to), date: env.date?.toISOString() ?? new Date().toISOString(), seen: msg.flags?.has("\\Seen") ?? false, flagged: msg.flags?.has("\\Flagged") ?? false, hasAttachments: hasAttachmentParts(msg.bodyStructure), size: msg.size ?? 0, preview: "", }); } // Reverse so newest is first messages.reverse(); return { messages, total }; } finally { lock.release(); } } finally { await client.logout(); } } /** Check if bodyStructure has attachment parts */ function hasAttachmentParts(structure: any): boolean { if (!structure) return false; if (structure.disposition === "attachment") return true; if (structure.childNodes) { return structure.childNodes.some((n: any) => hasAttachmentParts(n)); } return false; } /** Fetch a single message by UID with full body */ export async function getMessage( creds: MailCredentials, folder: string, uid: number ): Promise { const client = await createConnection(creds); try { const lock = await client.getMailboxLock(folder); try { const raw = await client.download(String(uid), undefined, { uid: true }); if (!raw?.content) return null; const parsed: ParsedMail = await simpleParser(raw.content); // Mark as seen await client.messageFlagsAdd(String(uid), ["\\Seen"], { uid: true }); return { uid, subject: parsed.subject ?? "(Konu yok)", from: parsed.from?.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? [], to: (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : []) .flatMap((t) => t.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? []), cc: (Array.isArray(parsed.cc) ? parsed.cc : parsed.cc ? [parsed.cc] : []) .flatMap((c) => c.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? []), date: parsed.date?.toISOString() ?? new Date().toISOString(), html: parsed.html || "", text: parsed.text || "", seen: true, flagged: false, attachments: (parsed.attachments ?? []).map((att) => ({ filename: att.filename ?? "unnamed", contentType: att.contentType ?? "application/octet-stream", size: att.size ?? 0, cid: att.cid, })), }; } finally { lock.release(); } } finally { await client.logout(); } } /** Download attachment by filename */ export async function getAttachment( creds: MailCredentials, folder: string, uid: number, filename: string ): Promise<{ content: Buffer; contentType: string } | null> { const client = await createConnection(creds); try { const lock = await client.getMailboxLock(folder); try { const raw = await client.download(String(uid), undefined, { uid: true }); if (!raw?.content) return null; const parsed = await simpleParser(raw.content); const att = parsed.attachments?.find((a) => a.filename === filename); if (!att) return null; return { content: att.content, contentType: att.contentType ?? "application/octet-stream", }; } finally { lock.release(); } } finally { await client.logout(); } } /** Move message to another folder */ export async function moveMessage( creds: MailCredentials, fromFolder: string, uid: number, toFolder: string ): Promise { const client = await createConnection(creds); try { const lock = await client.getMailboxLock(fromFolder); try { await client.messageMove(String(uid), toFolder, { uid: true }); } finally { lock.release(); } } finally { await client.logout(); } } /** Delete message (move to Trash or permanent delete) */ export async function deleteMessage( creds: MailCredentials, folder: string, uid: number ): Promise { const client = await createConnection(creds); try { const lock = await client.getMailboxLock(folder); try { // If already in Trash, permanently delete if (folder.toLowerCase().includes("trash")) { await client.messageFlagsAdd(String(uid), ["\\Deleted"], { uid: true }); // Message flagged as deleted; will be expunged when mailbox is closed } else { // Move to Trash await client.messageMove(String(uid), "Trash", { uid: true }); } } finally { lock.release(); } } finally { await client.logout(); } } /** Toggle flag on a message */ export async function toggleFlag( creds: MailCredentials, folder: string, uid: number, flag: string, add: boolean ): Promise { const client = await createConnection(creds); try { const lock = await client.getMailboxLock(folder); try { if (add) { await client.messageFlagsAdd(String(uid), [flag], { uid: true }); } else { await client.messageFlagsRemove(String(uid), [flag], { uid: true }); } } finally { lock.release(); } } finally { await client.logout(); } }