/** * Email transport for system emails (password resets, invitations, notifications). * * Providers are selected by scoped secrets: * RESEND_API_KEY — https://resend.com * SENDGRID_API_KEY — https://sendgrid.com * EMAIL_FROM — "Name " (optional; defaults to Resend's sandbox) * * With neither provider configured, `sendEmail` logs the message to the console * so the reset-password flow still works end-to-end for local development. */ import { FAVICON_PNG_BASE64 } from "../assets/branding/favicon-base64.js"; import { resolveSecret } from "./credential-provider.js"; import { AGENT_NATIVE_EMAIL_LOGO_CONTENT_ID } from "./email-template.js"; export type EmailProvider = "resend" | "sendgrid" | "dev"; export interface EmailAttachment { filename: string; content: string | Buffer; contentType?: string; contentId?: string; disposition?: "attachment" | "inline"; } export interface SendEmailArgs { to: string; subject: string; html: string; text?: string; from?: string; cc?: string | string[]; replyTo?: string; inReplyTo?: string; references?: string; attachments?: EmailAttachment[]; timeoutMs?: number; } let cachedAgentNativeLogo: Buffer | undefined; function getAgentNativeLogoAttachment(): EmailAttachment { cachedAgentNativeLogo ??= Buffer.from(FAVICON_PNG_BASE64, "base64"); return { filename: "agent-native-logo.png", content: cachedAgentNativeLogo, contentType: "image/png", contentId: AGENT_NATIVE_EMAIL_LOGO_CONTENT_ID, disposition: "inline", }; } function resolveAttachments( args: SendEmailArgs, ): EmailAttachment[] | undefined { if (!args.html.includes(`cid:${AGENT_NATIVE_EMAIL_LOGO_CONTENT_ID}`)) { return args.attachments; } if ( args.attachments?.some( (attachment) => attachment.contentId === AGENT_NATIVE_EMAIL_LOGO_CONTENT_ID, ) ) { return args.attachments; } return [...(args.attachments ?? []), getAgentNativeLogoAttachment()]; } interface EmailTransportConfig { provider: EmailProvider; resendApiKey?: string; sendgridApiKey?: string; from?: string; } async function resolveEmailTransport(): Promise { const [resendApiKey, sendgridApiKey, from] = await Promise.all([ resolveSecret("RESEND_API_KEY"), resolveSecret("SENDGRID_API_KEY"), resolveSecret("EMAIL_FROM"), ]); const resolvedFrom = from ?? undefined; if (resendApiKey) { return { provider: "resend", resendApiKey, from: resolvedFrom, }; } if (sendgridApiKey) { return { provider: "sendgrid", sendgridApiKey, from: resolvedFrom, }; } return { provider: "dev", from: resolvedFrom }; } export async function isEmailConfigured(): Promise { return (await resolveEmailTransport()).provider !== "dev"; } export async function getEmailProvider(): Promise { return (await resolveEmailTransport()).provider; } function getFromAddress( config: EmailTransportConfig, override?: string, ): string { const explicit = override || config.from; if (explicit) return explicit; // Resend lets unverified accounts send from its sandbox domain; SendGrid // does not, so falling back there would cause silent 403s at runtime. if (config.provider === "sendgrid") { throw new Error( "EMAIL_FROM is required when using SendGrid — save it as a verified sender address.", ); } return "Agent Native "; } async function sendEmailWithSignal( args: SendEmailArgs, signal?: AbortSignal, ): Promise { const config = await resolveEmailTransport(); signal?.throwIfAborted(); const provider = config.provider; const from = getFromAddress(config, args.from); const attachments = resolveAttachments(args); if (provider === "resend") { const payload: Record = { from, to: args.to, subject: args.subject, html: args.html, text: args.text, }; if (args.cc) payload.cc = Array.isArray(args.cc) ? args.cc : [args.cc]; if (args.replyTo) payload.reply_to = args.replyTo; if (attachments?.length) { payload.attachments = attachments.map((a) => ({ filename: a.filename, content: typeof a.content === "string" ? a.content : a.content.toString("base64"), content_type: a.contentType, content_id: a.contentId, })); } const headers: Record = {}; if (args.inReplyTo) headers["In-Reply-To"] = args.inReplyTo; if (args.references) headers["References"] = args.references; if (Object.keys(headers).length) payload.headers = headers; const res = await fetch("https://api.resend.com/emails", { method: "POST", headers: { Authorization: `Bearer ${config.resendApiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(payload), signal, }); if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error(`Resend error ${res.status}: ${body}`); } return; } if (provider === "sendgrid") { const personalization: Record = { to: [{ email: args.to }], }; if (args.cc) { const ccList = Array.isArray(args.cc) ? args.cc : [args.cc]; personalization.cc = ccList.map((email) => ({ email })); } const sgPayload: Record = { personalizations: [personalization], from: parseSendGridFrom(from), subject: args.subject, content: [ ...(args.text ? [{ type: "text/plain", value: args.text }] : []), { type: "text/html", value: args.html }, ], }; if (args.replyTo) sgPayload.reply_to = parseSendGridFrom(args.replyTo); const sgHeaders: Record = {}; if (args.inReplyTo) sgHeaders["In-Reply-To"] = args.inReplyTo; if (args.references) sgHeaders["References"] = args.references; if (Object.keys(sgHeaders).length) sgPayload.headers = sgHeaders; if (attachments?.length) { sgPayload.attachments = attachments.map((a) => ({ filename: a.filename, content: typeof a.content === "string" ? Buffer.from(a.content).toString("base64") : a.content.toString("base64"), type: a.contentType, disposition: a.disposition ?? (a.contentId ? "inline" : undefined), content_id: a.contentId, })); } const res = await fetch("https://api.sendgrid.com/v3/mail/send", { method: "POST", headers: { Authorization: `Bearer ${config.sendgridApiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(sgPayload), signal, }); if (!res.ok) { const body = await res.text().catch(() => ""); throw new Error(`SendGrid error ${res.status}: ${body}`); } return; } // Dev fallback — no provider configured. Logging the full body exposes // reset tokens, so only do it outside production. In production, refuse // to send rather than silently leaking secrets to logs. if (process.env.NODE_ENV === "production") { throw new Error( "No email provider configured. Save RESEND_API_KEY or SENDGRID_API_KEY in settings.", ); } console.log( `\n[agent-native:email] No email provider configured. ` + `Save RESEND_API_KEY or SENDGRID_API_KEY in settings to send real emails.\n` + `---\nTo: ${args.to}\nFrom: ${from}\nSubject: ${args.subject}\n\n` + `${args.text || stripHtml(args.html)}\n---\n`, ); } export async function sendEmail(args: SendEmailArgs): Promise { const requestedTimeoutMs = Number(args.timeoutMs); if (!Number.isFinite(requestedTimeoutMs) || requestedTimeoutMs <= 0) { return sendEmailWithSignal(args); } const timeoutMs = Math.floor(requestedTimeoutMs); const controller = new AbortController(); const timeoutError = new Error(`Email send timed out after ${timeoutMs}ms`); let timeout: ReturnType | undefined; try { await Promise.race([ sendEmailWithSignal(args, controller.signal), new Promise((_, reject) => { timeout = setTimeout(() => { controller.abort(timeoutError); reject(timeoutError); }, timeoutMs); }), ]); } finally { if (timeout) clearTimeout(timeout); } } function parseSendGridFrom(from: string): { email: string; name?: string } { const m = from.match(/^\s*(.*?)\s*<(.+)>\s*$/); if (m && m[2]) return { name: m[1] || undefined, email: m[2] }; return { email: from.trim() }; } function stripHtml(html: string): string { return html .replace(//gi, "\n") .replace(/<\/p>/gi, "\n\n") .replace(/<[^>]+>/g, "") .replace(/ /g, " ") .trim(); }