From c0f9fb8afa66d7de51835ec0fee83752f206cbe3 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 4 Aug 2026 13:19:04 +0000 Subject: [PATCH 01/10] Add email catalog actions and logging infrastructure --- packages/core/package.json | 1 + .../email-catalog/actions/list-email-log.ts | 17 ++ .../actions/list-transactional-emails.ts | 63 +++++++ .../render-transactional-email-preview.ts | 26 +++ packages/core/src/email-catalog/log.ts | 163 ++++++++++++++++++ packages/core/src/email-catalog/registry.ts | 117 +++++++++++++ packages/core/src/email-catalog/schema.ts | 52 ++++++ .../core/src/email-catalog/system-emails.ts | 84 +++++++++ packages/core/src/org/handlers.ts | 9 +- packages/core/src/server/action-discovery.ts | 15 ++ .../core/src/server/better-auth-instance.ts | 22 ++- packages/core/src/server/email.ts | 67 ++++++- packages/core/src/server/index.ts | 8 + 13 files changed, 637 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/email-catalog/actions/list-email-log.ts create mode 100644 packages/core/src/email-catalog/actions/list-transactional-emails.ts create mode 100644 packages/core/src/email-catalog/actions/render-transactional-email-preview.ts create mode 100644 packages/core/src/email-catalog/log.ts create mode 100644 packages/core/src/email-catalog/registry.ts create mode 100644 packages/core/src/email-catalog/schema.ts create mode 100644 packages/core/src/email-catalog/system-emails.ts diff --git a/packages/core/package.json b/packages/core/package.json index 05b49c465f..1c8f9ed95e 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-log.ts b/packages/core/src/email-catalog/actions/list-email-log.ts new file mode 100644 index 0000000000..63f16dca97 --- /dev/null +++ b/packages/core/src/email-catalog/actions/list-email-log.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +import { defineAction } from "../../action.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" }, + run: async ({ templateId, limit }) => ({ + entries: await listEmailLog({ 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..4f31f38ba4 --- /dev/null +++ b/packages/core/src/email-catalog/actions/list-transactional-emails.ts @@ -0,0 +1,63 @@ +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 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 | null = + null; + let statsError: string | null = null; + try { + const stats = await getEmailSendStats(since); + statsById = new Map(stats.map((row) => [row.templateId, row])); + } catch (error) { + statsError = error instanceof Error ? error.message : String(error); + } + + return { + app: getAppSlug() ?? null, + 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, + sender: definition.sender, + 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/log.ts b/packages/core/src/email-catalog/log.ts new file mode 100644 index 0000000000..99358ed4d1 --- /dev/null +++ b/packages/core/src/email-catalog/log.ts @@ -0,0 +1,163 @@ +/** + * 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"; + +import { + EMAIL_LOG_CREATE_SQL, + EMAIL_LOG_TEMPLATE_INDEX_SQL, +} from "./schema.js"; + +let _initPromise: Promise | undefined; + +async function ensureTable(): Promise { + if (!_initPromise) { + _initPromise = (async () => { + // 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, +): 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 template_id IS NOT NULL AND created_at >= ? + GROUP BY template_id`, + args: [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, newest first, optionally filtered to one template. */ +export async function listEmailLog(options?: { + templateId?: string; + limit?: number; +}): Promise { + await ensureTable(); + const limit = Math.min(Math.max(options?.limit ?? 100, 1), 500); + const where = options?.templateId ? `WHERE template_id = ?` : ""; + const args = options?.templateId ? [options.templateId, limit] : [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/registry.ts b/packages/core/src/email-catalog/registry.ts new file mode 100644 index 0000000000..6936a7acb6 --- /dev/null +++ b/packages/core/src/email-catalog/registry.ts @@ -0,0 +1,117 @@ +/** + * 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; + /** Plain-language description of how From and Reply-To are chosen. */ + sender: 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(); + +/** + * 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 = registry.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); + 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(); +} 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..d82ef4c6db --- /dev/null +++ b/packages/core/src/email-catalog/system-emails.ts @@ -0,0 +1,84 @@ +/** + * 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.", + recipient: + "The address typed into the invite form. One email per invited address.", + 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.", + recipient: "The address the account was registered with.", + 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.", + recipient: + "The account address the reset was requested for, never an address supplied in the request body.", + 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/org/handlers.ts b/packages/core/src/org/handlers.ts index 664a6b7fdc..b9093c36b4 100644 --- a/packages/core/src/org/handlers.ts +++ b/packages/core/src/org/handlers.ts @@ -40,6 +40,7 @@ import { getDbExec, isPostgres } from "../db/client.js"; import { ssrfSafeFetch } from "../extensions/url-safety.js"; import { getAppProductionUrl } from "../server/app-url.js"; import { getSession } from "../server/auth.js"; +import { CORE_INVITE_EMAIL_ID } from "../email-catalog/system-emails.js"; import { renderInviteEmail } from "../server/email-templates.js"; import { sendEmail, isEmailConfigured } from "../server/email.js"; import { readBody } from "../server/h3-helpers.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..4d27d8fc53 100644 --- a/packages/core/src/server/action-discovery.ts +++ b/packages/core/src/server/action-discovery.ts @@ -602,6 +602,21 @@ 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"), + ], [ "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 670901aadc..11431fc8ad 100644 --- a/packages/core/src/server/better-auth-instance.ts +++ b/packages/core/src/server/better-auth-instance.ts @@ -58,6 +58,10 @@ import { } from "./attribution.js"; import { resolveAuthCookieNamespace } from "./cookie-namespace.js"; import { getWorkspaceA2ADerivedSecret } from "./derived-secret.js"; +import { + CORE_RESET_PASSWORD_EMAIL_ID, + CORE_VERIFY_SIGNUP_EMAIL_ID, +} from "../email-catalog/system-emails.js"; import { renderResetPasswordEmail, renderVerifySignupEmail, @@ -1226,7 +1230,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: { @@ -1252,7 +1263,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..8e5dc8aae8 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() ?? undefined, + 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() ?? undefined, + 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, From 73254c8b60846c27f807fc9d081b97392ba205b9 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 4 Aug 2026 13:37:14 +0000 Subject: [PATCH 02/10] Add email engagement and activity actions for transactional emails --- packages/dispatch/src/actions/index.ts | 4 + .../src/actions/list-email-activity.ts | 19 ++ .../src/actions/list-email-engagement.ts | 18 ++ .../src/client/transactional-emails.ts | 110 ++++++++ .../src/server/lib/email-provider-metrics.ts | 202 ++++++++++++++ .../analytics/server/lib/dashboard-report.ts | 2 + templates/analytics/server/lib/emails.ts | 49 ++++ .../server/plugins/transactional-emails.ts | 5 + .../calendar/server/lib/booking-emails.ts | 224 ++++++++++----- templates/calendar/server/lib/emails.ts | 155 +++++++++++ .../server/lib/event-guest-notifications.ts | 92 +++++-- .../server/plugins/transactional-emails.ts | 5 + templates/clips/actions/invite-member.ts | 53 +++- templates/clips/server/lib/emails.ts | 256 ++++++++++++++++++ .../lib/transactional-email-templates.test.ts | 1 + .../lib/transactional-email-templates.ts | 2 + .../plugins/transactional-email-catalog.ts | 5 + .../server/lib/comment-notifications.ts | 83 ++++-- templates/content/server/lib/emails.ts | 66 +++++ .../server/plugins/transactional-emails.ts | 5 + templates/forms/server/lib/emails.ts | 50 ++++ templates/forms/server/lib/response-email.ts | 2 + .../server/plugins/transactional-emails.ts | 5 + templates/plan/actions/request-plan-access.ts | 44 ++- .../plan/server/lib/comment-notifications.ts | 79 ++++-- templates/plan/server/lib/emails.ts | 60 ++++ .../server/plugins/transactional-emails.ts | 5 + .../server/lib/comment-notifications.ts | 65 +++-- templates/slides/server/lib/emails.ts | 38 +++ .../server/plugins/transactional-emails.ts | 5 + 30 files changed, 1510 insertions(+), 199 deletions(-) create mode 100644 packages/dispatch/src/actions/list-email-activity.ts create mode 100644 packages/dispatch/src/actions/list-email-engagement.ts create mode 100644 packages/dispatch/src/client/transactional-emails.ts create mode 100644 packages/dispatch/src/server/lib/email-provider-metrics.ts create mode 100644 templates/analytics/server/lib/emails.ts create mode 100644 templates/analytics/server/plugins/transactional-emails.ts create mode 100644 templates/calendar/server/lib/emails.ts create mode 100644 templates/calendar/server/plugins/transactional-emails.ts create mode 100644 templates/clips/server/lib/emails.ts create mode 100644 templates/clips/server/plugins/transactional-email-catalog.ts create mode 100644 templates/content/server/lib/emails.ts create mode 100644 templates/content/server/plugins/transactional-emails.ts create mode 100644 templates/forms/server/lib/emails.ts create mode 100644 templates/forms/server/plugins/transactional-emails.ts create mode 100644 templates/plan/server/lib/emails.ts create mode 100644 templates/plan/server/plugins/transactional-emails.ts create mode 100644 templates/slides/server/lib/emails.ts create mode 100644 templates/slides/server/plugins/transactional-emails.ts diff --git a/packages/dispatch/src/actions/index.ts b/packages/dispatch/src/actions/index.ts index 01e22e26f5..32db83e901 100644 --- a/packages/dispatch/src/actions/index.ts +++ b/packages/dispatch/src/actions/index.ts @@ -37,6 +37,8 @@ import listAvailableWorkspaceTemplates from "./list-available-workspace-template import listConnectedAgents from "./list-connected-agents.js"; import listCuratedWorkspaceTemplates from "./list-curated-workspace-templates.js"; import listDestinations from "./list-destinations.js"; +import listEmailActivity from "./list-email-activity.js"; +import listEmailEngagement from "./list-email-engagement.js"; import listDispatchApprovals from "./list-dispatch-approvals.js"; import listDispatchAudit from "./list-dispatch-audit.js"; import listDispatchOverview from "./list-dispatch-overview.js"; @@ -139,6 +141,8 @@ export const dispatchActions: Record = { "list-curated-workspace-templates": listCuratedWorkspaceTemplates, "list-connected-agents": listConnectedAgents, "list-destinations": listDestinations, + "list-email-activity": listEmailActivity, + "list-email-engagement": listEmailEngagement, "list-dispatch-approvals": listDispatchApprovals, "list-dispatch-audit": listDispatchAudit, "list-dispatch-overview": listDispatchOverview, diff --git a/packages/dispatch/src/actions/list-email-activity.ts b/packages/dispatch/src/actions/list-email-activity.ts new file mode 100644 index 0000000000..bd366d9f48 --- /dev/null +++ b/packages/dispatch/src/actions/list-email-activity.ts @@ -0,0 +1,19 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; + +import { fetchEmailActivity } from "../server/lib/email-provider-metrics.js"; + +export default defineAction({ + description: + "List recent per-message email activity from the provider (recipient, subject, delivery status, opens, clicks), optionally scoped to one registered email id. The provider's feed has a short retention window, so an empty result does not mean nothing was sent.", + schema: z.object({ + templateId: z + .string() + .optional() + .describe("Registered email id to scope the feed to."), + limit: z.coerce.number().int().min(1).max(1000).default(50), + }), + http: { method: "GET" }, + run: async ({ templateId, limit }) => + fetchEmailActivity({ templateId, limit }), +}); diff --git a/packages/dispatch/src/actions/list-email-engagement.ts b/packages/dispatch/src/actions/list-email-engagement.ts new file mode 100644 index 0000000000..f88310ef57 --- /dev/null +++ b/packages/dispatch/src/actions/list-email-engagement.ts @@ -0,0 +1,18 @@ +import { defineAction } from "@agent-native/core"; +import { z } from "zod"; + +import { fetchEmailEngagement } from "../server/lib/email-provider-metrics.js"; + +export default defineAction({ + description: + "Read delivered, unique-open and unique-click totals for transactional emails from the email provider, keyed by registered email id. Returns availability separately from the numbers so an unconfigured or failing provider is never reported as zero engagement.", + schema: z.object({ + templateIds: z + .array(z.string()) + .describe("Registered email ids to report on."), + windowDays: z.coerce.number().int().min(1).max(365).default(30), + }), + http: { method: "POST" }, + run: async ({ templateIds, windowDays }) => + fetchEmailEngagement(templateIds, windowDays), +}); diff --git a/packages/dispatch/src/client/transactional-emails.ts b/packages/dispatch/src/client/transactional-emails.ts new file mode 100644 index 0000000000..942e80b55d --- /dev/null +++ b/packages/dispatch/src/client/transactional-emails.ts @@ -0,0 +1,110 @@ +/** + * 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; + sender: 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; +} + +function actionUrl(appPath: string, action: string, query = ""): string { + const base = appPath.replace(/\/$/, ""); + return `${base}/_agent-native/actions/${action}${query}`; +} + +/** + * 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. + */ +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" } }, + ); + 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/server/lib/email-provider-metrics.ts b/packages/dispatch/src/server/lib/email-provider-metrics.ts new file mode 100644 index 0000000000..f552e75bb3 --- /dev/null +++ b/packages/dispatch/src/server/lib/email-provider-metrics.ts @@ -0,0 +1,202 @@ +/** + * Engagement metrics and activity for transactional emails, read live from the + * email provider. + * + * Opens and clicks are only known to the provider, so they are read on demand + * rather than mirrored into our own tables — a mirrored open count goes stale + * the moment someone opens an old message. Our `email_log` remains the source + * of truth for what we *sent*; this module only answers what happened to it + * afterwards. + * + * Attribution depends on `sendEmail` tagging each message with its registered + * email id as a SendGrid category. Messages sent before that tagging existed, + * or sent by anything else sharing the account, are not attributable to an + * email in the catalog and are deliberately not counted toward one. + */ + +import { resolveSecret } from "@agent-native/core/server"; + +const SENDGRID_API = "https://api.sendgrid.com/v3"; + +/** + * Distinguishes "the provider isn't configured" from "the provider says zero". + * Callers must render these differently — showing 0% open rate for an + * unconfigured provider invents a fact. + */ +export type ProviderMetricsResult = + | { available: true; data: T } + | { available: false; reason: string }; + +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; +} + +async function sendgridKey(): Promise { + return resolveSecret("SENDGRID_API_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(() => ""); + throw new Error(`SendGrid ${res.status} on ${path}: ${body.slice(0, 300)}`); + } + return res.json(); +} + +/** + * Per-email delivered / open / click totals over the window, keyed by the + * registered email id. + */ +export async function fetchEmailEngagement( + templateIds: string[], + windowDays: number, +): Promise> { + if (!templateIds.length) return { available: true, data: [] }; + + const key = await sendgridKey(); + if (!key) { + return { + available: false, + reason: + "SENDGRID_API_KEY is not configured, so open and click rates cannot be read.", + }; + } + + const end = Date.now(); + const start = end - windowDays * 24 * 60 * 60 * 1000; + + try { + // SendGrid caps categories per request; chunk rather than silently + // truncating the catalog. + const chunks: string[][] = []; + for (let i = 0; i < templateIds.length; i += 10) { + chunks.push(templateIds.slice(i, i + 10)); + } + + const totals = new Map(); + for (const chunk of chunks) { + const payload = (await sendgridGet(key, "/categories/stats", [ + ["start_date", isoDate(start)], + ["end_date", isoDate(end)], + ["aggregated_by", "day"], + ...chunk.map((id): [string, string] => ["categories[]", id]), + ])) as Array<{ + stats?: Array<{ + name?: string; + metrics?: Record; + }>; + }>; + + for (const day of payload ?? []) { + for (const entry of day.stats ?? []) { + const name = entry.name; + if (!name) continue; + const metrics = entry.metrics ?? {}; + const current = totals.get(name) ?? { + templateId: name, + delivered: 0, + uniqueOpens: 0, + uniqueClicks: 0, + openRate: null, + }; + current.delivered += Number(metrics.delivered ?? 0); + current.uniqueOpens += Number(metrics.unique_opens ?? 0); + current.uniqueClicks += Number(metrics.unique_clicks ?? 0); + totals.set(name, current); + } + } + } + + const data = [...totals.values()].map((entry) => ({ + ...entry, + openRate: + entry.delivered > 0 ? entry.uniqueOpens / entry.delivered : null, + })); + 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; +} + +/** + * Recent provider-side activity, newest first. SendGrid's Email Activity feed + * only retains three days without the extended-retention add-on, so an empty + * result here does not mean nothing was ever sent — `email_log` covers that. + */ +export async function fetchEmailActivity(options: { + templateId?: string; + limit?: number; +}): Promise> { + const key = await sendgridKey(); + if (!key) { + return { + available: false, + reason: + "SENDGRID_API_KEY is not configured, so the provider activity feed cannot be read.", + }; + } + + const limit = Math.min(Math.max(options.limit ?? 50, 1), 1000); + const params: Array<[string, string]> = [["limit", String(limit)]]; + if (options.templateId) { + // Category equality is the only way to scope the feed to one email; the + // id is ours and contains no quotes, but escape anyway so a future id + // cannot break out of the query literal. + const safe = options.templateId.replace(/["\\]/g, ""); + params.push(["query", `category="${safe}"`]); + } + + try { + const payload = (await sendgridGet(key, "/messages", params)) as { + messages?: Array>; + }; + const data = (payload.messages ?? []).map((message) => ({ + msgId: String(message.msg_id ?? ""), + toEmail: String(message.to_email ?? ""), + fromEmail: String(message.from_email ?? ""), + subject: String(message.subject ?? ""), + status: String(message.status ?? ""), + opensCount: Number(message.opens_count ?? 0), + clicksCount: Number(message.clicks_count ?? 0), + lastEventTime: String(message.last_event_time ?? ""), + })); + return { available: true, data }; + } catch (error) { + return { + available: false, + reason: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/templates/analytics/server/lib/dashboard-report.ts b/templates/analytics/server/lib/dashboard-report.ts index ef144d7734..03a58f616b 100644 --- a/templates/analytics/server/lib/dashboard-report.ts +++ b/templates/analytics/server/lib/dashboard-report.ts @@ -18,6 +18,7 @@ import { type DashboardReportCaptureOutcome, type DashboardReportSubscription, } from "./dashboard-report-subscriptions"; +import { ANALYTICS_DASHBOARD_REPORT_EMAIL_ID } from "./emails"; const DATE_FILTER_TYPES: ReadonlySet = new Set([ "date", @@ -326,6 +327,7 @@ export async function sendDashboardReportSubscription( ? { attachments: rendered.attachments } : {}), timeoutMs: emailTimeoutMs, + templateId: ANALYTICS_DASHBOARD_REPORT_EMAIL_ID, }); } diff --git a/templates/analytics/server/lib/emails.ts b/templates/analytics/server/lib/emails.ts new file mode 100644 index 0000000000..1ff1bde1a0 --- /dev/null +++ b/templates/analytics/server/lib/emails.ts @@ -0,0 +1,49 @@ +/** + * Catalog entries for the transactional emails Analytics sends. + * + * Registered from `server/plugins/transactional-emails.ts` so Dispatch can list + * and preview them without the app having sent anything yet. + */ + +import { defineTransactionalEmail } from "@agent-native/core/email-catalog"; +import { emailStrong, renderEmail } from "@agent-native/core/server"; + +export const ANALYTICS_DASHBOARD_REPORT_EMAIL_ID = "analytics.dashboard-report"; + +let registered = false; + +export function registerAnalyticsEmails(): void { + if (registered) return; + registered = true; + + defineTransactionalEmail({ + id: ANALYTICS_DASHBOARD_REPORT_EMAIL_ID, + name: "Scheduled dashboard report", + trigger: + "A dashboard report subscription comes due and at least one panel produced usable data. A run where every queried panel failed throws instead of mailing, and a degraded run is skipped when the subscription asks for complete reports only.", + recipient: + "The normalized recipient list stored on the subscription. One email per address, all carrying the same rendered snapshot.", + sender: + "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`.", + // The real renderer runs every panel query and rasterizes charts, so it + // cannot back a preview that must stay offline. This shows the frame a + // recipient sees; the panel body is whatever that run produced. + preview: () => ({ + subject: "Daily dashboard: Growth overview — 3/4/2025", + ...renderEmail({ + preheader: "Your daily Growth overview report is ready.", + heading: "Daily dashboard: Growth overview", + paragraphs: [ + `This report renders each panel of ${emailStrong("Growth overview")} as of the run time, with charts drawn server-side and attached inline.`, + "Panels that could not be queried are called out in place rather than dropped, so a partial report never reads as a complete one.", + ], + cta: { + label: "Open dashboard", + url: "https://example.com/dashboards/dash_sample", + }, + footer: + "You received this because you are on the recipient list for this dashboard report.", + }), + }), + }); +} diff --git a/templates/analytics/server/plugins/transactional-emails.ts b/templates/analytics/server/plugins/transactional-emails.ts new file mode 100644 index 0000000000..e689d4c56e --- /dev/null +++ b/templates/analytics/server/plugins/transactional-emails.ts @@ -0,0 +1,5 @@ +import { registerAnalyticsEmails } from "../lib/emails"; + +export default function registerTransactionalEmails(): void { + registerAnalyticsEmails(); +} diff --git a/templates/calendar/server/lib/booking-emails.ts b/templates/calendar/server/lib/booking-emails.ts index 7c81d1ee6f..38ff844412 100644 --- a/templates/calendar/server/lib/booking-emails.ts +++ b/templates/calendar/server/lib/booking-emails.ts @@ -11,6 +11,12 @@ import { DEFAULT_BOOKING_TIMEZONE, safeBookingTimeZone, } from "./booking-timezone.js"; +import { + CALENDAR_BOOKING_CANCELLED_EMAIL_ID, + CALENDAR_BOOKING_CANCELLED_HOST_EMAIL_ID, + CALENDAR_BOOKING_CONFIRMED_EMAIL_ID, + CALENDAR_BOOKING_RECEIVED_EMAIL_ID, +} from "./emails.js"; function stripCrlf(value: string | undefined): string { return (value ?? "").replace(/[\r\n]+/g, " ").trim(); @@ -57,6 +63,128 @@ async function sendBestEffort( } } +export function renderBookingConfirmedEmail({ + title, + when, + host, + manageUrl, + meetingLink, +}: { + title: string; + when: string; + host: string; + manageUrl: string; + meetingLink?: string | null; +}) { + const paragraphs = [ + `You're booked for ${emailStrong(title)} with ${emailStrong(host)}.`, + `Time: ${emailStrong(when)}.`, + ]; + if (meetingLink) { + paragraphs.push(`Meeting link: ${emailLink("Join meeting", meetingLink)}.`); + } + + return { + subject: `Confirmed: ${title}`, + ...renderEmail({ + preheader: `You're booked for ${title} on ${when}.`, + heading: "Your meeting is booked", + paragraphs, + cta: { label: "Manage booking", url: manageUrl }, + footer: + "Use the manage link if you need to cancel or reschedule this meeting.", + }), + }; +} + +export function renderBookingReceivedEmail({ + title, + when, + attendeeName, + attendee, + manageUrl, + meetingLink, +}: { + title: string; + when: string; + attendeeName: string; + attendee: string; + manageUrl: string; + meetingLink?: string | null; +}) { + return { + subject: `New booking: ${title}`, + ...renderEmail({ + preheader: `${attendeeName} booked ${title} on ${when}.`, + heading: "New booking", + paragraphs: [ + `${emailStrong(attendeeName)} booked ${emailStrong(title)}.`, + `Time: ${emailStrong(when)}.`, + `Guest: ${emailStrong(attendee)}.`, + ...(meetingLink + ? [`Meeting link: ${emailLink("Join meeting", meetingLink)}.`] + : []), + ], + cta: { label: "View booking", url: manageUrl }, + footer: "This booking was created from your calendar booking link.", + }), + }; +} + +export function renderBookingCancelledEmail({ + title, + when, + host, + bookAgainUrl, +}: { + title: string; + when: string; + host: string; + bookAgainUrl?: string; +}) { + return { + subject: `Cancelled: ${title}`, + ...renderEmail({ + preheader: `${title} on ${when} was cancelled.`, + heading: "Your meeting was cancelled", + paragraphs: [ + `${emailStrong(title)} with ${emailStrong(host || "the host")} has been cancelled.`, + `Original time: ${emailStrong(when)}.`, + ], + cta: bookAgainUrl + ? { label: "Book another time", url: bookAgainUrl } + : undefined, + footer: "If this was unexpected, contact the meeting host.", + }), + }; +} + +export function renderBookingCancelledHostEmail({ + title, + when, + attendeeName, + attendee, +}: { + title: string; + when: string; + attendeeName: string; + attendee: string; +}) { + return { + subject: `Cancelled booking: ${title}`, + ...renderEmail({ + preheader: `${attendeeName}'s booking for ${title} was cancelled.`, + heading: "Booking cancelled", + paragraphs: [ + `${emailStrong(attendeeName)}'s booking for ${emailStrong(title)} was cancelled.`, + `Original time: ${emailStrong(when)}.`, + `Guest: ${emailStrong(attendee)}.`, + ], + footer: "No further action is needed.", + }), + }; +} + export async function sendBookingConfirmationEmails({ booking, hostEmail, @@ -74,54 +202,31 @@ export async function sendBookingConfirmationEmails({ const attendee = stripCrlf(booking.email); const attendeeName = stripCrlf(booking.name) || "there"; - const attendeeParagraphs = [ - `You're booked for ${emailStrong(title)} with ${emailStrong(host)}.`, - `Time: ${emailStrong(when)}.`, - ]; - if (booking.meetingLink) { - attendeeParagraphs.push( - `Meeting link: ${emailLink("Join meeting", booking.meetingLink)}.`, - ); - } - - const attendeeEmail = renderEmail({ - preheader: `You're booked for ${title} on ${when}.`, - heading: "Your meeting is booked", - paragraphs: attendeeParagraphs, - cta: { label: "Manage booking", url: manageUrl }, - footer: - "Use the manage link if you need to cancel or reschedule this meeting.", - }); - await sendBestEffort("attendee confirmation", { to: attendee, - subject: `Confirmed: ${title}`, - html: attendeeEmail.html, - text: attendeeEmail.text, + ...renderBookingConfirmedEmail({ + title, + when, + host, + manageUrl, + meetingLink: booking.meetingLink, + }), replyTo: host, - }); - - const hostEmailMessage = renderEmail({ - preheader: `${attendeeName} booked ${title} on ${when}.`, - heading: "New booking", - paragraphs: [ - `${emailStrong(attendeeName)} booked ${emailStrong(title)}.`, - `Time: ${emailStrong(when)}.`, - `Guest: ${emailStrong(attendee)}.`, - ...(booking.meetingLink - ? [`Meeting link: ${emailLink("Join meeting", booking.meetingLink)}.`] - : []), - ], - cta: { label: "View booking", url: manageUrl }, - footer: "This booking was created from your calendar booking link.", + templateId: CALENDAR_BOOKING_CONFIRMED_EMAIL_ID, }); await sendBestEffort("host notification", { to: host, - subject: `New booking: ${title}`, - html: hostEmailMessage.html, - text: hostEmailMessage.text, + ...renderBookingReceivedEmail({ + title, + when, + attendeeName, + attendee, + manageUrl, + meetingLink: booking.meetingLink, + }), replyTo: attendee, + templateId: CALENDAR_BOOKING_RECEIVED_EMAIL_ID, }); } @@ -142,45 +247,24 @@ export async function sendBookingCancellationEmails({ const attendee = stripCrlf(booking.email); const attendeeName = stripCrlf(booking.name) || "The guest"; - const attendeeEmail = renderEmail({ - preheader: `${title} on ${when} was cancelled.`, - heading: "Your meeting was cancelled", - paragraphs: [ - `${emailStrong(title)} with ${emailStrong(host || "the host")} has been cancelled.`, - `Original time: ${emailStrong(when)}.`, - ], - cta: bookAgainUrl - ? { label: "Book another time", url: bookAgainUrl } - : undefined, - footer: "If this was unexpected, contact the meeting host.", - }); - await sendBestEffort("attendee cancellation", { to: attendee, - subject: `Cancelled: ${title}`, - html: attendeeEmail.html, - text: attendeeEmail.text, + ...renderBookingCancelledEmail({ title, when, host, bookAgainUrl }), replyTo: host || undefined, + templateId: CALENDAR_BOOKING_CANCELLED_EMAIL_ID, }); if (!host) return; - const hostEmailMessage = renderEmail({ - preheader: `${attendeeName}'s booking for ${title} was cancelled.`, - heading: "Booking cancelled", - paragraphs: [ - `${emailStrong(attendeeName)}'s booking for ${emailStrong(title)} was cancelled.`, - `Original time: ${emailStrong(when)}.`, - `Guest: ${emailStrong(attendee)}.`, - ], - footer: "No further action is needed.", - }); - await sendBestEffort("host cancellation notification", { to: host, - subject: `Cancelled booking: ${title}`, - html: hostEmailMessage.html, - text: hostEmailMessage.text, + ...renderBookingCancelledHostEmail({ + title, + when, + attendeeName, + attendee, + }), replyTo: attendee, + templateId: CALENDAR_BOOKING_CANCELLED_HOST_EMAIL_ID, }); } diff --git a/templates/calendar/server/lib/emails.ts b/templates/calendar/server/lib/emails.ts new file mode 100644 index 0000000000..02c5ac7c16 --- /dev/null +++ b/templates/calendar/server/lib/emails.ts @@ -0,0 +1,155 @@ +/** + * Catalog entries for the transactional emails Calendar sends. + * + * Registered from `server/plugins/transactional-emails.ts` so Dispatch can list + * and preview them without the app having sent anything yet. + */ + +import { defineTransactionalEmail } from "@agent-native/core/email-catalog"; + +import { + renderBookingCancelledEmail, + renderBookingCancelledHostEmail, + renderBookingConfirmedEmail, + renderBookingReceivedEmail, +} from "./booking-emails.js"; +import { renderEventGuestNote } from "./event-guest-notifications.js"; + +/** Obviously-fake sample data — these render in a preview pane, never send. */ +const SAMPLE_TITLE = "Intro call"; +const SAMPLE_WHEN = "Tuesday, March 4, 2025, 10:00 AM - 10:30 AM PST"; +const SAMPLE_HOST = "dana.hill@example.com"; +const SAMPLE_GUEST = "sam.rivera@example.com"; +const SAMPLE_GUEST_NAME = "Sam Rivera"; +const SAMPLE_MANAGE_URL = "https://example.com/book/dana/manage/sample-token"; +const SAMPLE_BOOK_AGAIN_URL = "https://example.com/book/dana/intro-call"; +const SAMPLE_MEETING_LINK = "https://meet.example.com/sample-intro-call"; + +export const CALENDAR_BOOKING_CONFIRMED_EMAIL_ID = "calendar.booking-confirmed"; +export const CALENDAR_BOOKING_RECEIVED_EMAIL_ID = "calendar.booking-received"; +export const CALENDAR_BOOKING_CANCELLED_EMAIL_ID = "calendar.booking-cancelled"; +export const CALENDAR_BOOKING_CANCELLED_HOST_EMAIL_ID = + "calendar.booking-cancelled-host"; +export const CALENDAR_EVENT_UPDATE_NOTE_EMAIL_ID = "calendar.event-update-note"; +export const CALENDAR_EVENT_CANCELLATION_NOTE_EMAIL_ID = + "calendar.event-cancellation-note"; + +let registered = false; + +export function registerCalendarEmails(): void { + if (registered) return; + registered = true; + + defineTransactionalEmail({ + id: CALENDAR_BOOKING_CONFIRMED_EMAIL_ID, + name: "Booking confirmed (guest)", + trigger: + "A guest completes a booking on a public `/book/{username}/{slug}` page and the booking row is created.", + recipient: + "The email address the guest typed into the booking form (`booking.email`).", + sender: + "The configured EMAIL_FROM, with reply-to set to the booking link owner's address.", + preview: () => + renderBookingConfirmedEmail({ + title: SAMPLE_TITLE, + when: SAMPLE_WHEN, + host: SAMPLE_HOST, + manageUrl: SAMPLE_MANAGE_URL, + meetingLink: SAMPLE_MEETING_LINK, + }), + }); + + defineTransactionalEmail({ + id: CALENDAR_BOOKING_RECEIVED_EMAIL_ID, + name: "New booking (host)", + trigger: + "Sent alongside the guest confirmation, immediately after a public booking is created.", + recipient: + "The owner of the booking link, looked up from the link's slug at send time.", + sender: + "The configured EMAIL_FROM, with reply-to set to the guest so the host can reply directly.", + preview: () => + renderBookingReceivedEmail({ + title: SAMPLE_TITLE, + when: SAMPLE_WHEN, + attendeeName: SAMPLE_GUEST_NAME, + attendee: SAMPLE_GUEST, + manageUrl: SAMPLE_MANAGE_URL, + meetingLink: SAMPLE_MEETING_LINK, + }), + }); + + defineTransactionalEmail({ + id: CALENDAR_BOOKING_CANCELLED_EMAIL_ID, + name: "Booking cancelled (guest)", + trigger: + "A booking that was not already cancelled is cancelled, either by the guest through the manage/cancel token link or by someone with access to the booking link.", + recipient: "The guest address stored on the booking row.", + sender: + "The configured EMAIL_FROM, with reply-to set to the host when a host address could be resolved from the link slug.", + preview: () => + renderBookingCancelledEmail({ + title: SAMPLE_TITLE, + when: SAMPLE_WHEN, + host: SAMPLE_HOST, + bookAgainUrl: SAMPLE_BOOK_AGAIN_URL, + }), + }); + + defineTransactionalEmail({ + id: CALENDAR_BOOKING_CANCELLED_HOST_EMAIL_ID, + name: "Booking cancelled (host)", + trigger: + "Sent after the guest cancellation notice, and only when a host address could be resolved from the booking link slug.", + recipient: "The booking link owner's address.", + sender: + "The configured EMAIL_FROM, with reply-to set to the guest who was booked.", + preview: () => + renderBookingCancelledHostEmail({ + title: SAMPLE_TITLE, + when: SAMPLE_WHEN, + attendeeName: SAMPLE_GUEST_NAME, + attendee: SAMPLE_GUEST, + }), + }); + + defineTransactionalEmail({ + id: CALENDAR_EVENT_UPDATE_NOTE_EMAIL_ID, + name: "Event update note", + trigger: + "`update-event` runs with a non-empty guest notification message. Google Calendar still sends its own update invite; this carries only the organizer's note.", + recipient: + "Every attendee on the event with a syntactically valid address, excluding the organizer's own `self` attendee row. One email per address.", + sender: + "The configured EMAIL_FROM, with reply-to set to the organizer running the update.", + preview: () => + renderEventGuestNote({ + title: "Design review", + organizer: "Dana Hill", + message: "Moving this an hour later so the whole team can join.", + when: SAMPLE_WHEN, + kind: "update", + htmlLink: "https://calendar.example.com/event/sample", + }), + }); + + defineTransactionalEmail({ + id: CALENDAR_EVENT_CANCELLATION_NOTE_EMAIL_ID, + name: "Event cancellation note", + trigger: + "`delete-event` runs with guest notification requested and a non-empty message. Google Calendar sends the cancellation itself; this carries only the organizer's note.", + recipient: + "Every attendee on the deleted event with a syntactically valid address, excluding the organizer's own `self` attendee row. One email per address.", + sender: + "The configured EMAIL_FROM, with reply-to set to the organizer running the deletion.", + preview: () => + renderEventGuestNote({ + title: "Design review", + organizer: "Dana Hill", + message: "Cancelling this week — we will pick it up after the launch.", + when: SAMPLE_WHEN, + kind: "cancellation", + appliesTo: "all events in the series", + }), + }); +} diff --git a/templates/calendar/server/lib/event-guest-notifications.ts b/templates/calendar/server/lib/event-guest-notifications.ts index 56bfda4277..2bd72d83e1 100644 --- a/templates/calendar/server/lib/event-guest-notifications.ts +++ b/templates/calendar/server/lib/event-guest-notifications.ts @@ -6,6 +6,10 @@ import { } from "@agent-native/core/server"; import type { CalendarEvent, DeleteEventScope } from "../../shared/api.js"; +import { + CALENDAR_EVENT_CANCELLATION_NOTE_EMAIL_ID, + CALENDAR_EVENT_UPDATE_NOTE_EMAIL_ID, +} from "./emails.js"; export interface GuestNotificationResult { requested: boolean; @@ -96,6 +100,53 @@ function scopeLabel(scope: DeleteEventScope | undefined): string | undefined { return "this and following events"; } +export function renderEventGuestNote({ + title, + organizer, + message, + when, + kind, + appliesTo, + htmlLink, +}: { + title: string; + organizer: string; + message: string; + when: string; + kind: GuestNotificationKind; + appliesTo?: string; + htmlLink?: string | null; +}) { + const heading = + kind === "cancellation" ? "Event cancellation note" : "Event update note"; + const subjectPrefix = + kind === "cancellation" ? "Cancellation note" : "Update note"; + const eventAction = kind === "cancellation" ? "cancelling" : "updating"; + const paragraphs = [ + `${emailStrong(organizer)} added this note while ${eventAction} ${emailStrong(title)}.`, + messageParagraph(message), + `When: ${emailStrong(when)}.`, + ]; + if (appliesTo) { + paragraphs.push(`Applies to: ${emailStrong(appliesTo)}.`); + } + + return { + subject: `${subjectPrefix}: ${title}`, + ...renderEmail({ + preheader: `${subjectPrefix}: ${title}`, + heading, + paragraphs, + cta: + kind === "update" && htmlLink + ? { label: "Open in Google Calendar", url: htmlLink } + : undefined, + footer: + "Google Calendar sends the calendar update separately. This message carries the organizer note.", + }), + }; +} + export async function sendEventGuestNotificationNote({ event, organizerEmail, @@ -138,33 +189,14 @@ export async function sendEventGuestNotificationNote({ }; } - const title = stripCrlf(event.title) || "Calendar event"; - const organizer = stripCrlf(event.organizer?.displayName) || organizerEmail; - const heading = - kind === "cancellation" ? "Event cancellation note" : "Event update note"; - const subjectPrefix = - kind === "cancellation" ? "Cancellation note" : "Update note"; - const eventAction = kind === "cancellation" ? "cancelling" : "updating"; - const paragraphs = [ - `${emailStrong(organizer)} added this note while ${eventAction} ${emailStrong(title)}.`, - messageParagraph(normalizedMessage), - `When: ${emailStrong(formatWhen(event))}.`, - ]; - const appliesTo = scopeLabel(scope); - if (appliesTo) { - paragraphs.push(`Applies to: ${emailStrong(appliesTo)}.`); - } - - const rendered = renderEmail({ - preheader: `${subjectPrefix}: ${title}`, - heading, - paragraphs, - cta: - kind === "update" && event.htmlLink - ? { label: "Open in Google Calendar", url: event.htmlLink } - : undefined, - footer: - "Google Calendar sends the calendar update separately. This message carries the organizer note.", + const rendered = renderEventGuestNote({ + title: stripCrlf(event.title) || "Calendar event", + organizer: stripCrlf(event.organizer?.displayName) || organizerEmail, + message: normalizedMessage, + when: formatWhen(event), + kind, + appliesTo: scopeLabel(scope), + htmlLink: event.htmlLink, }); let sentCount = 0; @@ -174,10 +206,14 @@ export async function sendEventGuestNotificationNote({ try { await sendEmail({ to, - subject: `${subjectPrefix}: ${title}`, + subject: rendered.subject, html: rendered.html, text: rendered.text, replyTo: organizerEmail, + templateId: + kind === "cancellation" + ? CALENDAR_EVENT_CANCELLATION_NOTE_EMAIL_ID + : CALENDAR_EVENT_UPDATE_NOTE_EMAIL_ID, }); sentCount += 1; } catch (error) { diff --git a/templates/calendar/server/plugins/transactional-emails.ts b/templates/calendar/server/plugins/transactional-emails.ts new file mode 100644 index 0000000000..3c0763fd29 --- /dev/null +++ b/templates/calendar/server/plugins/transactional-emails.ts @@ -0,0 +1,5 @@ +import { registerCalendarEmails } from "../lib/emails.js"; + +export default function registerTransactionalEmails(): void { + registerCalendarEmails(); +} diff --git a/templates/clips/actions/invite-member.ts b/templates/clips/actions/invite-member.ts index 98a31af403..df3acde9c5 100644 --- a/templates/clips/actions/invite-member.ts +++ b/templates/clips/actions/invite-member.ts @@ -24,6 +24,7 @@ import { and, eq, sql } from "drizzle-orm"; import { z } from "zod"; import { getDb } from "../server/db/index.js"; +import { CLIPS_ORGANIZATION_INVITE_EMAIL_ID } from "../server/lib/emails.js"; import { getCurrentOwnerEmail, nanoid, @@ -67,6 +68,35 @@ async function fetchOrgName(orgId: string): Promise { return row?.name ?? "Organization"; } +export function renderClipsInviteEmail({ + appName, + orgName, + inviter, + role, + inviteUrl, +}: { + appName: string; + orgName: string; + inviter: string; + role: "admin" | "member"; + inviteUrl: string; +}) { + return { + subject: `You're invited to ${orgName} on ${appName}`, + ...renderEmail({ + brandName: appName, + preheader: `${inviter} invited you to ${orgName} on ${appName}.`, + heading: `You're invited to join ${orgName}`, + paragraphs: [ + `${emailStrong(inviter)} invited you to the ${emailStrong(orgName)} organization on ${emailStrong(appName)} as ${emailStrong(role)}.`, + `Click the button below to accept the invite and start collaborating.`, + ], + cta: { label: "Accept invite", url: inviteUrl }, + brandColor: "#18181B", + }), + }; +} + export default defineAction({ description: "Invite someone to the active organization by email. Creates a pending invitation. Role 'admin' maps to admin; all other Clips roles collapse to 'member'. Sends an email when a provider is configured.", @@ -123,24 +153,17 @@ export default defineAction({ const orgName = await fetchOrgName(organizationId); const inviteUrl = `${baseUrl()}/invite/${token}`; - const appName = getAppName(); - const { html, text } = renderEmail({ - brandName: appName, - preheader: `${inviter} invited you to ${orgName} on ${appName}.`, - heading: `You're invited to join ${orgName}`, - paragraphs: [ - `${emailStrong(inviter)} invited you to the ${emailStrong(orgName)} organization on ${emailStrong(appName)} as ${emailStrong(role)}.`, - `Click the button below to accept the invite and start collaborating.`, - ], - cta: { label: "Accept invite", url: inviteUrl }, - brandColor: "#18181B", - }); try { await sendEmail({ + ...renderClipsInviteEmail({ + appName: getAppName(), + orgName, + inviter, + role, + inviteUrl, + }), to: args.email, - subject: `You're invited to ${orgName} on ${appName}`, - html, - text, + templateId: CLIPS_ORGANIZATION_INVITE_EMAIL_ID, }); } catch (err) { console.warn("[invite-member] email send failed:", err); diff --git a/templates/clips/server/lib/emails.ts b/templates/clips/server/lib/emails.ts new file mode 100644 index 0000000000..eb4e0b451d --- /dev/null +++ b/templates/clips/server/lib/emails.ts @@ -0,0 +1,256 @@ +/** + * Catalog entries for the transactional emails Clips sends. + * + * Registered from `server/plugins/transactional-emails.ts` so Dispatch can list + * and preview them without the app having sent anything yet. + * + * Every entry except the organization invite renders through + * `renderClipsTransactionalEmail`, so a preview shows the real template rather + * than a copy of it. + */ + +import { defineTransactionalEmail } from "@agent-native/core/email-catalog"; + +import { renderClipsInviteEmail } from "../../actions/invite-member.js"; +import { + renderClipsTransactionalEmail, + type ClipsTransactionalEmailInput, + type ClipsTransactionalEmailRenderOptions, +} from "./transactional-email-templates.js"; + +/** Obviously-fake sample data — these render in a preview pane, never send. */ +const PREVIEW_OPTIONS: ClipsTransactionalEmailRenderOptions = { + appUrl: "https://example.com", +}; +const SAMPLE_RECORDING_ID = "rec_sample"; +const SAMPLE_TITLE = "Onboarding walkthrough"; +const SAMPLE_TO = "sam.rivera@example.com"; + +function preview(input: ClipsTransactionalEmailInput) { + return renderClipsTransactionalEmail(input, PREVIEW_OPTIONS); +} + +export const CLIPS_FIRST_VIEW_EMAIL_ID = "clips.first-view"; +export const CLIPS_UNVIEWED_REMINDER_EMAIL_ID = "clips.unviewed-reminder"; +export const CLIPS_FIRST_AGENT_VIEW_EMAIL_ID = "clips.first-agent-view"; +export const CLIPS_FIRST_IMPORT_EMAIL_ID = "clips.first-import"; +export const CLIPS_MONTHLY_RECAP_EMAIL_ID = "clips.monthly-recap"; +export const CLIPS_TWO_CLIPS_EMAIL_ID = "clips.two-clips"; +export const CLIPS_ACTIVITY_COMMENT_EMAIL_ID = "clips.activity-comment"; +export const CLIPS_ACTIVITY_REACTION_EMAIL_ID = "clips.activity-reaction"; +export const CLIPS_ORGANIZATION_INVITE_EMAIL_ID = "clips.organization-invite"; + +/** + * Every kind the shared Clips sender can render, so `sendEmail` tags the + * message without each caller repeating the mapping. + */ +export const CLIPS_EMAIL_ID_BY_KIND: Record< + ClipsTransactionalEmailInput["kind"], + string +> = { + "first-view": CLIPS_FIRST_VIEW_EMAIL_ID, + "unviewed-reminder": CLIPS_UNVIEWED_REMINDER_EMAIL_ID, + "first-agent-view": CLIPS_FIRST_AGENT_VIEW_EMAIL_ID, + "first-import": CLIPS_FIRST_IMPORT_EMAIL_ID, + "monthly-recap": CLIPS_MONTHLY_RECAP_EMAIL_ID, + "two-clips": CLIPS_TWO_CLIPS_EMAIL_ID, + "activity-comment": CLIPS_ACTIVITY_COMMENT_EMAIL_ID, + "activity-reaction": CLIPS_ACTIVITY_REACTION_EMAIL_ID, +}; + +/** + * How the shared Clips sender resolves From and Reply-To for every kind it + * renders, so each entry can say so without restating the mechanism. + */ +const CLIPS_SENDER = + 'From is the configured EMAIL_FROM with the display name "Agent-Native Clips"; on first-party agent-native.com deployments it becomes clips@agent-native.com. Reply-to is hello@agent-native.com.'; + +let registered = false; + +export function registerClipsEmails(): void { + if (registered) return; + registered = true; + + defineTransactionalEmail({ + id: CLIPS_FIRST_VIEW_EMAIL_ID, + name: "First view on a Clip", + trigger: + "The background transactional-email sweep finds the first counted view of a Clip by someone other than its owner, recorded after transactional email was switched on. One per Clip.", + recipient: + "The Clip's owner. Suppressed recipient addresses are skipped, and the send is dropped if the owner is no longer the recording's owner at send time.", + sender: CLIPS_SENDER, + preview: () => + preview({ + kind: "first-view", + to: SAMPLE_TO, + recordingId: SAMPLE_RECORDING_ID, + title: SAMPLE_TITLE, + viewerEmail: "alex.chen@example.com", + }), + }); + + defineTransactionalEmail({ + id: CLIPS_UNVIEWED_REMINDER_EMAIL_ID, + name: "Unviewed Clip reminder", + trigger: + "A direct share is 48 hours old and the recipient still has no counted view of that Clip. Re-checked at send time, so a view in the meantime cancels it.", + recipient: + "The address the Clip was directly shared with. Requires the share to still exist.", + sender: + 'Same as the other Clips emails, except the display name becomes " (via Agent-Native Clips)" and reply-to is the sharer\'s own address when it is a valid address, falling back to hello@agent-native.com.', + preview: () => + preview({ + kind: "unviewed-reminder", + to: SAMPLE_TO, + recordingId: SAMPLE_RECORDING_ID, + title: SAMPLE_TITLE, + senderEmail: "dana.hill@example.com", + senderName: "Dana Hill", + }), + }); + + defineTransactionalEmail({ + id: CLIPS_FIRST_AGENT_VIEW_EMAIL_ID, + name: "First agent read of a Clip", + trigger: + "An AI agent reads one of an owner's Clips for the first time since transactional email was switched on. One per owner, not per Clip.", + recipient: + "The owner of the Clip the agent read. Re-checked at send time against the owner's actual first agent view.", + sender: CLIPS_SENDER, + preview: () => + preview({ + kind: "first-agent-view", + to: SAMPLE_TO, + recordingId: SAMPLE_RECORDING_ID, + title: SAMPLE_TITLE, + agentName: "Claude Code", + }), + }); + + defineTransactionalEmail({ + id: CLIPS_FIRST_IMPORT_EMAIL_ID, + name: "First imported video is ready", + trigger: + "An owner's first imported (rather than recorded) video finishes processing and becomes ready. One per owner.", + recipient: + "The owner of the imported recording, re-checked at send time so a later import does not resend.", + sender: CLIPS_SENDER, + preview: () => + preview({ + kind: "first-import", + to: SAMPLE_TO, + recordingId: SAMPLE_RECORDING_ID, + title: "Quarterly demo recording", + }), + }); + + defineTransactionalEmail({ + id: CLIPS_MONTHLY_RECAP_EMAIL_ID, + name: "Monthly Clips recap", + trigger: + "Once per owner per calendar month, from 14:00 UTC on the 1st, for owners whose Clips had any human views or agent reads in the closed month. Metrics and the top Clip are recomputed at send time.", + recipient: + "The Clip owner whose audience the recap reports. Suppressed addresses are skipped.", + sender: CLIPS_SENDER, + preview: () => + preview({ + kind: "monthly-recap", + to: SAMPLE_TO, + month: "2025-02", + humanViews: 42, + agentSessions: 7, + topClip: { + recordingId: SAMPLE_RECORDING_ID, + title: SAMPLE_TITLE, + thumbnailUrl: "https://example.com/thumbnails/sample.png", + durationMs: 214_000, + recordedAt: "2025-02-11T16:00:00.000Z", + humanViews: 18, + agentSessions: 4, + }, + copy: { + heroLine: "Your clips were watched 42 times. 7 agents read them.", + completionNote: "68% average completion · most stopped at 2:31", + agentBreakdown: "4 from Claude Code · 3 unidentified", + }, + }), + }); + + defineTransactionalEmail({ + id: CLIPS_TWO_CLIPS_EMAIL_ID, + name: "Two Clips received", + trigger: + "A one-time nudge once someone who owns no Clips of their own has had two distinct Clips directly shared with them. The summary line is AI-generated before the job is released to send.", + recipient: + "The shared-with address. Dropped at send time if they now own a Clip or no longer hold both shares.", + sender: CLIPS_SENDER, + preview: () => + preview({ + kind: "two-clips", + to: SAMPLE_TO, + generatedSummary: + "Two teammates walked you through the new onboarding flow.", + }), + }); + + defineTransactionalEmail({ + id: CLIPS_ACTIVITY_COMMENT_EMAIL_ID, + name: "Clip comment", + trigger: + "Someone comments or replies on a Clip. Subject and copy differ slightly for a reply; both send under this id.", + recipient: + "The recording owner, plus every prior author in the thread when the comment is a reply. The list is re-checked against the recording's live ACL and filtered by each user's `emailNotifications` preference; the comment's own author never receives it.", + sender: CLIPS_SENDER, + preview: () => + preview({ + kind: "activity-comment", + to: SAMPLE_TO, + recordingId: SAMPLE_RECORDING_ID, + title: SAMPLE_TITLE, + authorEmail: "alex.chen@example.com", + authorName: "Alex Chen", + content: "The step at 1:20 is the part new hires always miss.", + videoTimestampMs: 80_000, + isReply: false, + }), + }); + + defineTransactionalEmail({ + id: CLIPS_ACTIVITY_REACTION_EMAIL_ID, + name: "Clip reaction", + trigger: "A viewer reacts with an emoji on a Clip.", + recipient: + "The recording owner plus any extra recipients the caller passes, re-checked against the recording's live ACL and filtered by each user's `emailNotifications` preference. The reacting viewer never receives it.", + sender: CLIPS_SENDER, + preview: () => + preview({ + kind: "activity-reaction", + to: SAMPLE_TO, + recordingId: SAMPLE_RECORDING_ID, + title: SAMPLE_TITLE, + emoji: "🎉", + authorEmail: "alex.chen@example.com", + authorName: "Alex Chen", + videoTimestampMs: 45_000, + }), + }); + + defineTransactionalEmail({ + id: CLIPS_ORGANIZATION_INVITE_EMAIL_ID, + name: "Organization invitation", + trigger: + "An organization admin runs `invite-member`. Any earlier pending invite for the same address is cancelled first, so a re-invite sends a fresh email with a new token.", + recipient: + "The address passed to the action, exactly as typed. One email per invite.", + sender: + "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`, so unlike the other Clips emails it does not send as Agent-Native Clips.", + preview: () => + renderClipsInviteEmail({ + appName: "Clips", + orgName: "Northwind Design", + inviter: "dana.hill@example.com", + role: "member", + inviteUrl: "https://example.com/invite/sample-token", + }), + }); +} diff --git a/templates/clips/server/lib/transactional-email-templates.test.ts b/templates/clips/server/lib/transactional-email-templates.test.ts index 7c4121dfd5..e7f413e605 100644 --- a/templates/clips/server/lib/transactional-email-templates.test.ts +++ b/templates/clips/server/lib/transactional-email-templates.test.ts @@ -70,6 +70,7 @@ describe("renderClipsTransactionalEmail", () => { title: "Imported demo", }, subject: "Your first imported video is now Agent-Native", + templateId: "clips.first-import", heading: "Your video is ready for more than playback", cta: "Open your Agent-Native Clip: https://clips.example/r/rec-3", }, diff --git a/templates/clips/server/lib/transactional-email-templates.ts b/templates/clips/server/lib/transactional-email-templates.ts index b9d95d894d..4d7a60ea65 100644 --- a/templates/clips/server/lib/transactional-email-templates.ts +++ b/templates/clips/server/lib/transactional-email-templates.ts @@ -5,6 +5,7 @@ import { sendEmail, } from "@agent-native/core/server"; +import { CLIPS_EMAIL_ID_BY_KIND } from "./emails.js"; import { recapMonthLabel } from "./recap-metrics.js"; import type { RecapCopy } from "./transactional-email-store.js"; @@ -613,6 +614,7 @@ export async function sendClipsTransactionalEmail( ? (validReplyTo(input.senderEmail) ?? FRIENDLY_REPLY_TO) : FRIENDLY_REPLY_TO, timeoutMs: EMAIL_SEND_TIMEOUT_MS, + templateId: CLIPS_EMAIL_ID_BY_KIND[input.kind], }); } diff --git a/templates/clips/server/plugins/transactional-email-catalog.ts b/templates/clips/server/plugins/transactional-email-catalog.ts new file mode 100644 index 0000000000..06f5b51fd7 --- /dev/null +++ b/templates/clips/server/plugins/transactional-email-catalog.ts @@ -0,0 +1,5 @@ +import { registerClipsEmails } from "../lib/emails.js"; + +export default function registerTransactionalEmailCatalog(): void { + registerClipsEmails(); +} diff --git a/templates/content/server/lib/comment-notifications.ts b/templates/content/server/lib/comment-notifications.ts index bd29251f35..a9048f4bd9 100644 --- a/templates/content/server/lib/comment-notifications.ts +++ b/templates/content/server/lib/comment-notifications.ts @@ -21,6 +21,10 @@ import { and, eq } from "drizzle-orm"; import { CONTENT_USER_PREFS_KEY } from "../../shared/content-user-prefs.js"; import { getDb, schema } from "../db/index.js"; +import { + CONTENT_DOCUMENT_COMMENT_EMAIL_ID, + CONTENT_DOCUMENT_MENTION_EMAIL_ID, +} from "./emails.js"; export type DocumentCommentNotificationResult = ActivityNotificationResult; @@ -70,6 +74,48 @@ export interface DocumentCommentNotificationInput { isReply: boolean; } +export function renderDocumentCommentEmail({ + actor, + title, + url, + content, + isReply, + wasMentioned, +}: { + actor: string; + title: string; + url: string; + content: string; + isReply: boolean; + wasMentioned: boolean; +}) { + const lead = wasMentioned + ? `${emailStrong(actor)} mentioned you in a comment on ${emailStrong(title)}.` + : isReply + ? `${emailStrong(actor)} replied in a comment thread on ${emailStrong(title)}.` + : `${emailStrong(actor)} commented on ${emailStrong(title)}.`; + + return { + subject: wasMentioned + ? `${actor} mentioned you on "${title}"` + : isReply + ? `${actor} replied to a comment on "${title}"` + : `${actor} commented on "${title}"`, + ...renderEmail({ + preheader: `${actor} commented on ${title}.`, + heading: wasMentioned + ? "You were mentioned" + : isReply + ? "New reply on your document" + : "New comment", + paragraphs: [lead, `"${excerpt(content)}"`], + cta: { label: "Open document", url }, + footer: + "You received this because you own, were mentioned in, or participated in this thread. Turn these off in Documents settings.", + }), + }; +} + export async function notifyDocumentComment( input: DocumentCommentNotificationInput, ): Promise { @@ -112,34 +158,19 @@ async function deliverDocumentCommentEmails( logLabel: LOG_LABEL, send: async (to) => { const wasMentioned = mentioned.has(to); - const lead = wasMentioned - ? `${emailStrong(actor)} mentioned you in a comment on ${emailStrong(title)}.` - : input.isReply - ? `${emailStrong(actor)} replied in a comment thread on ${emailStrong(title)}.` - : `${emailStrong(actor)} commented on ${emailStrong(title)}.`; - - const { html, text } = renderEmail({ - preheader: `${actor} commented on ${title}.`, - heading: wasMentioned - ? "You were mentioned" - : input.isReply - ? "New reply on your document" - : "New comment", - paragraphs: [lead, `"${excerpt(input.content)}"`], - cta: { label: "Open document", url }, - footer: - "You received this because you own, were mentioned in, or participated in this thread. Turn these off in Documents settings.", - }); - await sendEmail({ + ...renderDocumentCommentEmail({ + actor, + title, + url, + content: input.content, + isReply: input.isReply, + wasMentioned, + }), to, - subject: wasMentioned - ? `${actor} mentioned you on "${title}"` - : input.isReply - ? `${actor} replied to a comment on "${title}"` - : `${actor} commented on "${title}"`, - html, - text, + templateId: wasMentioned + ? CONTENT_DOCUMENT_MENTION_EMAIL_ID + : CONTENT_DOCUMENT_COMMENT_EMAIL_ID, }); }, }); diff --git a/templates/content/server/lib/emails.ts b/templates/content/server/lib/emails.ts new file mode 100644 index 0000000000..bc32e696e0 --- /dev/null +++ b/templates/content/server/lib/emails.ts @@ -0,0 +1,66 @@ +/** + * Catalog entries for the transactional emails Documents sends. + * + * Registered from `server/plugins/transactional-emails.ts` so Dispatch can list + * and preview them without the app having sent anything yet. + */ + +import { defineTransactionalEmail } from "@agent-native/core/email-catalog"; + +import { renderDocumentCommentEmail } from "./comment-notifications.js"; + +/** Obviously-fake sample data — these render in a preview pane, never send. */ +const SAMPLE_TITLE = "Q3 launch brief"; +const SAMPLE_URL = "https://example.com/page/doc_sample"; +const SAMPLE_COMMENT = + "Can we tighten the second paragraph? It repeats the intro."; + +export const CONTENT_DOCUMENT_COMMENT_EMAIL_ID = "content.document-comment"; +export const CONTENT_DOCUMENT_MENTION_EMAIL_ID = "content.document-mention"; + +let registered = false; + +export function registerContentEmails(): void { + if (registered) return; + registered = true; + + defineTransactionalEmail({ + id: CONTENT_DOCUMENT_COMMENT_EMAIL_ID, + name: "Document comment", + trigger: + "Someone posts a comment or a thread reply on a document. Sent to recipients who were not mentioned in it; the copy and subject differ slightly for a reply.", + recipient: + "The document owner, plus every prior author in the thread when the new comment is a reply. The list is re-checked against the document's live ACL and filtered by each user's `emailNotifications` preference; the comment's own author never receives it.", + sender: + "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`.", + preview: () => + renderDocumentCommentEmail({ + actor: "Sam Rivera", + title: SAMPLE_TITLE, + url: SAMPLE_URL, + content: SAMPLE_COMMENT, + isReply: false, + wasMentioned: false, + }), + }); + + defineTransactionalEmail({ + id: CONTENT_DOCUMENT_MENTION_EMAIL_ID, + name: "Document mention", + trigger: + "A comment or reply mentions someone by email. Sent instead of the plain comment notification to each mentioned recipient.", + recipient: + "The mentioned addresses supplied by the caller, re-checked against the document's live ACL and filtered by each user's `emailNotifications` preference.", + sender: + "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`.", + preview: () => + renderDocumentCommentEmail({ + actor: "Sam Rivera", + title: SAMPLE_TITLE, + url: SAMPLE_URL, + content: SAMPLE_COMMENT, + isReply: false, + wasMentioned: true, + }), + }); +} diff --git a/templates/content/server/plugins/transactional-emails.ts b/templates/content/server/plugins/transactional-emails.ts new file mode 100644 index 0000000000..37a9ac621e --- /dev/null +++ b/templates/content/server/plugins/transactional-emails.ts @@ -0,0 +1,5 @@ +import { registerContentEmails } from "../lib/emails.js"; + +export default function registerTransactionalEmails(): void { + registerContentEmails(); +} diff --git a/templates/forms/server/lib/emails.ts b/templates/forms/server/lib/emails.ts new file mode 100644 index 0000000000..e2ee2f3322 --- /dev/null +++ b/templates/forms/server/lib/emails.ts @@ -0,0 +1,50 @@ +/** + * Catalog entries for the transactional emails Forms sends. + * + * Registered from `server/plugins/transactional-emails.ts` so Dispatch can list + * and preview them without the app having sent anything yet. + */ + +import { defineTransactionalEmail } from "@agent-native/core/email-catalog"; + +import { renderNewResponseEmail } from "./response-email.js"; + +export const FORMS_NEW_RESPONSE_EMAIL_ID = "forms.new-response"; + +let registered = false; + +export function registerFormsEmails(): void { + if (registered) return; + registered = true; + + defineTransactionalEmail({ + id: FORMS_NEW_RESPONSE_EMAIL_ID, + name: "New form response", + trigger: + "A response is submitted and persisted for a form whose settings have `emailOnNewResponses` enabled. Delivery is best-effort: a send failure never rejects the submission.", + recipient: + "The form's `ownerEmail`, and only that address — the respondent is never emailed. Skipped when the form has no owner address.", + sender: + "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`, so a reply goes to the sending mailbox rather than to the respondent.", + preview: () => + renderNewResponseEmail({ + formTitle: "Beta feedback", + fields: [ + { id: "name", type: "text", label: "Name", required: true }, + { id: "email", type: "email", label: "Email", required: true }, + { + id: "notes", + type: "textarea", + label: "What should we fix first?", + required: false, + }, + ], + data: { + name: "Sam Rivera", + email: "sam.rivera@example.com", + notes: "The export button is hard to find on mobile.", + }, + submittedAt: "2025-03-04T17:20:00.000Z", + }), + }); +} diff --git a/templates/forms/server/lib/response-email.ts b/templates/forms/server/lib/response-email.ts index 9b70427b25..ab40312317 100644 --- a/templates/forms/server/lib/response-email.ts +++ b/templates/forms/server/lib/response-email.ts @@ -1,6 +1,7 @@ import { emailStrong, renderEmail, sendEmail } from "@agent-native/core/server"; import type { FormField } from "../../shared/types.js"; +import { FORMS_NEW_RESPONSE_EMAIL_ID } from "./emails.js"; export interface NewResponseEmailArgs { to: string; @@ -61,5 +62,6 @@ export async function sendNewResponseEmail( await sendEmail({ to: args.to, ...renderNewResponseEmail(args), + templateId: FORMS_NEW_RESPONSE_EMAIL_ID, }); } diff --git a/templates/forms/server/plugins/transactional-emails.ts b/templates/forms/server/plugins/transactional-emails.ts new file mode 100644 index 0000000000..96e0f3b09a --- /dev/null +++ b/templates/forms/server/plugins/transactional-emails.ts @@ -0,0 +1,5 @@ +import { registerFormsEmails } from "../lib/emails.js"; + +export default function registerTransactionalEmails(): void { + registerFormsEmails(); +} diff --git a/templates/plan/actions/request-plan-access.ts b/templates/plan/actions/request-plan-access.ts index 8a7bfe0c1e..fd41c1fe21 100644 --- a/templates/plan/actions/request-plan-access.ts +++ b/templates/plan/actions/request-plan-access.ts @@ -15,6 +15,7 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; +import { PLAN_ACCESS_REQUEST_EMAIL_ID } from "../server/lib/emails.js"; import { isAnonymousPublicViewer, isGuestAuthorIdentity, @@ -49,6 +50,28 @@ function absolutePlanUrl(planId: string, kind: "plan" | "recap"): string { } } +export function renderPlanAccessRequestEmail(input: { + requesterName: string; + requesterEmail: string; + planTitle: string; + url: string; +}) { + const subject = `${input.requesterName} requested access to "${input.planTitle}"`; + return { + subject, + ...renderEmail({ + preheader: subject, + heading: "Access request", + paragraphs: [ + `${emailStrong(input.requesterName)} (${emailStrong(input.requesterEmail)}) requested access to ${emailStrong(input.planTitle)}.`, + "Open the plan and use Share to grant access if this request should be approved.", + ], + cta: { label: "Open plan", url: input.url }, + footer: "You received this because you own this Agent-Native Plan.", + }), + }; +} + async function notifyOwner(input: { planId: string; planKind: "plan" | "recap"; @@ -62,19 +85,16 @@ async function notifyOwner(input: { return false; } - const url = absolutePlanUrl(input.planId, input.planKind); - const subject = `${input.requesterName} requested access to "${input.planTitle}"`; - const { html, text } = renderEmail({ - preheader: subject, - heading: "Access request", - paragraphs: [ - `${emailStrong(input.requesterName)} (${emailStrong(input.requesterEmail)}) requested access to ${emailStrong(input.planTitle)}.`, - "Open the plan and use Share to grant access if this request should be approved.", - ], - cta: { label: "Open plan", url }, - footer: "You received this because you own this Agent-Native Plan.", + await sendEmail({ + ...renderPlanAccessRequestEmail({ + requesterName: input.requesterName, + requesterEmail: input.requesterEmail, + planTitle: input.planTitle, + url: absolutePlanUrl(input.planId, input.planKind), + }), + to: input.ownerEmail, + templateId: PLAN_ACCESS_REQUEST_EMAIL_ID, }); - await sendEmail({ to: input.ownerEmail, subject, html, text }); return true; } diff --git a/templates/plan/server/lib/comment-notifications.ts b/templates/plan/server/lib/comment-notifications.ts index 7b56072b0c..c62df78d14 100644 --- a/templates/plan/server/lib/comment-notifications.ts +++ b/templates/plan/server/lib/comment-notifications.ts @@ -13,6 +13,7 @@ import { } from "../../shared/comment-context.js"; import type { PlanBundle, PlanComment } from "../../shared/types.js"; import { getDb, schema } from "../db/index.js"; +import { PLAN_COMMENT_EMAIL_ID } from "./emails.js"; type CommentNotificationInput = { bundle: PlanBundle; @@ -205,40 +206,64 @@ export function planCommentNotificationRecipients(input: { return Array.from(recipients.values()); } -async function sendPlanCommentNotification(input: { - recipient: NotificationRecipient; - comment: PlanComment; +export function renderPlanCommentEmail(input: { + actor: string; + app: string; planTitle: string; - planId: string; + planUrl: string; + message: string; + isReply: boolean; + reason: NotificationRecipient["reason"]; }) { - const actor = actorName(input.comment); - const app = appName(); - const isReply = Boolean(input.comment.parentCommentId); - const subject = isReply - ? `${actor} replied to a comment on "${input.planTitle}"` - : `${actor} commented on "${input.planTitle}"`; const actionText = - input.recipient.reason === "mention" + input.reason === "mention" ? "mentioned you in a comment on" - : input.recipient.reason === "plan-owner" + : input.reason === "plan-owner" ? "left a comment on your plan" : "replied in a comment thread you participated in"; - const { html, text } = renderEmail({ - preheader: `${actor} ${actionText} on ${app}.`, - heading: isReply ? "New reply on your plan" : "New comment on your plan", - paragraphs: [ - `${emailStrong(actor)} ${actionText} ${emailStrong(input.planTitle)}.`, - `Comment: "${commentExcerpt(input.comment.message)}"`, - ], - cta: { label: "Open plan", url: planUrl(input.planId) }, - footer: - input.recipient.reason === "plan-owner" - ? "You received this because you own this plan." - : input.recipient.reason === "mention" - ? "You received this because you were mentioned in this comment." - : "You received this because you participated in this comment thread.", + return { + subject: input.isReply + ? `${input.actor} replied to a comment on "${input.planTitle}"` + : `${input.actor} commented on "${input.planTitle}"`, + ...renderEmail({ + preheader: `${input.actor} ${actionText} on ${input.app}.`, + heading: input.isReply + ? "New reply on your plan" + : "New comment on your plan", + paragraphs: [ + `${emailStrong(input.actor)} ${actionText} ${emailStrong(input.planTitle)}.`, + `Comment: "${commentExcerpt(input.message)}"`, + ], + cta: { label: "Open plan", url: input.planUrl }, + footer: + input.reason === "plan-owner" + ? "You received this because you own this plan." + : input.reason === "mention" + ? "You received this because you were mentioned in this comment." + : "You received this because you participated in this comment thread.", + }), + }; +} + +async function sendPlanCommentNotification(input: { + recipient: NotificationRecipient; + comment: PlanComment; + planTitle: string; + planId: string; +}) { + await sendEmail({ + ...renderPlanCommentEmail({ + actor: actorName(input.comment), + app: appName(), + planTitle: input.planTitle, + planUrl: planUrl(input.planId), + message: input.comment.message, + isReply: Boolean(input.comment.parentCommentId), + reason: input.recipient.reason, + }), + to: input.recipient.email, + templateId: PLAN_COMMENT_EMAIL_ID, }); - await sendEmail({ to: input.recipient.email, subject, html, text }); } export async function notifyPlanCommentRecipients({ diff --git a/templates/plan/server/lib/emails.ts b/templates/plan/server/lib/emails.ts new file mode 100644 index 0000000000..8b62905325 --- /dev/null +++ b/templates/plan/server/lib/emails.ts @@ -0,0 +1,60 @@ +/** + * Catalog entries for the transactional emails Plan sends. + * + * Registered from `server/plugins/transactional-emails.ts` so Dispatch can list + * and preview them without the app having sent anything yet. + */ + +import { defineTransactionalEmail } from "@agent-native/core/email-catalog"; + +import { renderPlanAccessRequestEmail } from "../../actions/request-plan-access.js"; +import { renderPlanCommentEmail } from "./comment-notifications.js"; + +export const PLAN_COMMENT_EMAIL_ID = "plan.plan-comment"; +export const PLAN_ACCESS_REQUEST_EMAIL_ID = "plan.access-request"; + +let registered = false; + +export function registerPlanEmails(): void { + if (registered) return; + registered = true; + + defineTransactionalEmail({ + id: PLAN_COMMENT_EMAIL_ID, + name: "Plan comment", + trigger: + "A human (never the agent) posts a comment or reply on a plan or recap. The subject changes for a reply and the body changes with why the recipient was picked; all variants send under this id.", + recipient: + "The plan owner, everyone mentioned in the comment, and — for a reply — every other human author in the same thread. The comment's own author, synthetic QA addresses, and the owner when the comment mentions the source author are all dropped. One email per remaining address.", + sender: + "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`.", + preview: () => + renderPlanCommentEmail({ + actor: "Sam Rivera", + app: "Agent-Native Plan", + planTitle: "Checkout rewrite", + planUrl: "https://example.com/plans/plan_sample", + message: "Can we split the migration into two deploys?", + isReply: false, + reason: "plan-owner", + }), + }); + + defineTransactionalEmail({ + id: PLAN_ACCESS_REQUEST_EMAIL_ID, + name: "Plan access request", + trigger: + "A signed-in user who cannot resolve access calls `request-plan-access` on a private plan URL. Skipped when email is not configured or the requester is the owner.", + recipient: + "The plan's `ownerEmail` column. Anonymous public viewers and guest author identities cannot reach this path.", + sender: + "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`, so the owner cannot reply straight to the requester.", + preview: () => + renderPlanAccessRequestEmail({ + requesterName: "Sam Rivera", + requesterEmail: "sam.rivera@example.com", + planTitle: "Checkout rewrite", + url: "https://example.com/plans/plan_sample", + }), + }); +} diff --git a/templates/plan/server/plugins/transactional-emails.ts b/templates/plan/server/plugins/transactional-emails.ts new file mode 100644 index 0000000000..5190cbd818 --- /dev/null +++ b/templates/plan/server/plugins/transactional-emails.ts @@ -0,0 +1,5 @@ +import { registerPlanEmails } from "../lib/emails.js"; + +export default function registerTransactionalEmails(): void { + registerPlanEmails(); +} diff --git a/templates/slides/server/lib/comment-notifications.ts b/templates/slides/server/lib/comment-notifications.ts index 9d480bddf8..31b0d88dad 100644 --- a/templates/slides/server/lib/comment-notifications.ts +++ b/templates/slides/server/lib/comment-notifications.ts @@ -21,6 +21,7 @@ import { and, eq } from "drizzle-orm"; import { getDeckUrl } from "../../actions/_app-url.js"; import { SLIDES_USER_PREFS_KEY } from "../../shared/slides-user-prefs.js"; import { getDb, schema } from "../db/index.js"; +import { SLIDES_DECK_COMMENT_EMAIL_ID } from "./emails.js"; /** * `deck-missing` stays distinct from `no-recipients`: one means the deck could @@ -99,6 +100,41 @@ async function threadParticipants( return rows.map((row) => row.authorEmail); } +export function renderDeckCommentEmail({ + actor, + title, + url, + content, + isReply, +}: { + actor: string; + title: string; + url: string; + content: string; + isReply: boolean; +}) { + return { + subject: isReply + ? `${actor} replied to a comment on "${title}"` + : `${actor} commented on "${title}"`, + ...renderEmail({ + preheader: isReply + ? `${actor} replied to a comment on ${title}.` + : `${actor} commented on ${title}.`, + heading: isReply ? "New reply on your deck" : "New comment", + paragraphs: [ + isReply + ? `${emailStrong(actor)} replied in a comment thread on ${emailStrong(title)}.` + : `${emailStrong(actor)} commented on ${emailStrong(title)}.`, + `"${excerpt(content)}"`, + ], + cta: { label: "Open deck", url }, + footer: + "You received this because you own or participated in this thread. Turn these off in Slides settings.", + }), + }; +} + export async function notifyDeckComment(input: { deckId: string; slideId: string; @@ -153,29 +189,16 @@ async function deliverDeckCommentEmails(input: { preferenceKey: SLIDES_USER_PREFS_KEY, logLabel: LOG_LABEL, send: async (to) => { - const { html, text } = renderEmail({ - preheader: input.isReply - ? `${actor} replied to a comment on ${deck.title}.` - : `${actor} commented on ${deck.title}.`, - heading: input.isReply ? "New reply on your deck" : "New comment", - paragraphs: [ - input.isReply - ? `${emailStrong(actor)} replied in a comment thread on ${emailStrong(deck.title)}.` - : `${emailStrong(actor)} commented on ${emailStrong(deck.title)}.`, - `"${excerpt(input.content)}"`, - ], - cta: { label: "Open deck", url }, - footer: - "You received this because you own or participated in this thread. Turn these off in Slides settings.", - }); - await sendEmail({ + ...renderDeckCommentEmail({ + actor, + title: deck.title, + url, + content: input.content, + isReply: input.isReply, + }), to, - subject: input.isReply - ? `${actor} replied to a comment on "${deck.title}"` - : `${actor} commented on "${deck.title}"`, - html, - text, + templateId: SLIDES_DECK_COMMENT_EMAIL_ID, }); }, }); diff --git a/templates/slides/server/lib/emails.ts b/templates/slides/server/lib/emails.ts new file mode 100644 index 0000000000..665c849f0f --- /dev/null +++ b/templates/slides/server/lib/emails.ts @@ -0,0 +1,38 @@ +/** + * Catalog entries for the transactional emails Slides sends. + * + * Registered from `server/plugins/transactional-emails.ts` so Dispatch can list + * and preview them without the app having sent anything yet. + */ + +import { defineTransactionalEmail } from "@agent-native/core/email-catalog"; + +import { renderDeckCommentEmail } from "./comment-notifications.js"; + +export const SLIDES_DECK_COMMENT_EMAIL_ID = "slides.deck-comment"; + +let registered = false; + +export function registerSlidesEmails(): void { + if (registered) return; + registered = true; + + defineTransactionalEmail({ + id: SLIDES_DECK_COMMENT_EMAIL_ID, + name: "Deck comment", + trigger: + "Someone posts a comment or a thread reply on a slide. Subject and copy differ slightly for a reply; both send under this id.", + recipient: + "The deck owner, plus every prior author in the thread when the new comment is a reply. The list is re-checked against the deck's live ACL and filtered by each user's `emailNotifications` preference; the comment's own author never receives it.", + sender: + "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`.", + preview: () => + renderDeckCommentEmail({ + actor: "Sam Rivera", + title: "Series A narrative", + url: "https://example.com/decks/deck_sample?slide=3", + content: "Slide 3 needs the updated revenue chart before Thursday.", + isReply: false, + }), + }); +} diff --git a/templates/slides/server/plugins/transactional-emails.ts b/templates/slides/server/plugins/transactional-emails.ts new file mode 100644 index 0000000000..01fb862c10 --- /dev/null +++ b/templates/slides/server/plugins/transactional-emails.ts @@ -0,0 +1,5 @@ +import { registerSlidesEmails } from "../lib/emails.js"; + +export default function registerTransactionalEmails(): void { + registerSlidesEmails(); +} From 8c33b9c42c96fb44459a38697976d3838bb3907b Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 4 Aug 2026 13:47:50 +0000 Subject: [PATCH 03/10] Add transactional email monitoring page to Dispatch --- .../core/src/localization/default-messages.ts | 45 ++ .../dispatch/src/components/layout/Layout.tsx | 8 + packages/dispatch/src/routes/index.ts | 1 + .../src/routes/pages/transactional-email.tsx | 653 ++++++++++++++++++ 4 files changed, 707 insertions(+) create mode 100644 packages/dispatch/src/routes/pages/transactional-email.tsx diff --git a/packages/core/src/localization/default-messages.ts b/packages/core/src/localization/default-messages.ts index f439161813..efb52381df 100644 --- a/packages/core/src/localization/default-messages.ts +++ b/packages/core/src/localization/default-messages.ts @@ -171,6 +171,7 @@ const messages = { resources: "Resources", messaging: "Messaging", destinations: "Destinations", + transactionalEmail: "Transactional email", identities: "Identities", approvals: "Approvals", automations: "Automations", @@ -202,6 +203,50 @@ 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", + 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.", + email: "Email", + trigger: "Trigger", + recipientAndSender: "Recipient / sender", + 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", + 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", + status: "Status", + opens: "Opens", + lastEvent: "Last event", + }, pages: { appsDescription: "Open workspace apps and start new app creation from Dispatch.", diff --git a/packages/dispatch/src/components/layout/Layout.tsx b/packages/dispatch/src/components/layout/Layout.tsx index e9f05b2ba2..94f6f94654 100644 --- a/packages/dispatch/src/components/layout/Layout.tsx +++ b/packages/dispatch/src/components/layout/Layout.tsx @@ -27,6 +27,7 @@ import { IconKey, IconChevronDown, IconLayersSubtract, + IconMail, IconMessageQuestion, IconMessages, IconPlugConnected, @@ -157,6 +158,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/routes/index.ts b/packages/dispatch/src/routes/index.ts index 21933b1ff3..7e16d23714 100644 --- a/packages/dispatch/src/routes/index.ts +++ b/packages/dispatch/src/routes/index.ts @@ -47,6 +47,7 @@ export const dispatchRoutes: RouteConfig = [ route("workspace", "./pages/workspace.js"), route("messaging", "./pages/messaging.js"), route("destinations", "./pages/destinations.js"), + route("transactional-email", "./pages/transactional-email.js"), route("identities", "./pages/identities.js"), route("approval", "./pages/approval.js"), route("approvals", "./pages/approvals.js"), diff --git a/packages/dispatch/src/routes/pages/transactional-email.tsx b/packages/dispatch/src/routes/pages/transactional-email.tsx new file mode 100644 index 0000000000..9109bae66b --- /dev/null +++ b/packages/dispatch/src/routes/pages/transactional-email.tsx @@ -0,0 +1,653 @@ +import { callAction, useActionQuery } from "@agent-native/core/client/hooks"; +import { useT } from "@agent-native/core/client/i18n"; +import { + IconAlertTriangle, + IconEye, + IconInfoCircle, + IconList, + IconMail, +} from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; + +import { + fetchAppEmailCatalog, + fetchEmailPreview, + type AppEmailCatalog, + type AppTransactionalEmail, +} from "../../client/transactional-emails"; +import { ActionQueryError } from "../../components/action-query-error"; +import { DispatchShell } from "../../components/dispatch-shell"; +import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert"; +import { Button } from "../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../../components/ui/dialog"; +import { Skeleton } from "../../components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "../../components/ui/tooltip"; + +export function meta() { + return [{ title: "Transactional email — Dispatch" }]; +} + +const WINDOW_DAYS = 30; +const ACTIVITY_LIMIT = 50; + +interface WorkspaceAppRef { + id: string; + name: string; + path: string; + status?: "ready" | "pending"; +} + +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; +} + +interface EmailActivityEntry { + msgId: string; + toEmail: string; + fromEmail: string; + subject: string; + status: string; + opensCount: number; + clicksCount: number; + lastEventTime: string; +} + +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. + */ +function UnknownMetric({ reason }: { reason: string }) { + return ( + + + + — + + + {reason} + + ); +} + +function SendsCell({ email }: { email: AppTransactionalEmail }) { + const t = useT(); + if (email.sent === null) { + return ( + + ); + } + return ( + + {email.sent} + {email.failed !== null && email.failed > 0 ? ( + + {t("dispatch.transactionalEmail.failedCount", { + count: email.failed, + })} + + ) : null} + {email.failed === null ? ( + + {t("dispatch.transactionalEmail.failuresUnknown")} + + ) : null} + + ); +} + +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)}%`} + + ); +} + +function PreviewDialog({ + email, + appPath, + open, + onOpenChange, +}: { + email: AppTransactionalEmail; + appPath: string; + open: boolean; + onOpenChange: (next: boolean) => void; +}) { + const t = useT(); + const preview = useQuery({ + queryKey: ["transactional-email-preview", appPath, email.id], + queryFn: () => fetchEmailPreview(appPath, email.id), + enabled: open, + retry: false, + }); + + return ( + + + + {email.name} + + {t("dispatch.transactionalEmail.previewDescription")} + + + {preview.isError ? ( + + + + {t("dispatch.transactionalEmail.previewFailed")} + + + {preview.error instanceof Error + ? preview.error.message + : String(preview.error)} + + + ) : preview.isLoading || !preview.data ? ( + + ) : ( +
+
+
+ {t("dispatch.transactionalEmail.subject")} +
+
+ {preview.data.subject} +
+
+ {/* sandbox="" (no allow-scripts) keeps arbitrary email HTML from + running script in the Dispatch origin. */} +