@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
JavaScript
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