diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index f4a9447c..6e2a6fe3 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -38,6 +38,7 @@ jobs: deps docs github + mail models release runner diff --git a/AGENTS.md b/AGENTS.md index 8205e40f..5958f4e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,7 @@ aube run check:repo # community files and Agent Skills aube run db:generate # generate Drizzle migrations aube run db:migrate # apply migrations to the configured database aube run i18n:report # find missing or dynamic i18n keys +aube run mail:compile # re-render the checked-in mail templates aube run mail:preview # start the Maizzle preview server aube run skills:list # show Skilld-managed project skills aube run skills:install # restore Skilld links for Codex @@ -104,7 +105,7 @@ aube run clean # remove build artifacts ### Package scripts -Every runtime package exposes `build`, `clean`, `lint`, `lint:fix`, `test`, and `typecheck`. `packages/database` adds `db:generate` and `db:migrate`, `packages/i18n` adds the `i18n:*` scripts, and the Nuxt apps add `dev`, `preview`, and browser-test scripts. +Every runtime package exposes `build`, `clean`, `lint`, `lint:fix`, `test`, and `typecheck`. `packages/database` adds `db:generate` and `db:migrate`, `packages/i18n` adds the `i18n:*` scripts, `packages/mail` adds `mail:compile`, and the Nuxt apps add `dev`, `preview`, and browser-test scripts. ### Targeting specific packages diff --git a/apps/docs/guide/mails.md b/apps/docs/guide/mails.md index 4d1cedab..8cc9d199 100644 --- a/apps/docs/guide/mails.md +++ b/apps/docs/guide/mails.md @@ -18,14 +18,26 @@ ## Templates -Templates are authored with Maizzle and rendered through the package's template registry: +Templates are authored with Maizzle and declared in the package's template registry: -- `mailTemplates` maps a `MailTemplateId` to its renderer; +- `mailTemplates` maps a `MailTemplateId` to its template file, subject, and context fields; - `MailTemplateContext` types the data each template needs; - `maizzle.config.ts` is discovered by Maizzle's own `render()` call by filesystem convention. Adding a template means adding it to the registry with a typed context, keeping delivery and content concerns separate: providers never know what a template says, and templates never know how mail is sent. +## Compiling templates + +Rendering a template runs Maizzle's pipeline — Vue SSR, CSS inlining, plaintext — on top of a Vite SSR server, which needs a bundler with its platform-native binary and a writable working directory. The deployments that send mail are serverless functions with neither, so rendering happens once at build time instead: + +```bash +aube run mail:compile +``` + +That writes `packages/mail/src/util/compiled-templates.json`, which is checked in and bundled into the package. Each template is rendered once per combination of the `conditional` fields the registry declares — the halves a template renders away when empty — with every other field standing in as a placeholder. `sendEmail` then picks the variant its context matches and substitutes the values, escaping them for the HTML part and leaving them bare in the plaintext one. + +Run it after editing anything under `packages/mail/emails/`. `aube test` fails when the checked-in artifact no longer matches what the templates render. + ## Previewing templates `apps/mail-preview` wraps Maizzle's dev server around the package's templates: diff --git a/knip.jsonc b/knip.jsonc index 899a873b..ac34704f 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -26,9 +26,11 @@ "entry": ["src/index.ts", "scripts/*.ts", "lunaria.config.ts"], }, "packages/mail": { - // maizzle.config.ts is discovered by Maizzle's own `render()` call by filesystem - // convention (it walks up from the template path), not imported by anything in src/. - "entry": ["src/index.ts", "maizzle.config.ts"], + // The template compiler is invoked directly (`node scripts/compile-templates.ts`) from + // package.json rather than imported, so it is its own entry point. maizzle.config.ts is + // discovered by Maizzle's own `render()` call by filesystem convention (it walks up from + // the template path), not imported by anything in src/. + "entry": ["src/index.ts", "scripts/*.ts", "maizzle.config.ts"], // Resolved by Maizzle's Tailwind integration from the package name, not imported // directly: it registers itself as the `@maizzle/tailwindcss` plugin for `css.inline`. "ignoreDependencies": ["@maizzle/tailwindcss"], diff --git a/package.json b/package.json index 6fbda246..ee5a5d50 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "lint": "turbo run lint", "lint:ci": "aube run format:check && aube run lint && aube run knip", "lint:fix": "turbo run lint:fix", + "mail:compile": "turbo run mail:compile", "mail:preview": "turbo run dev --filter=@agent-zero/mail-preview", "prepare": "node .husky/install.mjs && skilld prepare --agent codex", "skills:install": "skilld install --agent codex", diff --git a/packages/mail/package.json b/packages/mail/package.json index faaaab0d..b74afe08 100644 --- a/packages/mail/package.json +++ b/packages/mail/package.json @@ -25,16 +25,15 @@ "scripts": { "build": "tsdown", "clean": "tsc -b --clean", - "lint": "oxlint --config ../../tooling/oxc/packages.oxlintrc.json --type-aware --type-check src", - "lint:fix": "oxlint --fix --config ../../tooling/oxc/packages.oxlintrc.json --type-aware --type-check src", + "lint": "oxlint --config ../../tooling/oxc/packages.oxlintrc.json --type-aware --type-check src scripts", + "lint:fix": "oxlint --fix --config ../../tooling/oxc/packages.oxlintrc.json --type-aware --type-check src scripts", + "mail:compile": "node scripts/compile-templates.ts", "test": "vitest run src --passWithNoTests", "typecheck": "tsc --project tsconfig.json --pretty false --noEmit" }, - "dependencies": { - "@maizzle/framework": "^6.0.13", - "@maizzle/tailwindcss": "^1.5.6" - }, "devDependencies": { + "@maizzle/framework": "^6.0.13", + "@maizzle/tailwindcss": "^1.5.6", "@types/nodemailer": "^8.0.1", "nodemailer": "^9.0.5", "oxlint": "^1.44.0", diff --git a/packages/mail/scripts/compile-templates.ts b/packages/mail/scripts/compile-templates.ts new file mode 100644 index 00000000..c1376e16 --- /dev/null +++ b/packages/mail/scripts/compile-templates.ts @@ -0,0 +1,107 @@ +// Renders every template under `emails/` to static markup, once, so that sending a message never +// has to. Maizzle renders a Vue SFC by starting a Vite SSR server: that needs a bundler and its +// platform-native binary, a writable working directory, and the package's own directory as the +// working directory — none of which the serverless deployment that sends the mail has. The +// compiled artifact this writes is what `src/mail.ts` ships and substitutes into. +// +// Run through `aube run mail:compile` after editing anything under `emails/`; `mail.test.ts` +// fails when the checked-in artifact no longer matches what the templates render. +import { writeFile } from 'node:fs/promises'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { render } from '@maizzle/framework'; + +import { mailTemplatePlaceholder, type CompiledMailTemplate } from '../src/util/compiled.ts'; +import { + mailTemplateConditionalFields, + mailTemplateFields, + mailTemplateIds, + mailTemplates, + type MailTemplateId, +} from '../src/util/templates.ts'; + +/** Templates live beside this script's package, not beside the process that runs it. */ +const emailsDirectory = fileURLToPath(new URL('../emails/', import.meta.url)); +const compiledArtifactPath = fileURLToPath( + new URL('../src/util/compiled-templates.json', import.meta.url), +); + +/** + * Every combination of the template's conditional fields being filled, as the subsets of them + * that are filled — the empty subset first, in declaration order — so each rendered variant is + * named the way `mailTemplateVariantKey` names the context that selects it. + */ +function fieldSubsets(fields: readonly string[]): readonly (readonly string[])[] { + const subsets: (readonly string[])[] = [[]]; + for (const field of fields) { + const grown = subsets.map((subset) => [...subset, field]); + subsets.push(...grown); + } + return subsets; +} + +/** + * Renders one variant. + * + * Filled fields carry a placeholder rather than a sample value: it is non-empty, so the template + * takes the same branch a real value would, and it survives Vue's escaping, Maizzle's CSS + * inlining and its plaintext conversion intact, so the sender can substitute into either output. + */ +async function renderVariant( + id: MailTemplateId, + filled: readonly string[], +): Promise { + const { file } = mailTemplates[id]; + const conditional = new Set(mailTemplateConditionalFields(id)); + const context = Object.fromEntries( + mailTemplateFields(id).map((field) => { + const empty = conditional.has(field) && !filled.includes(field); + return [field, empty ? '' : mailTemplatePlaceholder(field)]; + }), + ); + + const { html, plaintext } = await render(`${emailsDirectory}${file}`, { + ...context, + plaintext: true, + }); + + return { html, text: plaintext ?? '' }; +} + +/** + * Renders every variant of every template, in registry order. + * + * Returned as a plain record rather than as {@link CompiledMailTemplates}: it is built key by + * key, and claiming the narrower type would mean asserting the very completeness the artifact is + * written to prove. `mail.ts` reads the checked-in JSON under the narrow type; the test compares + * the two. + */ +export async function compileMailTemplates(): Promise< + Record> +> { + const compiled: Record> = {}; + + for (const id of mailTemplateIds) { + const conditionalFields = mailTemplateConditionalFields(id); + const variants: Record = {}; + + for (const filled of fieldSubsets(conditionalFields)) { + // Sequential rather than concurrent: each render starts its own Vite SSR server, and there + // are eight of them in total — a pool would cost more to coordinate than it saves. + variants[filled.join('+')] = await renderVariant(id, filled); + } + + compiled[id] = variants; + } + + return compiled; +} + +if (import.meta.main) { + const compiled = await compileMailTemplates(); + await writeFile(compiledArtifactPath, `${JSON.stringify(compiled, undefined, 2)}\n`, 'utf8'); + process.stdout.write( + `Compiled ${mailTemplateIds.length} mail templates to ${compiledArtifactPath}\n`, + ); +} diff --git a/packages/mail/src/mail.ts b/packages/mail/src/mail.ts index c232b663..6e361df6 100644 --- a/packages/mail/src/mail.ts +++ b/packages/mail/src/mail.ts @@ -1,13 +1,32 @@ -import { fileURLToPath } from 'node:url'; - -import { render } from '@maizzle/framework'; - import { mailProviderFromEnvironment } from './provider/index.js'; import type { MailProvider } from './provider/types.js'; -import { mailTemplates, type MailTemplateContext, type MailTemplateId } from './util/templates.js'; +import compiledTemplates from './util/compiled-templates.json' with { type: 'json' }; +import { + escapeMailHtml, + interpolateMailTemplate, + mailTemplateVariantKey, + type CompiledMailTemplates, +} from './util/compiled.js'; +import { + mailTemplateConditionalFields, + mailTemplates, + type MailTemplateContext, + type MailTemplateId, +} from './util/templates.js'; + +/** + * The templates as they were rendered at build time by `scripts/compile-templates.ts`. + * + * Imported rather than read from disk so the markup travels inside the module: the deployments + * that send mail bundle this package into a single server file, where a path resolved from + * `import.meta.url` no longer points anywhere near `emails/`. + */ +const compiled = compiledTemplates as CompiledMailTemplates; -/** Templates ship beside the compiled output, so resolve them relative to this module. */ -const emailsDirectory = fileURLToPath(new URL('../emails/', import.meta.url)); +/** Plaintext is already plain: a value goes in exactly as the recipient should read it. */ +function asPlaintext(value: string): string { + return value; +} /** A message to deliver, addressed by template id rather than by file path. */ export interface SendEmailOptions { @@ -34,29 +53,42 @@ function resolveFrom(from: string | undefined): string { } /** - * Render a template and hand it to the configured provider. + * Fill in a template and hand it to the configured provider. * - * Rendering happens per send rather than at build time: the templates carry per-recipient tokens, - * so there is no reusable compiled artifact to cache, and Maizzle's pipeline (SSR, CSS inlining, - * plaintext) is what turns the Vue source into something a mail client renders. + * Rendering happened at build time (`scripts/compile-templates.ts`), because Maizzle's pipeline — + * Vue SSR, CSS inlining, plaintext — runs a bundler, and the deployments that send mail are + * serverless functions with neither a writable working directory nor the platform-native binary + * that bundler needs. What is left per message is the part that genuinely varies: choosing the + * variant whose branches match this context, and substituting the values into it. */ export async function sendEmail( options: SendEmailOptions, mailer: MailerOptions = {}, ): Promise { const template = mailTemplates[options.templateId]; - const { html, plaintext } = await render(`${emailsDirectory}${template.file}`, { - ...options.context, - plaintext: true, - }); + // Every context is a flat record of strings; the per-template types exist to stop a caller + // passing the wrong one, which `SendEmailOptions` has already done by here. + const context: Readonly> = options.context; + const variantKey = mailTemplateVariantKey( + mailTemplateConditionalFields(options.templateId), + context, + ); + const variant = compiled[options.templateId][variantKey]; + if (!variant) { + // Only reachable when the checked-in artifact was rendered from a different registry than the + // one this build ships, which `aube run mail:compile` repairs and `mail.test.ts` catches. + throw new Error( + `mail template ${options.templateId} has no compiled variant ${JSON.stringify(variantKey)}`, + ); + } const provider = mailer.provider ?? mailProviderFromEnvironment(); await provider({ to: options.to, subject: options.subject ?? template.subject, - html, - text: plaintext ?? '', + html: interpolateMailTemplate(variant.html, context, escapeMailHtml), + text: interpolateMailTemplate(variant.text, context, asPlaintext), from: resolveFrom(mailer.from), }); } diff --git a/packages/mail/src/util/compiled-templates.json b/packages/mail/src/util/compiled-templates.json new file mode 100644 index 00000000..57f48457 --- /dev/null +++ b/packages/mail/src/util/compiled-templates.json @@ -0,0 +1,42 @@ +{ + "privateInvitation": { + "": { + "html": "\n
You have been invited to Agent Zero. ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  
AZ Agent Zero

