diff --git a/.changeset/transactional-email-catalog.md b/.changeset/transactional-email-catalog.md new file mode 100644 index 0000000000..93aa59028c --- /dev/null +++ b/.changeset/transactional-email-catalog.md @@ -0,0 +1,25 @@ +--- +"@agent-native/core": minor +"@agent-native/dispatch": minor +--- + +Add a transactional email catalog. + +Apps declare the transactional emails they send with `defineTransactionalEmail` +from `@agent-native/core/email-catalog`, giving each one a stable id, a +plain-language trigger, recipient and sender logic, and a preview rendered from +dummy data. Three actions (`list-transactional-emails`, +`render-transactional-email-preview`, `list-email-log`) mount into every app +automatically, so the catalog is readable without each app opting in. + +`sendEmail` now accepts a `templateId`. It tags the message at the provider so +delivery and open metrics attribute to one email instead of the whole account, +and records every attempt — success and failure — to a new additive `email_log` +table, which keeps send counts and last-sent independent of the provider's short +activity retention window. + +Dispatch gains a Transactional email screen listing every app's emails with +previews, send counts, open rates, and a per-message activity feed, plus a +read-only detail page per email. Metrics distinguish "not yet sent" from "could +not be read": an unreadable send log renders as unknown rather than zero, and an +unconfigured provider surfaces the reason instead of a 0% open rate. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba6bc7f8d..2a670791aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -467,6 +467,8 @@ jobs: -t "pnpm install, pnpm typecheck, and pnpm action hello all succeed" - name: Scaffold workspace (chat + calendar + dispatch) and verify pnpm install + build + env: + AGENT_NATIVE_CREATE_USE_LOCAL_CORE: "1" # Dispatch is in the combo because it exercises the # workspacify.ts dispatch-rewrite path (workspace:* → "latest" for # @agent-native/dispatch, the only published package consumed as a diff --git a/packages/core/package.json b/packages/core/package.json index 57f51c834b..0e0d3384eb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -236,6 +236,7 @@ "./comments-review/actions/get-review-feedback": "./dist/review/actions/get-review-feedback.js", "./comments-review/actions/set-review-status": "./dist/review/actions/set-review-status.js", "./comments-review/actions/send-review-thread-to-agent": "./dist/review/actions/send-review-thread-to-agent.js", + "./email-catalog": "./dist/email-catalog/registry.js", "./sharing": "./dist/sharing/index.js", "./sharing/actions/share-resource": "./dist/sharing/actions/share-resource.js", "./sharing/actions/unshare-resource": "./dist/sharing/actions/unshare-resource.js", diff --git a/packages/core/src/email-catalog/actions/list-email-activity.ts b/packages/core/src/email-catalog/actions/list-email-activity.ts new file mode 100644 index 0000000000..1616d86005 --- /dev/null +++ b/packages/core/src/email-catalog/actions/list-email-activity.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { defineAction } from "../../action.js"; +import { authorizeTransactionalEmailRead } from "../authorize.js"; +import { fetchEmailActivity } from "../provider-metrics.js"; + +export default defineAction({ + description: + "List recent provider activity for one registered transactional email in this app. Organization admin only.", + schema: z.object({ + templateId: z.string().min(1), + limit: z.coerce.number().int().min(1).max(1000).default(50), + }), + http: { method: "GET" }, + authorize: ({ templateId }) => authorizeTransactionalEmailRead([templateId]), + run: async ({ templateId, limit }) => + fetchEmailActivity({ templateId, limit }), +}); diff --git a/packages/core/src/email-catalog/actions/list-email-engagement.ts b/packages/core/src/email-catalog/actions/list-email-engagement.ts new file mode 100644 index 0000000000..43c86f9db9 --- /dev/null +++ b/packages/core/src/email-catalog/actions/list-email-engagement.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { defineAction } from "../../action.js"; +import { authorizeTransactionalEmailRead } from "../authorize.js"; +import { fetchEmailEngagement } from "../provider-metrics.js"; + +export default defineAction({ + description: + "Read provider engagement metrics for registered transactional emails in this app. Organization admin only.", + schema: z.object({ + templateIds: z.array(z.string().min(1)).max(100), + windowDays: z.coerce.number().int().min(1).max(365).default(30), + }), + http: { method: "POST" }, + authorize: ({ templateIds }) => authorizeTransactionalEmailRead(templateIds), + run: async ({ templateIds, windowDays }) => + fetchEmailEngagement(templateIds, windowDays), +}); diff --git a/packages/core/src/email-catalog/actions/list-email-log.ts b/packages/core/src/email-catalog/actions/list-email-log.ts new file mode 100644 index 0000000000..663ae57425 --- /dev/null +++ b/packages/core/src/email-catalog/actions/list-email-log.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +import { defineAction } from "../../action.js"; +import { getAppSlug } from "../../server/app-name.js"; +import { authorizeTransactionalEmailRead } from "../authorize.js"; +import { listEmailLog } from "../log.js"; + +export default defineAction({ + description: + "List recent transactional email sends from this app, newest first, optionally filtered to one registered email id.", + schema: z.object({ + templateId: z.string().optional(), + limit: z.coerce.number().int().min(1).max(500).default(100), + }), + http: { method: "GET" }, + authorize: ({ templateId }) => + authorizeTransactionalEmailRead(templateId ? [templateId] : []), + run: async ({ templateId, limit }) => ({ + entries: await listEmailLog({ + app: getAppSlug() ?? "unknown", + templateId, + limit, + }), + }), +}); diff --git a/packages/core/src/email-catalog/actions/list-transactional-emails.ts b/packages/core/src/email-catalog/actions/list-transactional-emails.ts new file mode 100644 index 0000000000..d044f193c7 --- /dev/null +++ b/packages/core/src/email-catalog/actions/list-transactional-emails.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { defineAction } from "../../action.js"; +import { getAppSlug } from "../../server/app-name.js"; +import { getEmailSendStats } from "../log.js"; +import { listTransactionalEmails } from "../registry.js"; +import { registerCoreSystemEmails } from "../system-emails.js"; + +const DEFAULT_WINDOW_DAYS = 30; + +export default defineAction({ + description: + "List the transactional emails this app can send, with the trigger, recipient and sender logic for each, plus local send counts and last-sent. Engagement metrics such as open rate are not included here — they live at the email provider.", + schema: z.object({ + windowDays: z.coerce + .number() + .int() + .min(1) + .max(365) + .default(DEFAULT_WINDOW_DAYS) + .describe("How many days of send history to summarize."), + }), + http: { method: "GET" }, + run: async ({ windowDays }) => { + registerCoreSystemEmails(); + const since = Date.now() - windowDays * 24 * 60 * 60 * 1000; + const app = getAppSlug() ?? "unknown"; + const definitions = listTransactionalEmails(); + + // A failed stats read must not masquerade as "no email ever sent" — the + // catalog is still worth returning, but the caller has to be able to tell + // that the numbers are missing rather than zero. + let statsById: Map< + string, + { sent: number; failed: number; lastSentAt: number | null } + > | null = null; + let statsError: string | null = null; + try { + const stats = await getEmailSendStats(since, app); + statsById = new Map(stats.map((row) => [row.templateId, row])); + } catch (error) { + statsError = error instanceof Error ? error.message : String(error); + } + + return { + app, + windowDays, + statsAvailable: statsError === null, + statsError, + emails: definitions.map((definition) => { + const stats = statsById?.get(definition.id); + return { + id: definition.id, + app: definition.app, + name: definition.name, + trigger: definition.trigger, + recipient: definition.recipient, + recipientLabel: definition.recipientLabel, + sender: definition.sender, + senderLabel: definition.senderLabel, + sent: stats?.sent ?? (statsById ? 0 : null), + failed: stats?.failed ?? (statsById ? 0 : null), + lastSentAt: stats?.lastSentAt ?? null, + }; + }), + }; + }, +}); diff --git a/packages/core/src/email-catalog/actions/render-transactional-email-preview.ts b/packages/core/src/email-catalog/actions/render-transactional-email-preview.ts new file mode 100644 index 0000000000..84f0987782 --- /dev/null +++ b/packages/core/src/email-catalog/actions/render-transactional-email-preview.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +import { defineAction } from "../../action.js"; +import { renderTransactionalEmailPreview } from "../registry.js"; +import { registerCoreSystemEmails } from "../system-emails.js"; + +export default defineAction({ + description: + "Render one of this app's transactional emails with representative dummy data, returning the subject, HTML and plain-text bodies for preview.", + schema: z.object({ + id: z + .string() + .describe("Registered email id, e.g. calendar.booking-confirmed."), + }), + http: { method: "GET" }, + run: async ({ id }) => { + registerCoreSystemEmails(); + const rendered = renderTransactionalEmailPreview(id); + return { + id, + subject: rendered.subject, + html: rendered.html, + text: rendered.text, + }; + }, +}); diff --git a/packages/core/src/email-catalog/authorize.spec.ts b/packages/core/src/email-catalog/authorize.spec.ts new file mode 100644 index 0000000000..1d51c6ec91 --- /dev/null +++ b/packages/core/src/email-catalog/authorize.spec.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + currentRequestUserIsOrgAdmin: vi.fn(), +})); + +vi.mock("../server/org-admin.js", () => ({ + currentRequestUserIsOrgAdmin: mocks.currentRequestUserIsOrgAdmin, +})); + +import { authorizeTransactionalEmailRead } from "./authorize.js"; +import { + defineTransactionalEmail, + resetTransactionalEmailRegistry, +} from "./registry.js"; + +const definition = { + id: "calendar.booking-confirmed", + name: "Booking confirmed", + trigger: "A booking is confirmed.", + recipient: "The booking guest.", + recipientLabel: "Booking guest", + sender: "The configured sender.", + senderLabel: "Configured sender", + preview: () => ({ subject: "Booked", html: "

