|
| 1 | +// AgentMail host wiring for the CommandBoard API. The @logicsrc/plugin-agentmail |
| 2 | +// package deliberately ships no network libraries — it defines the service, |
| 3 | +// domain types, and a Mailu transport that expects injected IMAP/SMTP drivers. |
| 4 | +// This module supplies those drivers (imapflow + nodemailer + mailparser) and |
| 5 | +// builds the AgentMailService from the environment, pointing at the existing |
| 6 | +// agentbbs Mailu server (mail.profullstack.com). When mail isn't configured it |
| 7 | +// falls back to the in-memory transport so the API still boots and is testable. |
| 8 | +// |
| 9 | +// Env: |
| 10 | +// AGENTMAIL_BACKEND "mailu" to use the real server; anything else = memory |
| 11 | +// AGENTMAIL_MEMBER acting member handle (local-part), e.g. "chovy" |
| 12 | +// AGENTMAIL_PAID "false" to disable the paid gate (default paid=true) |
| 13 | +// AGENTMAIL_DOMAIN member address domain (default bbs.profullstack.com) |
| 14 | +// AGENTMAIL_IMAP_HOST / _PORT / _SECURE |
| 15 | +// AGENTMAIL_SMTP_HOST / _PORT / _SECURE / _TLS_SERVERNAME |
| 16 | +// AGENTMAIL_USER / AGENTMAIL_PASS IMAP/SMTP credentials for the mailbox |
| 17 | +import { ImapFlow, type FetchMessageObject } from "imapflow"; |
| 18 | +import { simpleParser, type AddressObject } from "mailparser"; |
| 19 | +import nodemailer from "nodemailer"; |
| 20 | +import { |
| 21 | + AgentMailService, |
| 22 | + createMailuTransport, |
| 23 | + InMemoryMailTransport, |
| 24 | + resolveMailuConfig, |
| 25 | + snippet, |
| 26 | + type Draft, |
| 27 | + type ImapDriver, |
| 28 | + type MailAddress, |
| 29 | + type Mailbox, |
| 30 | + type MailIdentity, |
| 31 | + type MailTransport, |
| 32 | + type MailuConfig, |
| 33 | + type Message, |
| 34 | + type MessageSummary, |
| 35 | + type SmtpDriver |
| 36 | +} from "@logicsrc/plugin-agentmail"; |
| 37 | + |
| 38 | +const DEFAULT_DOMAIN = "bbs.profullstack.com"; |
| 39 | + |
| 40 | +// Acting identity. For now a single configured service member (consistent with |
| 41 | +// the chovy@bbs.profullstack.com + plus-addressing decision); per-member auth is |
| 42 | +// a follow-up. A request may override the handle via the x-agentmail-member |
| 43 | +// header without changing which mailbox credentials are used. |
| 44 | +export function mailIdentity(memberHeader?: string | null): MailIdentity { |
| 45 | + const name = (memberHeader || process.env.AGENTMAIL_MEMBER || "chovy").trim(); |
| 46 | + const paid = process.env.AGENTMAIL_PAID !== "false"; |
| 47 | + return { name, paid }; |
| 48 | +} |
| 49 | + |
| 50 | +// Builds the AgentMailService for a request. Uses the real Mailu transport when |
| 51 | +// AGENTMAIL_BACKEND=mailu and credentials are present; otherwise an in-memory |
| 52 | +// transport (dev/test) so routes are always exercisable. |
| 53 | +export function buildAgentMailService(identity: MailIdentity): AgentMailService { |
| 54 | + const domain = process.env.AGENTMAIL_DOMAIN ?? DEFAULT_DOMAIN; |
| 55 | + const transport = resolveTransport(); |
| 56 | + return new AgentMailService({ transport, identity, domain }); |
| 57 | +} |
| 58 | + |
| 59 | +// A single in-memory transport shared across requests so the dev/test backend |
| 60 | +// behaves like a real server (sent mail persists for later reads in-process). |
| 61 | +let memoryTransport: InMemoryMailTransport | undefined; |
| 62 | + |
| 63 | +function resolveTransport(): MailTransport { |
| 64 | + const user = process.env.AGENTMAIL_USER; |
| 65 | + const pass = process.env.AGENTMAIL_PASS; |
| 66 | + if (process.env.AGENTMAIL_BACKEND !== "mailu" || !user || !pass) { |
| 67 | + memoryTransport ??= new InMemoryMailTransport(); |
| 68 | + return memoryTransport; |
| 69 | + } |
| 70 | + const config = resolveMailuConfig({ user, pass }); |
| 71 | + return createMailuTransport({ |
| 72 | + config, |
| 73 | + imap: createImapflowDriver(config), |
| 74 | + smtp: createNodemailerDriver(config) |
| 75 | + }); |
| 76 | +} |
| 77 | + |
| 78 | +// --- IMAP driver (imapflow + mailparser) --- |
| 79 | + |
| 80 | +function createImapflowDriver(config: MailuConfig): ImapDriver { |
| 81 | + const connect = () => |
| 82 | + new ImapFlow({ |
| 83 | + host: config.imap.host, |
| 84 | + port: config.imap.port, |
| 85 | + secure: config.imap.secure, |
| 86 | + auth: { user: config.auth.user, pass: config.auth.pass }, |
| 87 | + logger: false |
| 88 | + }); |
| 89 | + |
| 90 | + // Each call opens a short-lived connection so the host stays stateless. |
| 91 | + const withClient = async <T>(fn: (c: ImapFlow) => Promise<T>): Promise<T> => { |
| 92 | + const client = connect(); |
| 93 | + await client.connect(); |
| 94 | + try { |
| 95 | + return await fn(client); |
| 96 | + } finally { |
| 97 | + await client.logout().catch(() => {}); |
| 98 | + } |
| 99 | + }; |
| 100 | + |
| 101 | + return { |
| 102 | + async listMailboxes(): Promise<Mailbox[]> { |
| 103 | + return withClient(async (c) => { |
| 104 | + const out: Mailbox[] = []; |
| 105 | + for (const box of await c.list()) { |
| 106 | + const status = await c.status(box.path, { messages: true, unseen: true }); |
| 107 | + out.push({ |
| 108 | + name: box.name, |
| 109 | + path: box.path, |
| 110 | + unseen: status.unseen ?? 0, |
| 111 | + total: status.messages ?? 0 |
| 112 | + }); |
| 113 | + } |
| 114 | + return out; |
| 115 | + }); |
| 116 | + }, |
| 117 | + |
| 118 | + async listMessages({ mailbox, limit = 50 }): Promise<MessageSummary[]> { |
| 119 | + return withClient(async (c) => { |
| 120 | + const lock = await c.getMailboxLock(mailbox); |
| 121 | + try { |
| 122 | + const status = await c.status(mailbox, { messages: true }); |
| 123 | + const total = status.messages ?? 0; |
| 124 | + if (total === 0) return []; |
| 125 | + const start = Math.max(1, total - limit + 1); |
| 126 | + const rows: MessageSummary[] = []; |
| 127 | + for await (const msg of c.fetch(`${start}:*`, { uid: true, envelope: true, flags: true, internalDate: true })) { |
| 128 | + rows.push(toSummary(msg, mailbox)); |
| 129 | + } |
| 130 | + return rows.reverse(); |
| 131 | + } finally { |
| 132 | + lock.release(); |
| 133 | + } |
| 134 | + }); |
| 135 | + }, |
| 136 | + |
| 137 | + async readMessage(mailbox, uid): Promise<Message | null> { |
| 138 | + return withClient(async (c) => { |
| 139 | + const lock = await c.getMailboxLock(mailbox); |
| 140 | + try { |
| 141 | + const msg = await c.fetchOne(String(uid), { uid: true, envelope: true, flags: true, internalDate: true, source: true }, { uid: true }); |
| 142 | + if (!msg || !msg.source) return null; |
| 143 | + const parsed = await simpleParser(msg.source); |
| 144 | + const summary = toSummary(msg, mailbox); |
| 145 | + return { |
| 146 | + ...summary, |
| 147 | + snippet: snippet(parsed.text ?? summary.snippet), |
| 148 | + cc: toAddresses(addrValues(parsed.cc)), |
| 149 | + replyTo: addrValues(parsed.replyTo)[0] ? toAddress(addrValues(parsed.replyTo)[0]) : undefined, |
| 150 | + messageId: parsed.messageId ?? "", |
| 151 | + references: parsed.references ? [parsed.references].flat() : [], |
| 152 | + text: parsed.text ?? "", |
| 153 | + html: typeof parsed.html === "string" ? parsed.html : undefined, |
| 154 | + attachments: (parsed.attachments ?? []).map((a) => ({ |
| 155 | + filename: a.filename ?? "attachment", |
| 156 | + contentType: a.contentType ?? "application/octet-stream", |
| 157 | + size: a.size ?? 0 |
| 158 | + })) |
| 159 | + }; |
| 160 | + } finally { |
| 161 | + lock.release(); |
| 162 | + } |
| 163 | + }); |
| 164 | + }, |
| 165 | + |
| 166 | + async search({ mailbox = "INBOX", query, limit = 50 }): Promise<MessageSummary[]> { |
| 167 | + return withClient(async (c) => { |
| 168 | + const lock = await c.getMailboxLock(mailbox); |
| 169 | + try { |
| 170 | + // imapflow OR across subject/from/body for a free-text query. |
| 171 | + const uids = await c.search({ or: [{ subject: query }, { from: query }, { body: query }] }, { uid: true }); |
| 172 | + if (!uids || uids.length === 0) return []; |
| 173 | + const pick = uids.slice(-limit); |
| 174 | + const rows: MessageSummary[] = []; |
| 175 | + for await (const msg of c.fetch(pick, { uid: true, envelope: true, flags: true, internalDate: true }, { uid: true })) { |
| 176 | + rows.push(toSummary(msg, mailbox)); |
| 177 | + } |
| 178 | + return rows.sort((a, b) => b.uid - a.uid); |
| 179 | + } finally { |
| 180 | + lock.release(); |
| 181 | + } |
| 182 | + }); |
| 183 | + }, |
| 184 | + |
| 185 | + async setFlags(mailbox, uid, flags): Promise<void> { |
| 186 | + await withClient(async (c) => { |
| 187 | + const lock = await c.getMailboxLock(mailbox); |
| 188 | + try { |
| 189 | + const add: string[] = []; |
| 190 | + const remove: string[] = []; |
| 191 | + if (flags.seen === true) add.push("\\Seen"); |
| 192 | + if (flags.seen === false) remove.push("\\Seen"); |
| 193 | + if (flags.flagged === true) add.push("\\Flagged"); |
| 194 | + if (flags.flagged === false) remove.push("\\Flagged"); |
| 195 | + if (add.length) await c.messageFlagsAdd({ uid: String(uid) }, add, { uid: true }); |
| 196 | + if (remove.length) await c.messageFlagsRemove({ uid: String(uid) }, remove, { uid: true }); |
| 197 | + } finally { |
| 198 | + lock.release(); |
| 199 | + } |
| 200 | + }); |
| 201 | + }, |
| 202 | + |
| 203 | + async deleteMessage(mailbox, uid): Promise<void> { |
| 204 | + await withClient(async (c) => { |
| 205 | + const lock = await c.getMailboxLock(mailbox); |
| 206 | + try { |
| 207 | + await c.messageDelete({ uid: String(uid) }, { uid: true }); |
| 208 | + } finally { |
| 209 | + lock.release(); |
| 210 | + } |
| 211 | + }); |
| 212 | + } |
| 213 | + }; |
| 214 | +} |
| 215 | + |
| 216 | +// --- SMTP driver (nodemailer) --- |
| 217 | + |
| 218 | +function createNodemailerDriver(config: MailuConfig): SmtpDriver { |
| 219 | + const transport = nodemailer.createTransport({ |
| 220 | + host: config.smtp.host, |
| 221 | + port: config.smtp.port, |
| 222 | + secure: config.smtp.secure, |
| 223 | + auth: { user: config.auth.user, pass: config.auth.pass }, |
| 224 | + // Verify the cert against its real hostname even when dialing by IP/loopback. |
| 225 | + tls: process.env.AGENTMAIL_SMTP_TLS_SERVERNAME ? { servername: process.env.AGENTMAIL_SMTP_TLS_SERVERNAME } : undefined |
| 226 | + }); |
| 227 | + return { |
| 228 | + async send(from: string, draft: Draft) { |
| 229 | + const info = await transport.sendMail({ |
| 230 | + from, |
| 231 | + to: draft.to.map(formatAddr), |
| 232 | + cc: draft.cc?.map(formatAddr), |
| 233 | + bcc: draft.bcc?.map(formatAddr), |
| 234 | + subject: draft.subject, |
| 235 | + text: draft.text, |
| 236 | + html: draft.html, |
| 237 | + inReplyTo: draft.inReplyTo, |
| 238 | + references: draft.inReplyTo |
| 239 | + }); |
| 240 | + return { messageId: info.messageId }; |
| 241 | + } |
| 242 | + }; |
| 243 | +} |
| 244 | + |
| 245 | +// --- mapping helpers --- |
| 246 | + |
| 247 | +// mailparser types an address header as AddressObject | AddressObject[]; flatten |
| 248 | +// to the underlying address list regardless of shape. |
| 249 | +function addrValues(a: AddressObject | AddressObject[] | undefined): { name?: string; address?: string }[] { |
| 250 | + if (!a) return []; |
| 251 | + return (Array.isArray(a) ? a : [a]).flatMap((x) => x.value); |
| 252 | +} |
| 253 | + |
| 254 | +function toAddress(a: { name?: string; address?: string }): MailAddress { |
| 255 | + return a.name ? { name: a.name, address: a.address ?? "" } : { address: a.address ?? "" }; |
| 256 | +} |
| 257 | + |
| 258 | +function toAddresses(list: { name?: string; address?: string }[] | undefined): MailAddress[] { |
| 259 | + return (list ?? []).filter((a) => a.address).map(toAddress); |
| 260 | +} |
| 261 | + |
| 262 | +function toSummary(msg: FetchMessageObject, mailbox: string): MessageSummary { |
| 263 | + const env = msg.envelope; |
| 264 | + const flags = msg.flags ?? new Set<string>(); |
| 265 | + const date = env?.date ?? msg.internalDate ?? new Date(); |
| 266 | + const from = toAddresses(env?.from)[0] ?? { address: "" }; |
| 267 | + return { |
| 268 | + uid: msg.uid, |
| 269 | + mailbox, |
| 270 | + from, |
| 271 | + to: toAddresses(env?.to), |
| 272 | + subject: env?.subject ?? "", |
| 273 | + date: new Date(date).toISOString(), |
| 274 | + seen: flags.has("\\Seen"), |
| 275 | + flagged: flags.has("\\Flagged"), |
| 276 | + hasAttachments: false, |
| 277 | + snippet: "" |
| 278 | + }; |
| 279 | +} |
| 280 | + |
| 281 | +function formatAddr(a: MailAddress): string { |
| 282 | + return a.name ? `${a.name} <${a.address}>` : a.address; |
| 283 | +} |
0 commit comments