\n Invitation\n

\n You have been invited\n

Hello,

__AZ_MAIL_inviterName__ invited you to create an account on Agent Zero.\n

\n This link is tied to your email address and can be used once. If you were not expecting it,\n you can ignore this message.\n

\n If the button does not work, paste this address into your browser:\n

__AZ_MAIL_acceptUrl__

 

\n Automated message\n

\n If you were not expecting this message you can safely ignore it.\n

", + "text": "You have been invited to Agent Zero. Agent Zero\nInvitation\n\nYou have been invited\nHello, __AZ_MAIL_inviterName__ invited you to create an account on Agent Zero.\nAccept invitation\n\n__AZ_MAIL_acceptUrl__\n\nThis link is tied to your email address and can be used once. If you were not expecting it,\nyou can ignore this message.\n\nIf the button does not work, paste this address into your browser:\n__AZ_MAIL_acceptUrl__\nAutomated message\n\nIf you were not expecting this message you can safely ignore it." + }, + "name": { + "html": "\n
You have been invited to Agent Zero. ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  
AZ Agent Zero

\n Invitation\n

\n You have been invited\n

Hello __AZ_MAIL_name__,

__AZ_MAIL_inviterName__ invited you to create an account on Agent Zero.\n

\n This link is tied to your email address and can be used once. If you were not expecting it,\n you can ignore this message.\n