Booked

", text: "Booked" }), +}; + +describe("transactional email reporting authorization", () => { + beforeEach(() => { + resetTransactionalEmailRegistry(); + mocks.currentRequestUserIsOrgAdmin.mockReset(); + }); + + it("denies non-admin callers", async () => { + mocks.currentRequestUserIsOrgAdmin.mockResolvedValue(false); + defineTransactionalEmail(definition); + + await expect( + authorizeTransactionalEmailRead([definition.id]), + ).resolves.toBe(false); + }); + + it("denies unregistered categories", async () => { + mocks.currentRequestUserIsOrgAdmin.mockResolvedValue(true); + + await expect( + authorizeTransactionalEmailRead(["unrelated.private-category"]), + ).resolves.toBe(false); + }); + + it("allows admins to read registered categories", async () => { + mocks.currentRequestUserIsOrgAdmin.mockResolvedValue(true); + defineTransactionalEmail(definition); + + await expect( + authorizeTransactionalEmailRead([definition.id]), + ).resolves.toBe(true); + }); +}); diff --git a/packages/core/src/email-catalog/authorize.ts b/packages/core/src/email-catalog/authorize.ts new file mode 100644 index 0000000000..f96ffb6a33 --- /dev/null +++ b/packages/core/src/email-catalog/authorize.ts @@ -0,0 +1,11 @@ +import { currentRequestUserIsOrgAdmin } from "../server/org-admin.js"; +import { getTransactionalEmail } from "./registry.js"; +import { registerCoreSystemEmails } from "./system-emails.js"; + +export async function authorizeTransactionalEmailRead( + templateIds: string[] = [], +): Promise { + if (!(await currentRequestUserIsOrgAdmin())) return false; + registerCoreSystemEmails(); + return templateIds.every((id) => Boolean(getTransactionalEmail(id))); +} diff --git a/packages/core/src/email-catalog/log.spec.ts b/packages/core/src/email-catalog/log.spec.ts new file mode 100644 index 0000000000..28cbb53fe6 --- /dev/null +++ b/packages/core/src/email-catalog/log.spec.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const execute = vi.fn(async () => ({ rows: [] })); + +vi.mock("../db/client.js", () => ({ + getDbExec: () => ({ execute }), + getDialect: () => "sqlite", + isPostgres: () => false, +})); + +vi.mock("../db/ddl-guard.js", () => ({ + ensureTableExists: vi.fn(async () => undefined), + ensureIndexExists: vi.fn(async () => undefined), +})); + +import { getEmailSendStats, listEmailLog } from "./log.js"; + +describe("email log app scoping", () => { + beforeEach(() => { + execute.mockClear(); + }); + + it("scopes aggregate stats to one app", async () => { + await getEmailSendStats(1234, "calendar"); + + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ + sql: expect.stringContaining("WHERE app = ?"), + args: ["calendar", 1234], + }), + ); + }); + + it("scopes activity to app and template", async () => { + await listEmailLog({ + app: "calendar", + templateId: "calendar.booking-confirmed", + limit: 25, + }); + + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ + sql: expect.stringContaining("WHERE app = ? AND template_id = ?"), + args: ["calendar", "calendar.booking-confirmed", 25], + }), + ); + }); +}); diff --git a/packages/core/src/email-catalog/log.ts b/packages/core/src/email-catalog/log.ts new file mode 100644 index 0000000000..d73f051958 --- /dev/null +++ b/packages/core/src/email-catalog/log.ts @@ -0,0 +1,166 @@ +/** + * Durable send log for transactional emails. + * + * Written by `sendEmail` on every attempt, successful or not. Read by Dispatch + * to report per-email send counts and last-sent without depending on the + * provider's activity retention window. + */ + +import { randomUUID } from "node:crypto"; + +import { getDbExec, isPostgres } from "../db/client.js"; +import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js"; + +let _initPromise: Promise | undefined; + +async function ensureTable(): Promise { + if (!_initPromise) { + _initPromise = (async () => { + const { EMAIL_LOG_CREATE_SQL, EMAIL_LOG_TEMPLATE_INDEX_SQL } = + await import("./schema.js"); + // Generic INTEGER maps to BIGINT on Postgres, which millisecond + // timestamps need. + const createSql = isPostgres() + ? EMAIL_LOG_CREATE_SQL.replace(/\bINTEGER\b/g, "BIGINT") + : EMAIL_LOG_CREATE_SQL; + await ensureTableExists("email_log", createSql); + await ensureIndexExists( + "email_log_template_created_idx", + EMAIL_LOG_TEMPLATE_INDEX_SQL, + ); + })().catch((error) => { + // Don't memoize a failed bootstrap — the next send should retry rather + // than log nothing forever. + _initPromise = undefined; + throw error; + }); + } + return _initPromise; +} + +export interface RecordEmailSendArgs { + templateId?: string; + app?: string; + recipient: string; + sender: string; + subject: string; + status: "sent" | "failed"; + error?: string; + provider: string; +} + +/** + * Append one send record. + * + * Callers treat logging as best-effort: a logging failure must not turn a + * delivered email into a thrown send. The failure is surfaced on the console + * rather than swallowed, so a persistently broken log is visible instead of + * quietly producing an empty activity view. + */ +export async function recordEmailSend( + args: RecordEmailSendArgs, +): Promise { + try { + await ensureTable(); + await getDbExec().execute({ + sql: `INSERT INTO email_log + (id, template_id, app, recipient, sender, subject, status, error, provider, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: [ + randomUUID(), + args.templateId ?? null, + args.app ?? null, + args.recipient, + args.sender, + args.subject, + args.status, + args.error ?? null, + args.provider, + Date.now(), + ], + }); + } catch (error) { + console.error("[agent-native:email] failed to record send", error); + } +} + +export interface EmailSendStats { + templateId: string; + sent: number; + failed: number; + lastSentAt: number | null; +} + +/** + * Per-template send counts and last-sent, for sends at or after `since`. + * Templates with no rows are absent from the result — callers distinguish + * "never sent" from "sent zero times in window" by that absence. + */ +export async function getEmailSendStats( + since: number, + app: string, +): Promise { + await ensureTable(); + const { rows } = await getDbExec().execute({ + sql: `SELECT template_id, + SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END) AS sent, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed, + MAX(CASE WHEN status = 'sent' THEN created_at END) AS last_sent_at + FROM email_log + WHERE app = ? AND template_id IS NOT NULL AND created_at >= ? + GROUP BY template_id`, + args: [app, since], + }); + return rows.map((row: any) => ({ + templateId: String(row.template_id), + sent: Number(row.sent ?? 0), + failed: Number(row.failed ?? 0), + lastSentAt: row.last_sent_at == null ? null : Number(row.last_sent_at), + })); +} + +export interface EmailLogEntry { + id: string; + templateId: string | null; + app: string | null; + recipient: string; + sender: string; + subject: string; + status: string; + error: string | null; + provider: string; + createdAt: number; +} + +/** Most recent sends for one app, newest first, optionally filtered to one template. */ +export async function listEmailLog(options: { + app: string; + templateId?: string; + limit?: number; +}): Promise { + await ensureTable(); + const limit = Math.min(Math.max(options.limit ?? 100, 1), 500); + const where = options.templateId + ? `WHERE app = ? AND template_id = ?` + : `WHERE app = ?`; + const args = options.templateId + ? [options.app, options.templateId, limit] + : [options.app, limit]; + const { rows } = await getDbExec().execute({ + sql: `SELECT id, template_id, app, recipient, sender, subject, status, error, provider, created_at + FROM email_log ${where} ORDER BY created_at DESC LIMIT ?`, + args, + }); + return rows.map((row: any) => ({ + id: String(row.id), + templateId: row.template_id == null ? null : String(row.template_id), + app: row.app == null ? null : String(row.app), + recipient: String(row.recipient), + sender: String(row.sender), + subject: String(row.subject), + status: String(row.status), + error: row.error == null ? null : String(row.error), + provider: String(row.provider), + createdAt: Number(row.created_at), + })); +} diff --git a/packages/core/src/email-catalog/provider-metrics.spec.ts b/packages/core/src/email-catalog/provider-metrics.spec.ts new file mode 100644 index 0000000000..0b6803e5fd --- /dev/null +++ b/packages/core/src/email-catalog/provider-metrics.spec.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getEmailProvider: vi.fn(), + resolveSecret: vi.fn(), +})); + +vi.mock("../server/email.js", () => ({ + getEmailProvider: mocks.getEmailProvider, +})); +vi.mock("../server/credential-provider.js", () => ({ + resolveSecret: mocks.resolveSecret, +})); + +import { + fetchEmailActivity, + fetchEmailEngagement, +} from "./provider-metrics.js"; + +describe("email provider metrics", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + mocks.getEmailProvider.mockReset(); + mocks.resolveSecret.mockReset(); + }); + + it("does not query SendGrid when Resend is the active transport", async () => { + mocks.getEmailProvider.mockResolvedValue("resend"); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchEmailEngagement(["core.reset-password"], 30); + + expect(result).toEqual({ + available: false, + reason: + "Email delivery uses Resend, so SendGrid metrics do not describe the active transport.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("uses whole-window category sums without exposing unrelated categories", async () => { + mocks.getEmailProvider.mockResolvedValue("sendgrid"); + mocks.resolveSecret.mockResolvedValue("sendgrid-key"); + const fetchMock = vi.fn(async () => + Response.json({ + stats: [ + { + name: "calendar.booking-confirmed", + metrics: { + delivered: 4, + unique_opens: 3, + unique_clicks: 2, + }, + }, + { + name: "unrelated.private-category", + metrics: { + delivered: 100, + unique_opens: 99, + unique_clicks: 50, + }, + }, + ], + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchEmailEngagement( + ["calendar.booking-confirmed"], + 30, + ); + + expect(result).toEqual({ + available: true, + data: [ + { + templateId: "calendar.booking-confirmed", + delivered: 4, + uniqueOpens: 3, + uniqueClicks: 2, + openRate: 0.75, + }, + ], + }); + const url = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(url.pathname).toBe("/v3/categories/stats/sums"); + expect(url.searchParams.get("aggregated_by")).toBeNull(); + }); + + it("always scopes SendGrid activity to the requested template category", async () => { + mocks.getEmailProvider.mockResolvedValue("sendgrid"); + mocks.resolveSecret.mockResolvedValue("sendgrid-key"); + const fetchMock = vi.fn(async () => + Response.json({ messages: [] }, { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + + await fetchEmailActivity({ + templateId: "core.reset-password", + limit: 25, + }); + + const url = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(url.pathname).toBe("/v3/messages"); + expect(url.searchParams.get("limit")).toBe("25"); + expect(url.searchParams.get("query")).toBe( + 'category="core.reset-password"', + ); + }); +}); diff --git a/packages/core/src/email-catalog/provider-metrics.ts b/packages/core/src/email-catalog/provider-metrics.ts new file mode 100644 index 0000000000..e4432d1690 --- /dev/null +++ b/packages/core/src/email-catalog/provider-metrics.ts @@ -0,0 +1,223 @@ +/** + * Engagement metrics and activity for registered transactional emails, read + * from the active provider in the current app's request context. + */ + +import { z } from "zod"; + +import { resolveSecret } from "../server/credential-provider.js"; +import { getEmailProvider } from "../server/email.js"; + +const SENDGRID_API = "https://api.sendgrid.com/v3"; +const SENDGRID_PAGE_SIZE = 1000; +const SENDGRID_MAX_PAGES = 10; + +export type ProviderMetricsResult = + | { available: true; data: T } + | { available: false; reason: string }; + +export interface EmailEngagement { + templateId: string; + delivered: number; + uniqueOpens: number; + uniqueClicks: number; + openRate: number | null; +} + +type SendGridAccess = + | { available: true; key: string } + | { available: false; reason: string }; + +const categoryMetricsSchema = z + .object({ + delivered: z.number().default(0), + unique_opens: z.number().default(0), + unique_clicks: z.number().default(0), + }) + .passthrough(); + +const categoryStatSchema = z + .object({ + name: z.string(), + metrics: categoryMetricsSchema.default(() => ({ + delivered: 0, + unique_opens: 0, + unique_clicks: 0, + })), + }) + .passthrough(); + +const categorySumsSchema = z.object({ + stats: z.array(categoryStatSchema).default([]), +}); + +const activitySchema = z.object({ + messages: z + .array( + z + .object({ + msg_id: z.string().default(""), + to_email: z.string().default(""), + from_email: z.string().default(""), + subject: z.string().default(""), + status: z.string().default(""), + opens_count: z.coerce.number().default(0), + clicks_count: z.coerce.number().default(0), + last_event_time: z.string().default(""), + }) + .passthrough(), + ) + .default([]), +}); + +async function activeSendGrid(): Promise { + const provider = await getEmailProvider(); + if (provider === "resend") { + return { + available: false, + reason: + "Email delivery uses Resend, so SendGrid metrics do not describe the active transport.", + }; + } + if (provider !== "sendgrid") { + return { + available: false, + reason: + "No email provider is configured, so provider metrics cannot be read.", + }; + } + const key = await resolveSecret("SENDGRID_API_KEY"); + if (!key) { + return { + available: false, + reason: + "SendGrid is the active email transport, but SENDGRID_API_KEY could not be resolved.", + }; + } + return { available: true, key }; +} + +function isoDate(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +async function sendgridGet( + key: string, + path: string, + params: Array<[string, string]>, +): Promise { + const url = new URL(`${SENDGRID_API}${path}`); + for (const [name, value] of params) url.searchParams.append(name, value); + const res = await fetch(url, { + headers: { Authorization: `Bearer ${key}` }, + }); + if (!res.ok) { + const body = await res + .text() + .catch((cause) => ``); + throw new Error(`SendGrid ${res.status} on ${path}: ${body.slice(0, 300)}`); + } + return res.json(); +} + +export async function fetchEmailEngagement( + templateIds: string[], + windowDays: number, +): Promise> { + if (!templateIds.length) return { available: true, data: [] }; + + const sendgrid = await activeSendGrid(); + if (!sendgrid.available) return sendgrid; + + const end = Date.now(); + const start = end - windowDays * 24 * 60 * 60 * 1000; + const requested = new Set(templateIds); + const data: EmailEngagement[] = []; + + try { + for (let page = 0; page < SENDGRID_MAX_PAGES; page += 1) { + const payload = categorySumsSchema.parse( + await sendgridGet(sendgrid.key, "/categories/stats/sums", [ + ["start_date", isoDate(start)], + ["end_date", isoDate(end)], + ["limit", String(SENDGRID_PAGE_SIZE)], + ["offset", String(page * SENDGRID_PAGE_SIZE)], + ]), + ); + + for (const entry of payload.stats) { + if (!requested.has(entry.name)) continue; + requested.delete(entry.name); + const delivered = entry.metrics.delivered; + const uniqueOpens = entry.metrics.unique_opens; + data.push({ + templateId: entry.name, + delivered, + uniqueOpens, + uniqueClicks: entry.metrics.unique_clicks, + openRate: delivered > 0 ? Math.min(uniqueOpens / delivered, 1) : null, + }); + } + + if (requested.size === 0 || payload.stats.length < SENDGRID_PAGE_SIZE) { + break; + } + } + + return { available: true, data }; + } catch (error) { + return { + available: false, + reason: error instanceof Error ? error.message : String(error), + }; + } +} + +export interface EmailActivityEntry { + msgId: string; + toEmail: string; + fromEmail: string; + subject: string; + status: string; + opensCount: number; + clicksCount: number; + lastEventTime: string; +} + +export async function fetchEmailActivity(options: { + templateId: string; + limit?: number; +}): Promise> { + const sendgrid = await activeSendGrid(); + if (!sendgrid.available) return sendgrid; + + const limit = Math.min(Math.max(options.limit ?? 50, 1), 1000); + const safe = options.templateId.replace(/["\\]/g, ""); + + try { + const payload = activitySchema.parse( + await sendgridGet(sendgrid.key, "/messages", [ + ["limit", String(limit)], + ["query", `category="${safe}"`], + ]), + ); + return { + available: true, + data: payload.messages.map((message) => ({ + msgId: message.msg_id, + toEmail: message.to_email, + fromEmail: message.from_email, + subject: message.subject, + status: message.status, + opensCount: message.opens_count, + clicksCount: message.clicks_count, + lastEventTime: message.last_event_time, + })), + }; + } catch (error) { + return { + available: false, + reason: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/packages/core/src/email-catalog/registry.spec.ts b/packages/core/src/email-catalog/registry.spec.ts new file mode 100644 index 0000000000..f71784b689 --- /dev/null +++ b/packages/core/src/email-catalog/registry.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + defineTransactionalEmail, + getTransactionalEmail, + listTransactionalEmails, + renderTransactionalEmailPreview, + resetTransactionalEmailRegistry, +} from "./registry.js"; + +function define(id: string, overrides: Record = {}) { + return defineTransactionalEmail({ + id, + name: id, + app: "test-app", + trigger: "trigger", + recipient: "recipient", + recipientLabel: "Recipient", + sender: "sender", + senderLabel: "Sender", + preview: () => ({ + subject: `subject:${id}`, + html: "

hi

", + text: "hi", + }), + ...overrides, + }); +} + +describe("transactional email registry", () => { + beforeEach(() => resetTransactionalEmailRegistry()); + + it("registers and returns a definition", () => { + define("test.one"); + expect(getTransactionalEmail("test.one")?.name).toBe("test.one"); + }); + + it("sorts by app then name", () => { + define("b.one", { app: "b", name: "zebra" }); + define("a.one", { app: "a", name: "apple" }); + define("b.two", { app: "b", name: "alpha" }); + expect(listTransactionalEmails().map((e) => e.id)).toEqual([ + "a.one", + "b.two", + "b.one", + ]); + }); + + it("throws on a duplicate id rather than silently merging", () => { + define("test.dupe"); + expect(() => define("test.dupe")).toThrow(/Duplicate transactional email/); + }); + + it("is idempotent when the same definition object re-registers", () => { + const definition = { + id: "test.same", + name: "same", + app: "test-app", + trigger: "t", + recipient: "r", + recipientLabel: "R", + sender: "s", + senderLabel: "S", + preview: () => ({ subject: "s", html: "h", text: "t" }), + }; + defineTransactionalEmail(definition); + expect(() => defineTransactionalEmail(definition)).not.toThrow(); + expect(listTransactionalEmails()).toHaveLength(1); + }); + + it("renders a preview by id", () => { + define("test.preview"); + expect(renderTransactionalEmailPreview("test.preview").subject).toBe( + "subject:test.preview", + ); + }); + + it("throws for an unknown preview id instead of returning an empty body", () => { + expect(() => renderTransactionalEmailPreview("test.missing")).toThrow( + /Unknown transactional email/, + ); + }); +}); diff --git a/packages/core/src/email-catalog/registry.ts b/packages/core/src/email-catalog/registry.ts new file mode 100644 index 0000000000..182be3e69e --- /dev/null +++ b/packages/core/src/email-catalog/registry.ts @@ -0,0 +1,128 @@ +/** + * Registry of the transactional emails an app can send. + * + * An app declares each email it sends here so the workspace can answer, without + * reading the app's source: what emails exist, what makes one send, who it goes + * to, who it comes from, and what it looks like. Dispatch aggregates these + * across every mounted app via the `list-transactional-emails` action. + * + * Declare emails next to the code that sends them, then import that module from + * a server plugin so registration happens at startup: + * + * defineTransactionalEmail({ + * id: "calendar.booking-confirmed", + * name: "Booking confirmed", + * trigger: "A guest completes a booking on a public scheduling page.", + * recipient: "The guest email captured on the booking form.", + * sender: "EMAIL_FROM, with reply-to set to the event host.", + * preview: () => renderBookingConfirmedEmail(SAMPLE_BOOKING), + * }); + * + * The `id` doubles as the SendGrid category `sendEmail` tags the message with, + * which is how per-email delivery and open metrics are attributed later. + */ + +import { getAppSlug } from "../server/app-name.js"; +import type { RenderedEmailMessage } from "../server/email-templates.js"; + +export interface TransactionalEmailDefinition { + /** + * Stable, globally unique id in `.` form, e.g. + * `calendar.booking-confirmed`. Used as the SendGrid category, so changing it + * orphans historical metrics for this email. + */ + id: string; + /** Human-readable name, e.g. "Booking confirmed". */ + name: string; + /** + * App slug this email belongs to. Defaults to the running app, which is + * correct for every app-declared email; core system emails set it explicitly. + */ + app?: string; + /** Plain-language description of the condition that causes a send. */ + trigger: string; + /** Plain-language description of how the recipient address is chosen. */ + recipient: string; + /** + * Two-to-four word summary of the recipient for table cells, e.g. + * "Booking guest". The full `recipient` sentence is shown on the detail view. + */ + recipientLabel: string; + /** Plain-language description of how From and Reply-To are chosen. */ + sender: string; + /** Two-to-four word summary of the sender, e.g. "Default, reply-to host". */ + senderLabel: string; + /** + * Render the email with representative dummy data. Must not read from the + * database or touch the network — previews are rendered on demand from + * Dispatch, for apps whose data the caller may not be able to see. + */ + preview: () => RenderedEmailMessage; +} + +/** A definition with its app resolved, as returned to callers. */ +export type RegisteredTransactionalEmail = TransactionalEmailDefinition & { + app: string; +}; + +const registry = new Map(); +/** Source definitions, so re-registering the same one is a no-op rather than a clash. */ +const sources = new Map(); + +/** + * Register a transactional email. Returns the definition so the call site can + * export it and reuse `id` when sending. + */ +export function defineTransactionalEmail( + definition: TransactionalEmailDefinition, +): RegisteredTransactionalEmail { + const existing = sources.get(definition.id); + if (existing && existing !== definition) { + // Two emails sharing an id would silently merge their metrics and make the + // catalog claim one exists when the other actually sent. + throw new Error( + `Duplicate transactional email id "${definition.id}". Ids must be unique across the app.`, + ); + } + const resolved: RegisteredTransactionalEmail = { + ...definition, + app: definition.app ?? getAppSlug() ?? "unknown", + }; + registry.set(definition.id, resolved); + sources.set(definition.id, definition); + return resolved; +} + +/** Every registered email, sorted by app then name. */ +export function listTransactionalEmails(): RegisteredTransactionalEmail[] { + return [...registry.values()].sort( + (a, b) => a.app.localeCompare(b.app) || a.name.localeCompare(b.name), + ); +} + +export function getTransactionalEmail( + id: string, +): RegisteredTransactionalEmail | undefined { + return registry.get(id); +} + +/** + * Render one email's preview. Throws when the id is unknown or the renderer + * fails — a preview that silently returns an empty body would look like an + * email that legitimately renders blank. + */ +export function renderTransactionalEmailPreview( + id: string, +): RenderedEmailMessage { + const definition = registry.get(id); + if (!definition) { + throw new Error(`Unknown transactional email "${id}".`); + } + return definition.preview(); +} + +/** Test seam — drops all registrations. */ +export function resetTransactionalEmailRegistry(): void { + registry.clear(); + sources.clear(); +} diff --git a/packages/core/src/email-catalog/schema.ts b/packages/core/src/email-catalog/schema.ts new file mode 100644 index 0000000000..a410330a76 --- /dev/null +++ b/packages/core/src/email-catalog/schema.ts @@ -0,0 +1,52 @@ +/** + * Drizzle schema for the transactional email send log. + * + * One row per `sendEmail` attempt, written by the transport itself so every + * send is recorded regardless of which app or code path triggered it. This is + * the durable record of "did we send it": the provider's own activity feed ages + * out (SendGrid keeps 3 days without the extended-retention add-on), so send + * counts and last-sent must not depend on it. + * + * Engagement (opens, clicks, bounces) is deliberately NOT stored here. Only the + * provider knows it, and mirroring it would go stale the moment a recipient + * opens an old message. Dispatch reads engagement live from the provider and + * joins on `template_id`. + */ + +import { table, text, integer } from "../db/schema.js"; + +export const emailLog = table("email_log", { + id: text("id").primaryKey(), + /** Registered transactional email id, e.g. "calendar.booking-confirmed". */ + templateId: text("template_id"), + /** App slug that sent it. */ + app: text("app"), + /** Recipient address. */ + recipient: text("recipient").notNull(), + /** Resolved From address, after app-sender branding is applied. */ + sender: text("sender").notNull(), + subject: text("subject").notNull(), + /** "sent" once the provider accepted it, or "failed". Never optimistic. */ + status: text("status", { enum: ["sent", "failed"] }).notNull(), + /** Provider error text when status is "failed". */ + error: text("error"), + /** "resend" | "sendgrid" | "dev". */ + provider: text("provider").notNull(), + createdAt: integer("created_at").notNull(), +}); + +export const EMAIL_LOG_CREATE_SQL = `CREATE TABLE IF NOT EXISTS email_log ( + id TEXT PRIMARY KEY, + template_id TEXT, + app TEXT, + recipient TEXT NOT NULL, + sender TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, + error TEXT, + provider TEXT NOT NULL, + created_at INTEGER NOT NULL +)`; + +export const EMAIL_LOG_TEMPLATE_INDEX_SQL = `CREATE INDEX IF NOT EXISTS email_log_template_created_idx + ON email_log (template_id, created_at)`; diff --git a/packages/core/src/email-catalog/system-emails.ts b/packages/core/src/email-catalog/system-emails.ts new file mode 100644 index 0000000000..c31428021e --- /dev/null +++ b/packages/core/src/email-catalog/system-emails.ts @@ -0,0 +1,89 @@ +/** + * Catalog entries for the framework's own system emails. + * + * These ship with every app rather than belonging to one, so they register + * under the `core` app instead of the running app's slug. Importing this module + * performs the registration; `register-system-emails.ts` is the single import + * site so the emails appear in every app's catalog without each template + * remembering to opt in. + */ + +import { + renderInviteEmail, + renderResetPasswordEmail, + renderVerifySignupEmail, +} from "../server/email-templates.js"; +import { defineTransactionalEmail } from "./registry.js"; + +/** Obviously-fake sample data — these render in a preview pane, never send. */ +const SAMPLE_URL = "https://example.com/accept/sample-token"; +const SAMPLE_EMAIL = "sam.rivera@example.com"; + +export const CORE_INVITE_EMAIL_ID = "core.organization-invite"; +export const CORE_VERIFY_SIGNUP_EMAIL_ID = "core.verify-signup"; +export const CORE_RESET_PASSWORD_EMAIL_ID = "core.reset-password"; + +let registered = false; + +export function registerCoreSystemEmails(): void { + if (registered) return; + registered = true; + + defineTransactionalEmail({ + id: CORE_INVITE_EMAIL_ID, + app: "core", + name: "Organization invitation", + trigger: + "A member invites someone to their organization from the team settings page.", + recipientLabel: "Invited address", + recipient: + "The address typed into the invite form. One email per invited address.", + senderLabel: "Default, app-branded", + sender: + "The configured EMAIL_FROM. On first-party agent-native.com deployments the display name becomes the app's own, with reply-to agent-native@builder.io.", + preview: () => + renderInviteEmail({ + invitee: SAMPLE_EMAIL, + orgName: "Northwind Design", + acceptUrl: SAMPLE_URL, + inviter: "alex.chen@example.com", + }), + }); + + defineTransactionalEmail({ + id: CORE_VERIFY_SIGNUP_EMAIL_ID, + app: "core", + name: "Verify signup", + trigger: + "A new account is created with email and password, before the account can be used.", + recipientLabel: "New account address", + recipient: "The address the account was registered with.", + senderLabel: "Default, app-branded", + sender: + "The configured EMAIL_FROM, branded with the app name the signup happened in.", + preview: () => + renderVerifySignupEmail({ + email: SAMPLE_EMAIL, + verifyUrl: SAMPLE_URL, + }), + }); + + defineTransactionalEmail({ + id: CORE_RESET_PASSWORD_EMAIL_ID, + app: "core", + name: "Reset password", + trigger: + "A user requests a password reset from the sign-in screen. The link expires after one hour.", + recipientLabel: "Account address", + recipient: + "The account address the reset was requested for, never an address supplied in the request body.", + senderLabel: "Default, app-branded", + sender: + "The configured EMAIL_FROM, branded with the app name the reset was requested from.", + preview: () => + renderResetPasswordEmail({ + email: SAMPLE_EMAIL, + resetUrl: SAMPLE_URL, + }), + }); +} diff --git a/packages/core/src/localization/default-messages.ts b/packages/core/src/localization/default-messages.ts index c0733abeb9..ced4b74310 100644 --- a/packages/core/src/localization/default-messages.ts +++ b/packages/core/src/localization/default-messages.ts @@ -176,6 +176,7 @@ const messages = { resources: "Resources", messaging: "Messaging", destinations: "Destinations", + transactionalEmail: "Transactional email", identities: "Identities", approvals: "Approvals", automations: "Automations", @@ -207,6 +208,57 @@ const messages = { suggestionRouteSlack: "Route Slack mentions to my analytics app", suggestionGrantKey: "Grant my OpenAI key to this app", }, + transactionalEmail: { + title: "Transactional email", + description: + "Every transactional email each app in this workspace can send, with its trigger, recipients, and delivery metrics.", + retentionTitle: "Activity feed retention is short", + retentionNote: + "The email provider's activity feed only retains recent messages, so an empty activity list does not mean nothing was sent.", + openRatesUnavailable: "Open rates unavailable", + sharedProviderMetricsUnavailable: + "Provider metrics vary by sending app. Expand an app to see its provider data.", + catalogFanoutFailed: "Could not load app email catalogs", + retry: "Retry", + noApps: "No ready apps reported a transactional email catalog.", + catalogUnreadable: "This app's email catalog could not be read", + countsUnreadable: "Send counts could not be read", + appSendsNoEmail: "This app sends no transactional email of its own.", + emailNotFound: "Email not found", + emailNotFoundDescription: "This email is not in the app's catalog.", + sharedTitle: "Shared system email", + sharedSubtitle: "sent by every app", + email: "Email", + trigger: "Trigger", + sends: "Sends", + openRate: "Open rate", + lastSent: "Last sent", + sendLogUnread: + "The send log could not be read, so the number of sends is unknown.", + failedCount: "{{count}} failed", + failuresUnknown: "failures unknown", + noProviderRecord: + "The provider reported no record for this email in the window.", + noDeliveredMail: "No delivered mail yet", + neverSent: "Never sent", + lastSentUnknown: + "The send log could not be read, so the last send time is unknown.", + preview: "Preview", + previewDescription: + "Rendered with dummy data. Scripts are disabled in this preview.", + previewFailed: "Preview could not be rendered", + previewFrameTitle: "Preview of {{name}}", + subject: "Subject", + activityLink: "Activity log", + activityTitle: "Activity for {{name}}", + activityUnavailable: "Activity feed unavailable", + activityEmpty: "No recent activity in the provider's retention window.", + recipient: "Recipient", + sender: "Sender", + status: "Status", + opens: "Opens", + lastEvent: "Last event", + }, pages: { appsDescription: "Open workspace apps and start new app creation from Dispatch.", diff --git a/packages/core/src/org/handlers.ts b/packages/core/src/org/handlers.ts index 664a6b7fdc..1828c48d4a 100644 --- a/packages/core/src/org/handlers.ts +++ b/packages/core/src/org/handlers.ts @@ -37,6 +37,7 @@ const nanoid = (): string => globalThis.crypto?.randomUUID?.().replace(/-/g, "") ?? Math.random().toString(36).slice(2) + Date.now().toString(36); import { getDbExec, isPostgres } from "../db/client.js"; +import { CORE_INVITE_EMAIL_ID } from "../email-catalog/system-emails.js"; import { ssrfSafeFetch } from "../extensions/url-safety.js"; import { getAppProductionUrl } from "../server/app-url.js"; import { getSession } from "../server/auth.js"; @@ -338,7 +339,13 @@ async function inviteOne( acceptUrl: getInviteAppUrl(event), inviter: ctx.email, }); - await sendEmail({ to: email, subject, html, text }); + await sendEmail({ + to: email, + subject, + html, + text, + templateId: CORE_INVITE_EMAIL_ID, + }); emailSent = true; } catch (err) { emailError = err instanceof Error ? err.message : String(err); diff --git a/packages/core/src/server/action-discovery.ts b/packages/core/src/server/action-discovery.ts index 0284908146..6a0d89923d 100644 --- a/packages/core/src/server/action-discovery.ts +++ b/packages/core/src/server/action-discovery.ts @@ -602,6 +602,29 @@ export async function mergeCoreSharingActions( () => import("../sharing/actions/create-agent-resource-link.js"), ], ["upload-image", () => import("../file-upload/actions/upload-image.js")], + // Transactional email catalog — mounted everywhere so Dispatch can ask any + // app what it sends without that app opting in. + [ + "list-transactional-emails", + () => import("../email-catalog/actions/list-transactional-emails.js"), + ], + [ + "render-transactional-email-preview", + () => + import("../email-catalog/actions/render-transactional-email-preview.js"), + ], + [ + "list-email-log", + () => import("../email-catalog/actions/list-email-log.js"), + ], + [ + "list-email-activity", + () => import("../email-catalog/actions/list-email-activity.js"), + ], + [ + "list-email-engagement", + () => import("../email-catalog/actions/list-email-engagement.js"), + ], [ "get-feature-flags", () => import("../feature-flags/actions/get-feature-flags.js"), diff --git a/packages/core/src/server/better-auth-instance.ts b/packages/core/src/server/better-auth-instance.ts index 84873aed9b..575b960969 100644 --- a/packages/core/src/server/better-auth-instance.ts +++ b/packages/core/src/server/better-auth-instance.ts @@ -43,6 +43,10 @@ import { onSharedDbPoolReplaced, } from "../db/client.js"; import { ensureTableExists } from "../db/ddl-guard.js"; +import { + CORE_RESET_PASSWORD_EMAIL_ID, + CORE_VERIFY_SIGNUP_EMAIL_ID, +} from "../email-catalog/system-emails.js"; import { saveOAuthTokens } from "../oauth-tokens/store.js"; import { acceptPendingInvitationsForEmail } from "../org/accept-pending.js"; import { @@ -1231,7 +1235,14 @@ async function createBetterAuthInstance( email: user.email, resetUrl, }); - await sendEmail({ to: user.email, subject, html, text, appSender }); + await sendEmail({ + to: user.email, + subject, + html, + text, + appSender, + templateId: CORE_RESET_PASSWORD_EMAIL_ID, + }); }, }, emailVerification: { @@ -1257,7 +1268,14 @@ async function createBetterAuthInstance( email: user.email, verifyUrl, }); - await sendEmail({ to: user.email, subject, html, text, appSender }); + await sendEmail({ + to: user.email, + subject, + html, + text, + appSender, + templateId: CORE_VERIFY_SIGNUP_EMAIL_ID, + }); }, }, socialProviders, diff --git a/packages/core/src/server/email.ts b/packages/core/src/server/email.ts index 0a4fe47862..65ec51caa5 100644 --- a/packages/core/src/server/email.ts +++ b/packages/core/src/server/email.ts @@ -11,6 +11,8 @@ */ import { FAVICON_PNG_BASE64 } from "../assets/branding/favicon-base64.js"; +import { recordEmailSend } from "../email-catalog/log.js"; +import { getAppSlug } from "./app-name.js"; import { resolveSecret } from "./credential-provider.js"; import { AGENT_NATIVE_EMAIL_LOGO_CONTENT_ID } from "./email-template.js"; @@ -51,6 +53,15 @@ export interface SendEmailArgs { references?: string; attachments?: EmailAttachment[]; timeoutMs?: number; + /** + * Registered transactional email id (see `defineTransactionalEmail`), e.g. + * `calendar.booking-confirmed`. Tags the message at the provider so delivery + * and open metrics attribute to one email instead of to the whole account, + * and keys the row written to `email_log`. Omit for genuinely one-off sends. + */ + templateId?: string; + /** App slug that owns the send. Defaults to the running app. */ + app?: string; } let cachedAgentNativeLogo: Buffer | undefined; @@ -208,10 +219,15 @@ function resolveAppSender( }; } -async function sendEmailWithSignal( +interface DeliveryOutcome { + provider: EmailProvider; + from: string; +} + +async function deliverEmail( args: SendEmailArgs, signal?: AbortSignal, -): Promise { +): Promise { const config = await resolveEmailTransport(); signal?.throwIfAborted(); const provider = config.provider; @@ -262,7 +278,7 @@ async function sendEmailWithSignal( const body = await res.text().catch(() => ""); throw new Error(`Resend error ${res.status}: ${body}`); } - return; + return { provider, from }; } if (provider === "sendgrid") { @@ -284,6 +300,13 @@ async function sendEmailWithSignal( ], }; if (replyTo) sgPayload.reply_to = parseSendGridFrom(replyTo); + // Categories are how per-email delivery/open stats are attributed. Without + // them every send lands in one undifferentiated account-wide bucket, which + // is indistinguishable from an email that never sent. + const categories = [args.templateId, args.app ?? getAppSlug()].filter( + (value): value is string => Boolean(value), + ); + if (categories.length) sgPayload.categories = categories; const sgHeaders: Record = {}; if (args.inReplyTo) sgHeaders["In-Reply-To"] = args.inReplyTo; if (args.references) sgHeaders["References"] = args.references; @@ -314,7 +337,7 @@ async function sendEmailWithSignal( const body = await res.text().catch(() => ""); throw new Error(`SendGrid error ${res.status}: ${body}`); } - return; + return { provider, from }; } // Dev fallback — no provider configured. Logging the full body exposes @@ -331,6 +354,42 @@ async function sendEmailWithSignal( `---\nTo: ${args.to}\nFrom: ${from}\nSubject: ${args.subject}\n\n` + `${args.text || stripHtml(args.html)}\n---\n`, ); + return { provider, from }; +} + +/** + * Deliver, then record the attempt. Recording lives here rather than in each + * provider branch so a new transport cannot be added without being logged. + */ +async function sendEmailWithSignal( + args: SendEmailArgs, + signal?: AbortSignal, +): Promise { + let outcome: DeliveryOutcome | undefined; + try { + outcome = await deliverEmail(args, signal); + } catch (error) { + await recordEmailSend({ + templateId: args.templateId, + app: args.app ?? getAppSlug() ?? "unknown", + recipient: args.to, + sender: outcome?.from ?? args.from ?? "unknown", + subject: args.subject, + status: "failed", + error: error instanceof Error ? error.message : String(error), + provider: outcome?.provider ?? "unknown", + }); + throw error; + } + await recordEmailSend({ + templateId: args.templateId, + app: args.app ?? getAppSlug() ?? "unknown", + recipient: args.to, + sender: outcome.from, + subject: args.subject, + status: "sent", + provider: outcome.provider, + }); } export async function sendEmail(args: SendEmailArgs): Promise { diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index de203ca44a..6bf2f7abb5 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -547,6 +547,14 @@ export { type EmailProvider, type SendEmailArgs, } from "./email.js"; +export { + defineTransactionalEmail, + listTransactionalEmails, + getTransactionalEmail, + renderTransactionalEmailPreview, + type TransactionalEmailDefinition, + type RegisteredTransactionalEmail, +} from "../email-catalog/registry.js"; export { notifyActivity, runActivityNotification, diff --git a/packages/dispatch/src/client/transactional-emails.spec.ts b/packages/dispatch/src/client/transactional-emails.spec.ts new file mode 100644 index 0000000000..91a97ebe8a --- /dev/null +++ b/packages/dispatch/src/client/transactional-emails.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + aggregateSharedEmails, + type AppEmailCatalog, + type AppTransactionalEmail, + type LocalTransactionalEmailCatalog, +} from "./transactional-emails"; + +const coreEmail: AppTransactionalEmail = { + id: "core.reset-password", + app: "core", + name: "Reset password", + trigger: "A password reset is requested.", + recipient: "The account address.", + recipientLabel: "Account address", + sender: "The configured sender.", + senderLabel: "Configured sender", + sent: 2, + failed: 1, + lastSentAt: 100, +}; + +const local: LocalTransactionalEmailCatalog = { + app: "dispatch", + statsAvailable: true, + statsError: null, + emails: [coreEmail], +}; + +function appCatalog( + appId: string, + email: AppTransactionalEmail, +): AppEmailCatalog { + return { + appId, + appName: appId, + appPath: `/${appId}`, + emails: [email], + error: null, + statsError: null, + }; +} + +describe("aggregateSharedEmails", () => { + it("sums shared email counts across sending apps", () => { + const result = aggregateSharedEmails(local, [ + appCatalog("calendar", { + ...coreEmail, + sent: 3, + failed: 0, + lastSentAt: 200, + }), + appCatalog("forms", { + ...coreEmail, + sent: 1, + failed: 2, + lastSentAt: 150, + }), + ]); + + expect(result).toEqual({ + statsError: null, + emails: [ + { + ...coreEmail, + sent: 6, + failed: 3, + lastSentAt: 200, + }, + ], + }); + }); + + it("does not present partial totals when an app log is unreadable", () => { + const unreadable = appCatalog("calendar", coreEmail); + unreadable.statsError = "database unavailable"; + + expect(aggregateSharedEmails(local, [unreadable])).toEqual({ + statsError: "database unavailable", + emails: [ + { + ...coreEmail, + sent: null, + failed: null, + lastSentAt: null, + }, + ], + }); + }); +}); diff --git a/packages/dispatch/src/client/transactional-emails.ts b/packages/dispatch/src/client/transactional-emails.ts new file mode 100644 index 0000000000..e061dfe24a --- /dev/null +++ b/packages/dispatch/src/client/transactional-emails.ts @@ -0,0 +1,201 @@ +/** + * Reads each mounted app's transactional email catalog from the browser. + * + * Workspace apps are path-mounted on a single origin, so the browser can call + * another app's action endpoint directly and its own session cookie carries the + * caller's identity. Fanning out here rather than server-side keeps every app's + * existing access checks in force — Dispatch never sees a catalog the signed-in + * user could not have loaded themselves. + */ + +export interface AppTransactionalEmail { + id: string; + app: string; + name: string; + trigger: string; + recipient: string; + recipientLabel: string; + sender: string; + senderLabel: string; + /** null when the send log could not be read — not the same as zero sends. */ + sent: number | null; + failed: number | null; + lastSentAt: number | null; +} + +export interface AppEmailCatalog { + appId: string; + appName: string; + appPath: string; + emails: AppTransactionalEmail[]; + /** Set when this app's catalog could not be read at all. */ + error: string | null; + /** Set when the catalog loaded but its send counts did not. */ + statsError: string | null; +} + +export interface LocalTransactionalEmailCatalog { + app: string; + statsAvailable: boolean; + statsError: string | null; + emails: AppTransactionalEmail[]; +} + +export function aggregateSharedEmails( + local: LocalTransactionalEmailCatalog, + appCatalogs: AppEmailCatalog[], +): { emails: AppTransactionalEmail[]; statsError: string | null } { + const catalogs = [ + { + appId: local.app, + appName: local.app, + appPath: "", + emails: local.emails, + error: null, + statsError: local.statsAvailable ? null : local.statsError, + }, + ...appCatalogs.filter((catalog) => catalog.appId !== local.app), + ]; + const unreadable = catalogs.find( + (catalog) => catalog.error || catalog.statsError, + ); + const statsAvailable = !unreadable; + + return { + statsError: unreadable?.error ?? unreadable?.statsError ?? null, + emails: local.emails + .filter((email) => email.app === "core") + .map((definition) => { + if (!statsAvailable) { + return { ...definition, sent: null, failed: null, lastSentAt: null }; + } + let sent = 0; + let failed = 0; + let lastSentAt: number | null = null; + for (const catalog of catalogs) { + const email = catalog.emails.find( + (candidate) => candidate.id === definition.id, + ); + sent += email?.sent ?? 0; + failed += email?.failed ?? 0; + if ( + email?.lastSentAt != null && + (lastSentAt === null || email.lastSentAt > lastSentAt) + ) { + lastSentAt = email.lastSentAt; + } + } + return { ...definition, sent, failed, lastSentAt }; + }), + }; +} + +function actionUrl(appPath: string, action: string, query = ""): string { + const base = appPath.replace(/\/$/, ""); + return `${base}/_agent-native/actions/${action}${query}`; +} + +export async function callAppAction( + appPath: string, + action: string, + params: Record, + method: "GET" | "POST", +): Promise { + const query = + method === "GET" + ? `?${new URLSearchParams( + Object.entries(params).map(([key, value]) => [key, String(value)]), + )}` + : ""; + const res = await fetch(actionUrl(appPath, action, query), { + method, + credentials: "include", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Agent-Native-Frontend": "1", + }, + ...(method === "POST" ? { body: JSON.stringify(params) } : {}), + }); + if (!res.ok) throw new Error(`${action} failed: HTTP ${res.status}`); + return (await res.json()) as T; +} + +/** + * Load one app's catalog. Never throws — a single unreachable app must not + * blank the whole screen, but its failure is returned rather than swallowed so + * the UI can say "couldn't read" instead of showing it as having no emails. + */ +const CATALOG_TIMEOUT_MS = 10_000; + +export async function fetchAppEmailCatalog( + app: { id: string; name: string; path: string }, + windowDays: number, +): Promise { + const base: AppEmailCatalog = { + appId: app.id, + appName: app.name, + appPath: app.path, + emails: [], + error: null, + statsError: null, + }; + try { + const res = await fetch( + actionUrl( + app.path, + "list-transactional-emails", + `?windowDays=${windowDays}`, + ), + { + credentials: "include", + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS), + }, + ); + if (!res.ok) { + return { ...base, error: `HTTP ${res.status}` }; + } + const body = (await res.json()) as { + emails?: AppTransactionalEmail[]; + statsAvailable?: boolean; + statsError?: string | null; + }; + return { + ...base, + emails: body.emails ?? [], + statsError: + body.statsAvailable === false ? (body.statsError ?? "unknown") : null, + }; + } catch (error) { + return { + ...base, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export interface EmailPreview { + subject: string; + html: string; + text: string; +} + +/** Render one email's preview with dummy data, from the owning app. */ +export async function fetchEmailPreview( + appPath: string, + id: string, +): Promise { + const res = await fetch( + actionUrl( + appPath, + "render-transactional-email-preview", + `?id=${encodeURIComponent(id)}`, + ), + { credentials: "include", headers: { Accept: "application/json" } }, + ); + if (!res.ok) { + throw new Error(`Preview failed: HTTP ${res.status}`); + } + return (await res.json()) as EmailPreview; +} diff --git a/packages/dispatch/src/components/layout/Layout.tsx b/packages/dispatch/src/components/layout/Layout.tsx index 30ce9f0dfd..9e74091278 100644 --- a/packages/dispatch/src/components/layout/Layout.tsx +++ b/packages/dispatch/src/components/layout/Layout.tsx @@ -28,6 +28,7 @@ import { IconKey, IconChevronDown, IconLayersSubtract, + IconMail, IconMessageQuestion, IconMessages, IconPlugConnected, @@ -161,6 +162,13 @@ const OPERATIONS_NAV_ITEMS = [ icon: IconPuzzle, section: "operations", }, + { + id: "transactional-email", + to: "/transactional-email", + label: "Transactional email", + icon: IconMail, + section: "operations", + }, { id: "vault", to: "/vault", diff --git a/packages/dispatch/src/components/transactional-email-activity.tsx b/packages/dispatch/src/components/transactional-email-activity.tsx new file mode 100644 index 0000000000..57059a88d3 --- /dev/null +++ b/packages/dispatch/src/components/transactional-email-activity.tsx @@ -0,0 +1,102 @@ +import { useT } from "@agent-native/core/client/i18n"; +import { IconInfoCircle } from "@tabler/icons-react"; + +import { ActionQueryError } from "./action-query-error"; +import { type ProviderMetricsResult } from "./transactional-email-metrics"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Skeleton } from "./ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "./ui/table"; + +export interface EmailActivityEntry { + msgId: string; + toEmail: string; + fromEmail: string; + subject: string; + status: string; + opensCount: number; + clicksCount: number; + lastEventTime: string; +} + +/** + * Renders one email's activity feed. Used both inline on the detail page and + * inside the list page's activity dialog, so the "unavailable" and "empty" + * states only need to be written once. + */ +export function ActivityTable({ + result, + isLoading, + isError, + error, + onRetry, +}: { + result: ProviderMetricsResult | undefined; + isLoading: boolean; + isError: boolean; + error: unknown; + onRetry: () => void; +}) { + const t = useT(); + + if (isError) { + return ; + } + if (isLoading) { + return ; + } + if (result && !result.available) { + return ( + + + + {t("dispatch.transactionalEmail.activityUnavailable")} + + {result.reason} + + ); + } + if (result && result.data.length === 0) { + return ( +
+ {t("dispatch.transactionalEmail.activityEmpty")} +
+ ); + } + if (!result) return null; + + return ( +
+ + + + {t("dispatch.transactionalEmail.recipient")} + {t("dispatch.transactionalEmail.subject")} + {t("dispatch.transactionalEmail.status")} + {t("dispatch.transactionalEmail.opens")} + {t("dispatch.transactionalEmail.lastEvent")} + + + + {result.data.map((entry) => ( + + {entry.toEmail} + {entry.subject} + {entry.status} + + {entry.opensCount} + + {entry.lastEventTime} + + ))} + +
+
+ ); +} diff --git a/packages/dispatch/src/components/transactional-email-metrics.tsx b/packages/dispatch/src/components/transactional-email-metrics.tsx new file mode 100644 index 0000000000..890ae06985 --- /dev/null +++ b/packages/dispatch/src/components/transactional-email-metrics.tsx @@ -0,0 +1,127 @@ +import { useT } from "@agent-native/core/client/i18n"; + +import { Skeleton } from "./ui/skeleton"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +/** + * Shared between the transactional email list and detail pages so both + * render the same "unknown vs. zero" and provider-availability rules. + */ + +export interface EmailEngagement { + templateId: string; + delivered: number; + uniqueOpens: number; + uniqueClicks: number; + /** null when nothing was delivered in the window, so there is no rate yet. */ + openRate: number | null; +} + +export type ProviderMetricsResult = + | { available: true; data: T } + | { available: false; reason: string }; + +/** + * Renders a metric the backend could not read. "Unknown" and "zero" must stay + * visibly different — a dash with a reason is the only honest rendering. + */ +export function UnknownMetric({ reason }: { reason: string }) { + return ( + + + + — + + + {reason} + + ); +} + +export function SendsCell({ + sent, + failed, +}: { + sent: number | null; + failed: number | null; +}) { + const t = useT(); + if (sent === null) { + return ( + + ); + } + return ( + + {sent} + {failed !== null && failed > 0 ? ( + + {t("dispatch.transactionalEmail.failedCount", { count: failed })} + + ) : null} + {failed === null ? ( + + {t("dispatch.transactionalEmail.failuresUnknown")} + + ) : null} + + ); +} + +export function OpenRateCell({ + engagement, + unavailableReason, + loading, +}: { + engagement: EmailEngagement | undefined; + unavailableReason: string | null; + loading: boolean; +}) { + const t = useT(); + if (unavailableReason) { + return ; + } + if (loading) return ; + if (!engagement) { + return ( + + ); + } + if (engagement.openRate === null) { + return ( + + {t("dispatch.transactionalEmail.noDeliveredMail")} + + ); + } + return ( + + {`${(engagement.openRate * 100).toFixed(1)}%`} + + ); +} + +export function LastSentCell({ + lastSentAt, + sent, +}: { + lastSentAt: number | null; + sent: number | null; +}) { + const t = useT(); + if (lastSentAt !== null) { + return <>{new Date(lastSentAt).toLocaleString()}; + } + if (sent === 0) { + return ( + + {t("dispatch.transactionalEmail.neverSent")} + + ); + } + return ( + + ); +} diff --git a/packages/dispatch/src/components/transactional-email-preview.tsx b/packages/dispatch/src/components/transactional-email-preview.tsx new file mode 100644 index 0000000000..d1e7e24f99 --- /dev/null +++ b/packages/dispatch/src/components/transactional-email-preview.tsx @@ -0,0 +1,88 @@ +import { useActionQuery } from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import { IconAlertTriangle } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; + +import { + fetchEmailPreview, + type EmailPreview, +} from "../client/transactional-emails"; +import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; +import { Skeleton } from "./ui/skeleton"; + +/** + * The "core" app id means the email was read from Dispatch's own local + * catalog (see transactional-email.tsx), not a cross-app fetch, so its + * preview must render the same way — Dispatch always has the definition + * registered locally and a cross-app fetch would have no real appPath to hit. + */ +function useEmailPreviewQuery(appId: string, appPath: string, id: string) { + const isCore = appId === "core"; + const local = useActionQuery( + "render-transactional-email-preview", + { id }, + { enabled: isCore, retry: false }, + ); + const remote = useQuery({ + queryKey: ["transactional-email-preview", appPath, id], + queryFn: () => fetchEmailPreview(appPath, id), + enabled: !isCore, + retry: false, + }); + return isCore ? local : remote; +} + +export function EmailPreviewPane({ + appId, + appPath, + id, + name, +}: { + appId: string; + appPath: string; + id: string; + name: string; +}) { + const t = useT(); + const preview = useEmailPreviewQuery(appId, appPath, id); + + if (preview.isError) { + return ( + + + + {t("dispatch.transactionalEmail.previewFailed")} + + + {preview.error instanceof Error + ? preview.error.message + : String(preview.error)} + + + ); + } + if (preview.isLoading || !preview.data) { + return ; + } + return ( +
+
+
+ {t("dispatch.transactionalEmail.subject")} +
+
+ {preview.data.subject} +
+
+ {/* sandbox="" (no allow-scripts) keeps arbitrary email HTML from + running script in the Dispatch origin. */} +