UNPKG

@essentialai/cogent-bridge

Version:

Cogent Bridge — cross-agent comms for Claude Code, OpenAI Codex and Slack. Codex users: install with `curl -fsSL https://cogent.tools/install.sh | sh`, then start with `cogent-codex` for real-time peer wake.

188 lines • 7.72 kB
import fs from "node:fs/promises"; import path from "node:path"; /** In-memory fetcher: a fixed inbox. The test double the tool drives. */ export class InMemoryMailFetcher { inbox; constructor(inbox = []) { this.inbox = inbox; } async list(opts = {}) { let rows = this.inbox.map(({ uid, from, subject, date, unread }) => ({ uid, from, subject, date, unread })); if (opts.unreadOnly) rows = rows.filter((m) => m.unread); if (opts.limit && opts.limit > 0) rows = rows.slice(0, opts.limit); return rows; } async fetchOne(uid, downloadDir) { const msg = this.inbox.find((m) => m.uid === uid); if (!msg) throw new Error(`no message with uid ${uid}`); const saved = await saveAttachments(msg.attachments, downloadDir); return { uid, from: msg.from, subject: msg.subject, date: msg.date, text: msg.text, attachments: saved }; } } /** * Real IMAP fetcher. The imapflow session is created LAZILY (dynamic import) so imapflow never * loads on the cold-start path — only when the agent actually fetches mail. `sessionFactory` is * the injection seam for tests (default connects a real imapflow session). */ export class ImapflowMailFetcher { creds; sessionFactory; constructor(creds, sessionFactory = defaultImapSessionFactory) { this.creds = creds; this.sessionFactory = sessionFactory; } async list(opts = {}) { const session = await this.sessionFactory(this.creds); try { let rows = await session.list(opts.unreadOnly ?? false); if (opts.limit && opts.limit > 0) rows = rows.slice(0, opts.limit); return rows; } finally { await session.close(); } } async fetchOne(uid, downloadDir) { const session = await this.sessionFactory(this.creds); try { const c = await session.fetch(uid); const saved = await saveAttachments(c.attachments, downloadDir); return { uid, from: c.from, subject: c.subject, date: c.date, text: c.text, attachments: saved }; } finally { await session.close(); } } } /** Write each in-memory attachment to `downloadDir` (created if needed), named by basename. */ async function saveAttachments(attachments, downloadDir) { if (attachments.length === 0) return []; await fs.mkdir(downloadDir, { recursive: true }); const saved = []; for (const a of attachments) { const name = path.basename(a.filename) || "attachment"; const savedPath = path.join(downloadDir, name); await fs.writeFile(savedPath, a.content); saved.push({ filename: name, savedPath, size: a.content.length }); } return saved; } /** * Default imapflow session. Imported lazily (dynamic import with a LITERAL specifier) so the * module is evaluated only when an agent actually fetches mail — off the cold-start path — while * still being bundled, so a plugin install with no node_modules can resolve it. The imapflow call shapes here are verified against the real * mail box at the M2.8 live A→B E2E (the same "wiring proven at live E2E" seam used by * NodemailerMailSender.defaultTransport); the disk-save + summary logic around it is unit-tested. */ async function defaultImapSessionFactory(creds) { // LITERAL, not a variable — see the note in mail-sender.ts defaultTransport(). A variable // specifier is invisible to esbuild, which left imapflow OUT of the shipped bundles and broke // cogent_fetch_mail for every plugin user exactly as it broke cogent_send_mail. const { ImapFlow } = (await import("imapflow")); const client = new ImapFlow({ host: creds.imapHost, port: creds.imapPort, secure: creds.imapPort === 993, auth: { user: creds.address, pass: creds.password }, logger: false, }); await client.connect(); await client.mailboxOpen("INBOX"); return { async list(unreadOnly) { const criteria = unreadOnly ? { seen: false } : { all: true }; const rows = []; for await (const msg of client.fetch(criteria, { uid: true, envelope: true, flags: true })) { const env = msg.envelope ?? {}; rows.push({ uid: msg.uid, from: env.from?.[0]?.address ?? "", subject: env.subject ?? "", date: env.date instanceof Date ? env.date.toISOString() : String(env.date ?? ""), unread: !hasFlag(msg.flags, "\\Seen"), }); } return rows.reverse(); // newest first }, async fetch(uid) { const msg = await client.fetchOne(uid, { uid: true, envelope: true, bodyStructure: true }, { uid: true }); if (!msg) throw new Error(`no message with uid ${uid}`); const parts = flattenBodyStructure(msg.bodyStructure); let text = ""; const attachments = []; for (const p of parts) { if (p.filename) { const dl = await client.download(uid, p.part, { uid: true }); attachments.push({ filename: p.filename, content: await streamToBuffer(dl.content) }); } else if (p.isText && !text) { const dl = await client.download(uid, p.part, { uid: true }); text = (await streamToBuffer(dl.content)).toString("utf-8"); } } const env = msg.envelope ?? {}; return { from: env.from?.[0]?.address ?? "", subject: env.subject ?? "", date: env.date instanceof Date ? env.date.toISOString() : String(env.date ?? ""), text, attachments, }; }, async close() { try { await client.logout(); } catch { /* best-effort close */ } }, }; } /** * Flatten an imapflow bodyStructure into its leaf parts. Attachment leaves carry a `filename` * (from disposition or content-type params); text/plain leaves are marked `isText`. Multipart * container nodes (those with `childNodes`) are recursed, not emitted. */ export function flattenBodyStructure(node, acc = []) { if (!node || typeof node !== "object") return acc; const n = node; if (Array.isArray(n.childNodes) && n.childNodes.length > 0) { for (const child of n.childNodes) flattenBodyStructure(child, acc); return acc; } const type = (n.type ?? "").toLowerCase(); const filename = n.dispositionParameters?.filename ?? n.parameters?.name; const isAttachment = (n.disposition ?? "").toLowerCase() === "attachment" || Boolean(filename); acc.push({ part: n.part ?? "1", // single-part messages have no `part`; imapflow addresses them as "1" type, isText: type === "text/plain" && !isAttachment, filename: isAttachment ? filename : undefined, }); return acc; } function hasFlag(flags, flag) { if (flags instanceof Set) return flags.has(flag); if (Array.isArray(flags)) return flags.includes(flag); return false; } async function streamToBuffer(stream) { const chunks = []; if (!stream) return Buffer.alloc(0); for await (const chunk of stream) chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks); } //# sourceMappingURL=mail-fetcher.js.map