\n If the button does not work, paste this address into your browser:\n

__AZ_MAIL_acceptUrl__

 

\n Automated message\n

\n If you were not expecting this message you can safely ignore it.\n

", + "text": "You have been invited to Agent Zero. Agent Zero\nInvitation\n\nYou have been invited\nHello __AZ_MAIL_name__, __AZ_MAIL_inviterName__ invited you to create an account on Agent Zero.\nAccept invitation\n\n__AZ_MAIL_acceptUrl__\n\nThis link is tied to your email address and can be used once. If you were not expecting it,\nyou can ignore this message.\n\nIf the button does not work, paste this address into your browser:\n__AZ_MAIL_acceptUrl__\nAutomated message\n\nIf you were not expecting this message you can safely ignore it." + }, + "organizationName": { + "html": "\n
You have been invited to Agent Zero. ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  
AZ Agent Zero

\n Invitation\n

\n You have been invited\n

Hello,

__AZ_MAIL_inviterName__ invited you to join __AZ_MAIL_organizationName__ on Agent Zero.\n

\n This link is tied to your email address and can be used once. If you were not expecting it,\n you can ignore this message.\n

\n If the button does not work, paste this address into your browser:\n

__AZ_MAIL_acceptUrl__

 

\n Automated message\n

\n If you were not expecting this message you can safely ignore it.\n

