Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/semantic-pull-requests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
deps
docs
github
mail
models
release
runner
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
16 changes: 14 additions & 2 deletions apps/docs/guide/mails.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 5 additions & 6 deletions packages/mail/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
107 changes: 107 additions & 0 deletions packages/mail/scripts/compile-templates.ts
Original file line number Diff line number Diff line change
@@ -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<CompiledMailTemplate> {
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<string, Record<string, CompiledMailTemplate>>
> {
const compiled: Record<string, Record<string, CompiledMailTemplate>> = {};

for (const id of mailTemplateIds) {
const conditionalFields = mailTemplateConditionalFields(id);
const variants: Record<string, CompiledMailTemplate> = {};

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`,
);
}
66 changes: 49 additions & 17 deletions packages/mail/src/mail.ts
Original file line number Diff line number Diff line change
@@ -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<Id extends MailTemplateId = MailTemplateId> {
Expand All @@ -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<Id extends MailTemplateId>(
options: SendEmailOptions<Id>,
mailer: MailerOptions = {},
): Promise<void> {
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<Record<string, string>> = 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),
});
}
Expand Down
Loading
Loading