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:
@@ -16,6 +16,7 @@ COPY packages/backend/package.json ./packages/backend/package.json
|
||||
COPY packages/frontend/package.json ./packages/frontend/package.json
|
||||
COPY packages/buttplug/package.json ./packages/buttplug/package.json
|
||||
COPY packages/types/package.json ./packages/types/package.json
|
||||
COPY packages/email/package.json ./packages/email/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --ignore-scripts
|
||||
|
||||
@@ -23,8 +24,11 @@ RUN pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --ignore-s
|
||||
RUN pnpm rebuild argon2 sharp
|
||||
|
||||
COPY packages/types ./packages/types
|
||||
COPY packages/email ./packages/email
|
||||
COPY packages/backend ./packages/backend
|
||||
|
||||
RUN pnpm --filter @sexy.pivoine.art/email build
|
||||
|
||||
RUN pnpm --filter @sexy.pivoine.art/backend build
|
||||
|
||||
RUN CI=true pnpm install --frozen-lockfile --filter @sexy.pivoine.art/backend --prod --ignore-scripts
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@fastify/static": "^8.1.1",
|
||||
"@pothos/core": "^4.4.0",
|
||||
"@pothos/plugin-errors": "^4.2.0",
|
||||
"@sexy.pivoine.art/email": "workspace:*",
|
||||
"@sexy.pivoine.art/types": "workspace:*",
|
||||
"argon2": "^0.43.0",
|
||||
"bullmq": "^5.70.4",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { renderVerification, renderPasswordReset } from "@sexy.pivoine.art/email";
|
||||
import { mailQueue } from "../queues/index.js";
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
@@ -14,24 +15,15 @@ const transporter = nodemailer.createTransport({
|
||||
});
|
||||
|
||||
const FROM = process.env.EMAIL_FROM || "noreply@sexy.pivoine.art";
|
||||
const BASE_URL = process.env.PUBLIC_URL || "http://localhost:3000";
|
||||
|
||||
export async function sendVerification(email: string, token: string): Promise<void> {
|
||||
await transporter.sendMail({
|
||||
from: FROM,
|
||||
to: email,
|
||||
subject: "Verify your email",
|
||||
html: `<p>Click <a href="${BASE_URL}/signup/verify?token=${token}">here</a> to verify your email.</p>`,
|
||||
});
|
||||
const { subject, html } = await renderVerification({ token });
|
||||
await transporter.sendMail({ from: FROM, to: email, subject, html });
|
||||
}
|
||||
|
||||
export async function sendPasswordReset(email: string, token: string): Promise<void> {
|
||||
await transporter.sendMail({
|
||||
from: FROM,
|
||||
to: email,
|
||||
subject: "Reset your password",
|
||||
html: `<p>Click <a href="${BASE_URL}/password/reset?token=${token}">here</a> to reset your password.</p>`,
|
||||
});
|
||||
const { subject, html } = await renderPasswordReset({ token });
|
||||
await transporter.sendMail({ from: FROM, to: email, subject, html });
|
||||
}
|
||||
|
||||
const jobOpts = { attempts: 3, backoff: { type: "exponential" as const, delay: 5000 } };
|
||||
|
||||
25
packages/email/package.json
Normal file
25
packages/email/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@sexy.pivoine.art/email",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@maizzle/framework": "6.0.0-15",
|
||||
"@maizzle/tailwindcss": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
25
packages/email/src/index.ts
Normal file
25
packages/email/src/index.ts
Normal 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}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
43
packages/email/src/render.ts
Normal file
43
packages/email/src/render.ts
Normal 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;
|
||||
}
|
||||
75
packages/email/templates/layouts/main.html
Normal file
75
packages/email/templates/layouts/main.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<!--[if mso]>
|
||||
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
|
||||
<![endif]-->
|
||||
<title>{{ page.title || 'sexy.pivoine.art' }}</title>
|
||||
<style>
|
||||
@import "@maizzle/tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Brand colors — match frontend app.css */
|
||||
--color-brand: oklch(56.971% 0.27455 319.257);
|
||||
--color-brand-foreground: oklch(0.98 0.01 320);
|
||||
--color-brand-dark: oklch(48% 0.26 319);
|
||||
|
||||
/* Surface */
|
||||
--color-surface: oklch(0.98 0.01 320);
|
||||
--color-on-dark: oklch(0.98 0.01 280);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[oklch(0.95_0.01_310)] m-0 p-0 font-sans">
|
||||
|
||||
<!-- Preview text (hidden) -->
|
||||
<if condition="page.previewText || previewText">
|
||||
<div class="hidden max-h-0 overflow-hidden">
|
||||
{{ page.previewText || previewText }}
|
||||
<!-- padding to push any trailing content out of preview -->
|
||||
‌ ‌ ‌ ‌ ‌ ‌
|
||||
</div>
|
||||
</if>
|
||||
|
||||
<div class="py-8 px-4">
|
||||
<table class="w-full max-w-[600px] mx-auto" role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
|
||||
<!-- Brand header -->
|
||||
<tr>
|
||||
<td class="bg-[oklch(0.08_0.02_280)] rounded-t-2xl px-8 py-6 text-center">
|
||||
<a href="{{ baseUrl }}" style="text-decoration: none">
|
||||
<span class="text-sm font-semibold tracking-[0.22em] uppercase text-on-dark">
|
||||
sexy<span class="text-brand">.</span>pivoine<span class="text-brand">.</span>art
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td class="bg-white px-8 py-10 text-[14px] text-zinc-700 leading-relaxed">
|
||||
<yield />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td class="bg-zinc-50 border-t border-zinc-100 rounded-b-2xl px-8 py-6 text-center">
|
||||
<p class="text-[11px] text-zinc-400 m-0">
|
||||
© {{ new Date().getFullYear() }} sexy.pivoine.art — For adults only (18+)
|
||||
</p>
|
||||
<p class="text-[11px] text-zinc-400 mt-2 mb-0">
|
||||
If you did not request this email, you can safely ignore it.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
44
packages/email/templates/password-reset.html
Normal file
44
packages/email/templates/password-reset.html
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: "Reset your password — sexy.pivoine.art"
|
||||
previewText: "You requested a password reset. Use the link below to set a new one."
|
||||
---
|
||||
|
||||
<x-main>
|
||||
|
||||
<h1 class="text-[22px] font-semibold text-zinc-900 m-0 mb-2">
|
||||
Reset your password
|
||||
</h1>
|
||||
<p class="text-zinc-500 m-0 mb-6">
|
||||
We received a request to reset the password for your account. Click the button below to choose a new one.
|
||||
</p>
|
||||
|
||||
<!-- CTA button -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="mb-6">
|
||||
<tr>
|
||||
<td class="rounded-lg" style="background: #b700d9">
|
||||
<a href="{{ url }}"
|
||||
class="inline-block px-8 py-[14px] text-[14px] font-semibold text-white no-underline rounded-lg"
|
||||
style="background: #b700d9">
|
||||
Reset my password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="text-[13px] text-zinc-500 m-0 mb-6">
|
||||
This link expires in <strong class="text-zinc-700">1 hour</strong>.
|
||||
If you did not request a password reset, no action is needed — your account remains secure.
|
||||
</p>
|
||||
|
||||
<hr class="border-0 border-t border-zinc-100 my-6" />
|
||||
|
||||
<p class="text-[12px] text-zinc-400 m-0">
|
||||
Button not working? Copy and paste this link into your browser:
|
||||
</p>
|
||||
<p class="text-[12px] m-0 mt-1">
|
||||
<a href="{{ url }}" class="text-brand break-all" style="color: #b700d9">
|
||||
{{ url }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</x-main>
|
||||
43
packages/email/templates/verification.html
Normal file
43
packages/email/templates/verification.html
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: "Verify your email — sexy.pivoine.art"
|
||||
previewText: "Almost there — confirm your email address to activate your account."
|
||||
---
|
||||
|
||||
<x-main>
|
||||
|
||||
<h1 class="text-[22px] font-semibold text-zinc-900 m-0 mb-2">
|
||||
Verify your email address
|
||||
</h1>
|
||||
<p class="text-zinc-500 m-0 mb-6">
|
||||
Thanks for signing up! Click the button below to confirm your email address and activate your account.
|
||||
</p>
|
||||
|
||||
<!-- CTA button -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="mb-6">
|
||||
<tr>
|
||||
<td class="rounded-lg" style="background: #b700d9">
|
||||
<a href="{{ url }}"
|
||||
class="inline-block px-8 py-[14px] text-[14px] font-semibold text-white no-underline rounded-lg"
|
||||
style="background: #b700d9">
|
||||
Verify my email
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="text-[13px] text-zinc-500 m-0 mb-6">
|
||||
This link expires in <strong class="text-zinc-700">24 hours</strong>.
|
||||
</p>
|
||||
|
||||
<hr class="border-0 border-t border-zinc-100 my-6" />
|
||||
|
||||
<p class="text-[12px] text-zinc-400 m-0">
|
||||
Button not working? Copy and paste this link into your browser:
|
||||
</p>
|
||||
<p class="text-[12px] m-0 mt-1">
|
||||
<a href="{{ url }}" class="text-brand break-all" style="color: #b700d9">
|
||||
{{ url }}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
</x-main>
|
||||
14
packages/email/tsconfig.json
Normal file
14
packages/email/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
2366
pnpm-lock.yaml
generated
2366
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user