", + "text": "You have been invited to Agent Zero. Agent Zero\nInvitation\n\nYou have been invited\nHello, __AZ_MAIL_inviterName__ invited you to join __AZ_MAIL_organizationName__ on Agent Zero.\nAccept invitation\n\n__AZ_MAIL_acceptUrl__\n\nThis link is tied to your email address and can be used once. If you were not expecting it,\nyou can ignore this message.\n\nIf the button does not work, paste this address into your browser:\n__AZ_MAIL_acceptUrl__\nAutomated message\n\nIf you were not expecting this message you can safely ignore it." + }, + "name+organizationName": { + "html": "\n
You have been invited to Agent Zero. ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  
AZ Agent Zero

\n Invitation\n

\n You have been invited\n

Hello __AZ_MAIL_name__,

__AZ_MAIL_inviterName__ invited you to join __AZ_MAIL_organizationName__ on Agent Zero.\n

\n This link is tied to your email address and can be used once. If you were not expecting it,\n you can ignore this message.\n

\n If the button does not work, paste this address into your browser:\n

__AZ_MAIL_acceptUrl__

 

\n Automated message\n

\n If you were not expecting this message you can safely ignore it.\n

", + "text": "You have been invited to Agent Zero. Agent Zero\nInvitation\n\nYou have been invited\nHello __AZ_MAIL_name__, __AZ_MAIL_inviterName__ invited you to join __AZ_MAIL_organizationName__ on Agent Zero.\nAccept invitation\n\n__AZ_MAIL_acceptUrl__\n\nThis link is tied to your email address and can be used once. If you were not expecting it,\nyou can ignore this message.\n\nIf the button does not work, paste this address into your browser:\n__AZ_MAIL_acceptUrl__\nAutomated message\n\nIf you were not expecting this message you can safely ignore it." + } + }, + "publicInvitation": { + "": { + "html": "\n
Your public Agent Zero invitation is ready to share. ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  
AZ Agent Zero

