瀏覽代碼

Shared layout for account mails, with a device card in the new sign-in mail (#392)

Verify, reset, confirm-address and new-device mails now share one shell: wordmark, card with heading, one button with the link written out, and a footer that says why the mail came. The new-device mail lists device, address and time as rows.
Bernt Christian Egeland 2 周之前
父節點
當前提交
19057895e2
共有 4 個文件被更改,包括 247 次插入 和 44 次删除
  1. 79 13
      src/__tests__/lib/account-mail.test.ts
  2. 46 0
      src/__tests__/lib/new-device-mail.test.ts
  3. 80 19
      src/lib/account-mail.ts
  4. 42 12
      src/lib/known-devices.ts

+ 79 - 13
src/__tests__/lib/account-mail.test.ts

@@ -2,9 +2,10 @@ import { describe, expect, it } from 'vitest'
 import { renderAccountMail } from '@/lib/account-mail'
 import { renderAccountMail } from '@/lib/account-mail'
 
 
 /**
 /**
- * Account mails read like a mail from a person: left-aligned text, a
- * greeting, one ordinary link, a sign-off, and a plain-text half that says
- * the same thing. Nothing is centred and nothing is a button.
+ * Account mails share one shell: the wordmark, a white card with a heading,
+ * a greeting and a few sentences, an optional facts card, one button with
+ * its address written out, quiet notes, and a footer that says why the mail
+ * came. The plain-text half says the same thing without the layout.
  */
  */
 describe('renderAccountMail', () => {
 describe('renderAccountMail', () => {
   const mail = renderAccountMail({
   const mail = renderAccountMail({
@@ -16,20 +17,43 @@ describe('renderAccountMail', () => {
     notes: ["If you didn't ask for this, ignore it."],
     notes: ["If you didn't ask for this, ignore it."],
   })
   })
 
 
-  it('is plain and left-aligned, with no box or button', () => {
-    expect(mail.html).toContain('text-align: left')
-    expect(mail.html).not.toContain('margin: 0 auto')
-    expect(mail.html).not.toContain('max-width')
-    expect(mail.html).not.toContain('display: inline-block')
-    expect(mail.html).not.toContain('<h2')
+  it('wraps the mail in the shared shell', () => {
+    const html = mail.html
+    expect(html).toContain('max-width: 560px')
+    expect(html).toContain('>Torqvoice</td>')
+    expect(html).toContain('<h1 style=')
+    expect(html).toContain('>Reset your password</h1>')
+    expect(html).toContain(
+      'You are receiving this because this is the address on your Torqvoice account: a@b.test'
+    )
+    expect(html.indexOf('<h1')).toBeLessThan(html.indexOf('Hi Kari'))
+  })
+
+  it('shows the link as a button and writes the address out for copying', () => {
+    const html = mail.html
+    expect(html).toContain(
+      'href="https://app.test/reset?token=abc&amp;x=1" style="display: inline-block;'
+    )
+    expect(html).toContain('>Reset your password</a>')
+    expect(html).toContain('Or copy this link into your browser: ')
+    expect(html).toContain('>https://app.test/reset?token=abc&amp;x=1</a>')
+  })
+
+  it('uses a heading of its own when given one', () => {
+    const custom = renderAccountMail({
+      to: 'a@b.test',
+      subject: 'Subject line',
+      heading: 'Card heading',
+      paragraphs: ['x'],
+    })
+    expect(custom.html).toContain('>Card heading</h1>')
+    expect(custom.html).not.toContain('Subject line')
   })
   })
 
 
-  it('escapes what people typed and shows the link address in full', () => {
+  it('escapes what people typed', () => {
     expect(mail.html).toContain('Hi Kari &lt;Nordmann&gt;,')
     expect(mail.html).toContain('Hi Kari &lt;Nordmann&gt;,')
     expect(mail.html).not.toContain('<Nordmann>')
     expect(mail.html).not.toContain('<Nordmann>')
-    expect(mail.html).toContain('href="https://app.test/reset?token=abc&amp;x=1"')
-    expect(mail.html).toContain('>https://app.test/reset?token=abc&amp;x=1</a>')
-    expect(mail.html).toContain('If you didn&quot;t'.replace('&quot;', "'"))
+    expect(mail.html).toContain("If you didn't ask for this")
   })
   })
 
 
   it('carries the same words in the text half', () => {
   it('carries the same words in the text half', () => {
@@ -53,4 +77,46 @@ describe('renderAccountMail', () => {
     expect(anonymous.text.startsWith('Hi,\n')).toBe(true)
     expect(anonymous.text.startsWith('Hi,\n')).toBe(true)
     expect(anonymous.html).not.toContain('undefined')
     expect(anonymous.html).not.toContain('undefined')
   })
   })
+
+  describe('facts card', () => {
+    const withCard = renderAccountMail({
+      to: 'a@b.test',
+      subject: 'New sign-in',
+      paragraphs: [
+        'A new device signed in.',
+        {
+          rows: [
+            { label: 'Device', value: 'Chrome on <Windows>' },
+            { label: 'When', value: '13 September 2026 at 10:26 UTC' },
+          ],
+        },
+        'If this was you, there is nothing to do.',
+      ],
+    })
+
+    it('renders the rows as a table between the paragraphs', () => {
+      const html = withCard.html
+      const cardStart = html.indexOf('<table', html.indexOf('A new device signed in.'))
+      const cardEnd = html.indexOf('</table>', cardStart)
+      expect(cardStart).toBeGreaterThan(-1)
+      expect(html.slice(cardStart, cardEnd)).toContain('>Device</td>')
+      expect(html.slice(cardStart, cardEnd)).toContain('>When</td>')
+      expect(cardEnd).toBeLessThan(html.indexOf('If this was you'))
+    })
+
+    it('escapes row values and keeps them in the text half', () => {
+      expect(withCard.html).toContain('Chrome on &lt;Windows&gt;')
+      expect(withCard.html).not.toContain('<Windows>')
+      expect(withCard.text).toContain(
+        [
+          'A new device signed in.',
+          '',
+          'Device: Chrome on <Windows>',
+          'When: 13 September 2026 at 10:26 UTC',
+          '',
+          'If this was you',
+        ].join('\n')
+      )
+    })
+  })
 })
 })

+ 46 - 0
src/__tests__/lib/new-device-mail.test.ts

@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/db', () => ({ db: {} }))
+
+import { renderAccountMail } from '@/lib/account-mail'
+import { newDeviceMail } from '@/lib/known-devices'
+
+describe('newDeviceMail', () => {
+  const mail = newDeviceMail({
+    to: 'bernt@example.test',
+    name: 'Bernt Christian Egeland',
+    label: 'Chrome on Windows',
+    ip: '89.10.251.100',
+    at: new Date('2026-09-13T10:26:42Z'),
+  })
+
+  it('puts device, address and time in a card, not in a sentence', () => {
+    expect(mail.paragraphs[0]).toBe(
+      'A device we have not seen before just signed in to your Torqvoice account.'
+    )
+    expect(mail.heading).toBe('New sign-in to your account')
+    expect(mail.paragraphs[1]).toEqual({
+      rows: [
+        { label: 'Device', value: 'Chrome on Windows' },
+        { label: 'Address', value: '89.10.251.100' },
+        { label: 'When', value: '13 September 2026 at 10:26 UTC' },
+      ],
+    })
+    expect(mail.link?.url).toMatch(/\/settings\/account$/)
+  })
+
+  it('leaves the address row out when the request had no IP', () => {
+    const noIp = newDeviceMail({ to: 'a@b.test', label: 'Safari on iPhone', at: new Date(0) })
+    const rows = (noIp.paragraphs[1] as { rows: { label: string }[] }).rows.map((r) => r.label)
+    expect(rows).toEqual(['Device', 'When'])
+  })
+
+  it('reads the same in text and html', () => {
+    const rendered = renderAccountMail(mail)
+    expect(rendered.text).toContain(
+      'Device: Chrome on Windows\nAddress: 89.10.251.100\nWhen: 13 September 2026 at 10:26 UTC'
+    )
+    expect(rendered.html).toContain('>Chrome on Windows</td>')
+    expect(rendered.html).toContain('Hi Bernt Christian Egeland,')
+  })
+})

+ 80 - 19
src/lib/account-mail.ts

@@ -4,23 +4,41 @@ import 'server-only'
  * The mails the platform sends to a person about their own account: verify
  * The mails the platform sends to a person about their own account: verify
  * the address, reset the password, confirm a new address, a new device.
  * the address, reset the password, confirm a new address, a new device.
  *
  *
- * Written like a mail from a person, not a marketing piece: left-aligned
- * text, a greeting, a few sentences, one ordinary link, a sign-off. No box
- * in the middle of the page, no button. Every mail has a plain-text half.
- * Nothing here is workshop-branded: these go through the platform sender
- * and reach people who may belong to several workshops or none.
+ * One shell for all of them, in the shape people know from GitHub and
+ * Google: the wordmark, a white card on a grey page with a heading, a
+ * greeting and a few sentences, an optional card of facts, one button, the
+ * same link written out for copying, quieter closing notes, and a footer
+ * that says why the mail came and to which address. Every mail has a
+ * plain-text half. Nothing here is workshop-branded: these go through the
+ * platform sender and reach people who may belong to several workshops or
+ * none.
  */
  */
+/** A row in a facts card: "Device", "Chrome on Windows". */
+export interface AccountMailRow {
+  label: string
+  value: string
+}
+
+export type AccountMailBlock = string | { rows: AccountMailRow[] }
+
 export interface AccountMail {
 export interface AccountMail {
   to: string
   to: string
   subject: string
   subject: string
+  /** Headline inside the card; the subject when not given. Escaped here. */
+  heading?: string
   /** The person's name for the greeting; escaped here. */
   /** The person's name for the greeting; escaped here. */
   name?: string | null
   name?: string | null
-  /** Sentences in order, one paragraph each; escaped here. */
-  paragraphs: string[]
-  /** The one thing to click, shown as a normal link with its address. */
+  /**
+   * Sentences in order, one paragraph each, or a card of label/value rows
+   * where a few facts read better lined up than in a sentence. Escaped here.
+   */
+  paragraphs: AccountMailBlock[]
+  /** The one thing to click: a button, with the address written out under it. */
   link?: { text: string; url: string }
   link?: { text: string; url: string }
   /** Quieter closing sentences: ignore if not you, expiry. Escaped here. */
   /** Quieter closing sentences: ignore if not you, expiry. Escaped here. */
   notes?: string[]
   notes?: string[]
+  /** Footer: "You are receiving this because <reason>." Escaped here. */
+  reason?: string
 }
 }
 
 
 export function escapeHtml(value: string): string {
 export function escapeHtml(value: string): string {
@@ -31,36 +49,79 @@ export function escapeHtml(value: string): string {
     .replace(/"/g, '&quot;')
     .replace(/"/g, '&quot;')
 }
 }
 
 
+const FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
+const INK = '#1f2328'
+const MUTED = '#59636e'
+const LINE = '#d1d9e0'
+const PAGE = '#f6f8fa'
+const BUTTON = '#1f883d'
+
 /** Exported for tests: the two halves of the mail, from one description. */
 /** Exported for tests: the two halves of the mail, from one description. */
 export function renderAccountMail(mail: AccountMail): { html: string; text: string } {
 export function renderAccountMail(mail: AccountMail): { html: string; text: string } {
   const greeting = mail.name ? `Hi ${mail.name.trim()},` : 'Hi,'
   const greeting = mail.name ? `Hi ${mail.name.trim()},` : 'Hi,'
+  const heading = mail.heading ?? mail.subject
   const link = mail.link
   const link = mail.link
   const notes = mail.notes ?? []
   const notes = mail.notes ?? []
+  const reason = mail.reason ?? 'this is the address on your Torqvoice account'
 
 
   const text = [
   const text = [
     greeting,
     greeting,
     '',
     '',
-    ...mail.paragraphs.flatMap((p) => [p, '']),
+    ...mail.paragraphs.flatMap((block) =>
+      typeof block === 'string'
+        ? [block, '']
+        : [...block.rows.map((row) => `${row.label}: ${row.value}`), '']
+    ),
     ...(link ? [`${link.text}: ${link.url}`, ''] : []),
     ...(link ? [`${link.text}: ${link.url}`, ''] : []),
     ...notes.flatMap((n) => [n, '']),
     ...notes.flatMap((n) => [n, '']),
     'Torqvoice',
     'Torqvoice',
   ].join('\n')
   ].join('\n')
 
 
   const p = (s: string, muted = false) =>
   const p = (s: string, muted = false) =>
-    `<p style="margin: 0 0 14px 0;${muted ? ' color: #555555;' : ''}">${escapeHtml(s)}</p>`
+    `<p style="margin: 0 0 14px 0; font-size: 15px; line-height: 1.55;${muted ? ` color: ${MUTED};` : ''}">${escapeHtml(s)}</p>`
+
+  // Tables throughout: the one layout element mail clients render alike.
+  const card = (rows: AccountMailRow[]) =>
+    [
+      `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 20px 0; background: ${PAGE}; border: 1px solid ${LINE}; border-radius: 6px; border-collapse: separate; text-align: left;">`,
+      ...rows.map((row, i) => {
+        const border = i < rows.length - 1 ? ` border-bottom: 1px solid ${LINE};` : ''
+        return (
+          '<tr>' +
+          `<td style="padding: 10px 16px; width: 90px; font-size: 13px; color: ${MUTED}; white-space: nowrap; vertical-align: top;${border}">${escapeHtml(row.label)}</td>` +
+          `<td style="padding: 10px 16px; font-size: 14px; font-weight: 600; color: ${INK}; vertical-align: top;${border}">${escapeHtml(row.value)}</td>` +
+          '</tr>'
+        )
+      }),
+      '</table>',
+    ].join('\n')
+
+  const button = (l: { text: string; url: string }) =>
+    [
+      '<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 6px 0 14px 0;">',
+      `<tr><td style="background: ${BUTTON}; border-radius: 6px;">`,
+      `<a href="${escapeHtml(l.url)}" style="display: inline-block; padding: 11px 20px; font-size: 14px; font-weight: 600; color: #ffffff; text-decoration: none;">${escapeHtml(l.text)}</a>`,
+      '</td></tr>',
+      '</table>',
+      `<p style="margin: 0 0 20px 0; font-size: 12px; line-height: 1.5; color: ${MUTED}; word-break: break-all;">Or copy this link into your browser: <a href="${escapeHtml(l.url)}" style="color: ${MUTED};">${escapeHtml(l.url)}</a></p>`,
+    ].join('\n')
 
 
   const html = [
   const html = [
-    '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Helvetica, Arial, sans-serif; font-size: 15px; line-height: 1.5; color: #111111; text-align: left;">',
+    `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: ${PAGE}; font-family: ${FONT};">`,
+    '<tr><td align="center" style="padding: 32px 16px;">',
+    `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 560px; color: ${INK}; text-align: left;">`,
+    `<tr><td style="padding: 0 0 20px 0; font-size: 18px; font-weight: 700; color: ${INK};">Torqvoice</td></tr>`,
+    `<tr><td style="background: #ffffff; border: 1px solid ${LINE}; border-radius: 8px; padding: 32px;">`,
+    `<h1 style="margin: 0 0 12px 0; font-size: 22px; font-weight: 600; line-height: 1.3; color: ${INK};">${escapeHtml(heading)}</h1>`,
     p(greeting),
     p(greeting),
-    ...mail.paragraphs.map((s) => p(s)),
-    ...(link
-      ? [
-          `<p style="margin: 0 0 14px 0;">${escapeHtml(link.text)}: <a href="${escapeHtml(link.url)}" style="color: #1a56db;">${escapeHtml(link.url)}</a></p>`,
-        ]
-      : []),
+    ...mail.paragraphs.map((block) => (typeof block === 'string' ? p(block) : card(block.rows))),
+    ...(link ? [button(link)] : []),
     ...notes.map((s) => p(s, true)),
     ...notes.map((s) => p(s, true)),
-    '<p style="margin: 18px 0 0 0;">Torqvoice</p>',
-    '</div>',
+    '</td></tr>',
+    `<tr><td style="padding: 20px 8px 0 8px; font-size: 12px; line-height: 1.5; color: ${MUTED};">You are receiving this because ${escapeHtml(reason)}: ${escapeHtml(mail.to)}<br>Torqvoice</td></tr>`,
+    '</table>',
+    '</td></tr>',
+    '</table>',
   ].join('\n')
   ].join('\n')
 
 
   return { html, text }
   return { html, text }

+ 42 - 12
src/lib/known-devices.ts

@@ -1,7 +1,7 @@
 import 'server-only'
 import 'server-only'
 import { createHash } from 'node:crypto'
 import { createHash } from 'node:crypto'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
-import { sendAccountMail } from '@/lib/account-mail'
+import { type AccountMail, type AccountMailRow, sendAccountMail } from '@/lib/account-mail'
 import { DEVICE_COOKIE, readDeviceCookie } from '@/lib/device-cookie'
 import { DEVICE_COOKIE, readDeviceCookie } from '@/lib/device-cookie'
 
 
 /**
 /**
@@ -156,29 +156,59 @@ export async function noteDevice(
   return { isNew: true, isFirst: others === 0, label }
   return { isNew: true, isFirst: others === 0, label }
 }
 }
 
 
-/**
- * "A new device signed in": sent to the account's address, from the
- * platform sender, never through a workshop's own mail setup. Best effort;
- * the sign-in has already happened and must not fail on a mail error.
- */
-export async function sendNewDeviceMail(input: {
+export interface NewDeviceMailInput {
   to: string
   to: string
   name?: string | null
   name?: string | null
   label: string
   label: string
   ip?: string | null
   ip?: string | null
   at: Date
   at: Date
-}): Promise<void> {
+}
+
+/** "13 September 2026, 10:26 UTC": the mail has no idea what timezone the reader is in. */
+function formatSignInTime(at: Date): string {
+  return new Intl.DateTimeFormat('en-GB', {
+    day: 'numeric',
+    month: 'long',
+    year: 'numeric',
+    hour: '2-digit',
+    minute: '2-digit',
+    hour12: false,
+    timeZone: 'UTC',
+    timeZoneName: 'short',
+  }).format(at)
+}
+
+/**
+ * The mail itself, separate from sending so a test can look at it. The
+ * device, address and time sit in a card so a person can compare them with
+ * what is in front of them at a glance.
+ */
+export function newDeviceMail(input: NewDeviceMailInput): AccountMail {
   const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.torqvoice.com'
   const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.torqvoice.com'
-  const where = input.ip ? ` from ${input.ip}` : ''
-  await sendAccountMail({
+  const rows: AccountMailRow[] = [{ label: 'Device', value: input.label }]
+  if (input.ip) rows.push({ label: 'Address', value: input.ip })
+  rows.push({ label: 'When', value: formatSignInTime(input.at) })
+  return {
     to: input.to,
     to: input.to,
     subject: 'New sign-in to your Torqvoice account',
     subject: 'New sign-in to your Torqvoice account',
+    heading: 'New sign-in to your account',
     name: input.name,
     name: input.name,
+    reason: 'a new device signed in to your Torqvoice account',
     paragraphs: [
     paragraphs: [
-      `A new device signed in to your Torqvoice account: ${input.label}${where}, ${input.at.toUTCString()}.`,
+      'A device we have not seen before just signed in to your Torqvoice account.',
+      { rows },
       'If this was you, there is nothing to do.',
       'If this was you, there is nothing to do.',
       'If it was not, change your password and sign out the other devices from your account page.',
       'If it was not, change your password and sign out the other devices from your account page.',
     ],
     ],
     link: { text: 'Review signed-in devices', url: `${appUrl}/settings/account` },
     link: { text: 'Review signed-in devices', url: `${appUrl}/settings/account` },
-  })
+  }
+}
+
+/**
+ * Sent to the account's address, from the platform sender, never through a
+ * workshop's own mail setup. Best effort; the sign-in has already happened
+ * and must not fail on a mail error.
+ */
+export async function sendNewDeviceMail(input: NewDeviceMailInput): Promise<void> {
+  await sendAccountMail(newDeviceMail(input))
 }
 }