Просмотр исходного кода

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 недель назад
Родитель
Сommit
19057895e2

+ 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'
 
 /**
- * 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', () => {
   const mail = renderAccountMail({
@@ -16,20 +17,43 @@ describe('renderAccountMail', () => {
     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).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', () => {
@@ -53,4 +77,46 @@ describe('renderAccountMail', () => {
     expect(anonymous.text.startsWith('Hi,\n')).toBe(true)
     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 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 {
   to: 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. */
   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 }
   /** Quieter closing sentences: ignore if not you, expiry. Escaped here. */
   notes?: string[]
+  /** Footer: "You are receiving this because <reason>." Escaped here. */
+  reason?: string
 }
 
 export function escapeHtml(value: string): string {
@@ -31,36 +49,79 @@ export function escapeHtml(value: string): string {
     .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. */
 export function renderAccountMail(mail: AccountMail): { html: string; text: string } {
   const greeting = mail.name ? `Hi ${mail.name.trim()},` : 'Hi,'
+  const heading = mail.heading ?? mail.subject
   const link = mail.link
   const notes = mail.notes ?? []
+  const reason = mail.reason ?? 'this is the address on your Torqvoice account'
 
   const text = [
     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}`, ''] : []),
     ...notes.flatMap((n) => [n, '']),
     'Torqvoice',
   ].join('\n')
 
   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 = [
-    '<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),
-    ...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)),
-    '<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')
 
   return { html, text }

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

@@ -1,7 +1,7 @@
 import 'server-only'
 import { createHash } from 'node:crypto'
 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'
 
 /**
@@ -156,29 +156,59 @@ export async function noteDevice(
   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
   name?: string | null
   label: string
   ip?: string | null
   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 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,
     subject: 'New sign-in to your Torqvoice account',
+    heading: 'New sign-in to your account',
     name: input.name,
+    reason: 'a new device signed in to your Torqvoice account',
     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 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` },
-  })
+  }
+}
+
+/**
+ * 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))
 }