Ver Fonte

Line and paragraph spacing in the email theme (#384)

Bernt Christian Egeland há 2 semanas atrás
pai
commit
21757f7f80

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

@@ -129,6 +129,31 @@ test.describe('the designer', () => {
     await expect(railBlock(page, 'intro')).toHaveAttribute('aria-pressed', 'false')
   })
 
+  test('line and paragraph spacing set in the theme reach the mail', async ({ page }) => {
+    // Every line of body text used to sit at one fixed height and every
+    // paragraph at one fixed gap; a workshop that found the mail airy had
+    // nothing to turn. The theme has two steps for it now.
+    await openPreset(page, 'invoice_sent')
+    await openSubjectAndTheme(page)
+    const intro = preview(page).locator('[data-block="intro"] td').first()
+    await expect(intro).toHaveAttribute('style', /line-height:1\.6;/)
+
+    await page.getByRole('combobox', { name: 'Line spacing' }).click()
+    await page.getByRole('option', { name: 'Relaxed', exact: true }).click()
+    await expect(intro, 'the body lines open up').toHaveAttribute('style', /line-height:1\.8;/)
+
+    await page.getByRole('combobox', { name: 'Paragraph spacing' }).click()
+    await page.getByRole('option', { name: 'Tight', exact: true }).click()
+    await expect(intro, 'the text blocks close ranks').toHaveAttribute(
+      'style',
+      /padding:0 0 10px 0;/
+    )
+
+    await page.getByRole('combobox', { name: 'Line spacing' }).click()
+    await page.getByRole('option', { name: 'Compact', exact: true }).click()
+    await expect(intro).toHaveAttribute('style', /line-height:1\.4;/)
+  })
+
   test('a block hidden in the rail leaves the mail', async ({ page }) => {
     await openPreset(page, 'invoice_sent')
     const row = railBlock(page, 'outro')

+ 13 - 0
messages/de/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Zeilenabstand",
+      "lineSpacings": {
+        "compact": "Kompakt",
+        "normal": "Normal",
+        "relaxed": "Locker"
+      },
+      "paragraphSpacing": "Absatzabstand",
+      "paragraphSpacings": {
+        "tight": "Eng",
+        "normal": "Normal",
+        "loose": "Weit"
+      },
+      "spacingHint": "Der Zeilenabstand ist die Höhe jeder Zeile; der Absatzabstand ist der Raum zwischen Absätzen und zwischen Textblöcken. Enter beginnt einen neuen Absatz, Umschalt+Enter eine neue Zeile darin.",
       "shapes": "Formen",
       "buttonRadius": "Schaltflächenecken",
       "topBar": "Farbbalken oben",

+ 13 - 0
messages/en/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Line spacing",
+      "lineSpacings": {
+        "compact": "Compact",
+        "normal": "Normal",
+        "relaxed": "Relaxed"
+      },
+      "paragraphSpacing": "Paragraph spacing",
+      "paragraphSpacings": {
+        "tight": "Tight",
+        "normal": "Normal",
+        "loose": "Loose"
+      },
+      "spacingHint": "Line spacing is the height of each line; paragraph spacing is the room between paragraphs and between text blocks. Enter starts a new paragraph, Shift+Enter a new line inside one.",
       "shapes": "Shapes",
       "buttonRadius": "Button corners",
       "topBar": "Colour bar at the top",

+ 13 - 0
messages/es/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Interlineado",
+      "lineSpacings": {
+        "compact": "Compacto",
+        "normal": "Normal",
+        "relaxed": "Amplio"
+      },
+      "paragraphSpacing": "Espacio entre párrafos",
+      "paragraphSpacings": {
+        "tight": "Estrecho",
+        "normal": "Normal",
+        "loose": "Amplio"
+      },
+      "spacingHint": "El interlineado es la altura de cada línea; el espacio entre párrafos es el hueco entre párrafos y entre bloques de texto. Intro empieza un párrafo nuevo, Mayús+Intro una línea nueva dentro del mismo.",
       "shapes": "Formas",
       "buttonRadius": "Esquinas del botón",
       "topBar": "Barra de color arriba",

+ 13 - 0
messages/fr/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Interligne",
+      "lineSpacings": {
+        "compact": "Compact",
+        "normal": "Normal",
+        "relaxed": "Aéré"
+      },
+      "paragraphSpacing": "Espacement des paragraphes",
+      "paragraphSpacings": {
+        "tight": "Serré",
+        "normal": "Normal",
+        "loose": "Large"
+      },
+      "spacingHint": "L'interligne est la hauteur de chaque ligne ; l'espacement des paragraphes est l'espace entre les paragraphes et entre les blocs de texte. Entrée commence un nouveau paragraphe, Maj+Entrée une nouvelle ligne dans le même.",
       "shapes": "Formes",
       "buttonRadius": "Angles du bouton",
       "topBar": "Barre de couleur en haut",

+ 13 - 0
messages/it/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Interlinea",
+      "lineSpacings": {
+        "compact": "Compatta",
+        "normal": "Normale",
+        "relaxed": "Ampia"
+      },
+      "paragraphSpacing": "Spazio tra paragrafi",
+      "paragraphSpacings": {
+        "tight": "Stretto",
+        "normal": "Normale",
+        "loose": "Ampio"
+      },
+      "spacingHint": "L'interlinea è l'altezza di ogni riga; lo spazio tra paragrafi è lo spazio tra i paragrafi e tra i blocchi di testo. Invio inizia un nuovo paragrafo, Maiusc+Invio una nuova riga nello stesso.",
       "shapes": "Forme",
       "buttonRadius": "Angoli del pulsante",
       "topBar": "Barra colorata in alto",

+ 13 - 0
messages/lt/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Eilučių tarpai",
+      "lineSpacings": {
+        "compact": "Glaustai",
+        "normal": "Įprastai",
+        "relaxed": "Laisvai"
+      },
+      "paragraphSpacing": "Pastraipų tarpai",
+      "paragraphSpacings": {
+        "tight": "Ankštai",
+        "normal": "Įprastai",
+        "loose": "Plačiai"
+      },
+      "spacingHint": "Eilučių tarpas yra kiekvienos eilutės aukštis; pastraipų tarpas yra vieta tarp pastraipų ir tarp teksto blokų. Enter pradeda naują pastraipą, Shift+Enter – naują eilutę toje pačioje.",
       "shapes": "Formos",
       "buttonRadius": "Mygtuko kampai",
       "topBar": "Spalvota juosta viršuje",

+ 13 - 0
messages/nb/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Linjeavstand",
+      "lineSpacings": {
+        "compact": "Kompakt",
+        "normal": "Normal",
+        "relaxed": "Luftig"
+      },
+      "paragraphSpacing": "Avsnittsavstand",
+      "paragraphSpacings": {
+        "tight": "Tett",
+        "normal": "Normal",
+        "loose": "Romslig"
+      },
+      "spacingHint": "Linjeavstand er høyden på hver linje; avsnittsavstand er rommet mellom avsnitt og mellom tekstblokker. Enter starter et nytt avsnitt, Shift+Enter en ny linje i samme avsnitt.",
       "shapes": "Former",
       "buttonRadius": "Knappehjørner",
       "topBar": "Fargestripe øverst",

+ 13 - 0
messages/nl/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Regelafstand",
+      "lineSpacings": {
+        "compact": "Compact",
+        "normal": "Normaal",
+        "relaxed": "Ruim"
+      },
+      "paragraphSpacing": "Alinea-afstand",
+      "paragraphSpacings": {
+        "tight": "Krap",
+        "normal": "Normaal",
+        "loose": "Ruim"
+      },
+      "spacingHint": "Regelafstand is de hoogte van elke regel; alinea-afstand is de ruimte tussen alinea's en tussen tekstblokken. Enter begint een nieuwe alinea, Shift+Enter een nieuwe regel erbinnen.",
       "shapes": "Vormen",
       "buttonRadius": "Knophoeken",
       "topBar": "Kleurbalk bovenaan",

+ 13 - 0
messages/pl/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Interlinia",
+      "lineSpacings": {
+        "compact": "Zwarta",
+        "normal": "Normalna",
+        "relaxed": "Luźna"
+      },
+      "paragraphSpacing": "Odstęp między akapitami",
+      "paragraphSpacings": {
+        "tight": "Mały",
+        "normal": "Normalny",
+        "loose": "Duży"
+      },
+      "spacingHint": "Interlinia to wysokość każdego wiersza; odstęp między akapitami to miejsce między akapitami i między blokami tekstu. Enter zaczyna nowy akapit, Shift+Enter nowy wiersz w tym samym.",
       "shapes": "Kształty",
       "buttonRadius": "Rogi przycisku",
       "topBar": "Kolorowy pasek u góry",

+ 13 - 0
messages/pt-BR/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Entrelinha",
+      "lineSpacings": {
+        "compact": "Compacta",
+        "normal": "Normal",
+        "relaxed": "Ampla"
+      },
+      "paragraphSpacing": "Espaço entre parágrafos",
+      "paragraphSpacings": {
+        "tight": "Apertado",
+        "normal": "Normal",
+        "loose": "Amplo"
+      },
+      "spacingHint": "A entrelinha é a altura de cada linha; o espaço entre parágrafos é o vão entre parágrafos e entre blocos de texto. Enter começa um parágrafo novo, Shift+Enter uma linha nova dentro dele.",
       "shapes": "Formas",
       "buttonRadius": "Cantos do botão",
       "topBar": "Barra colorida no topo",

+ 13 - 0
messages/ru/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Межстрочный интервал",
+      "lineSpacings": {
+        "compact": "Плотный",
+        "normal": "Обычный",
+        "relaxed": "Свободный"
+      },
+      "paragraphSpacing": "Интервал между абзацами",
+      "paragraphSpacings": {
+        "tight": "Узкий",
+        "normal": "Обычный",
+        "loose": "Широкий"
+      },
+      "spacingHint": "Межстрочный интервал — высота каждой строки; интервал между абзацами — расстояние между абзацами и между текстовыми блоками. Enter начинает новый абзац, Shift+Enter — новую строку внутри него.",
       "shapes": "Формы",
       "buttonRadius": "Углы кнопки",
       "topBar": "Цветная полоса сверху",

+ 13 - 0
messages/tr/settings.json

@@ -2295,6 +2295,19 @@
         "trebuchet": "Trebuchet MS",
         "courier": "Courier New"
       },
+      "lineSpacing": "Satır aralığı",
+      "lineSpacings": {
+        "compact": "Sıkı",
+        "normal": "Normal",
+        "relaxed": "Geniş"
+      },
+      "paragraphSpacing": "Paragraf aralığı",
+      "paragraphSpacings": {
+        "tight": "Dar",
+        "normal": "Normal",
+        "loose": "Geniş"
+      },
+      "spacingHint": "Satır aralığı her satırın yüksekliğidir; paragraf aralığı paragraflar ve metin blokları arasındaki boşluktur. Enter yeni paragraf, Shift+Enter aynı paragrafta yeni satır başlatır.",
       "shapes": "Şekiller",
       "buttonRadius": "Düğme köşeleri",
       "topBar": "Üstte renkli şerit",

+ 135 - 0
src/__tests__/features/email/email-spacing.test.ts

@@ -0,0 +1,135 @@
+/**
+ * Line and paragraph spacing, set once in the theme and carried into every
+ * line of the mail. Body text is the reference; headings and small print
+ * scale with it, a paragraph's own gap and the room between text blocks
+ * follow the paragraph step, and a theme saved before the steps existed
+ * reads as normal, which is what every mail looked like until now.
+ */
+import { describe, expect, it } from 'vitest'
+import en from '../../../../messages/en/email.json'
+import { buildEmailSpec } from '@/features/email/Lib/buildEmailSpec'
+import { tagValuesFor } from '@/features/email/Lib/emailContext'
+import { emailSpacing } from '@/features/email/Lib/emailTemplate'
+import { type EmailMessages, presetTemplate } from '@/features/email/Lib/emailPresets'
+import { richToHtml } from '@/features/email/Render/richTextHtml'
+import { renderEmailHtml } from '@/features/email/Render/renderEmailHtml'
+import { readStoredTemplate } from '@/features/email/Schema/emailTemplateSchema'
+
+const messages = en as EmailMessages
+const workshop = { name: 'Bergen Bil', phone: '', email: 'post@bergenbil.no', address: '' }
+const input = {
+  values: tagValuesFor(
+    'invoice_sent',
+    {
+      customerName: 'Alex',
+      document: { number: 'INV-1', total: 100, paid: 0, currencyCode: 'NOK' },
+    },
+    { workshop }
+  ),
+}
+
+function mailWith(theme: Record<string, unknown>) {
+  const template = presetTemplate('invoice_sent', messages)
+  template.theme = { ...template.theme, ...theme }
+  return renderEmailHtml(buildEmailSpec(template, input))
+}
+
+describe('emailSpacing', () => {
+  it('reads an unset theme as normal', () => {
+    expect(emailSpacing({})).toEqual({
+      body: 1.6,
+      heading: 1.3,
+      small: 1.5,
+      paragraphGap: 10,
+      blockGap: 16,
+    })
+  })
+
+  it('scales headings and small print with the body', () => {
+    expect(emailSpacing({ lineSpacing: 'compact' })).toMatchObject({
+      body: 1.4,
+      heading: 1.14,
+      small: 1.31,
+    })
+    expect(emailSpacing({ lineSpacing: 'relaxed' })).toMatchObject({
+      body: 1.8,
+      heading: 1.46,
+      small: 1.69,
+    })
+  })
+
+  it('keeps the paragraph gap and the block gap in step', () => {
+    expect(emailSpacing({ paragraphSpacing: 'tight' })).toMatchObject({
+      paragraphGap: 4,
+      blockGap: 10,
+    })
+    expect(emailSpacing({ paragraphSpacing: 'loose' })).toMatchObject({
+      paragraphGap: 16,
+      blockGap: 24,
+    })
+  })
+
+  it('treats a value it does not know as normal rather than failing', () => {
+    expect(emailSpacing({ lineSpacing: 'huge' as never }).body).toBe(1.6)
+    expect(emailSpacing({ paragraphSpacing: 'none' as never }).paragraphGap).toBe(10)
+  })
+})
+
+describe('the rendered mail', () => {
+  it('sets every body line at the theme height, and stays at 1.6 by default', () => {
+    expect(mailWith({})).toContain('font-size:15px;line-height:1.6;')
+    expect(mailWith({})).not.toContain('line-height:1.8;')
+    const relaxed = mailWith({ lineSpacing: 'relaxed' })
+    expect(relaxed).toContain('font-size:15px;line-height:1.8;')
+    expect(relaxed, 'no body line is left at the old height').not.toContain(
+      'font-size:15px;line-height:1.6;'
+    )
+    expect(relaxed, 'the heading follows').toContain('font-size:22px;line-height:1.46;')
+    expect(relaxed, 'so does the footer').toContain('font-size:12.5px;line-height:1.8;')
+  })
+
+  it('spaces the text blocks by the paragraph step', () => {
+    expect(mailWith({})).toContain('line-height:1.6;color:#111827;padding:0 0 16px 0;')
+    expect(mailWith({ paragraphSpacing: 'tight' })).toContain(
+      'line-height:1.6;color:#111827;padding:0 0 10px 0;'
+    )
+    expect(mailWith({ paragraphSpacing: 'loose' })).toContain(
+      'line-height:1.6;color:#111827;padding:0 0 24px 0;'
+    )
+  })
+
+  it('carries the paragraph gap into rich text', () => {
+    const doc = {
+      type: 'doc' as const,
+      content: [
+        { type: 'paragraph' as const, content: [{ type: 'text' as const, text: 'One' }] },
+        { type: 'paragraph' as const, content: [{ type: 'text' as const, text: 'Two' }] },
+      ],
+    }
+    const style = {
+      font: 'sans-serif',
+      color: '#000',
+      fontSize: 15,
+      lineHeight: 1.6,
+      linkColor: '#00f',
+    }
+    expect(richToHtml(doc, style)).toContain('margin:0 0 10px 0;')
+    expect(richToHtml(doc, { ...style, paragraphGap: 16 })).toContain('margin:0 0 16px 0;')
+    // The last paragraph never carries a gap, whatever the step.
+    expect(richToHtml(doc, { ...style, paragraphGap: 16 })).toContain('margin:0;">Two')
+  })
+})
+
+describe('a stored template', () => {
+  it('accepts the spacing steps and nothing else in their place', () => {
+    const template = presetTemplate('invoice_sent', messages)
+    const stored = readStoredTemplate({
+      ...template,
+      theme: { ...template.theme, lineSpacing: 'relaxed', paragraphSpacing: 'loose' },
+    })
+    expect(stored?.theme.lineSpacing).toBe('relaxed')
+    expect(stored?.theme.paragraphSpacing).toBe('loose')
+    // Saved before the steps existed: still a valid template, read as normal.
+    expect(readStoredTemplate(template)?.theme.lineSpacing).toBeUndefined()
+  })
+})

+ 39 - 0
src/features/email/Components/EmailDesignerInspector.tsx

@@ -34,6 +34,10 @@ import {
   emailImagePublicPath,
   emailLogoPublicPath,
   SUMMARY_ROWS,
+  EMAIL_LINE_SPACINGS,
+  EMAIL_PARAGRAPH_SPACINGS,
+  type EmailLineSpacing,
+  type EmailParagraphSpacing,
 } from '../Lib/emailTemplate'
 import { plainToRich } from '../Lib/richText'
 import { Choice, ColorField, Group, Note, Row, Slider, Toggle } from './EmailDesignerControls'
@@ -459,6 +463,41 @@ function ThemeFields({
           </Select>
         </Row>
         <Note>{t('theme.fontHint')}</Note>
+        <Row label={t('theme.lineSpacing')}>
+          <Select
+            value={theme.lineSpacing ?? 'normal'}
+            onValueChange={(value) => onTheme({ lineSpacing: value as EmailLineSpacing })}
+          >
+            <SelectTrigger size="sm" aria-label={t('theme.lineSpacing')} className="text-xs">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              {EMAIL_LINE_SPACINGS.map((id) => (
+                <SelectItem key={id} value={id}>
+                  {t(`theme.lineSpacings.${id}`)}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </Row>
+        <Row label={t('theme.paragraphSpacing')}>
+          <Select
+            value={theme.paragraphSpacing ?? 'normal'}
+            onValueChange={(value) => onTheme({ paragraphSpacing: value as EmailParagraphSpacing })}
+          >
+            <SelectTrigger size="sm" aria-label={t('theme.paragraphSpacing')} className="text-xs">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              {EMAIL_PARAGRAPH_SPACINGS.map((id) => (
+                <SelectItem key={id} value={id}>
+                  {t(`theme.paragraphSpacings.${id}`)}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </Row>
+        <Note>{t('theme.spacingHint')}</Note>
       </Group>
 
       <Group title={t('theme.shapes')}>

+ 47 - 0
src/features/email/Lib/emailTemplate.ts

@@ -108,6 +108,53 @@ export interface EmailTheme {
   buttonRadius: number
   /** The bar in the primary colour along the top of the card. Absent counts as on. */
   topBar?: boolean
+  /** How tall a line of text is, as a step. Absent counts as normal. */
+  lineSpacing?: EmailLineSpacing
+  /** How much room between paragraphs and text blocks, as a step. Absent counts as normal. */
+  paragraphSpacing?: EmailParagraphSpacing
+}
+
+export const EMAIL_LINE_SPACINGS = ['compact', 'normal', 'relaxed'] as const
+export type EmailLineSpacing = (typeof EMAIL_LINE_SPACINGS)[number]
+
+export const EMAIL_PARAGRAPH_SPACINGS = ['tight', 'normal', 'loose'] as const
+export type EmailParagraphSpacing = (typeof EMAIL_PARAGRAPH_SPACINGS)[number]
+
+/**
+ * What each step means in the mail. Body text is the reference; headings
+ * and small print are scaled from it so the whole mail tightens or opens up
+ * together, and a paragraph's own gap and the room between text blocks
+ * follow the paragraph step.
+ */
+export const EMAIL_LINE_HEIGHTS: Record<EmailLineSpacing, number> = {
+  compact: 1.4,
+  normal: 1.6,
+  relaxed: 1.8,
+}
+
+export const EMAIL_PARAGRAPH_GAPS: Record<
+  EmailParagraphSpacing,
+  { paragraph: number; block: number }
+> = {
+  tight: { paragraph: 4, block: 10 },
+  normal: { paragraph: 10, block: 16 },
+  loose: { paragraph: 16, block: 24 },
+}
+
+/** The spacing a theme asks for, with anything unknown read as normal. */
+export function emailSpacing(theme: Pick<EmailTheme, 'lineSpacing' | 'paragraphSpacing'>) {
+  const line = EMAIL_LINE_HEIGHTS[theme.lineSpacing ?? 'normal'] ?? EMAIL_LINE_HEIGHTS.normal
+  const gaps =
+    EMAIL_PARAGRAPH_GAPS[theme.paragraphSpacing ?? 'normal'] ?? EMAIL_PARAGRAPH_GAPS.normal
+  const scale = line / EMAIL_LINE_HEIGHTS.normal
+  const round = (n: number) => Math.round(n * 100) / 100
+  return {
+    body: line,
+    heading: round(1.3 * scale),
+    small: round(1.5 * scale),
+    paragraphGap: gaps.paragraph,
+    blockGap: gaps.block,
+  }
 }
 
 export const DEFAULT_EMAIL_THEME: EmailTheme = {

+ 13 - 6
src/features/email/Render/renderEmailHtml.ts

@@ -5,6 +5,7 @@ import {
   EMAIL_LOGO_MAX_WIDTH,
   EMAIL_LOGO_MIN_WIDTH,
   type EmailTheme,
+  emailSpacing,
 } from '../Lib/emailTemplate'
 import { safeHref } from '../Lib/links'
 import { escapeHtml } from './escape'
@@ -59,6 +60,7 @@ function safeTheme(theme: EmailTheme) {
     font: EMAIL_FONTS[theme.fontFamily] ?? EMAIL_FONTS[d.fontFamily],
     radius,
     topBar: theme.topBar !== false,
+    spacing: emailSpacing(theme),
   }
 }
 
@@ -80,7 +82,12 @@ function wordsHtml(
   look: { color: string; fontSize: number; lineHeight: number }
 ): string {
   if (!words.rich) return paragraphHtml(words.text)
-  return richToHtml(words.rich, { font: t.font, linkColor: t.primary, ...look })
+  return richToHtml(words.rich, {
+    font: t.font,
+    linkColor: t.primary,
+    paragraphGap: t.spacing.paragraphGap,
+    ...look,
+  })
 }
 
 /** The summary rows that carry money, drawn heavier than the rest. */
@@ -117,19 +124,19 @@ function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
 
     case 'heading':
       return row(
-        `<td style="${base}font-size:22px;line-height:1.3;font-weight:700;letter-spacing:-0.2px;color:${t.text};padding:0 0 14px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 22, lineHeight: 1.3 })}</td>`
+        `<td style="${base}font-size:22px;line-height:${t.spacing.heading};font-weight:700;letter-spacing:-0.2px;color:${t.text};padding:0 0 ${t.spacing.blockGap - 2}px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 22, lineHeight: t.spacing.heading })}</td>`
       )
 
     case 'paragraph':
       return row(
-        `<td style="${base}font-size:15px;line-height:1.6;color:${t.text};padding:0 0 16px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.6 })}</td>`
+        `<td style="${base}font-size:15px;line-height:${t.spacing.body};color:${t.text};padding:0 0 ${t.spacing.blockGap}px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: t.spacing.body })}</td>`
       )
 
     case 'callout':
       return row(
         `<td style="padding:2px 0 20px 0;">` +
           `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="${t.background}" style="background:${t.background};border-left:4px solid ${t.primary};border-radius:6px;">` +
-          `<tr><td style="${base}font-size:15px;line-height:1.6;color:${t.text};padding:16px 18px;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.6 })}</td></tr>` +
+          `<tr><td style="${base}font-size:15px;line-height:${t.spacing.body};color:${t.text};padding:16px 18px;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: t.spacing.body })}</td></tr>` +
           `</table></td>`
       )
 
@@ -178,7 +185,7 @@ function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
 
     case 'attachment_note':
       return row(
-        `<td style="${base}font-size:13px;line-height:1.5;color:${t.muted};padding:0 0 16px 0;">${wordsHtml(block, t, { color: t.muted, fontSize: 13, lineHeight: 1.5 })}</td>`
+        `<td style="${base}font-size:13px;line-height:${t.spacing.small};color:${t.muted};padding:0 0 ${t.spacing.blockGap}px 0;">${wordsHtml(block, t, { color: t.muted, fontSize: 13, lineHeight: t.spacing.small })}</td>`
       )
 
     case 'image': {
@@ -225,7 +232,7 @@ function contactFooterCell(
     ? `<span style="font-weight:600;color:${t.text};">${paragraphHtml(name)}</span>`
     : ''
   const body = [nameHtml, ...rest.map((line) => paragraphHtml(line))].filter(Boolean).join('<br />')
-  return `<td align="${align}" style="font-family:${t.font};font-size:12.5px;line-height:1.6;color:${t.muted};padding:${padding};text-align:${align};">${body}</td>`
+  return `<td align="${align}" style="font-family:${t.font};font-size:12.5px;line-height:${t.spacing.body};color:${t.muted};padding:${padding};text-align:${align};">${body}</td>`
 }
 
 /** A row of the mail; in a preview it also says which block it is. */

+ 3 - 1
src/features/email/Render/richTextHtml.ts

@@ -20,6 +20,8 @@ export interface RichTextStyle {
   lineHeight: number
   /** Links take the theme's primary colour. */
   linkColor: string
+  /** Room under a paragraph that another follows, in pixels. Absent is 10. */
+  paragraphGap?: number
 }
 
 const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
@@ -76,7 +78,7 @@ function inlineHtml(nodes: RichNode[] | undefined, style: RichTextStyle): string
 
 function blockHtml(node: RichNode, style: RichTextStyle, last: boolean): string {
   const base = `font-family:${style.font};font-size:${style.fontSize}px;line-height:${style.lineHeight};color:${style.color};`
-  const margin = last ? 'margin:0;' : 'margin:0 0 10px 0;'
+  const margin = last ? 'margin:0;' : `margin:0 0 ${style.paragraphGap ?? 10}px 0;`
   switch (node.type) {
     case 'paragraph': {
       const align = node.attrs?.textAlign

+ 4 - 0
src/features/email/Schema/emailTemplateSchema.ts

@@ -16,6 +16,8 @@ import {
   type EmailTemplate,
   type SavedEmailTemplate,
   SUMMARY_ROWS,
+  EMAIL_LINE_SPACINGS,
+  EMAIL_PARAGRAPH_SPACINGS,
 } from '../Lib/emailTemplate'
 
 /**
@@ -68,6 +70,8 @@ export const emailThemeSchema = z.object({
   logoWidth: z.number().int().min(EMAIL_LOGO_MIN_WIDTH).max(EMAIL_LOGO_MAX_WIDTH),
   buttonRadius: z.number().int().min(0).max(32),
   topBar: z.boolean().optional(),
+  lineSpacing: z.enum(EMAIL_LINE_SPACINGS).optional(),
+  paragraphSpacing: z.enum(EMAIL_PARAGRAPH_SPACINGS).optional(),
 })
 
 export const emailKindSchema = z.enum(EMAIL_KINDS)