diff --git a/docs/plans/2026-07-24-luxury-verification-email-design.md b/docs/plans/2026-07-24-luxury-verification-email-design.md new file mode 100644 index 0000000..b826169 --- /dev/null +++ b/docs/plans/2026-07-24-luxury-verification-email-design.md @@ -0,0 +1,58 @@ +# Luxury verification email design + +## Goal + +Turn the registration verification email into a trustworthy brand touchpoint +without weakening deliverability, accessibility, security, or support for older +mail clients. + +## Visual direction + +The selected direction is **Eastern academy meets international editorial**. +It reuses the product palette instead of introducing a separate email brand: + +- warm ivory `#fffdf7` and parchment `#f1eee5` for the canvas; +- ink teal `#172c35` for the main panel and typography; +- cinnabar `#bf3d2f` for the verification action; +- restrained champagne gold `#c9a86a` for dividers and small details. + +The memorable element is a six-digit code presented like a contemporary +digital seal. Generous whitespace, one display-serif heading, precise +bilingual microcopy, and thin ornamental rules provide the premium character. +There are no stock illustrations, gradients, tracking pixels, remote fonts, or +generic app-style cards. + +## Information hierarchy + +1. Hidden preheader: explains that this is a time-sensitive registration step + without exposing the code on a lock screen. +2. Brand masthead: Chinese platform name, small English descriptor, and a + typographic seal. +3. Main message: concise welcome and one instruction. +4. Verification panel: six-digit code, ten-minute duration, and exact expiry in + Asia/Shanghai. +5. Security note: never share the code; ignore the email when unrequested. +6. Quiet footer: transactional-email explanation and copyright line. + +## Architecture and compatibility + +`createVerificationMessage` remains the only renderer. The HMAC relay payload, +SMTP transport configuration, API validation, and registration flow do not +change. The email uses presentation tables and inline styles because Outlook +desktop still relies on a Word-based renderer. A small mobile media query is +progressive enhancement; the base layout remains usable when style blocks are +removed. + +Dynamic values are HTML-escaped even though the API already restricts the code +to six digits. The HTML contains no remote image, script, form, or CSS URL. A +complete plain-text alternative remains mandatory. + +## Acceptance criteria + +- Gmail accepts the production message and the API returns no development code. +- The message is readable at 320 px and 640 px widths. +- The code is prominent, selectable, and present in both HTML and plain text. +- Content remains understandable with images disabled and CSS partially + stripped. +- Automated tests protect subject, expiry, escaping, structure, and the absence + of remote assets. diff --git a/docs/plans/2026-07-24-luxury-verification-email.md b/docs/plans/2026-07-24-luxury-verification-email.md new file mode 100644 index 0000000..0c91896 --- /dev/null +++ b/docs/plans/2026-07-24-luxury-verification-email.md @@ -0,0 +1,102 @@ +# Luxury Verification Email Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Ship a premium, responsive registration-code email that preserves the +existing secure relay and broad email-client compatibility. + +**Architecture:** Keep rendering in `createVerificationMessage` and leave the +Railway-to-Vercel HMAC contract unchanged. Build a self-contained, table-based +HTML document with inline styles, a plain-text fallback, HTML escaping, and no +remote assets. + +**Tech Stack:** Node.js, Nodemailer, semantic HTML email, inline CSS, Node test +runner, Prettier, Vercel Functions. + +--- + +### Task 1: Lock the message contract with tests + +**Files:** + +- Modify: `server/test/mail-provider.test.js` +- Modify: `server/test/mail-relay.test.js` + +**Step 1:** Import `createVerificationMessage` and add assertions for the +bilingual masthead, code, Shanghai expiry, presentation-table structure, +preheader, security copy, and text alternative. + +**Step 2:** Add a defensive escaping case that passes HTML metacharacters +directly to the renderer and verifies that executable markup never appears. + +**Step 3:** Run: + +```bash +pnpm exec node --test server/test/mail-provider.test.js server/test/mail-relay.test.js +``` + +Expected: the new visual-contract assertions fail before implementation while +the existing transport and HMAC tests continue to pass. + +### Task 2: Implement the premium renderer + +**Files:** + +- Modify: `server/services/mail-provider.js` + +**Step 1:** Add a small `escapeHtml` helper for renderer-owned dynamic content. + +**Step 2:** Improve the plain-text message with a clear title, code, expiry, +security guidance, and support context. + +**Step 3:** Replace the minimal HTML fragment with a complete email document: +hidden preheader, ink masthead, ivory body, cinnabar code panel, exact expiry, +security note, and restrained footer. + +**Step 4:** Keep every critical style inline, use `role="presentation"` layout +tables, and avoid external images, fonts, scripts, forms, and CSS URLs. + +**Step 5:** Re-run the two focused test files. Expected: all tests pass. + +### Task 3: Validate the visual output + +**Files:** + +- Temporary artifact only: `C:\tmp\international-chinese-verification-email.html` +- Temporary artifact only: `C:\tmp\international-chinese-verification-email.png` + +**Step 1:** Render a deterministic example using +`createVerificationMessage`. + +**Step 2:** Capture the HTML at a mobile-friendly viewport and inspect the +screenshot for hierarchy, clipping, contrast, spacing, and code legibility. + +**Step 3:** Fix any visible issue before continuing and delete temporary +preview artifacts after review. + +### Task 4: Run repository verification + +**Files:** No new files. + +**Step 1:** Run `pnpm lint:check`. + +**Step 2:** Run `pnpm format:check`. + +**Step 3:** Run `pnpm test:api`. + +**Step 4:** Run `pnpm build`. + +Expected: every command exits successfully. + +### Task 5: Release and production smoke test + +**Files:** Commit the renderer, tests, and design documents. + +**Step 1:** Commit and push `codex/luxury-verification-email`. + +**Step 2:** Merge only after GitHub CI and Vercel preview checks pass. + +**Step 3:** Confirm Railway and Vercel production deployments are healthy. + +**Step 4:** Request a production verification code through the public API and +confirm HTTP 200, an expiry timestamp, and no `developmentCode`. diff --git a/server/services/mail-provider.js b/server/services/mail-provider.js index 7e0c120..a32c6b4 100644 --- a/server/services/mail-provider.js +++ b/server/services/mail-provider.js @@ -2,27 +2,211 @@ import { createHmac } from 'node:crypto' import nodemailer from 'nodemailer' +const HTML_ENTITIES = Object.freeze({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +}) + +function escapeHtml(value) { + return String(value).replace( + /[&<>"']/g, + (character) => HTML_ENTITIES[character] + ) +} + function createVerificationMessage({ email, code, expiresAt, mailFrom }) { + const expiryDate = new Date(expiresAt) + if (Number.isNaN(expiryDate.getTime())) { + throw new TypeError('Verification code expiry is invalid') + } const expiry = new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Shanghai' - }).format(new Date(expiresAt)) + }).format(expiryDate) + const expiryWithZone = `${expiry}(北京时间)` + const safeCode = escapeHtml(code) + const safeExpiry = escapeHtml(expiryWithZone) return { from: mailFrom, to: email, subject: '国际中文学习平台注册验证码', - text: `你的注册验证码是 ${code},有效期至 ${expiry}。请勿向任何人透露此验证码。`, - html: ` -
-

