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

End-to-end tests for the email template designer (#375)

Twenty tests in e2e/specs/email/, in two files.

designer.spec.ts walks the gallery and the designer: every kind of mail on its
built-in template, a card that opens the designer in its own tab, the rail and
the preview carrying the same block marks, a block hidden from the mail, the
plain-text half, a misspelt tag and an invalid colour each putting Save out of
reach, the first save asking for a name and taking the kind over, a second
template refused the first one's name, and deleting the one in use putting the
kind back on its preset.

sending.spec.ts reads what arrives out of the mail sink: a test send marked
"[Test]" with both halves and no unfilled tag, an invoice written with the
workshop's own template rather than the preset and carrying that job's number
and customer, the attachment note only when the PDF rode along and the share
link when it did not, a message to a customer arriving with its markup escaped
rather than rendered, a portal sign-in link followed until it lands inside the
portal, and a logo uploaded and then fetched the way a mail client would, with
no session at all.

No defect found in the feature. Two things learned and written down: a trailing
divider is dropped from a mail on purpose, because the footer is lifted out of
the card and a rule above it would underline nothing; and the stored upload path
is mapped to a public one when the mail is built, which is what keeps a logo
from being a broken picture in every customer's inbox.

The two timezone specs no longer right-click today's calendar column. Other
specs book jobs into it, and a right-click that lands on a chip opens that
job's menu instead of the slot's; they ask the calendar for a day six weeks out
instead.
Bernt Christian Egeland 3 недель назад
Родитель
Сommit
87ccf875bd

+ 1 - 0
e2e/README.md

@@ -27,6 +27,7 @@ e2e/
                        workshop's own timezone
     inventory/         a stocked part leaves the shelf exactly once
     reminders/         a due time survives being displayed and re-saved
+    email/             the email template designer, and the mail it sends
     tech/              the technician app's API contract
     smoke/             the build is alive
 ```

BIN
e2e/fixtures/email-logo.png


+ 13 - 1
e2e/specs/calendar/booking.spec.ts

@@ -38,8 +38,20 @@ async function dayColumn(page: Page) {
   return columns.first()
 }
 
+/**
+ * A day far enough ahead that nothing is booked on it.
+ *
+ * Today's column is whatever the seed and the other specs have put there, and
+ * a right-click that lands on a chip opens that job's menu instead of the
+ * empty-slot one. The date is a query the calendar already takes.
+ */
+const EMPTY_DAY = (() => {
+  const day = new Date(Date.now() + 45 * 86_400_000)
+  return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
+})()
+
 async function openDayView(page: Page) {
-  await page.goto('/calendar')
+  await page.goto(`/calendar?view=day&date=${EMPTY_DAY}`)
   await settle(page)
   // The views have single-key shortcuts, which is both what a service adviser
   // uses all day and the steadiest way in: the switcher itself is a dropdown

+ 274 - 0
e2e/specs/email/designer.spec.ts

@@ -0,0 +1,274 @@
+import { expect, test } from '@playwright/test'
+import { emailTemplateNames, forgetEmailTemplates } from '../../support/db'
+import { settle } from '../../support/hydration'
+import {
+  openPreset,
+  openSubjectAndTheme,
+  preview,
+  railBlock,
+  railOrder,
+  saveDesign,
+  subjectField,
+} from '../../support/email-designer'
+
+/**
+ * The email designer, from the gallery to a saved template.
+ *
+ * Every mail a workshop sends is built from one of these, so the failure
+ * modes are worth naming: a block hidden in the designer that still prints in
+ * the mail, a misspelt tag saved and then sent to a customer as
+ * "{custmer_name}", or a template saved under a name that already exists and
+ * quietly replacing the other one.
+ *
+ * The preview is a real iframe of the rendered mail, which is why it can be
+ * asserted against at all: what the frame holds is what the mail client gets.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const TEMPLATE = `E2E invoice mail ${stamp}`
+const SECOND = `E2E second mail ${stamp}`
+/** A sentence nothing else in the app says, to find in the preview and the mail. */
+const SENTENCE = `Your invoice is ready, ${stamp}`
+
+test.afterAll(async () => {
+  // No test's design is left sending the workshop's mail.
+  await forgetEmailTemplates('E2E ')
+})
+
+test.describe('the gallery', () => {
+  test('offers every kind of mail, each on its built-in template', async ({ page }) => {
+    await page.goto('/settings/email-templates')
+    await settle(page)
+
+    // The kinds come from EMAIL_KINDS; the page groups them, and each group
+    // names the kinds under it.
+    for (const kind of [
+      'Invoice sent',
+      'Quote sent',
+      'Inspection report sent',
+      'Customer message',
+      'Portal sign-in',
+    ]) {
+      await expect(
+        page.getByRole('heading', { name: kind, exact: true }),
+        `${kind} has a section`
+      ).toBeVisible()
+    }
+
+    // Nothing is designed yet, so every kind sends with its preset and says so.
+    const builtIn = page.getByText('Built-in', { exact: true })
+    expect(await builtIn.count(), 'a built-in card per kind').toBeGreaterThanOrEqual(5)
+    expect(await page.getByText('In use', { exact: true }).count()).toBeGreaterThanOrEqual(5)
+  })
+
+  test('opens the designer in a tab of its own', async ({ page }) => {
+    await page.goto('/settings/email-templates')
+    await settle(page)
+
+    // A card is a link, and it opens beside the settings page rather than
+    // taking the workshop out of it.
+    const card = page.getByRole('link').filter({ hasText: 'Built-in' }).first()
+    await expect(card).toHaveAttribute('target', '_blank')
+    await expect(card).toHaveAttribute('href', /\/email-designer\?kind=\w+&preset=1/)
+  })
+})
+
+test.describe('the designer', () => {
+  test('shows the mail it is designing, block by block', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+
+    // The rail and the mail point at each other through these marks, which is
+    // how a click in one selects in the other.
+    const blocks = await railOrder(page)
+    expect(blocks, 'the preset is the mail a workshop expects').toEqual([
+      'header',
+      'heading',
+      'intro',
+      'message',
+      'summary',
+      'cta',
+      'attachment',
+      'outro',
+      'divider',
+      'footer',
+    ])
+
+    for (const id of blocks.filter((block) => block !== 'divider')) {
+      await expect(
+        preview(page).locator(`[data-block="${id}"]`),
+        `${id} is in the mail`
+      ).toBeAttached()
+    }
+
+    // The exception, and it is deliberate: the footer is lifted out of the
+    // card, and a rule immediately above it would underline nothing, so it
+    // goes with it. Moving the footer up brings the rule back.
+    await expect(
+      preview(page).locator('[data-block="divider"]'),
+      'a rule at the foot of the card is dropped with the footer'
+    ).toHaveCount(0)
+  })
+
+  test('a block clicked in the rail is the one the inspector edits', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+    await expect(async () => {
+      await railBlock(page, 'intro').click()
+      await expect(railBlock(page, 'intro')).toHaveAttribute('aria-pressed', 'true', {
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+
+    // The inspector swaps from "Subject and theme" to that block's own fields.
+    await expect(page.getByLabel('Text', { exact: true })).toBeVisible()
+    await expect(subjectField(page), 'the subject is put away while a block is open').toHaveCount(0)
+
+    // And the way back, which is how a workshop reaches the subject again.
+    await openSubjectAndTheme(page)
+    await expect(railBlock(page, 'intro')).toHaveAttribute('aria-pressed', 'false')
+  })
+
+  test('a block hidden in the rail leaves the mail', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+    const row = railBlock(page, 'outro')
+
+    await expect(preview(page).locator('[data-block="outro"]')).toBeAttached()
+    await expect(async () => {
+      await row.getByRole('button', { name: /Shown in the email/ }).click()
+      await expect(
+        preview(page).locator('[data-block="outro"]'),
+        'the hidden block is not in the mail'
+      ).toHaveCount(0, { timeout: 3_000 })
+    }).toPass({ timeout: 30_000 })
+
+    // And the row says what it is now, so the eye is not a mystery toggle.
+    await expect(row.getByRole('button', { name: /Hidden from the email/ })).toBeVisible()
+  })
+
+  test('the plain text half says the same as the mail', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+
+    // Every mail goes out with both halves; a client that shows text only
+    // must still be able to read it, and follow the button.
+    await page.getByRole('button', { name: 'Show the plain-text version' }).click()
+    const text = await page.locator('pre').first().innerText()
+    expect(text.length, 'the text half has words in it').toBeGreaterThan(40)
+    // A button cannot be clicked in plain text, so it is spelled out.
+    expect(text).toMatch(/https?:\/\//)
+  })
+
+  test('a misspelt tag is a problem, and the problem blocks the save', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+
+    const subject = subjectField(page)
+    await expect(subject).toBeVisible()
+    await subject.fill('Invoice for {custmer_name}')
+
+    // The badge counts what is wrong and the tooltip names the tag; either
+    // way Save is out of reach until it is fixed.
+    await expect(page.getByText(/problem/)).toBeVisible()
+    await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeDisabled()
+
+    // Spelled correctly, it is a tag again and the mail fills it in.
+    await subject.fill('Invoice for {customer_name}')
+    await expect(page.getByText(/problem/)).toHaveCount(0)
+    await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeEnabled()
+  })
+
+  test('an invalid colour blocks the save and says why', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+
+    const hex = page.getByLabel('Primary as hex')
+    await hex.fill('#zzz')
+    await expect(page.getByText('A colour is not a valid hex value')).toBeVisible()
+    await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeDisabled()
+
+    await hex.fill('#1d4ed8')
+    await expect(page.getByText('A colour is not a valid hex value')).toHaveCount(0)
+  })
+})
+
+test.describe('saving a design', () => {
+  test('the first save asks for a name and takes over the kind', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+
+    // A sentence of the workshop's own, so the saved template is recognisable
+    // in the gallery and in the mail that goes out later.
+    await expect(async () => {
+      await railBlock(page, 'intro').click()
+      await expect(page.getByLabel('Text', { exact: true })).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByLabel('Text', { exact: true }).fill(SENTENCE)
+    // In the block it was typed into. It is also in the mail's preheader, the
+    // hidden line an inbox shows beside the subject, which is drawn from the
+    // first paragraph.
+    await expect(preview(page).locator('[data-block="intro"]').getByText(SENTENCE)).toBeVisible()
+
+    await saveDesign(page, TEMPLATE)
+
+    // The tab is now on the saved template rather than the preset it started
+    // from: saving again updates it instead of making a second one.
+    await expect(page).toHaveURL(/[?&]template=/)
+    // The button is named for what it would do, so "Saved" is also the check
+    // that there is nothing left unsaved.
+    await expect(page.getByRole('button', { name: 'Saved', exact: true })).toBeVisible()
+    expect(await emailTemplateNames('invoice_sent')).toContain(TEMPLATE)
+  })
+
+  test('the gallery shows it in use, and offers the way back', async ({ page }) => {
+    await page.goto('/settings/email-templates')
+    await settle(page)
+
+    const card = page
+      .locator('div')
+      .filter({ has: page.getByText(TEMPLATE, { exact: true }) })
+      .last()
+    await expect(card.getByText('In use', { exact: true })).toBeVisible()
+    // The built-in card is no longer the one sending, so it offers itself.
+    await expect(page.getByRole('button', { name: 'Use built-in' }).first()).toBeVisible()
+  })
+
+  test('a second template cannot take the first one’s name', async ({ page }) => {
+    await openPreset(page, 'invoice_sent')
+    await subjectField(page).fill(`Second ${stamp}`)
+
+    await page.getByRole('button', { name: 'Save', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: 'Save this template' })
+    await dialog.getByPlaceholder('Template name').fill(TEMPLATE)
+
+    // Refused on the spot, before the save is even attempted: a workshop with
+    // two templates called the same thing cannot tell them apart afterwards.
+    await expect(dialog.getByRole('alert')).toHaveText(/already exists/)
+    await expect(dialog.getByRole('button', { name: 'Save template', exact: true })).toBeDisabled()
+
+    // Under its own name it saves.
+    await dialog.getByPlaceholder('Template name').fill(SECOND)
+    await dialog.getByRole('button', { name: 'Save template', exact: true }).click()
+    await expect(
+      page.locator('[data-sonner-toast]').filter({ hasText: 'Saved' }).first()
+    ).toBeVisible({ timeout: 30_000 })
+    expect(await emailTemplateNames('invoice_sent')).toEqual(
+      expect.arrayContaining([TEMPLATE, SECOND])
+    )
+  })
+
+  test('deleting the one in use puts the kind back on its preset', async ({ page }) => {
+    await page.goto('/settings/email-templates')
+    await settle(page)
+
+    // The second save took the kind over, so this is the one in use.
+    const card = page
+      .locator('div')
+      .filter({ has: page.getByText(SECOND, { exact: true }) })
+      .last()
+    await expect(async () => {
+      await card.getByRole('button', { name: 'Delete' }).click()
+      await expect(page.getByRole('alertdialog')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByRole('alertdialog').getByRole('button', { name: 'Delete', exact: true }).click()
+
+    await expect(page.getByText(SECOND, { exact: true })).toHaveCount(0, { timeout: 30_000 })
+    expect(await emailTemplateNames('invoice_sent')).not.toContain(SECOND)
+  })
+})

+ 294 - 0
e2e/specs/email/sending.spec.ts

@@ -0,0 +1,294 @@
+import { expect, type Page, test } from '@playwright/test'
+import { customerOfVehicle, forgetEmailTemplates, ownerOrganizationId } from '../../support/db'
+import { openPreset, preview, railBlock, saveDesign } from '../../support/email-designer'
+import { settle } from '../../support/hydration'
+import { clearMailbox, waitForMail } from '../../support/mail'
+import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * What the workshop designed is what the customer receives.
+ *
+ * The designer is only worth anything if the mail that goes out is the one on
+ * screen, so this file designs a template with a sentence nothing else says,
+ * makes it the one the workshop sends invoices with, and then sends real mail
+ * and reads it out of the sink.
+ *
+ * Three things are asserted of every mail, because each has its own way of
+ * going wrong: the words came from the active template rather than the
+ * built-in preset, every tag was filled (a literal "{customer_name}" in a
+ * customer's inbox is the failure this feature can produce), and the
+ * plain-text half is there, since a mail with only an HTML part is what a
+ * spam filter looks for.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const TEMPLATE = `E2E sent invoice ${stamp}`
+/** The workshop's own words, so a preset cannot pass for them. */
+const SENTENCE = `Thank you for your custom, ${stamp}.`
+const RECIPIENT = `e2e-mail-${stamp}@example.com`
+
+let jobUrl = ''
+let invoiceNumber = ''
+let customer: { name: string; email: string }
+
+/** Every literal tag left in a mail, which should be none. */
+function leftoverTags(...parts: string[]): string[] {
+  return parts.flatMap((part) => part.match(/\{[a-z_]+\}/g) ?? [])
+}
+
+/** Opens the email dialog on the work order and sends it. */
+async function emailInvoice(page: Page, to: string, attachPdf: boolean): Promise<void> {
+  await page.goto(jobUrl)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Email', exact: true }).click()
+    await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  await page.locator('#email').fill(to)
+  const attach = page.locator('#attach-pdf-send')
+  await expect(attach).toBeVisible()
+  // The workshop's default answers this, so it is set rather than clicked.
+  if ((await attach.getAttribute('data-state')) !== (attachPdf ? 'checked' : 'unchecked')) {
+    await attach.click()
+  }
+  await expect(attach).toHaveAttribute('data-state', attachPdf ? 'checked' : 'unchecked')
+
+  await page.getByRole('button', { name: 'Send Email', exact: true }).click()
+}
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+
+  // A job worth a real invoice: one part, saved, so the mail has a number, a
+  // vehicle and a total to talk about.
+  const vehicleUrl = await seededVehicleUrl(page)
+  customer = await customerOfVehicle(vehicleUrl.split('/').pop() ?? '')
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E mail job ${stamp}`)
+  await addPart(page, { name: `E2E gasket ${stamp}`, quantity: 2, unitPrice: 250 })
+  await saveWorkOrder(page)
+  invoiceNumber = await page.getByLabel('Invoice Number').inputValue()
+
+  // The workshop's own invoice mail: the preset's words replaced by one
+  // sentence and the tags a customer's mail has to fill.
+  await openPreset(page, 'invoice_sent')
+  await expect(async () => {
+    await railBlock(page, 'intro').click()
+    await expect(page.getByLabel('Text', { exact: true })).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page
+    .getByLabel('Text', { exact: true })
+    .fill(`${SENTENCE} {customer_name}, your {vehicle} is done. Total {total}.`)
+  await expect(preview(page).locator('[data-block="intro"]').getByText(SENTENCE)).toBeVisible()
+  await saveDesign(page, TEMPLATE)
+
+  await page.close()
+})
+
+test.afterAll(async () => {
+  // Back to the built-in preset, or every other spec's mail is this one.
+  await forgetEmailTemplates('E2E ')
+})
+
+test.describe('a test send from the designer', () => {
+  test('arrives marked as a test, in both halves, with nothing left unfilled', async ({ page }) => {
+    await clearMailbox()
+    await page.goto(`/settings/email-templates`)
+    await settle(page)
+
+    // The designer's own Send test, on the template that is now in use.
+    await openPreset(page, 'quote_sent')
+    await page.getByRole('button', { name: 'Send test' }).click()
+    const dialog = page.getByRole('dialog', { name: 'Send a test email' })
+    await dialog.getByPlaceholder('Email address').fill(RECIPIENT)
+    await dialog.getByRole('button', { name: 'Send', exact: true }).click()
+
+    const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
+    // Marked, so a test sent to a customer by accident says what it is.
+    expect(mail.subject).toContain('[Test]')
+    expect(mail.html.length, 'the HTML half').toBeGreaterThan(200)
+    expect(mail.text.length, 'the plain-text half').toBeGreaterThan(40)
+    // Sample data fills the tags: a test mail full of braces tells a workshop
+    // nothing about what a customer will see.
+    expect(leftoverTags(mail.html, mail.text)).toEqual([])
+  })
+})
+
+test.describe('an invoice a customer is sent', () => {
+  test('is written with the workshop’s template, not the built-in one', async ({ page }) => {
+    await clearMailbox()
+    await emailInvoice(page, RECIPIENT, true)
+
+    const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
+    expect(mail.html, 'the words the workshop designed').toContain(SENTENCE)
+    expect(mail.text, 'and in the plain-text half too').toContain(SENTENCE)
+
+    // The tags filled from this job, not from sample data.
+    expect(mail.html).toContain(invoiceNumber)
+    expect(mail.html).toContain(customer.name)
+    expect(leftoverTags(mail.html, mail.text)).toEqual([])
+
+    // With the PDF attached, the mail says so.
+    expect(mail.attachments.map((file) => file.filename).join(' ')).toContain('.pdf')
+    expect(mail.html, 'the attachment note').toContain('attached')
+  })
+
+  test('says nothing about an attachment when it is sent as a link', async ({ page }) => {
+    await clearMailbox()
+    await emailInvoice(page, RECIPIENT, false)
+
+    const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
+    expect(mail.attachments, 'no PDF rode along').toHaveLength(0)
+    expect(mail.html, 'and no note about one').not.toContain('A PDF copy is attached')
+    // The link instead, which is the whole point of sending it this way.
+    expect(mail.html).toMatch(/\/share\/invoice\//)
+    expect(leftoverTags(mail.html, mail.text)).toEqual([])
+  })
+})
+
+test.describe('a message to a customer', () => {
+  test('is wrapped in the workshop’s mail, and the words are shown as words', async ({ page }) => {
+    await clearMailbox()
+    await page.goto(jobUrl)
+    await settle(page)
+
+    // Typed by a service adviser, including something that looks like markup:
+    // it has to reach the customer as text, not as formatting.
+    const typed = `Ready for collection <b>today</b> ${stamp}`
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Notify' }).click()
+      await expect(page.getByRole('dialog')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    const dialog = page.getByRole('dialog')
+    await dialog.getByRole('textbox').first().fill(typed)
+    const email = dialog.locator('#notify-email')
+    if ((await email.getAttribute('data-state')) !== 'checked') await email.click()
+    await dialog.getByRole('button', { name: 'Send', exact: true }).click()
+
+    const mail = await waitForMail(customer.email, { timeout: 30_000 })
+    // The workshop's message template around the adviser's words.
+    expect(mail.text).toContain(`Ready for collection <b>today</b> ${stamp}`)
+    expect(mail.html, 'the markup is escaped, not rendered').not.toContain('<b>today</b>')
+    expect(mail.html).toContain('&lt;b&gt;today&lt;/b&gt;')
+    expect(leftoverTags(mail.html, mail.text)).toEqual([])
+  })
+})
+
+test.describe('a customer asking for a sign-in link', () => {
+  /** The portal switch saves as it is turned, so there is no Save to press. */
+  async function setPortal(page: Page, open: boolean): Promise<void> {
+    await page.goto('/settings/customer-portal')
+    await settle(page)
+    const toggle = page.locator('#portal-enabled')
+    await expect(toggle).toBeVisible()
+    if ((await toggle.getAttribute('aria-checked')) === String(open)) return
+    await expect(async () => {
+      await toggle.click()
+      await expect(toggle).toHaveAttribute('aria-checked', String(open), { timeout: 3_000 })
+    }).toPass({ timeout: 30_000 })
+  }
+
+  let wasOpen = false
+
+  test('is sent the portal template, with a link that signs them in', async ({ page, request }) => {
+    await page.goto('/settings/customer-portal')
+    await settle(page)
+    wasOpen = (await page.locator('#portal-enabled').getAttribute('aria-checked')) === 'true'
+    await setPortal(page, true)
+
+    await clearMailbox()
+    const organizationId = await ownerOrganizationId()
+    // Asked the way the portal's own sign-in form asks, which is a public
+    // route: no session, and a workshop id in the path.
+    const asked = await request.post(`/api/public/portal/${organizationId}/auth/request`, {
+      data: { email: customer.email },
+    })
+    expect(asked.ok(), 'the workshop accepted the request').toBe(true)
+
+    const mail = await waitForMail(customer.email, { timeout: 30_000 })
+    const link = (mail.html.match(/https?:\/\/[^"'\s]*\/auth\/verify\?token=[^"'\s&]+/) ?? [])[0]
+    expect(link, 'the mail carries a sign-in link').toBeTruthy()
+    if (!link) throw new Error(`no sign-in link in "${mail.subject}"`)
+    expect(mail.text, 'and spells it out for a text-only client').toMatch(/https?:\/\//)
+    expect(leftoverTags(mail.html, mail.text)).toEqual([])
+
+    // Following it is the only proof that the mail is worth sending.
+    const customerPage = await page
+      .context()
+      .browser()
+      ?.newPage({
+        storageState: { cookies: [], origins: [] },
+      })
+    if (!customerPage) throw new Error('no browser to open the link with')
+    await customerPage.goto(link)
+    await expect(customerPage, 'the link lands the customer inside the portal').toHaveURL(
+      /\/portal\//,
+      { timeout: 30_000 }
+    )
+    await expect(customerPage.getByText(/verify|invalid|expired/i)).toHaveCount(0)
+    await customerPage.close()
+  })
+
+  test.afterAll(async ({ browser }) => {
+    // Left as it was found: the portal is off in a seeded workshop.
+    const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+    await setPortal(page, wasOpen)
+    await page.close()
+  })
+})
+
+test.describe('a logo in the mail', () => {
+  test('is uploaded, prepared, and fetchable by a mail client with no session', async ({
+    page,
+    playwright,
+  }) => {
+    await openPreset(page, 'invoice_sent')
+    await expect(async () => {
+      await railBlock(page, 'header').click()
+      await expect(page.getByRole('button', { name: 'Upload logo' })).toBeVisible({
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+
+    // The workshop's mark, with transparent margins, as one arrives from a
+    // designer. The route trims it and prepares it for mail.
+    await page.locator('input[type="file"]').first().setInputFiles('e2e/fixtures/email-logo.png')
+    const logo = preview(page).locator('[data-block="header"] img')
+    await expect(logo, 'the header draws the logo instead of the name').toBeVisible({
+      timeout: 30_000,
+    })
+
+    await clearMailbox()
+    await page.getByRole('button', { name: 'Send test' }).click()
+    const dialog = page.getByRole('dialog', { name: 'Send a test email' })
+    await dialog.getByPlaceholder('Email address').fill(RECIPIENT)
+    await dialog.getByRole('button', { name: 'Send', exact: true }).click()
+
+    const mail = await waitForMail(RECIPIENT, { timeout: 30_000 })
+    const src = (mail.html.match(/<img[^>]+src="([^"]+)"/) ?? [])[1]
+    expect(src, 'the mail carries the logo').toBeTruthy()
+    if (!src) throw new Error('no image in the test mail')
+
+    // A mail client has no cookies, so a logo behind the app's session is a
+    // broken picture in every customer's inbox. The address in the mail has
+    // to be the public one, and it has to answer.
+    //
+    // The empty storage state is spelled out because a request context made
+    // from the fixture inherits the project's, and the owner's cookie would
+    // answer for a mail client that has none.
+    expect(src, 'served from the public route').toContain('/api/public/email-')
+    const stranger = await playwright.request.newContext({
+      baseURL: new URL(src).origin,
+      storageState: { cookies: [], origins: [] },
+    })
+    const fetched = await stranger.get(src)
+    expect(fetched.status(), `${src} answers a mail client`).toBe(200)
+    expect(fetched.headers()['content-type']).toContain('image/')
+    expect(Number(fetched.headers()['content-length'] ?? '0'), 'small enough to mail').toBeLessThan(
+      1_000_000
+    )
+    await stranger.dispose()
+  })
+})

+ 5 - 1
e2e/specs/reminders/due-time.spec.ts

@@ -74,7 +74,11 @@ test.afterAll(async ({ browser }) => {
 
 test.describe('a reminder due at a time of day', () => {
   test('is booked at the time the calendar slot named', async ({ page }) => {
-    await page.goto('/calendar')
+    // A day nothing is booked on: a right-click that lands on a chip opens
+    // that job's menu, not the empty slot's.
+    const day = new Date(Date.now() + 45 * 86_400_000)
+    const empty = `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
+    await page.goto(`/calendar?view=day&date=${empty}`)
     await settle(page)
     await expect(async () => {
       await page.keyboard.press('d')

+ 55 - 0
e2e/support/db.ts

@@ -358,3 +358,58 @@ export async function scheduleServiceRecordInThePast(serviceRecordId: string): P
     )
   )
 }
+
+/**
+ * Email templates a spec made, gone again, and every kind back on its
+ * built-in preset.
+ *
+ * The gallery's own delete is what a workshop uses and one test walks it, but
+ * a file that fails halfway must not leave the workshop sending mail designed
+ * by a test: the pointer is an `email.template.<kind>` setting, and a
+ * template row it names is what the resolver prefers over the preset.
+ */
+export async function forgetEmailTemplates(namePrefix: string): Promise<void> {
+  await withDb(async (db) => {
+    await db.query(`delete from email_templates where name like $1`, [`${namePrefix}%`])
+    await db.query(
+      `delete from app_settings
+        where key like 'email.template.%'
+          and value not in (select 'design:' || id from email_templates)`
+    )
+  })
+}
+
+/** The names of the templates saved for one kind of mail. */
+export async function emailTemplateNames(kind: string): Promise<string[]> {
+  return withDb(async (db) => {
+    const result = await db.query<{ name: string }>(
+      `select name from email_templates where kind = $1 order by "createdAt"`,
+      [kind]
+    )
+    return result.rows.map((row) => row.name)
+  })
+}
+
+/**
+ * Whose car it is, and where to write to them.
+ *
+ * The customer of the vehicle a spec is working on, not the first customer in
+ * the workshop: a message sent from a job goes to the owner of that car, so a
+ * spec waiting on another customer's mailbox waits forever.
+ */
+export async function customerOfVehicle(
+  vehicleId: string
+): Promise<{ name: string; email: string }> {
+  return withDb(async (db) => {
+    const result = await db.query<{ name: string; email: string }>(
+      `select c.name, c.email
+         from vehicles v
+         join customers c on c.id = v."customerId"
+        where v.id = $1`,
+      [vehicleId]
+    )
+    const customer = result.rows[0]
+    if (!customer?.email) throw new Error(`vehicle ${vehicleId} has no customer with an email`)
+    return customer
+  })
+}

+ 75 - 0
e2e/support/email-designer.ts

@@ -0,0 +1,75 @@
+import { expect, type FrameLocator, type Page } from '@playwright/test'
+import { settle } from './hydration'
+
+/**
+ * Driving the email designer.
+ *
+ * The designer opens in its own tab from the gallery, on a route that takes
+ * either a preset (`&preset=1`) or a saved template (`&template=<id>`), so a
+ * spec can go straight to the one it means. The preview is an iframe rendered
+ * from `srcDoc`: the mail as a mail client would see it, with a `data-block`
+ * mark on every row so the rail and the preview can point at each other.
+ */
+
+/** Opens the designer on a kind's built-in preset. */
+export async function openPreset(page: Page, kind: string): Promise<void> {
+  await page.goto(`/email-designer?kind=${kind}&preset=1`)
+  await expect(page.locator('[data-rail-block]').first()).toBeVisible({ timeout: 30_000 })
+  await settle(page)
+}
+
+/** The mail itself, inside the preview frame. */
+export function preview(page: Page): FrameLocator {
+  return page.frameLocator('iframe')
+}
+
+/**
+ * The subject and theme fields.
+ *
+ * The inspector shows one thing at a time: a block's own fields when a block
+ * is selected, and the subject with the theme when none is. The rail's first
+ * button is the way back to the whole mail.
+ */
+export async function openSubjectAndTheme(page: Page): Promise<void> {
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Subject and theme' }).click()
+    await expect(subjectField(page)).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+}
+
+/** The subject line. Its id carries the tag-field prefix; the label does not. */
+export function subjectField(page: Page) {
+  return page.getByLabel('Subject', { exact: true })
+}
+
+/** The rail row for a block. */
+export function railBlock(page: Page, id: string) {
+  return page.locator(`[data-rail-block="${id}"]`)
+}
+
+/** The ids of the blocks the mail is made of, in the order the rail lists them. */
+export async function railOrder(page: Page): Promise<string[]> {
+  return page
+    .locator('[data-rail-block]')
+    .evaluateAll((rows) => rows.map((row) => row.getAttribute('data-rail-block') ?? ''))
+}
+
+/**
+ * Saves the design, naming it when the designer asks.
+ *
+ * A preset has no name yet, so the first save opens a dialog; a saved
+ * template updates in place. The dialog stays open on a refusal, which is
+ * what the duplicate-name test reads.
+ */
+export async function saveDesign(page: Page, name?: string): Promise<void> {
+  await page.getByRole('button', { name: 'Save', exact: true }).click()
+  if (name !== undefined) {
+    const dialog = page.getByRole('dialog', { name: 'Save this template' })
+    await expect(dialog).toBeVisible({ timeout: 10_000 })
+    await dialog.getByPlaceholder('Template name').fill(name)
+    await dialog.getByRole('button', { name: 'Save template', exact: true }).click()
+  }
+  await expect(
+    page.locator('[data-sonner-toast]').filter({ hasText: 'Saved' }).first()
+  ).toBeVisible({ timeout: 30_000 })
+}