feat: packages/email — Maizzle v6 + Tailwind CSS v4 HTML email templates

- New @sexy.pivoine.art/email package with @maizzle/framework@6.0.0-15
- Uses @maizzle/tailwindcss (TW v4 preset) with @theme brand tokens
  derived from the frontend's app.css oklch primary color
- LightningCSS automatically lowers oklch/lab to hex for email clients
- Real HTML template files (templates/layouts/main.html, verification.html,
  password-reset.html) — not JS template strings
- PostCSS `from` override so @import "@maizzle/tailwindcss" resolves from
  the email package's own node_modules
- Backend lib/email.ts now calls renderVerification/renderPasswordReset
  instead of inline HTML strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 10:41:12 +01:00
parent bb6bf7ca11
commit 60531771cf
11 changed files with 2639 additions and 19 deletions

View File

@@ -0,0 +1,25 @@
import { renderTemplate } from "./render.js";
const BASE_URL = process.env.PUBLIC_URL ?? "https://sexy.pivoine.art";
export async function renderVerification(data: {
token: string;
}): Promise<{ subject: string; html: string }> {
return {
subject: "Verify your email address — sexy.pivoine.art",
html: await renderTemplate("verification", {
url: `${BASE_URL}/signup/verify?token=${data.token}`,
}),
};
}
export async function renderPasswordReset(data: {
token: string;
}): Promise<{ subject: string; html: string }> {
return {
subject: "Reset your password — sexy.pivoine.art",
html: await renderTemplate("password-reset", {
url: `${BASE_URL}/password/reset?token=${data.token}`,
}),
};
}

View File

@@ -0,0 +1,43 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
// Templates live at packages/email/templates/
// At runtime (dist/render.js), __dirname is packages/email/dist/
const PKG_ROOT = path.join(__dirname, "..");
const TEMPLATES_ROOT = path.join(PKG_ROOT, "templates");
const BASE_URL = process.env.PUBLIC_URL ?? "https://sexy.pivoine.art";
export interface RenderOptions {
url: string;
[key: string]: unknown;
}
export async function renderTemplate(
name: string,
locals: RenderOptions,
): Promise<string> {
// Dynamic import: @maizzle/framework v6 is ESM-only
const { render } = await import("@maizzle/framework");
const html = await readFile(path.join(TEMPLATES_ROOT, `${name}.html`), "utf8");
const { html: rendered } = await render(html, {
components: {
root: TEMPLATES_ROOT,
folders: ["layouts"],
},
// Override PostCSS `from` so @tailwindcss/postcss resolves @import "@maizzle/tailwindcss"
// from this package's node_modules (defu gives our value priority over the cwd default).
postcss: {
options: {
from: path.join(PKG_ROOT, "email.css"),
},
},
locals: {
baseUrl: BASE_URL,
...locals,
},
});
return rendered;
}