INTERNATIONAL CHINESE PLATFORM

-

完成你的平台注册

-

请在注册页面输入下面的六位验证码:

-

${code}

-

验证码有效期至 ${expiry}。如果这不是你的操作,请忽略本邮件。

-
- ` + text: `国际中文教育平台 +INTERNATIONAL CHINESE EDUCATION + +完成你的平台注册 + +你的注册验证码是: +${code} + +有效期 10 分钟,至 ${expiryWithZone}。 + +请勿向任何人透露此验证码。平台工作人员不会向你索取验证码。 +如果这不是你的操作,请忽略本邮件,无需进行任何处理。 + +这是一封由系统自动发送的事务邮件,请勿直接回复。`, + headers: { + 'Auto-Submitted': 'auto-generated', + 'X-Auto-Response-Suppress': 'All' + }, + html: ` + + + + + + + 国际中文学习平台注册验证码 + + + +
+ 你的注册验证码将在 10 分钟后失效,请及时完成验证。 +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ +` } } diff --git a/server/test/mail-provider.test.js b/server/test/mail-provider.test.js index b2f27fb..886d3f7 100644 --- a/server/test/mail-provider.test.js +++ b/server/test/mail-provider.test.js @@ -1,8 +1,74 @@ import assert from 'node:assert/strict' +import { Buffer } from 'node:buffer' import { createHmac } from 'node:crypto' import test from 'node:test' -import { createMailProvider } from '../services/mail-provider.js' +import { + createMailProvider, + createVerificationMessage +} from '../services/mail-provider.js' + +test('verification message renders a premium self-contained email', () => { + const message = createVerificationMessage({ + email: 'student@example.test', + code: '123456', + expiresAt: '2030-01-01T12:10:00.000Z', + mailFrom: 'Platform ' + }) + + assert.equal(message.subject, '国际中文学习平台注册验证码') + assert.doesNotMatch(message.subject, /123456/) + assert.doesNotMatch(message.subject, /student@example\.test/) + assert.match(message.text, /123456/) + assert.match(message.text, /北京时间/) + assert.match(message.text, /请勿向任何人透露/) + + assert.match(message.html, //i) + assert.match(message.html, //) + assert.match(message.html, /INTERNATIONAL CHINESE EDUCATION/) + assert.match(message.html, /完成你的平台注册/) + assert.match(message.html, /123456/) + assert.match(message.html, /有效期 10 分钟/) + assert.match(message.html, /北京时间/) + assert.match(message.html, /请勿向任何人透露/) + assert.match(message.html, /role="presentation"/) + assert.match(message.html, /dir="ltr"/) + assert.doesNotMatch(message.html, / { + const message = createVerificationMessage({ + email: 'student@example.test', + code: '', + expiresAt: '2030-01-01T12:10:00.000Z', + mailFrom: 'Platform ' + }) + + assert.doesNotMatch(message.html, / { + assert.throws( + () => + createVerificationMessage({ + email: 'student@example.test', + code: '123456', + expiresAt: 'not-a-date', + mailFrom: 'Platform ' + }), + /expiry is invalid/ + ) +}) test('SMTP mail provider sends the registration code with hardened transport options', async () => { let transportOptions diff --git a/server/test/mail-relay.test.js b/server/test/mail-relay.test.js index 3c8b81c..fe42f82 100644 --- a/server/test/mail-relay.test.js +++ b/server/test/mail-relay.test.js @@ -63,6 +63,9 @@ test('mail relay accepts a valid signed verification payload', async () => { assert.equal(message.to, 'student@example.test') assert.equal(message.subject, '国际中文学习平台注册验证码') assert.match(message.text, /123456/) + assert.match(message.html, /INTERNATIONAL CHINESE EDUCATION/) + assert.match(message.html, /role="presentation"/) + assert.doesNotMatch(message.html, / {