\n Public invitation\n

\n Your invitation is ready to share\n

\n Hello __AZ_MAIL_inviterName__, share this link with the people you want to invite on Agent Zero.\n

\n Invitation limits\n

\n Uses: __AZ_MAIL_maxUses__
Expires: __AZ_MAIL_expiresAt__

\n Copy and share this address:\n

__AZ_MAIL_shareUrl__

 

\n Automated message\n

\n If you were not expecting this message you can safely ignore it.\n

", + "text": "Your public Agent Zero invitation is ready to share. Agent Zero\nPublic invitation\n\nYour invitation is ready to share\n\nHello __AZ_MAIL_inviterName__, share this link with the people you want to invite on Agent Zero.\n\nInvitation limits\n\nUses: __AZ_MAIL_maxUses__ Expires: __AZ_MAIL_expiresAt__ Open invitation\n\n__AZ_MAIL_shareUrl__\n\nCopy and share this address:\n__AZ_MAIL_shareUrl__\nAutomated message\n\nIf you were not expecting this message you can safely ignore it." + }, + "organizationName": { + "html": "\n
Your public Agent Zero invitation is ready to share. ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  
AZ Agent Zero

\n Public invitation\n

\n Your invitation is ready to share\n

\n Hello __AZ_MAIL_inviterName__, share this link with the people you want to invite to __AZ_MAIL_organizationName__\n on Agent Zero.\n

\n Invitation limits\n

\n Uses: __AZ_MAIL_maxUses__
Expires: __AZ_MAIL_expiresAt__

\n Copy and share this address:\n

__AZ_MAIL_shareUrl__

 

\n Automated message\n

\n If you were not expecting this message you can safely ignore it.\n

", + "text": "Your public Agent Zero invitation is ready to share. Agent Zero\nPublic invitation\n\nYour invitation is ready to share\n\nHello __AZ_MAIL_inviterName__, share this link with the people you want to invite to __AZ_MAIL_organizationName__\non Agent Zero.\n\nInvitation limits\n\nUses: __AZ_MAIL_maxUses__ Expires: __AZ_MAIL_expiresAt__ Open invitation\n\n__AZ_MAIL_shareUrl__\n\nCopy and share this address:\n__AZ_MAIL_shareUrl__\nAutomated message\n\nIf you were not expecting this message you can safely ignore it." + } + }, + "emailVerification": { + "": { + "html": "\n
Confirm your email address to finish setting up your Agent Zero account. ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  
AZ Agent Zero

\n Account\n

\n Confirm your email address\n

\n Hello __AZ_MAIL_name__,\n

\n Confirm this address to finish setting up your Agent Zero account.\n

\n If the button does not work, paste this address into your browser:\n

__AZ_MAIL_verifyUrl__

 

\n Automated message\n

\n If you were not expecting this message you can safely ignore it.\n

", + "text": "Confirm your email address to finish setting up your Agent Zero account. Agent Zero\nAccount\n\nConfirm your email address\n\nHello __AZ_MAIL_name__,\n\nConfirm this address to finish setting up your Agent Zero account.\nConfirm email address\n\n__AZ_MAIL_verifyUrl__\n\nIf the button does not work, paste this address into your browser:\n__AZ_MAIL_verifyUrl__\nAutomated message\n\nIf you were not expecting this message you can safely ignore it." + } + }, + "passwordReset": { + "": { + "html": "\n
Choose a new password for your Agent Zero account. ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  
AZ Agent Zero

\n Security\n

\n Reset your password\n

\n Hello __AZ_MAIL_name__,\n

\n Use the link below to choose a new password. If you did not request a reset, your password\n remains unchanged.\n

\n If the button does not work, paste this address into your browser:\n

__AZ_MAIL_resetUrl__

 

\n Automated message\n

\n If you were not expecting this message you can safely ignore it.\n

", + "text": "Choose a new password for your Agent Zero account. Agent Zero\nSecurity\n\nReset your password\n\nHello __AZ_MAIL_name__,\n\nUse the link below to choose a new password. If you did not request a reset, your password\nremains unchanged.\nReset password\n\n__AZ_MAIL_resetUrl__\n\nIf the button does not work, paste this address into your browser:\n__AZ_MAIL_resetUrl__\nAutomated message\n\nIf you were not expecting this message you can safely ignore it." + } + } +} diff --git a/packages/mail/src/util/compiled.test.ts b/packages/mail/src/util/compiled.test.ts new file mode 100644 index 00000000..b2fe6564 --- /dev/null +++ b/packages/mail/src/util/compiled.test.ts @@ -0,0 +1,99 @@ +import { fileURLToPath } from 'node:url'; + +import { render } from '@maizzle/framework'; +import { describe, expect, it } from 'vitest'; + +import { compileMailTemplates } from '../../scripts/compile-templates.ts'; +import { sendEmail } from '../mail.js'; +import type { OutgoingMail } from '../provider/types.js'; +import compiledTemplates from './compiled-templates.json' with { type: 'json' }; +import { mailTemplateIds, mailTemplates } from './templates.js'; + +const emailsDirectory = fileURLToPath(new URL('../../emails/', import.meta.url)); + +function recordingProvider() { + const sent: OutgoingMail[] = []; + return { + sent, + provider: (mail: OutgoingMail) => { + sent.push(mail); + return Promise.resolve(); + }, + }; +} + +/** + * The guards the compiled artifact needs. The first two render through the real Maizzle pipeline, + * because the checked-in markup is only trustworthy while it still matches what the templates + * produce, and substitution is only correct if a filled-in message is indistinguishable from one + * rendered with those values in the first place. + * + * They are slow — every render starts a Vite SSR server — which is the whole reason sending does + * none of this. + */ +describe('compiled mail templates', () => { + it('are listed in the same order as the registry declares them', () => { + // `mailTemplateIds` is written out by hand so nothing has to assert `Object.keys` back into + // the id type; this is what keeps the two from drifting. + expect([...mailTemplateIds]).toEqual(Object.keys(mailTemplates)); + }); + + it('match what the templates render today', async () => { + // Fails when a template under emails/ changed without `aube run mail:compile` being run. + await expect(compileMailTemplates()).resolves.toEqual(compiledTemplates); + }, 120_000); + + it('fill in exactly what a live render of the same values produces', async () => { + const context = { + name: 'Ada', + inviterName: 'Grace', + organizationName: 'Acme Ops', + acceptUrl: 'https://dashboard.example.com/invite?token=abc', + }; + + const { sent, provider } = recordingProvider(); + await sendEmail( + { to: 'ada@example.com', templateId: 'privateInvitation', context }, + { provider, from: 'noreply@example.com' }, + ); + + const live = await render(`${emailsDirectory}PrivateInvitation.vue`, { + ...context, + plaintext: true, + }); + + const [mail] = sent; + expect(mail?.html).toBe(live.html); + expect(mail?.text).toBe(live.plaintext ?? ''); + }, 120_000); + + it('escapes the values it substitutes rather than letting them close a tag or an attribute', async () => { + // Organization names are typed by whoever created the organization, and the accept URL carries + // query separators: both are substituted into markup nothing else escapes for them. + const { sent, provider } = recordingProvider(); + await sendEmail( + { + to: 'ada@example.com', + templateId: 'privateInvitation', + context: { + name: 'Ada', + inviterName: 'Grace', + organizationName: ' & Co', + acceptUrl: 'https://dashboard.example.com/invite?token=abc&source=mail', + }, + }, + { provider, from: 'noreply@example.com' }, + ); + + const [mail] = sent; + expect(mail?.html).toContain('<script>alert("x")</script> & Co'); + expect(mail?.html).not.toContain(' & Co'); + expect(mail?.text).toContain('https://dashboard.example.com/invite?token=abc&source=mail'); + }); +}); diff --git a/packages/mail/src/util/compiled.ts b/packages/mail/src/util/compiled.ts new file mode 100644 index 00000000..be785d8e --- /dev/null +++ b/packages/mail/src/util/compiled.ts @@ -0,0 +1,101 @@ +/** + * The contract between the template compiler (`scripts/compile-templates.ts`) and the sender + * (`../mail.ts`). + * + * Maizzle renders a Vue SFC by starting a Vite SSR server, which is a build-time capability: it + * needs a bundler, its platform-native binary, and a writable working directory, none of which a + * serverless function has. So the templates are rendered once at build time into the artifact + * described here, and sending is reduced to selecting a variant and substituting placeholders. + * + * Both halves therefore have to agree on three things — how a placeholder is spelled, how a + * variant is named, and how a value is escaped — which is why all three live in this one module + * rather than being restated on each side. + */ + +import type { MailTemplateId } from './templates.js'; + +/** One rendered variant: the message as a mail client sees it, and its plaintext alternative. */ +export interface CompiledMailTemplate { + readonly html: string; + readonly text: string; +} + +/** + * Every template's variants, keyed by {@link mailTemplateVariantKey}. + * + * A template that branches on nothing has the single `''` variant. + */ +export type CompiledMailTemplates = { + readonly [Id in MailTemplateId]: Readonly>; +}; + +/** + * The stand-in the compiler renders in place of a context value. + * + * Deliberately alphanumeric and underscore-only: it travels through Vue's escaping, Maizzle's CSS + * inlining, its entity encoding, and its HTML-to-plaintext conversion, and has to come out the + * other side unchanged in text, in attributes, and inside `href` URLs alike. + */ +export function mailTemplatePlaceholder(field: string): string { + return `__AZ_MAIL_${field}__`; +} + +const PLACEHOLDER_PATTERN = /__AZ_MAIL_([A-Za-z0-9]+)__/g; +const HTML_ESCAPE_PATTERN = /["'&<>]/g; + +const HTML_ESCAPES: ReadonlyMap = new Map([ + ['&', '&'], + ['<', '<'], + ['>', '>'], + ['"', '"'], + ["'", '''], +]); + +/** + * Escapes exactly what Vue's server renderer escapes in an interpolation. + * + * A substituted value is the one part of the message that never went through Vue, so it is the + * one part that can carry markup: an organization name someone typed, or a URL whose query + * separators would end an attribute. Escaping it here is what keeps it text. + * + * Stricter than the surrounding markup, which Maizzle's entity transformer leaves with a bare `&` + * and `"` where Vue had written `&` and `"`. That decoding is safe for markup Maizzle + * itself produced and unsafe for a value substituted afterwards, so the two legitimately differ. + */ +export function escapeMailHtml(value: string): string { + return value.replaceAll( + HTML_ESCAPE_PATTERN, + (character) => HTML_ESCAPES.get(character) ?? character, + ); +} + +/** + * Names the variant a context selects: the fields it fills, in the order the template declares + * them, joined with `+`. An empty string names the variant where none of them are filled. + */ +export function mailTemplateVariantKey( + conditionalFields: readonly string[], + context: Readonly>, +): string { + // Plain truthiness, because that is what the template's own `v-if` tested before the markup was + // compiled: a value this treats as filled has to be one the rendered variant printed. + return conditionalFields.filter((field) => context[field]).join('+'); +} + +/** + * Substitutes the message's own values into a compiled variant. + * + * A placeholder with no matching context field is left standing rather than blanked: that can + * only mean the compiled artifact and the template registry disagree, which is a build problem to + * surface, not a message to silently mail out with a hole in it. + */ +export function interpolateMailTemplate( + compiled: string, + context: Readonly>, + escape: (value: string) => string, +): string { + return compiled.replaceAll(PLACEHOLDER_PATTERN, (placeholder, field: string) => { + const value = context[field]; + return value === undefined ? placeholder : escape(value); + }); +} diff --git a/packages/mail/src/util/templates.ts b/packages/mail/src/util/templates.ts index d2121822..f9fa46cf 100644 --- a/packages/mail/src/util/templates.ts +++ b/packages/mail/src/util/templates.ts @@ -44,27 +44,102 @@ export interface MailTemplateContext { export type MailTemplateId = keyof MailTemplateContext; -interface MailTemplateDefinition { +/** Context keys of one template, as the registry below spells them out. */ +type MailTemplateField = keyof MailTemplateContext[Id] & string; + +/** + * How a template uses one of its context fields. + * + * Templates are rendered to static markup at build time (`aube run mail:compile`), because + * rendering a Vue SFC needs a bundler and the deployment that sends the message has none. A + * branch therefore cannot be taken at send time: the compiler renders one variant per combination + * of the `conditional` fields being filled or empty, and `sendEmail` picks the variant its own + * context matches. `interpolated` fields are substituted into whichever variant was picked, so + * marking a field `conditional` that the template only prints doubles the compiled output for + * nothing. + */ +type MailTemplateFieldUse = 'interpolated' | 'conditional'; + +interface MailTemplateDefinition { /** Path relative to this package's `emails/` directory. */ readonly file: string; readonly subject: string; + /** + * Every field the template's context carries, and how the template uses it. + * + * Spelled out rather than derived from {@link MailTemplateContext}, which only exists at + * compile time: the compiler needs the field names as values to render a placeholder for each. + * The mapped type makes the two agree — a field that is added to the context and not listed + * here, or listed here and not in the context, fails to type-check. + */ + readonly fields: { readonly [Field in MailTemplateField]: MailTemplateFieldUse }; } -export const mailTemplates: Readonly> = { +export const mailTemplates: { + readonly [Id in MailTemplateId]: MailTemplateDefinition; +} = { privateInvitation: { file: 'PrivateInvitation.vue', subject: 'You have been invited to Agent Zero', + fields: { + // The inviter is not always asked for the invitee's name, and an invitation can grant an + // app-wide role rather than a membership: the template renders each half away when empty. + name: 'conditional', + organizationName: 'conditional', + inviterName: 'interpolated', + acceptUrl: 'interpolated', + }, }, publicInvitation: { file: 'PublicInvitation.vue', subject: 'Your public Agent Zero invitation is ready', + fields: { + organizationName: 'conditional', + inviterName: 'interpolated', + shareUrl: 'interpolated', + maxUses: 'interpolated', + expiresAt: 'interpolated', + }, }, emailVerification: { file: 'EmailVerification.vue', subject: 'Confirm your email address', + fields: { name: 'interpolated', verifyUrl: 'interpolated' }, }, passwordReset: { file: 'PasswordReset.vue', subject: 'Reset your password', + fields: { name: 'interpolated', resetUrl: 'interpolated' }, }, }; + +/** + * Every template, in registry order. + * + * Listed rather than derived, because `Object.keys` cannot report the key type the registry's + * mapped type guarantees, and asserting it back would be the one place a typo could hide. + * `compiled.test.ts` holds this list to the registry. + */ +export const mailTemplateIds = [ + 'privateInvitation', + 'publicInvitation', + 'emailVerification', + 'passwordReset', +] as const satisfies readonly MailTemplateId[]; + +/** + * Every field one template's context carries, in the order it declares them. + * + * That order is what names a variant, so the compiler and the sender both read it from here + * rather than sorting or re-deriving it. Field names are plain strings here: the registry's + * mapped type is what ties them to the context, and both callers address a context by name. + */ +export function mailTemplateFields(id: MailTemplateId): readonly string[] { + return Object.keys(mailTemplates[id].fields); +} + +/** The subset of {@link mailTemplateFields} the template branches on rather than only printing. */ +export function mailTemplateConditionalFields(id: MailTemplateId): readonly string[] { + const fields: Readonly> = mailTemplates[id].fields; + return mailTemplateFields(id).filter((field) => fields[field] === 'conditional'); +} diff --git a/packages/mail/tsconfig.json b/packages/mail/tsconfig.json index 5ee9c865..92c5c148 100644 --- a/packages/mail/tsconfig.json +++ b/packages/mail/tsconfig.json @@ -1,5 +1,11 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist" }, - "include": ["src/**/*.ts"] + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + // The template compiler runs directly through Node's type stripping, which resolves relative + // imports verbatim, so its specifiers must keep their real .ts extension. + "rewriteRelativeImportExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "src/util/compiled-templates.json"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76c9a46d..55fe3746 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -609,23 +609,13 @@ importers: version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) packages/mail: - dependencies: + devDependencies: '@maizzle/framework': specifier: ^6.0.13 version: 6.1.0(shiki@4.4.3)(tailwind-merge@3.6.0)(vue@3.5.41) '@maizzle/tailwindcss': specifier: ^1.5.6 version: 1.5.6 - shiki: - specifier: ^1 || ^2 || ^3 || ^4 - version: 4.4.3 - tailwind-merge: - specifier: ^2 || ^3 - version: 3.6.0 - vue: - specifier: ^3 - version: 3.5.41 - devDependencies: '@types/nodemailer': specifier: ^8.0.1 version: 8.0.1 @@ -641,6 +631,12 @@ importers: resend: specifier: 6.18.0 version: 6.18.0 + shiki: + specifier: ^1 || ^2 || ^3 || ^4 + version: 4.4.3 + tailwind-merge: + specifier: ^2 || ^3 + version: 3.6.0 tsdown: specifier: ^0.22.14 version: 0.22.14(@arethetypeswrong/core@0.18.5)(@vitejs/devtools@0.4.12(vite@8.2.1))(publint@0.3.23)(tsx@4.23.12) @@ -653,6 +649,9 @@ importers: vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.13.3)(@vitejs/devtools@0.4.12)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0)) + vue: + specifier: ^3 + version: 3.5.41 packages/models: dependencies: diff --git a/turbo.jsonc b/turbo.jsonc index ddb256d7..73f275d4 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -87,6 +87,12 @@ "env": ["DATABASE_URL", "AUTH_DATABASE_URL"], "outputs": [], }, + // Rewrites the checked-in render of every mail template (packages/mail). Uncached: it is run + // deliberately after editing a template, and its output is a source file, not a build artifact. + "mail:compile": { + "cache": false, + "outputs": [], + }, "i18n:check": { "cache": false, "outputs": [],