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

Email template designer (#357)

* new template engine

* Say where the customer message template is used

* Harden the email template designer after review

Uploads: pixel cap against decompression bombs, rate limit, one permission
check shared with the actions, PNG output honours the byte cap.
Engine: a tag typed across two formattings is filled whole; button links
are checked in the spec so both halves of the mail agree.
Actions: test send behind the plan and workshop gates; ids validated;
a save refuses a foreign upload, a missing upload or a name that
belongs to another template.
Sweep: references read off the raw JSON, one day of grace.
Backups carry the email upload folders and rewrite their URLs.
Designer: edits typed during a save survive, keyboard selection and
reordering, uploads cancelled on discard, stale In use badge, gallery
pending state, friendlier validation, debounced preview.
Bernt Christian Egeland 3 недель назад
Родитель
Сommit
d05b18192d
93 измененных файлов с 12390 добавлено и 278 удалено
  1. 6 0
      messages/de/audit.json
  2. 51 0
      messages/de/email.json
  3. 326 0
      messages/de/settings.json
  4. 6 0
      messages/en/audit.json
  5. 51 0
      messages/en/email.json
  6. 326 0
      messages/en/settings.json
  7. 6 0
      messages/es/audit.json
  8. 51 0
      messages/es/email.json
  9. 326 0
      messages/es/settings.json
  10. 6 0
      messages/fr/audit.json
  11. 51 0
      messages/fr/email.json
  12. 326 0
      messages/fr/settings.json
  13. 6 0
      messages/it/audit.json
  14. 51 0
      messages/it/email.json
  15. 326 0
      messages/it/settings.json
  16. 6 0
      messages/lt/audit.json
  17. 51 0
      messages/lt/email.json
  18. 326 0
      messages/lt/settings.json
  19. 6 0
      messages/nb/audit.json
  20. 51 0
      messages/nb/email.json
  21. 326 0
      messages/nb/settings.json
  22. 6 0
      messages/nl/audit.json
  23. 51 0
      messages/nl/email.json
  24. 326 0
      messages/nl/settings.json
  25. 6 0
      messages/pl/audit.json
  26. 51 0
      messages/pl/email.json
  27. 326 0
      messages/pl/settings.json
  28. 6 0
      messages/pt-BR/audit.json
  29. 51 0
      messages/pt-BR/email.json
  30. 326 0
      messages/pt-BR/settings.json
  31. 6 0
      messages/ru/audit.json
  32. 51 0
      messages/ru/email.json
  33. 326 0
      messages/ru/settings.json
  34. 6 0
      messages/tr/audit.json
  35. 51 0
      messages/tr/email.json
  36. 326 0
      messages/tr/settings.json
  37. 20 0
      prisma/migrations/20260908180000_email_templates/migration.sql
  38. 32 0
      prisma/schema/email-templates.prisma
  39. 1 0
      prisma/schema/organization.prisma
  40. 7 84
      src/__tests__/features/email/document-email.test.ts
  41. 656 0
      src/__tests__/features/email/email-template-engine.test.ts
  42. 126 0
      src/__tests__/features/email/send-templated-mail.test.ts
  43. 1 0
      src/__tests__/i18n/audit-summaries.test.ts
  44. 298 0
      src/app/(authenticated)/settings/email-templates/email-template-gallery.tsx
  45. 97 0
      src/app/(authenticated)/settings/email-templates/page.tsx
  46. 2 0
      src/app/(authenticated)/settings/settings-nav.tsx
  47. 3 0
      src/app/(authenticated)/work-orders/work-orders-client.tsx
  48. 95 0
      src/app/(designer)/email-designer/page.tsx
  49. 10 0
      src/app/api/protected/backup/export/route.ts
  50. 39 0
      src/app/api/protected/backup/import/route.ts
  51. 2 0
      src/app/api/protected/files/[...path]/route.ts
  52. 114 0
      src/app/api/protected/upload/email-image/route.ts
  53. 73 0
      src/app/api/protected/upload/email-logo/route.ts
  54. 45 0
      src/app/api/public/email-image/[orgId]/[file]/route.ts
  55. 46 0
      src/app/api/public/email-logo/[orgId]/[file]/route.ts
  56. 9 26
      src/app/api/public/portal/[orgId]/auth/request/route.ts
  57. 22 0
      src/components/notify-customer-dialog.tsx
  58. 80 90
      src/features/email/Actions/emailActions.ts
  59. 336 0
      src/features/email/Actions/emailTemplateActions.ts
  60. 752 0
      src/features/email/Components/EmailDesigner.tsx
  61. 166 0
      src/features/email/Components/EmailDesignerControls.tsx
  62. 811 0
      src/features/email/Components/EmailDesignerInspector.tsx
  63. 403 0
      src/features/email/Components/EmailDesignerRail.tsx
  64. 202 0
      src/features/email/Components/EmailPreview.tsx
  65. 675 0
      src/features/email/Components/EmailRichEditor.tsx
  66. 130 0
      src/features/email/Components/EmailThumbnail.tsx
  67. 223 0
      src/features/email/Components/TagPicker.tsx
  68. 211 0
      src/features/email/Lib/buildEmailSpec.ts
  69. 6 66
      src/features/email/Lib/documentEmail.ts
  70. 144 0
      src/features/email/Lib/emailAssets.server.ts
  71. 259 0
      src/features/email/Lib/emailContext.ts
  72. 108 0
      src/features/email/Lib/emailKinds.ts
  73. 25 0
      src/features/email/Lib/emailMessages.server.ts
  74. 85 0
      src/features/email/Lib/emailPresets.ts
  75. 229 0
      src/features/email/Lib/emailTemplate.ts
  76. 43 0
      src/features/email/Lib/emailUploadAccess.server.ts
  77. 27 0
      src/features/email/Lib/links.ts
  78. 45 0
      src/features/email/Lib/resolveEmailTemplate.ts
  79. 223 0
      src/features/email/Lib/richText.ts
  80. 145 0
      src/features/email/Lib/sendTemplatedMail.ts
  81. 159 0
      src/features/email/Lib/tags.ts
  82. 8 0
      src/features/email/Render/escape.ts
  83. 231 0
      src/features/email/Render/renderEmailHtml.ts
  84. 63 0
      src/features/email/Render/renderEmailText.ts
  85. 113 0
      src/features/email/Render/richTextHtml.ts
  86. 141 0
      src/features/email/Schema/emailTemplateSchema.ts
  87. 8 0
      src/features/integrations/Actions/videoCallActions.ts
  88. 6 12
      src/features/scheduled-messages/Lib/dispatchScheduledMessage.ts
  89. 10 0
      src/features/status-reports/Actions/sendStatusReport.ts
  90. 2 0
      src/features/vehicles/Components/service-page/ServicePageClient.tsx
  91. 2 0
      src/i18n/request.ts
  92. 11 0
      src/lib/backup/manifest.ts
  93. 14 0
      src/lib/email.ts

+ 6 - 0
messages/de/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Dokumentdesign gelöscht",
     "settings_applyDocumentDesign": "Standard-Dokumentdesign festgelegt",
     "settings_setDocumentDesignRule": "Verwendung eines Designs geändert",
+    "settings_saveEmailTemplate": "E-Mail-Vorlage gespeichert",
+    "settings_deleteEmailTemplate": "E-Mail-Vorlage gelöscht",
+    "settings_applyEmailTemplate": "Standard-E-Mail-Vorlage festgelegt",
     "settings_freezeInvoices": "Ältere Rechnungen gesperrt",
     "inspection_reminders_sent": "HU-Erinnerungen gesendet",
     "inspection_reminders_send": "HU-Erinnerungen gesendet",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "Hat das Dokumentdesign {name} gelöscht",
     "settings_applyDocumentDesign": "Hat {name} zum Standard-Dokumentdesign gemacht",
     "settings_setDocumentDesignRule": "Verwendung des Dokumentdesigns {name} geändert",
+    "settings_saveEmailTemplate": "Hat die E-Mail-Vorlage {name} gespeichert",
+    "settings_deleteEmailTemplate": "Hat die E-Mail-Vorlage {name} gelöscht",
+    "settings_applyEmailTemplate": "Hat {name} als Vorlage für {kind}-E-Mails festgelegt",
     "settings_freezeInvoices": "Hat {count, plural, one {# Rechnung} other {# Rechnungen}} von vor der Sperre mit dem aktuellen Design gesperrt",
     "inspection_reminders_sent": "{count} HU-Erinnerungen gesendet",
     "service_videoCall_send": "Link zum Videoanruf per {channels} gesendet",

+ 51 - 0
messages/de/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Rechnung",
+      "subject": "Rechnung {document_number} von {workshop_name}",
+      "heading": "Rechnung {document_number}",
+      "intro": "Hallo {customer_name},\n\nvielen Dank, dass Sie sich für uns entschieden haben. Ihre Rechnung ist fertig.",
+      "button": "Rechnung ansehen",
+      "outro": "Bei Fragen antworten Sie einfach auf diese E-Mail."
+    },
+    "quote_sent": {
+      "name": "Angebot",
+      "subject": "Angebot {document_number} von {workshop_name}",
+      "heading": "Angebot {document_number}",
+      "intro": "Hallo {customer_name},\n\nhier ist das gewünschte Angebot. Geben Sie uns einfach Bescheid, wenn wir loslegen sollen.",
+      "button": "Angebot ansehen",
+      "outro": "Bei Fragen antworten Sie einfach auf diese E-Mail."
+    },
+    "inspection_sent": {
+      "name": "Prüfbericht",
+      "subject": "Prüfbericht von {workshop_name}",
+      "heading": "Fahrzeugprüfbericht",
+      "intro": "Hallo {customer_name},\n\nwir haben die Prüfung abgeschlossen. Der Bericht liegt für Sie bereit.",
+      "button": "Bericht ansehen",
+      "outro": "Bei Fragen antworten Sie einfach auf diese E-Mail."
+    },
+    "message": {
+      "name": "Kundennachricht",
+      "subject": "Nachricht von {workshop_name}",
+      "intro": "Hallo {customer_name},",
+      "outro": "Bei Fragen antworten Sie einfach auf diese E-Mail."
+    },
+    "portal_signin": {
+      "name": "Portal-Anmeldung",
+      "subject": "Anmelden bei {workshop_name}",
+      "heading": "Melden Sie sich in Ihrem Kundenportal an",
+      "intro": "Hallo {customer_name},\n\nüber die Schaltfläche unten melden Sie sich im Kundenportal von {workshop_name} an.",
+      "button": "Anmelden",
+      "linkFallback": "Falls die Schaltfläche nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:\n{signin_link}",
+      "outro": "Dieser Link ist 15 Minuten gültig. Falls Sie ihn nicht angefordert haben, können Sie diese E-Mail ignorieren."
+    }
+  },
+  "attachmentNote": "Eine PDF-Kopie ist dieser E-Mail beigefügt.",
+  "summary": {
+    "reference": "Referenz",
+    "vehicle": "Fahrzeug",
+    "total": "Gesamt",
+    "balance": "Offener Betrag",
+    "due": "Fällig"
+  }
+}

+ 326 - 0
messages/de/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "HU-Erinnerungen",
         "description": "Buchungslink, Dauer und Vorlauf"
+      },
+      "emailTemplates": {
+        "title": "E-Mail-Vorlagen",
+        "description": "Was Ihre E-Mails zu Rechnung, Angebot und Bericht sagen"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "Die Zeitzone steht unter Lokalisierung auf automatisch, daher nutzt der Server {zone}, die zuletzt in einem Browser gesehene Zone. Wählen Sie die Zone der Werkstatt ausdrücklich, damit Buchungszeiten nie davon abhängen, wer zuletzt gespeichert hat.",
     "timeZoneExplicit": "Buchungszeiten und Öffnungszeiten werden in {zone} gelesen.",
     "timeZoneChange": "Unter Lokalisierung ändern"
+  },
+  "emailTemplates": {
+    "title": "E-Mail-Vorlagen",
+    "description": "Was die E-Mails Ihrer Werkstatt sagen und wie sie aussehen. Jede Art von E-Mail hat eine eingebaute Vorlage; gestalten Sie Ihre eigene, um Wortlaut, Farben und die Blöcke zu ändern, aus denen sie besteht.",
+    "lockedDescription": "Gestalten Sie die E-Mails Ihrer Werkstatt: Ändern Sie den Wortlaut, fügen Sie Blöcke hinzu oder entfernen Sie sie, und passen Sie die Farben an Ihre Marke an.",
+    "groups": {
+      "documents": "Dokumente",
+      "messages": "Nachrichten",
+      "portal": "Kundenportal"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Rechnung gesendet",
+        "description": "Wird gesendet, wenn eine Rechnung per E-Mail an einen Kunden geht, mit angehängtem PDF oder einem Link zum Ansehen."
+      },
+      "quote_sent": {
+        "name": "Angebot gesendet",
+        "description": "Wird gesendet, wenn ein Angebot per E-Mail an einen Kunden geht."
+      },
+      "inspection_sent": {
+        "name": "Prüfbericht gesendet",
+        "description": "Wird gesendet, wenn ein Prüfbericht per E-Mail an einen Kunden geht."
+      },
+      "message": {
+        "name": "Kundennachricht",
+        "description": "Eine Nachricht an einen Kunden: eine Benachrichtigung aus einem Auftrag, ein Statusbericht, eine Videoanruf-Einladung oder eine geplante Erinnerung."
+      },
+      "portal_signin": {
+        "name": "Portal-Anmeldung",
+        "description": "Der Anmeldelink, den ein Kunde im Kundenportal anfordert."
+      }
+    },
+    "blocks": {
+      "header": "Kopfbereich",
+      "heading": "Überschrift",
+      "paragraph": "Absatz",
+      "callout": "Hinweisfeld",
+      "button": "Schaltfläche",
+      "document_summary": "Dokumentübersicht",
+      "attachment_note": "Anhangshinweis",
+      "image": "Bild",
+      "divider": "Trennlinie",
+      "spacer": "Abstand",
+      "contact_footer": "Kontaktfußzeile"
+    },
+    "blockNotes": {
+      "header": "Ihr Logo oder der Werkstattname, wenn es kein Logo gibt oder es ausgeschaltet ist. Laden Sie das Logo hier hoch und legen Sie seine Größe fest; Farben und Schriften finden Sie unter Betreff und Design.",
+      "contact_footer": "Name, Adresse, Telefon und E-Mail Ihrer Werkstatt aus den Einstellungen unter Unternehmen. Tragen Sie sie dort ein, um zu ändern, was hier gedruckt wird.",
+      "divider": "Eine dünne Linie zwischen zwei Blöcken. Sie entfällt, wenn sie die E-Mail eröffnen oder abschließen würde.",
+      "callout": "Ein hervorgehobenes Feld mit farbigem Rand. Gut für die wichtigsten Worte, etwa die Nachricht selbst.",
+      "attachment_note": "Wird nur gedruckt, wenn der E-Mail ein PDF angehängt ist.",
+      "button": "Der Link ist meist ein Tag wie der Freigabelink. Eine Schaltfläche, deren Link keinen Wert hat, entfällt in der E-Mail.",
+      "document_summary": "Ein Feld mit den Eckdaten des Dokuments. Zeilen ohne Inhalt entfallen.",
+      "image": "Bilder werden beim Hochladen komprimiert und für E-Mails verkleinert. Nicht mehr verwendete Bilder werden beim Speichern der Vorlage entfernt.",
+      "summaryUnused": "Diese Art von E-Mail hat kein Dokument, die Übersicht druckt also nichts."
+    },
+    "tagGroups": {
+      "workshop": "Werkstatt",
+      "customer": "Kunde",
+      "vehicle": "Fahrzeug",
+      "document": "Dokument",
+      "message": "Nachricht",
+      "portal": "Portal"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Werkstattname",
+        "description": "Der Name Ihrer Werkstatt."
+      },
+      "workshop_phone": {
+        "label": "Werkstatt-Telefon",
+        "description": "Die Telefonnummer aus den Einstellungen unter Unternehmen."
+      },
+      "workshop_email": {
+        "label": "Werkstatt-E-Mail",
+        "description": "Die E-Mail-Adresse aus den Einstellungen unter Unternehmen."
+      },
+      "workshop_address": {
+        "label": "Werkstattadresse",
+        "description": "Die Adresse aus den Einstellungen unter Unternehmen, auf so vielen Zeilen, wie sie hat."
+      },
+      "current_user": {
+        "label": "Absendername",
+        "description": "Der Name der Person, die die E-Mail sendet."
+      },
+      "customer_name": {
+        "label": "Kundenname",
+        "description": "Der Name des Kunden, an den die E-Mail geht."
+      },
+      "vehicle": {
+        "label": "Fahrzeug",
+        "description": "Baujahr, Marke und Modell des Fahrzeugs."
+      },
+      "plate": {
+        "label": "Kennzeichen",
+        "description": "Das amtliche Kennzeichen des Fahrzeugs."
+      },
+      "mileage": {
+        "label": "Kilometerstand",
+        "description": "Der beim Fahrzeug erfasste Kilometerstand."
+      },
+      "document_number": {
+        "label": "Dokumentnummer",
+        "description": "Die Nummer der Rechnung oder des Angebots."
+      },
+      "document_title": {
+        "label": "Dokumenttitel",
+        "description": "Der Titel des Auftrags oder Dokuments."
+      },
+      "total": {
+        "label": "Gesamtbetrag",
+        "description": "Der Gesamtbetrag des Dokuments in seiner Währung."
+      },
+      "balance_due": {
+        "label": "Offener Betrag",
+        "description": "Was nach Zahlungen auf dem Dokument noch offen ist."
+      },
+      "due_date": {
+        "label": "Fälligkeitsdatum",
+        "description": "Das Datum, an dem die Zahlung fällig ist."
+      },
+      "share_link": {
+        "label": "Freigabelink",
+        "description": "Ein Link, über den der Kunde das Dokument online ansehen kann."
+      },
+      "message": {
+        "label": "Nachricht",
+        "description": "Die für diese E-Mail geschriebene Nachricht oder die dem Dokument hinzugefügte Notiz."
+      },
+      "signin_link": {
+        "label": "Anmeldelink",
+        "description": "Der Link, der den Kunden im Portal anmeldet. Er läuft nach kurzer Zeit ab."
+      },
+      "portal_link": {
+        "label": "Portallink",
+        "description": "Ein Link zum Kundenportal."
+      }
+    },
+    "optionalTag": "Nicht jede E-Mail hat einen Wert für dieses Tag. Bleibt es leer, werden die Worte darum herum bereinigt.",
+    "summaryRows": {
+      "reference": "Referenz",
+      "vehicle": "Fahrzeug",
+      "total": "Gesamtbetrag",
+      "balance": "Offener Betrag",
+      "due": "Fälligkeitsdatum"
+    },
+    "builtIn": "Eingebaut",
+    "inUse": "In Verwendung",
+    "useBuiltIn": "Eingebaute verwenden",
+    "setDefault": "Als Standard festlegen",
+    "defaultSet": "Wird jetzt mit „{name}“ gesendet",
+    "couldNotSetDefault": "Die verwendete Vorlage konnte nicht geändert werden",
+    "updatedOn": "Aktualisiert am {date}",
+    "delete": "Löschen",
+    "deleteTitle": "Vorlage löschen",
+    "deleteConfirm": "Die Vorlage „{name}“ löschen? Ist sie in Verwendung, greifen E-Mails wieder auf die eingebaute Vorlage zurück.",
+    "deleteTemplate": "Diese Vorlage löschen",
+    "deleted": "„{name}“ gelöscht",
+    "couldNotDelete": "Die Vorlage konnte nicht gelöscht werden",
+    "backToSettings": "Zurück zu den Einstellungen",
+    "unsaved": "(nicht gespeichert)",
+    "save": "Speichern",
+    "saving": "Wird gespeichert…",
+    "savedState": "Gespeichert",
+    "saved": "„{name}“ gespeichert",
+    "couldNotSave": "Speichern nicht möglich",
+    "saveAsNew": "Als neue speichern",
+    "discard": "Änderungen verwerfen",
+    "sendTest": "Test senden",
+    "cancel": "Abbrechen",
+    "problems": "{count, plural, one {# Problem} other {# Probleme}}",
+    "unknownTags": "Diese Tags gibt es für diese Art von E-Mail nicht: {tags}",
+    "missingTags": "Diese E-Mail muss {tags} enthalten",
+    "subjectRequired": "Der Betreff darf nicht leer sein",
+    "invalidTemplate": "Ein Text ist zu lang oder ein Feld hat einen ungültigen Wert.",
+    "leaveTitle": "Ohne Speichern verlassen?",
+    "leaveBody": "Ihre Änderungen an dieser Vorlage wurden nicht gespeichert. Sie gehen verloren.",
+    "leaveConfirm": "Verlassen",
+    "subjectAndTheme": "Betreff und Design",
+    "settingsSubtitle": "Gilt für die ganze E-Mail",
+    "blocksTitle": "Blöcke",
+    "addBlock": "Block hinzufügen",
+    "removeBlock": "Block entfernen",
+    "removeBlockLast": "Der letzte Block kann nicht entfernt werden",
+    "blockEmpty": "leer",
+    "blockEmptyHint": "Wird angezeigt, hat aber noch nichts zu drucken.",
+    "blockShownHint": "In der E-Mail sichtbar. Klicken, um ihn auszublenden.",
+    "blockHiddenHint": "Aus der E-Mail ausgeblendet. Klicken, um ihn anzuzeigen.",
+    "railHint": "Ziehen Sie einen Block an seinem Griff, um ihn zu verschieben. Die Linie zeigt, wo er landet. Klicken Sie einen Block an, um rechts seinen Text zu bearbeiten.",
+    "resizeHint": "Ziehen, um die Größe des Bereichs zu ändern. Doppelklick setzt sie zurück.",
+    "desktop": "Desktop",
+    "mobile": "Mobil",
+    "htmlView": "HTML",
+    "textView": "Nur Text",
+    "desktopTooltip": "Vorschau in Desktop-Breite",
+    "mobileTooltip": "Vorschau in Handy-Breite",
+    "htmlViewTooltip": "HTML-E-Mail anzeigen",
+    "previewWidth": "Vorschaubreite",
+    "previewView": "Vorschauansicht",
+    "textViewTooltip": "Nur-Text-Version anzeigen",
+    "inboxNow": "Jetzt",
+    "inboxNoSender": "Ihre Werkstatt",
+    "inboxNoSubject": "(kein Betreff)",
+    "subject": "Betreff",
+    "text": "Text",
+    "buttonLabel": "Schaltflächentext",
+    "buttonHref": "Link",
+    "spacerHeight": "Höhe",
+    "tagsTitle": "Tags",
+    "tagsHint": "Klicken Sie ein Tag an, um es an der Cursorposition in dem Feld einzufügen, in das Sie zuletzt getippt haben.",
+    "theme": {
+      "colors": "Farben",
+      "primaryColor": "Primär",
+      "textColor": "Text",
+      "mutedColor": "Gedämpfter Text",
+      "backgroundColor": "Hintergrund",
+      "panelColor": "Felder",
+      "invalidColor": "Eine Farbe ist kein gültiger Hex-Wert",
+      "hexLabel": "{label} als Hex",
+      "typography": "Typografie",
+      "font": "Schrift",
+      "fontHint": "Nur Schriften, die jedes Mailprogramm schon hat. Webfonts werden von den meisten nicht geladen.",
+      "fonts": {
+        "system": "System",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Formen",
+      "buttonRadius": "Schaltflächenecken",
+      "showLogo": "Logo anzeigen",
+      "showLogoHint": "Ist das Logo aus, druckt der Kopfbereich stattdessen den Werkstattnamen."
+    },
+    "nameDialog": {
+      "title": "Diese Vorlage speichern",
+      "body": "Geben Sie der Vorlage einen Namen, unter dem Sie sie in der Galerie wiederfinden. Speichern macht sie zugleich zu der Vorlage, mit der diese Art von E-Mail gesendet wird.",
+      "placeholder": "Name der Vorlage",
+      "nameExists": "Eine Vorlage mit diesem Namen gibt es bereits. Wählen Sie einen anderen Namen.",
+      "save": "Vorlage speichern",
+      "update": "Vorlage aktualisieren"
+    },
+    "testDialog": {
+      "title": "Test-E-Mail senden",
+      "body": "Die E-Mail wird mit Beispieldaten gesendet, damit Sie sie in einem echten Mailprogramm sehen können.",
+      "placeholder": "E-Mail-Adresse",
+      "send": "Senden",
+      "sending": "Wird gesendet…",
+      "sentTitle": "Test-E-Mail gesendet",
+      "sentBody": "Prüfen Sie den Posteingang von {email}. Es kann eine Minute dauern, bis sie ankommt.",
+      "failedTitle": "Test-E-Mail konnte nicht gesendet werden",
+      "failedBody": "Prüfen Sie den E-Mail-Anbieter unter Anbieter und versuchen Sie es erneut."
+    },
+    "toolbar": {
+      "bold": "Fett",
+      "italic": "Kursiv",
+      "underline": "Unterstrichen",
+      "strike": "Durchgestrichen",
+      "link": "Link",
+      "linkUrl": "Linkadresse",
+      "linkPlaceholder": "https://... oder {share_link}",
+      "linkHint": "Eine Webadresse oder ein Tag wie {share_link}, das beim Senden der E-Mail ausgefüllt wird.",
+      "linkApply": "Übernehmen",
+      "linkRemove": "Link entfernen",
+      "bulletList": "Aufzählung",
+      "orderedList": "Nummerierte Liste",
+      "align": {
+        "left": "Linksbündig",
+        "center": "Zentriert",
+        "right": "Rechtsbündig"
+      },
+      "size": {
+        "label": "Textgröße",
+        "default": "Standardgröße",
+        "small": "Klein",
+        "normal": "Normal",
+        "large": "Groß",
+        "extraLarge": "Sehr groß"
+      },
+      "color": {
+        "label": "Textfarbe",
+        "text": "Textfarbe des Themas",
+        "muted": "Gedämpfte Farbe des Themas",
+        "primary": "Primärfarbe des Themas",
+        "default": "Standard",
+        "hex": "Hex-Farbe",
+        "apply": "Übernehmen"
+      },
+      "clear": "Formatierung entfernen"
+    },
+    "logo": {
+      "title": "Logo",
+      "empty": "Kein Logo hochgeladen. Der Kopfbereich druckt den Werkstattnamen.",
+      "upload": "Logo hochladen",
+      "replace": "Logo ersetzen",
+      "remove": "Entfernen",
+      "uploading": "Wird hochgeladen…",
+      "width": "Logobreite",
+      "hint": "Unabhängig vom Firmenlogo. Es wird beim Hochladen zugeschnitten und für E-Mails aufbereitet. PNG, JPEG oder WebP bis 4 MB.",
+      "position": "Position",
+      "tooLarge": "Das Bild muss kleiner als 4 MB sein",
+      "uploadFailed": "Logo konnte nicht hochgeladen werden"
+    },
+    "image": {
+      "empty": "Kein Bild hochgeladen. Der Block druckt nichts, solange keines vorhanden ist.",
+      "upload": "Bild hochladen",
+      "replace": "Bild ersetzen",
+      "remove": "Entfernen",
+      "uploading": "Wird hochgeladen…",
+      "alt": "Beschreibung",
+      "altHint": "Wird Lesern vorgelesen, die das Bild nicht sehen können, und während des Ladens angezeigt. PNG, JPEG oder WebP bis 10 MB.",
+      "width": "Breite",
+      "widthHint": "600 px ist die volle Breite der E-Mail. Schmalere Bilder stehen dort, wo es unter Position festgelegt ist.",
+      "link": "Link",
+      "linkHint": "Optional. Wird beim Klick auf das Bild geöffnet; ein Tag wie der Freigabelink funktioniert hier ebenfalls.",
+      "tooLarge": "Das Bild muss kleiner als 10 MB sein",
+      "uploadFailed": "Bild konnte nicht hochgeladen werden"
+    },
+    "dragHandle": "Zum Verschieben ziehen",
+    "dragHandleKeyboard": "Ziehen Sie diesen Block oder verschieben Sie ihn mit den Pfeiltasten",
+    "tagDoesNotFit": "Das Tag passt nicht in dieses Feld."
   }
 }

+ 6 - 0
messages/en/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Deleted Document Design",
     "settings_applyDocumentDesign": "Set Default Document Design",
     "settings_setDocumentDesignRule": "Changed When a Design Is Used",
+    "settings_saveEmailTemplate": "Saved Email Template",
+    "settings_deleteEmailTemplate": "Deleted Email Template",
+    "settings_applyEmailTemplate": "Set Default Email Template",
     "settings_freezeInvoices": "Locked Older Invoices",
     "inspection_reminders_sent": "Sent inspection reminders",
     "inspection_reminders_send": "Sent inspection reminders",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "Deleted the document design {name}",
     "settings_applyDocumentDesign": "Made {name} the default document design",
     "settings_setDocumentDesignRule": "Changed when the document design {name} is used",
+    "settings_saveEmailTemplate": "Saved the email template {name}",
+    "settings_deleteEmailTemplate": "Deleted the email template {name}",
+    "settings_applyEmailTemplate": "Made {name} the template for {kind} emails",
     "settings_freezeInvoices": "Locked {count, plural, one {# invoice} other {# invoices}} from before locking with the current design",
     "inspection_reminders_sent": "Sent {count} inspection reminders",
     "service_videoCall_send": "Sent the video call link via {channels}",

+ 51 - 0
messages/en/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Invoice",
+      "subject": "Invoice {document_number} from {workshop_name}",
+      "heading": "Invoice {document_number}",
+      "intro": "Hi {customer_name},\n\nThank you for choosing us. Your invoice is ready.",
+      "button": "View invoice",
+      "outro": "Any questions, just reply to this email."
+    },
+    "quote_sent": {
+      "name": "Quote",
+      "subject": "Quote {document_number} from {workshop_name}",
+      "heading": "Quote {document_number}",
+      "intro": "Hi {customer_name},\n\nHere is the quote you asked for. Let us know if you would like us to go ahead.",
+      "button": "View quote",
+      "outro": "Any questions, just reply to this email."
+    },
+    "inspection_sent": {
+      "name": "Inspection report",
+      "subject": "Inspection report from {workshop_name}",
+      "heading": "Vehicle inspection report",
+      "intro": "Hi {customer_name},\n\nWe have finished the inspection. The report is ready for you.",
+      "button": "View report",
+      "outro": "Any questions, just reply to this email."
+    },
+    "message": {
+      "name": "Customer message",
+      "subject": "Message from {workshop_name}",
+      "intro": "Hi {customer_name},",
+      "outro": "Any questions, just reply to this email."
+    },
+    "portal_signin": {
+      "name": "Portal sign-in",
+      "subject": "Sign in to {workshop_name}",
+      "heading": "Sign in to your customer portal",
+      "intro": "Hi {customer_name},\n\nUse the button below to sign in to the {workshop_name} customer portal.",
+      "button": "Sign in",
+      "linkFallback": "If the button does not work, copy this link into your browser:\n{signin_link}",
+      "outro": "This link expires in 15 minutes. If you did not ask for it, you can ignore this email."
+    }
+  },
+  "attachmentNote": "A PDF copy is attached to this email.",
+  "summary": {
+    "reference": "Reference",
+    "vehicle": "Vehicle",
+    "total": "Total",
+    "balance": "Balance due",
+    "due": "Due"
+  }
+}

+ 326 - 0
messages/en/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "Inspection reminders",
         "description": "Booking link, duration and lead time"
+      },
+      "emailTemplates": {
+        "title": "Email templates",
+        "description": "What your invoice, quote and report emails say"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "Timezone is on automatic under Localization, so the server uses {zone}, the zone last seen in a browser that saved settings. Choose the workshop's zone explicitly so booking times never depend on who saved last.",
     "timeZoneExplicit": "Booking times and opening hours are read in {zone}.",
     "timeZoneChange": "Change under Localization"
+  },
+  "emailTemplates": {
+    "title": "Email templates",
+    "description": "What the emails your workshop sends say and how they look. Each kind of email has a built-in template; design your own to change the wording, the colours and the blocks it is made of.",
+    "lockedDescription": "Design the emails your workshop sends: change the wording, add or remove blocks, and match the colours to your brand.",
+    "groups": {
+      "documents": "Documents",
+      "messages": "Messages",
+      "portal": "Customer portal"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Invoice sent",
+        "description": "Sent when an invoice goes to a customer by email, with the PDF attached or a link to view it."
+      },
+      "quote_sent": {
+        "name": "Quote sent",
+        "description": "Sent when a quote goes to a customer by email."
+      },
+      "inspection_sent": {
+        "name": "Inspection report sent",
+        "description": "Sent when an inspection report goes to a customer by email."
+      },
+      "message": {
+        "name": "Customer message",
+        "description": "A message written to a customer: a notification from a work order, a status report, a video call invitation or a scheduled reminder."
+      },
+      "portal_signin": {
+        "name": "Portal sign-in",
+        "description": "The sign-in link a customer asks for on the customer portal."
+      }
+    },
+    "blocks": {
+      "header": "Header",
+      "heading": "Heading",
+      "paragraph": "Paragraph",
+      "callout": "Callout",
+      "button": "Button",
+      "document_summary": "Document summary",
+      "attachment_note": "Attachment note",
+      "image": "Image",
+      "divider": "Divider",
+      "spacer": "Spacer",
+      "contact_footer": "Contact footer"
+    },
+    "blockNotes": {
+      "header": "Your logo, or the workshop name when there is no logo or it is switched off. Upload and size the logo here; colours and fonts live under Subject and theme.",
+      "contact_footer": "Your workshop name, address, phone and email, taken from Company settings. Fill them in there to change what this prints.",
+      "divider": "A thin line between two blocks. It is left out when it would open or close the email.",
+      "callout": "A highlighted panel with a coloured edge. Good for the words that matter most, such as the message itself.",
+      "attachment_note": "Only printed when a PDF is attached to the email.",
+      "button": "The link is usually a tag such as the share link. A button whose link has no value is left out of the email.",
+      "document_summary": "A panel with the key figures of the document. Rows with nothing to show are left out.",
+      "image": "Pictures are compressed and resized for email when uploaded. Pictures no longer used are removed when the template is saved.",
+      "summaryUnused": "This kind of email has no document, so the summary prints nothing."
+    },
+    "tagGroups": {
+      "workshop": "Workshop",
+      "customer": "Customer",
+      "vehicle": "Vehicle",
+      "document": "Document",
+      "message": "Message",
+      "portal": "Portal"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Workshop name",
+        "description": "The name of your workshop."
+      },
+      "workshop_phone": {
+        "label": "Workshop phone",
+        "description": "The phone number from Company settings."
+      },
+      "workshop_email": {
+        "label": "Workshop email",
+        "description": "The email address from Company settings."
+      },
+      "workshop_address": {
+        "label": "Workshop address",
+        "description": "The address from Company settings, on as many lines as it has."
+      },
+      "current_user": {
+        "label": "Sender name",
+        "description": "The name of the person sending the email."
+      },
+      "customer_name": {
+        "label": "Customer name",
+        "description": "The name of the customer the email goes to."
+      },
+      "vehicle": {
+        "label": "Vehicle",
+        "description": "Year, make and model of the vehicle."
+      },
+      "plate": {
+        "label": "Registration plate",
+        "description": "The registration number of the vehicle."
+      },
+      "mileage": {
+        "label": "Mileage",
+        "description": "The mileage recorded on the vehicle."
+      },
+      "document_number": {
+        "label": "Document number",
+        "description": "The number of the invoice or quote."
+      },
+      "document_title": {
+        "label": "Document title",
+        "description": "The title of the job or document."
+      },
+      "total": {
+        "label": "Total",
+        "description": "The total of the document, in its currency."
+      },
+      "balance_due": {
+        "label": "Balance due",
+        "description": "What is still owed on the document after payments."
+      },
+      "due_date": {
+        "label": "Due date",
+        "description": "The date the payment is due."
+      },
+      "share_link": {
+        "label": "Share link",
+        "description": "A link the customer can open to view the document online."
+      },
+      "message": {
+        "label": "Message",
+        "description": "The message written for this email, or the note added to the document."
+      },
+      "signin_link": {
+        "label": "Sign-in link",
+        "description": "The link that signs the customer in to the portal. It expires after a short while."
+      },
+      "portal_link": {
+        "label": "Portal link",
+        "description": "A link to the customer portal."
+      }
+    },
+    "optionalTag": "Not every email has a value for this tag. When it is blank, the words around it are tidied up.",
+    "summaryRows": {
+      "reference": "Reference",
+      "vehicle": "Vehicle",
+      "total": "Total",
+      "balance": "Balance due",
+      "due": "Due date"
+    },
+    "builtIn": "Built-in",
+    "inUse": "In use",
+    "useBuiltIn": "Use built-in",
+    "setDefault": "Set as default",
+    "defaultSet": "Now sending with \"{name}\"",
+    "couldNotSetDefault": "Could not change the template in use",
+    "updatedOn": "Updated {date}",
+    "delete": "Delete",
+    "deleteTitle": "Delete template",
+    "deleteConfirm": "Delete the template \"{name}\"? If it is in use, emails go back to the built-in template.",
+    "deleteTemplate": "Delete this template",
+    "deleted": "Deleted \"{name}\"",
+    "couldNotDelete": "Could not delete the template",
+    "backToSettings": "Back to settings",
+    "unsaved": "(unsaved)",
+    "save": "Save",
+    "saving": "Saving…",
+    "savedState": "Saved",
+    "saved": "Saved \"{name}\"",
+    "couldNotSave": "Could not save",
+    "saveAsNew": "Save as new",
+    "discard": "Discard changes",
+    "sendTest": "Send test",
+    "cancel": "Cancel",
+    "problems": "{count, plural, one {# problem} other {# problems}}",
+    "unknownTags": "These tags do not exist for this kind of email: {tags}",
+    "missingTags": "This email has to include {tags}",
+    "subjectRequired": "The subject cannot be empty",
+    "invalidTemplate": "Some text is too long or a field has an invalid value.",
+    "leaveTitle": "Leave without saving?",
+    "leaveBody": "Your changes to this template have not been saved. They will be lost.",
+    "leaveConfirm": "Leave",
+    "subjectAndTheme": "Subject and theme",
+    "settingsSubtitle": "Applies to the whole email",
+    "blocksTitle": "Blocks",
+    "addBlock": "Add block",
+    "removeBlock": "Remove block",
+    "removeBlockLast": "The last block cannot be removed",
+    "blockEmpty": "empty",
+    "blockEmptyHint": "Shown, but there is nothing in it to print yet.",
+    "blockShownHint": "Shown in the email. Click to hide it.",
+    "blockHiddenHint": "Hidden from the email. Click to show it.",
+    "railHint": "Drag a block by its handle to move it. The line shows where it will land. Click a block to edit its words on the right.",
+    "resizeHint": "Drag to resize the panel. Double-click to reset.",
+    "desktop": "Desktop",
+    "mobile": "Mobile",
+    "htmlView": "HTML",
+    "textView": "Plain text",
+    "desktopTooltip": "Preview at desktop width",
+    "mobileTooltip": "Preview at phone width",
+    "htmlViewTooltip": "Show the HTML email",
+    "previewWidth": "Preview width",
+    "previewView": "Preview view",
+    "textViewTooltip": "Show the plain-text version",
+    "inboxNow": "Now",
+    "inboxNoSender": "Your workshop",
+    "inboxNoSubject": "(no subject)",
+    "subject": "Subject",
+    "text": "Text",
+    "buttonLabel": "Button text",
+    "buttonHref": "Link",
+    "spacerHeight": "Height",
+    "tagsTitle": "Tags",
+    "tagsHint": "Click a tag to insert it where the cursor is in the field you last typed in.",
+    "theme": {
+      "colors": "Colours",
+      "primaryColor": "Primary",
+      "textColor": "Text",
+      "mutedColor": "Muted text",
+      "backgroundColor": "Background",
+      "panelColor": "Panels",
+      "invalidColor": "A colour is not a valid hex value",
+      "hexLabel": "{label} as hex",
+      "typography": "Typography",
+      "font": "Font",
+      "fontHint": "Only fonts every mail client already has. Web fonts are not loaded by most of them.",
+      "fonts": {
+        "system": "System",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Shapes",
+      "buttonRadius": "Button corners",
+      "showLogo": "Show logo",
+      "showLogoHint": "With the logo off, the header prints the workshop name instead."
+    },
+    "nameDialog": {
+      "title": "Save this template",
+      "body": "Give the template a name to find it by in the gallery. Saving also makes it the one this kind of email sends with.",
+      "placeholder": "Template name",
+      "nameExists": "A template with this name already exists. Choose another name.",
+      "save": "Save template",
+      "update": "Update template"
+    },
+    "testDialog": {
+      "title": "Send a test email",
+      "body": "The email is sent with sample data, so you can see it in a real mail client.",
+      "placeholder": "Email address",
+      "send": "Send",
+      "sending": "Sending…",
+      "sentTitle": "Test email sent",
+      "sentBody": "Check the inbox of {email}. It may take a minute to arrive.",
+      "failedTitle": "Could not send the test email",
+      "failedBody": "Check the email provider under Providers and try again."
+    },
+    "toolbar": {
+      "bold": "Bold",
+      "italic": "Italic",
+      "underline": "Underline",
+      "strike": "Strikethrough",
+      "link": "Link",
+      "linkUrl": "Link address",
+      "linkPlaceholder": "https://... or {share_link}",
+      "linkHint": "A web address, or a tag such as {share_link} that is filled in when the email is sent.",
+      "linkApply": "Apply",
+      "linkRemove": "Remove link",
+      "bulletList": "Bulleted list",
+      "orderedList": "Numbered list",
+      "align": {
+        "left": "Align left",
+        "center": "Align centre",
+        "right": "Align right"
+      },
+      "size": {
+        "label": "Text size",
+        "default": "Default size",
+        "small": "Small",
+        "normal": "Normal",
+        "large": "Large",
+        "extraLarge": "Extra large"
+      },
+      "color": {
+        "label": "Text colour",
+        "text": "Theme text colour",
+        "muted": "Theme muted colour",
+        "primary": "Theme primary colour",
+        "default": "Default",
+        "hex": "Hex colour",
+        "apply": "Apply"
+      },
+      "clear": "Clear formatting"
+    },
+    "logo": {
+      "title": "Logo",
+      "empty": "No logo uploaded. The header prints the workshop name.",
+      "upload": "Upload logo",
+      "replace": "Replace logo",
+      "remove": "Remove",
+      "uploading": "Uploading…",
+      "width": "Logo width",
+      "hint": "Separate from the company logo. It is trimmed and prepared for email when uploaded. PNG, JPEG or WebP up to 4 MB.",
+      "tooLarge": "The image must be under 4 MB",
+      "uploadFailed": "Could not upload the logo",
+      "position": "Position"
+    },
+    "image": {
+      "empty": "No picture uploaded. The block prints nothing until there is one.",
+      "upload": "Upload picture",
+      "replace": "Replace picture",
+      "remove": "Remove",
+      "uploading": "Uploading…",
+      "alt": "Description",
+      "altHint": "Read aloud to readers who cannot see the picture, and shown while it loads. PNG, JPEG or WebP up to 10 MB.",
+      "width": "Width",
+      "widthHint": "600 px is the full width of the email. Narrower pictures sit where Position says.",
+      "link": "Link",
+      "linkHint": "Optional. Opens when the picture is clicked; a tag such as the share link works here too.",
+      "tooLarge": "The image must be under 10 MB",
+      "uploadFailed": "Could not upload the picture"
+    },
+    "dragHandle": "Drag to reorder",
+    "dragHandleKeyboard": "Drag, or use the arrow keys, to move this block",
+    "tagDoesNotFit": "The tag does not fit in this field."
   }
 }

+ 6 - 0
messages/es/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Diseño de documento eliminado",
     "settings_applyDocumentDesign": "Diseño de documento predeterminado establecido",
     "settings_setDocumentDesignRule": "Cambió cuándo se usa un diseño",
+    "settings_saveEmailTemplate": "Plantilla de correo guardada",
+    "settings_deleteEmailTemplate": "Plantilla de correo eliminada",
+    "settings_applyEmailTemplate": "Plantilla de correo predeterminada establecida",
     "settings_freezeInvoices": "Facturas anteriores bloqueadas",
     "inspection_reminders_sent": "Envió recordatorios de inspección",
     "inspection_reminders_send": "Envió recordatorios de inspección",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "Eliminó el diseño de documento {name}",
     "settings_applyDocumentDesign": "Estableció {name} como diseño de documento predeterminado",
     "settings_setDocumentDesignRule": "Cambió cuándo se usa el diseño de documento {name}",
+    "settings_saveEmailTemplate": "Guardó la plantilla de correo {name}",
+    "settings_deleteEmailTemplate": "Eliminó la plantilla de correo {name}",
+    "settings_applyEmailTemplate": "Estableció {name} como plantilla para los correos de {kind}",
     "settings_freezeInvoices": "Bloqueó {count, plural, one {# factura} other {# facturas}} anteriores al bloqueo con el diseño actual",
     "inspection_reminders_sent": "Envió {count} recordatorios de inspección",
     "service_videoCall_send": "Envió el enlace de la videollamada por {channels}",

+ 51 - 0
messages/es/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Factura",
+      "subject": "Factura {document_number} de {workshop_name}",
+      "heading": "Factura {document_number}",
+      "intro": "Hola {customer_name}:\n\nGracias por confiar en nosotros. Su factura ya está lista.",
+      "button": "Ver factura",
+      "outro": "Si tiene alguna pregunta, simplemente responda a este correo."
+    },
+    "quote_sent": {
+      "name": "Presupuesto",
+      "subject": "Presupuesto {document_number} de {workshop_name}",
+      "heading": "Presupuesto {document_number}",
+      "intro": "Hola {customer_name}:\n\nAquí tiene el presupuesto que nos pidió. Avísenos si quiere que sigamos adelante.",
+      "button": "Ver presupuesto",
+      "outro": "Si tiene alguna pregunta, simplemente responda a este correo."
+    },
+    "inspection_sent": {
+      "name": "Informe de inspección",
+      "subject": "Informe de inspección de {workshop_name}",
+      "heading": "Informe de inspección del vehículo",
+      "intro": "Hola {customer_name}:\n\nHemos terminado la inspección. El informe ya está disponible.",
+      "button": "Ver informe",
+      "outro": "Si tiene alguna pregunta, simplemente responda a este correo."
+    },
+    "message": {
+      "name": "Mensaje al cliente",
+      "subject": "Mensaje de {workshop_name}",
+      "intro": "Hola {customer_name}:",
+      "outro": "Si tiene alguna pregunta, simplemente responda a este correo."
+    },
+    "portal_signin": {
+      "name": "Acceso al portal",
+      "subject": "Inicie sesión en {workshop_name}",
+      "heading": "Inicie sesión en su portal de cliente",
+      "intro": "Hola {customer_name}:\n\nUse el botón de abajo para iniciar sesión en el portal de cliente de {workshop_name}.",
+      "button": "Iniciar sesión",
+      "linkFallback": "Si el botón no funciona, copie este enlace en su navegador:\n{signin_link}",
+      "outro": "Este enlace caduca en 15 minutos. Si no lo ha solicitado, puede ignorar este correo."
+    }
+  },
+  "attachmentNote": "Adjuntamos una copia en PDF a este correo.",
+  "summary": {
+    "reference": "Referencia",
+    "vehicle": "Vehículo",
+    "total": "Total",
+    "balance": "Saldo pendiente",
+    "due": "Vencimiento"
+  }
+}

+ 326 - 0
messages/es/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "Recordatorios de inspección",
         "description": "Enlace de reserva, duración y antelación"
+      },
+      "emailTemplates": {
+        "title": "Plantillas de correo",
+        "description": "Lo que dicen tus correos de factura, presupuesto e informe"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "La zona horaria está en automático en Localización, así que el servidor usa {zone}, la última vista en un navegador que guardó ajustes. Elige la zona del taller explícitamente para que las horas de reserva nunca dependan de quién guardó último.",
     "timeZoneExplicit": "Las horas de reserva y el horario se leen en {zone}.",
     "timeZoneChange": "Cambiar en Localización"
+  },
+  "emailTemplates": {
+    "title": "Plantillas de correo",
+    "description": "Lo que dicen los correos que envía tu taller y cómo se ven. Cada tipo de correo tiene una plantilla integrada; diseña la tuya para cambiar el texto, los colores y los bloques que la componen.",
+    "lockedDescription": "Diseña los correos que envía tu taller: cambia el texto, añade o quita bloques y ajusta los colores a tu marca.",
+    "groups": {
+      "documents": "Documentos",
+      "messages": "Mensajes",
+      "portal": "Portal del cliente"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Factura enviada",
+        "description": "Se envía cuando una factura va a un cliente por correo, con el PDF adjunto o un enlace para verla."
+      },
+      "quote_sent": {
+        "name": "Presupuesto enviado",
+        "description": "Se envía cuando un presupuesto va a un cliente por correo."
+      },
+      "inspection_sent": {
+        "name": "Informe de inspección enviado",
+        "description": "Se envía cuando un informe de inspección va a un cliente por correo."
+      },
+      "message": {
+        "name": "Mensaje al cliente",
+        "description": "Un mensaje escrito a un cliente: una notificación desde una orden de trabajo, un informe de estado, una invitación a videollamada o un recordatorio programado."
+      },
+      "portal_signin": {
+        "name": "Acceso al portal",
+        "description": "El enlace de acceso que un cliente solicita en el portal del cliente."
+      }
+    },
+    "blocks": {
+      "header": "Cabecera",
+      "heading": "Título",
+      "paragraph": "Párrafo",
+      "callout": "Destacado",
+      "button": "Botón",
+      "document_summary": "Resumen del documento",
+      "attachment_note": "Nota de adjunto",
+      "image": "Imagen",
+      "divider": "Separador",
+      "spacer": "Espacio",
+      "contact_footer": "Pie de contacto"
+    },
+    "blockNotes": {
+      "header": "Tu logotipo, o el nombre del taller cuando no hay logotipo o está desactivado. Sube el logotipo y ajusta su tamaño aquí; los colores y las fuentes están en Asunto y tema.",
+      "contact_footer": "El nombre, la dirección, el teléfono y el correo de tu taller, tomados de los ajustes de Empresa. Rellénalos allí para cambiar lo que se imprime aquí.",
+      "divider": "Una línea fina entre dos bloques. Se omite cuando abriría o cerraría el correo.",
+      "callout": "Un panel resaltado con un borde de color. Ideal para las palabras que más importan, como el propio mensaje.",
+      "attachment_note": "Solo se imprime cuando el correo lleva un PDF adjunto.",
+      "button": "El enlace suele ser una etiqueta, como el enlace para compartir. Un botón cuyo enlace no tiene valor se omite del correo.",
+      "document_summary": "Un panel con las cifras clave del documento. Las filas sin nada que mostrar se omiten.",
+      "image": "Las imágenes se comprimen y redimensionan para el correo al subirlas. Las imágenes que ya no se usan se eliminan al guardar la plantilla.",
+      "summaryUnused": "Este tipo de correo no tiene documento, así que el resumen no imprime nada."
+    },
+    "tagGroups": {
+      "workshop": "Taller",
+      "customer": "Cliente",
+      "vehicle": "Vehículo",
+      "document": "Documento",
+      "message": "Mensaje",
+      "portal": "Portal"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Nombre del taller",
+        "description": "El nombre de tu taller."
+      },
+      "workshop_phone": {
+        "label": "Teléfono del taller",
+        "description": "El número de teléfono de los ajustes de Empresa."
+      },
+      "workshop_email": {
+        "label": "Correo del taller",
+        "description": "La dirección de correo de los ajustes de Empresa."
+      },
+      "workshop_address": {
+        "label": "Dirección del taller",
+        "description": "La dirección de los ajustes de Empresa, en tantas líneas como tenga."
+      },
+      "current_user": {
+        "label": "Nombre del remitente",
+        "description": "El nombre de la persona que envía el correo."
+      },
+      "customer_name": {
+        "label": "Nombre del cliente",
+        "description": "El nombre del cliente al que va el correo."
+      },
+      "vehicle": {
+        "label": "Vehículo",
+        "description": "Año, marca y modelo del vehículo."
+      },
+      "plate": {
+        "label": "Matrícula",
+        "description": "El número de matrícula del vehículo."
+      },
+      "mileage": {
+        "label": "Kilometraje",
+        "description": "El kilometraje registrado en el vehículo."
+      },
+      "document_number": {
+        "label": "Número de documento",
+        "description": "El número de la factura o del presupuesto."
+      },
+      "document_title": {
+        "label": "Título del documento",
+        "description": "El título del trabajo o del documento."
+      },
+      "total": {
+        "label": "Total",
+        "description": "El total del documento, en su moneda."
+      },
+      "balance_due": {
+        "label": "Saldo pendiente",
+        "description": "Lo que aún se debe del documento tras los pagos."
+      },
+      "due_date": {
+        "label": "Fecha de vencimiento",
+        "description": "La fecha en que vence el pago."
+      },
+      "share_link": {
+        "label": "Enlace para compartir",
+        "description": "Un enlace que el cliente puede abrir para ver el documento en línea."
+      },
+      "message": {
+        "label": "Mensaje",
+        "description": "El mensaje escrito para este correo, o la nota añadida al documento."
+      },
+      "signin_link": {
+        "label": "Enlace de acceso",
+        "description": "El enlace que da acceso al cliente al portal. Caduca al poco tiempo."
+      },
+      "portal_link": {
+        "label": "Enlace del portal",
+        "description": "Un enlace al portal del cliente."
+      }
+    },
+    "optionalTag": "No todos los correos tienen un valor para esta etiqueta. Cuando está vacía, las palabras que la rodean se ordenan.",
+    "summaryRows": {
+      "reference": "Referencia",
+      "vehicle": "Vehículo",
+      "total": "Total",
+      "balance": "Saldo pendiente",
+      "due": "Fecha de vencimiento"
+    },
+    "builtIn": "Integrada",
+    "inUse": "En uso",
+    "useBuiltIn": "Usar la integrada",
+    "setDefault": "Establecer como predeterminada",
+    "defaultSet": "Ahora se envía con «{name}»",
+    "couldNotSetDefault": "No se pudo cambiar la plantilla en uso",
+    "updatedOn": "Actualizada el {date}",
+    "delete": "Eliminar",
+    "deleteTitle": "Eliminar plantilla",
+    "deleteConfirm": "¿Eliminar la plantilla «{name}»? Si está en uso, los correos vuelven a la plantilla integrada.",
+    "deleteTemplate": "Eliminar esta plantilla",
+    "deleted": "«{name}» eliminada",
+    "couldNotDelete": "No se pudo eliminar la plantilla",
+    "backToSettings": "Volver a ajustes",
+    "unsaved": "(sin guardar)",
+    "save": "Guardar",
+    "saving": "Guardando…",
+    "savedState": "Guardado",
+    "saved": "«{name}» guardada",
+    "couldNotSave": "No se pudo guardar",
+    "saveAsNew": "Guardar como nueva",
+    "discard": "Descartar cambios",
+    "sendTest": "Enviar prueba",
+    "cancel": "Cancelar",
+    "problems": "{count, plural, one {# problema} other {# problemas}}",
+    "unknownTags": "Estas etiquetas no existen para este tipo de correo: {tags}",
+    "missingTags": "Este correo tiene que incluir {tags}",
+    "subjectRequired": "El asunto no puede estar vacío",
+    "invalidTemplate": "Algún texto es demasiado largo o un campo tiene un valor no válido.",
+    "leaveTitle": "¿Salir sin guardar?",
+    "leaveBody": "Los cambios en esta plantilla no se han guardado. Se perderán.",
+    "leaveConfirm": "Salir",
+    "subjectAndTheme": "Asunto y tema",
+    "settingsSubtitle": "Se aplica a todo el correo",
+    "blocksTitle": "Bloques",
+    "addBlock": "Añadir bloque",
+    "removeBlock": "Quitar bloque",
+    "removeBlockLast": "El último bloque no se puede quitar",
+    "blockEmpty": "vacío",
+    "blockEmptyHint": "Se muestra, pero todavía no hay nada que imprimir en él.",
+    "blockShownHint": "Visible en el correo. Haz clic para ocultarlo.",
+    "blockHiddenHint": "Oculto en el correo. Haz clic para mostrarlo.",
+    "railHint": "Arrastra un bloque por su asa para moverlo. La línea muestra dónde quedará. Haz clic en un bloque para editar su texto a la derecha.",
+    "resizeHint": "Arrastra para cambiar el tamaño del panel. Haz doble clic para restablecerlo.",
+    "desktop": "Escritorio",
+    "mobile": "Móvil",
+    "htmlView": "HTML",
+    "textView": "Texto sin formato",
+    "desktopTooltip": "Vista previa en ancho de escritorio",
+    "mobileTooltip": "Vista previa en ancho de móvil",
+    "htmlViewTooltip": "Mostrar el correo HTML",
+    "previewWidth": "Ancho de la vista previa",
+    "previewView": "Modo de vista previa",
+    "textViewTooltip": "Mostrar la versión de texto sin formato",
+    "inboxNow": "Ahora",
+    "inboxNoSender": "Tu taller",
+    "inboxNoSubject": "(sin asunto)",
+    "subject": "Asunto",
+    "text": "Texto",
+    "buttonLabel": "Texto del botón",
+    "buttonHref": "Enlace",
+    "spacerHeight": "Altura",
+    "tagsTitle": "Etiquetas",
+    "tagsHint": "Haz clic en una etiqueta para insertarla donde está el cursor en el campo en el que escribiste por última vez.",
+    "theme": {
+      "colors": "Colores",
+      "primaryColor": "Principal",
+      "textColor": "Texto",
+      "mutedColor": "Texto atenuado",
+      "backgroundColor": "Fondo",
+      "panelColor": "Paneles",
+      "invalidColor": "Un color no es un valor hexadecimal válido",
+      "hexLabel": "{label} en hexadecimal",
+      "typography": "Tipografía",
+      "font": "Fuente",
+      "fontHint": "Solo fuentes que todos los clientes de correo ya tienen. La mayoría no carga fuentes web.",
+      "fonts": {
+        "system": "Sistema",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Formas",
+      "buttonRadius": "Esquinas del botón",
+      "showLogo": "Mostrar logotipo",
+      "showLogoHint": "Con el logotipo desactivado, la cabecera imprime el nombre del taller en su lugar."
+    },
+    "nameDialog": {
+      "title": "Guardar esta plantilla",
+      "body": "Dale un nombre a la plantilla para encontrarla en la galería. Al guardarla también pasa a ser la que usa este tipo de correo.",
+      "placeholder": "Nombre de la plantilla",
+      "nameExists": "Ya existe una plantilla con este nombre. Elige otro nombre.",
+      "save": "Guardar plantilla",
+      "update": "Actualizar plantilla"
+    },
+    "testDialog": {
+      "title": "Enviar un correo de prueba",
+      "body": "El correo se envía con datos de ejemplo, para que puedas verlo en un cliente de correo real.",
+      "placeholder": "Dirección de correo",
+      "send": "Enviar",
+      "sending": "Enviando…",
+      "sentTitle": "Correo de prueba enviado",
+      "sentBody": "Revisa la bandeja de entrada de {email}. Puede tardar un minuto en llegar.",
+      "failedTitle": "No se pudo enviar el correo de prueba",
+      "failedBody": "Revisa el proveedor de correo en Proveedores e inténtalo de nuevo."
+    },
+    "toolbar": {
+      "bold": "Negrita",
+      "italic": "Cursiva",
+      "underline": "Subrayado",
+      "strike": "Tachado",
+      "link": "Enlace",
+      "linkUrl": "Dirección del enlace",
+      "linkPlaceholder": "https://... o {share_link}",
+      "linkHint": "Una dirección web o una etiqueta como {share_link} que se rellena al enviar el correo.",
+      "linkApply": "Aplicar",
+      "linkRemove": "Quitar enlace",
+      "bulletList": "Lista con viñetas",
+      "orderedList": "Lista numerada",
+      "align": {
+        "left": "Alinear a la izquierda",
+        "center": "Centrar",
+        "right": "Alinear a la derecha"
+      },
+      "size": {
+        "label": "Tamaño del texto",
+        "default": "Tamaño predeterminado",
+        "small": "Pequeño",
+        "normal": "Normal",
+        "large": "Grande",
+        "extraLarge": "Muy grande"
+      },
+      "color": {
+        "label": "Color del texto",
+        "text": "Color de texto del tema",
+        "muted": "Color atenuado del tema",
+        "primary": "Color principal del tema",
+        "default": "Predeterminado",
+        "hex": "Color hexadecimal",
+        "apply": "Aplicar"
+      },
+      "clear": "Quitar formato"
+    },
+    "logo": {
+      "title": "Logotipo",
+      "empty": "No hay ningún logotipo subido. La cabecera imprime el nombre del taller.",
+      "upload": "Subir logotipo",
+      "replace": "Reemplazar logotipo",
+      "remove": "Quitar",
+      "uploading": "Subiendo…",
+      "width": "Ancho del logotipo",
+      "hint": "Independiente del logotipo de la empresa. Al subirlo se recorta y se prepara para el correo. PNG, JPEG o WebP de hasta 4 MB.",
+      "position": "Posición",
+      "tooLarge": "La imagen debe pesar menos de 4 MB",
+      "uploadFailed": "No se pudo subir el logotipo"
+    },
+    "image": {
+      "empty": "No hay ninguna imagen subida. El bloque no imprime nada hasta que haya una.",
+      "upload": "Subir imagen",
+      "replace": "Reemplazar imagen",
+      "remove": "Quitar",
+      "uploading": "Subiendo…",
+      "alt": "Descripción",
+      "altHint": "Se lee en voz alta a los lectores que no pueden ver la imagen y se muestra mientras se carga. PNG, JPEG o WebP de hasta 10 MB.",
+      "width": "Ancho",
+      "widthHint": "600 px es el ancho completo del correo. Las imágenes más estrechas se colocan donde indica Posición.",
+      "link": "Enlace",
+      "linkHint": "Opcional. Se abre al hacer clic en la imagen; aquí también sirve una etiqueta como el enlace para compartir.",
+      "tooLarge": "La imagen debe pesar menos de 10 MB",
+      "uploadFailed": "No se pudo subir la imagen"
+    },
+    "dragHandle": "Arrastra para reordenar",
+    "dragHandleKeyboard": "Arrastra, o usa las teclas de flecha, para mover este bloque",
+    "tagDoesNotFit": "La etiqueta no cabe en este campo."
   }
 }

+ 6 - 0
messages/fr/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Design de document supprimé",
     "settings_applyDocumentDesign": "Design de document par défaut défini",
     "settings_setDocumentDesignRule": "A modifié quand un design est utilisé",
+    "settings_saveEmailTemplate": "Modèle d'e-mail enregistré",
+    "settings_deleteEmailTemplate": "Modèle d'e-mail supprimé",
+    "settings_applyEmailTemplate": "Modèle d'e-mail par défaut défini",
     "settings_freezeInvoices": "Anciennes factures verrouillées",
     "inspection_reminders_sent": "Rappels de contrôle envoyés",
     "inspection_reminders_send": "Rappels de contrôle envoyés",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "A supprimé le design de document {name}",
     "settings_applyDocumentDesign": "A défini {name} comme design de document par défaut",
     "settings_setDocumentDesignRule": "A modifié quand le design de document {name} est utilisé",
+    "settings_saveEmailTemplate": "A enregistré le modèle d'e-mail {name}",
+    "settings_deleteEmailTemplate": "A supprimé le modèle d'e-mail {name}",
+    "settings_applyEmailTemplate": "A défini {name} comme modèle pour les e-mails {kind}",
     "settings_freezeInvoices": "A verrouillé {count, plural, one {# facture} other {# factures}} antérieures au verrouillage avec le design actuel",
     "inspection_reminders_sent": "{count} rappels de contrôle envoyés",
     "service_videoCall_send": "A envoyé le lien de l'appel vidéo par {channels}",

+ 51 - 0
messages/fr/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Facture",
+      "subject": "Facture {document_number} de {workshop_name}",
+      "heading": "Facture {document_number}",
+      "intro": "Bonjour {customer_name},\n\nMerci de nous avoir fait confiance. Votre facture est prête.",
+      "button": "Voir la facture",
+      "outro": "Pour toute question, il vous suffit de répondre à cet e-mail."
+    },
+    "quote_sent": {
+      "name": "Devis",
+      "subject": "Devis {document_number} de {workshop_name}",
+      "heading": "Devis {document_number}",
+      "intro": "Bonjour {customer_name},\n\nVoici le devis que vous nous avez demandé. Dites-nous si vous souhaitez que nous procédions aux travaux.",
+      "button": "Voir le devis",
+      "outro": "Pour toute question, il vous suffit de répondre à cet e-mail."
+    },
+    "inspection_sent": {
+      "name": "Rapport d'inspection",
+      "subject": "Rapport d'inspection de {workshop_name}",
+      "heading": "Rapport d'inspection du véhicule",
+      "intro": "Bonjour {customer_name},\n\nNous avons terminé l'inspection. Le rapport est à votre disposition.",
+      "button": "Voir le rapport",
+      "outro": "Pour toute question, il vous suffit de répondre à cet e-mail."
+    },
+    "message": {
+      "name": "Message au client",
+      "subject": "Message de {workshop_name}",
+      "intro": "Bonjour {customer_name},",
+      "outro": "Pour toute question, il vous suffit de répondre à cet e-mail."
+    },
+    "portal_signin": {
+      "name": "Connexion au portail",
+      "subject": "Connectez-vous à {workshop_name}",
+      "heading": "Connectez-vous à votre espace client",
+      "intro": "Bonjour {customer_name},\n\nUtilisez le bouton ci-dessous pour vous connecter à l'espace client de {workshop_name}.",
+      "button": "Se connecter",
+      "linkFallback": "Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :\n{signin_link}",
+      "outro": "Ce lien expire dans 15 minutes. Si vous n'en avez pas fait la demande, vous pouvez ignorer cet e-mail."
+    }
+  },
+  "attachmentNote": "Une copie PDF est jointe à cet e-mail.",
+  "summary": {
+    "reference": "Référence",
+    "vehicle": "Véhicule",
+    "total": "Total",
+    "balance": "Solde dû",
+    "due": "Échéance"
+  }
+}

+ 326 - 0
messages/fr/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "Rappels de contrôle",
         "description": "Lien de réservation, durée et délai"
+      },
+      "emailTemplates": {
+        "title": "Modèles d'e-mail",
+        "description": "Ce que disent vos e-mails de facture, devis et rapport"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "Le fuseau horaire est en automatique sous Localisation, le serveur utilise donc {zone}, le dernier fuseau vu dans un navigateur ayant enregistré les paramètres. Choisissez explicitement le fuseau de l'atelier pour que les horaires de réservation ne dépendent jamais de qui a enregistré en dernier.",
     "timeZoneExplicit": "Les horaires de réservation et d'ouverture sont lus en {zone}.",
     "timeZoneChange": "Modifier sous Localisation"
+  },
+  "emailTemplates": {
+    "title": "Modèles d'e-mail",
+    "description": "Ce que disent les e-mails envoyés par votre atelier et leur apparence. Chaque type d'e-mail a un modèle intégré ; créez le vôtre pour changer le texte, les couleurs et les blocs qui le composent.",
+    "lockedDescription": "Concevez les e-mails envoyés par votre atelier : changez le texte, ajoutez ou retirez des blocs et accordez les couleurs à votre marque.",
+    "groups": {
+      "documents": "Documents",
+      "messages": "Messages",
+      "portal": "Portail client"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Facture envoyée",
+        "description": "Envoyé quand une facture part chez un client par e-mail, avec le PDF en pièce jointe ou un lien pour la consulter."
+      },
+      "quote_sent": {
+        "name": "Devis envoyé",
+        "description": "Envoyé quand un devis part chez un client par e-mail."
+      },
+      "inspection_sent": {
+        "name": "Rapport d'inspection envoyé",
+        "description": "Envoyé quand un rapport d'inspection part chez un client par e-mail."
+      },
+      "message": {
+        "name": "Message au client",
+        "description": "Un message écrit à un client : une notification depuis un ordre de travail, un rapport d'état, une invitation à un appel vidéo ou un rappel programmé."
+      },
+      "portal_signin": {
+        "name": "Connexion au portail",
+        "description": "Le lien de connexion qu'un client demande sur le portail client."
+      }
+    },
+    "blocks": {
+      "header": "En-tête",
+      "heading": "Titre",
+      "paragraph": "Paragraphe",
+      "callout": "Encadré",
+      "button": "Bouton",
+      "document_summary": "Résumé du document",
+      "attachment_note": "Note de pièce jointe",
+      "image": "Image",
+      "divider": "Séparateur",
+      "spacer": "Espace",
+      "contact_footer": "Pied de page contact"
+    },
+    "blockNotes": {
+      "header": "Votre logo, ou le nom de l'atelier quand il n'y a pas de logo ou qu'il est désactivé. Importez et dimensionnez le logo ici ; les couleurs et les polices se trouvent sous Objet et thème.",
+      "contact_footer": "Le nom, l'adresse, le téléphone et l'e-mail de votre atelier, repris des paramètres Entreprise. Renseignez-les là-bas pour changer ce qui s'imprime ici.",
+      "divider": "Une ligne fine entre deux blocs. Elle est omise quand elle ouvrirait ou fermerait l'e-mail.",
+      "callout": "Un panneau mis en avant avec un bord coloré. Idéal pour les mots qui comptent le plus, comme le message lui-même.",
+      "attachment_note": "Imprimé seulement quand un PDF est joint à l'e-mail.",
+      "button": "Le lien est généralement une balise, comme le lien de partage. Un bouton dont le lien n'a pas de valeur est omis de l'e-mail.",
+      "document_summary": "Un panneau avec les chiffres clés du document. Les lignes sans rien à afficher sont omises.",
+      "image": "Les images sont compressées et redimensionnées pour l'e-mail lors de l'import. Les images qui ne sont plus utilisées sont supprimées à l'enregistrement du modèle.",
+      "summaryUnused": "Ce type d'e-mail n'a pas de document, le résumé n'imprime donc rien."
+    },
+    "tagGroups": {
+      "workshop": "Atelier",
+      "customer": "Client",
+      "vehicle": "Véhicule",
+      "document": "Document",
+      "message": "Message",
+      "portal": "Portail"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Nom de l'atelier",
+        "description": "Le nom de votre atelier."
+      },
+      "workshop_phone": {
+        "label": "Téléphone de l'atelier",
+        "description": "Le numéro de téléphone des paramètres Entreprise."
+      },
+      "workshop_email": {
+        "label": "E-mail de l'atelier",
+        "description": "L'adresse e-mail des paramètres Entreprise."
+      },
+      "workshop_address": {
+        "label": "Adresse de l'atelier",
+        "description": "L'adresse des paramètres Entreprise, sur autant de lignes qu'elle en compte."
+      },
+      "current_user": {
+        "label": "Nom de l'expéditeur",
+        "description": "Le nom de la personne qui envoie l'e-mail."
+      },
+      "customer_name": {
+        "label": "Nom du client",
+        "description": "Le nom du client destinataire de l'e-mail."
+      },
+      "vehicle": {
+        "label": "Véhicule",
+        "description": "Année, marque et modèle du véhicule."
+      },
+      "plate": {
+        "label": "Immatriculation",
+        "description": "Le numéro d'immatriculation du véhicule."
+      },
+      "mileage": {
+        "label": "Kilométrage",
+        "description": "Le kilométrage relevé sur le véhicule."
+      },
+      "document_number": {
+        "label": "Numéro du document",
+        "description": "Le numéro de la facture ou du devis."
+      },
+      "document_title": {
+        "label": "Titre du document",
+        "description": "Le titre de l'intervention ou du document."
+      },
+      "total": {
+        "label": "Total",
+        "description": "Le total du document, dans sa devise."
+      },
+      "balance_due": {
+        "label": "Solde dû",
+        "description": "Ce qui reste dû sur le document après les paiements."
+      },
+      "due_date": {
+        "label": "Date d'échéance",
+        "description": "La date à laquelle le paiement est dû."
+      },
+      "share_link": {
+        "label": "Lien de partage",
+        "description": "Un lien que le client peut ouvrir pour consulter le document en ligne."
+      },
+      "message": {
+        "label": "Message",
+        "description": "Le message écrit pour cet e-mail, ou la note ajoutée au document."
+      },
+      "signin_link": {
+        "label": "Lien de connexion",
+        "description": "Le lien qui connecte le client au portail. Il expire au bout d'un court délai."
+      },
+      "portal_link": {
+        "label": "Lien du portail",
+        "description": "Un lien vers le portail client."
+      }
+    },
+    "optionalTag": "Tous les e-mails n'ont pas de valeur pour cette balise. Quand elle est vide, les mots autour sont remis en ordre.",
+    "summaryRows": {
+      "reference": "Référence",
+      "vehicle": "Véhicule",
+      "total": "Total",
+      "balance": "Solde dû",
+      "due": "Date d'échéance"
+    },
+    "builtIn": "Intégré",
+    "inUse": "Utilisé",
+    "useBuiltIn": "Utiliser le modèle intégré",
+    "setDefault": "Définir par défaut",
+    "defaultSet": "Envoi désormais avec « {name} »",
+    "couldNotSetDefault": "Impossible de changer le modèle utilisé",
+    "updatedOn": "Mis à jour le {date}",
+    "delete": "Supprimer",
+    "deleteTitle": "Supprimer le modèle",
+    "deleteConfirm": "Supprimer le modèle « {name} » ? S'il est utilisé, les e-mails reviennent au modèle intégré.",
+    "deleteTemplate": "Supprimer ce modèle",
+    "deleted": "« {name} » supprimé",
+    "couldNotDelete": "Impossible de supprimer le modèle",
+    "backToSettings": "Retour aux paramètres",
+    "unsaved": "(non enregistré)",
+    "save": "Enregistrer",
+    "saving": "Enregistrement…",
+    "savedState": "Enregistré",
+    "saved": "« {name} » enregistré",
+    "couldNotSave": "Enregistrement impossible",
+    "saveAsNew": "Enregistrer comme nouveau",
+    "discard": "Annuler les modifications",
+    "sendTest": "Envoyer un test",
+    "cancel": "Annuler",
+    "problems": "{count, plural, one {# problème} other {# problèmes}}",
+    "unknownTags": "Ces balises n'existent pas pour ce type d'e-mail : {tags}",
+    "missingTags": "Cet e-mail doit contenir {tags}",
+    "subjectRequired": "L'objet ne peut pas être vide",
+    "invalidTemplate": "Un texte est trop long ou un champ contient une valeur non valide.",
+    "leaveTitle": "Quitter sans enregistrer ?",
+    "leaveBody": "Vos modifications de ce modèle n'ont pas été enregistrées. Elles seront perdues.",
+    "leaveConfirm": "Quitter",
+    "subjectAndTheme": "Objet et thème",
+    "settingsSubtitle": "S'applique à tout l'e-mail",
+    "blocksTitle": "Blocs",
+    "addBlock": "Ajouter un bloc",
+    "removeBlock": "Retirer le bloc",
+    "removeBlockLast": "Le dernier bloc ne peut pas être retiré",
+    "blockEmpty": "vide",
+    "blockEmptyHint": "Affiché, mais il n'a encore rien à imprimer.",
+    "blockShownHint": "Visible dans l'e-mail. Cliquez pour le masquer.",
+    "blockHiddenHint": "Masqué dans l'e-mail. Cliquez pour l'afficher.",
+    "railHint": "Glissez un bloc par sa poignée pour le déplacer. La ligne indique où il sera placé. Cliquez sur un bloc pour modifier son texte à droite.",
+    "resizeHint": "Glissez pour redimensionner le panneau. Double-cliquez pour réinitialiser.",
+    "desktop": "Ordinateur",
+    "mobile": "Mobile",
+    "htmlView": "HTML",
+    "textView": "Texte brut",
+    "desktopTooltip": "Aperçu en largeur ordinateur",
+    "mobileTooltip": "Aperçu en largeur téléphone",
+    "htmlViewTooltip": "Afficher l'e-mail HTML",
+    "previewWidth": "Largeur de l'aperçu",
+    "previewView": "Mode d'aperçu",
+    "textViewTooltip": "Afficher la version en texte brut",
+    "inboxNow": "Maintenant",
+    "inboxNoSender": "Votre atelier",
+    "inboxNoSubject": "(sans objet)",
+    "subject": "Objet",
+    "text": "Texte",
+    "buttonLabel": "Texte du bouton",
+    "buttonHref": "Lien",
+    "spacerHeight": "Hauteur",
+    "tagsTitle": "Balises",
+    "tagsHint": "Cliquez sur une balise pour l'insérer à la position du curseur dans le dernier champ où vous avez saisi du texte.",
+    "theme": {
+      "colors": "Couleurs",
+      "primaryColor": "Principale",
+      "textColor": "Texte",
+      "mutedColor": "Texte atténué",
+      "backgroundColor": "Fond",
+      "panelColor": "Panneaux",
+      "invalidColor": "Une couleur n'est pas une valeur hexadécimale valide",
+      "hexLabel": "{label} en hexadécimal",
+      "typography": "Typographie",
+      "font": "Police",
+      "fontHint": "Seulement des polices que chaque client mail possède déjà. La plupart ne chargent pas les polices web.",
+      "fonts": {
+        "system": "Système",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Formes",
+      "buttonRadius": "Angles du bouton",
+      "showLogo": "Afficher le logo",
+      "showLogoHint": "Logo désactivé, l'en-tête imprime le nom de l'atelier à la place."
+    },
+    "nameDialog": {
+      "title": "Enregistrer ce modèle",
+      "body": "Donnez un nom au modèle pour le retrouver dans la galerie. L'enregistrer en fait aussi le modèle avec lequel ce type d'e-mail est envoyé.",
+      "placeholder": "Nom du modèle",
+      "nameExists": "Un modèle porte déjà ce nom. Choisissez un autre nom.",
+      "save": "Enregistrer le modèle",
+      "update": "Mettre à jour le modèle"
+    },
+    "testDialog": {
+      "title": "Envoyer un e-mail de test",
+      "body": "L'e-mail est envoyé avec des données d'exemple, pour que vous puissiez le voir dans un vrai client mail.",
+      "placeholder": "Adresse e-mail",
+      "send": "Envoyer",
+      "sending": "Envoi…",
+      "sentTitle": "E-mail de test envoyé",
+      "sentBody": "Vérifiez la boîte de réception de {email}. Il peut mettre une minute à arriver.",
+      "failedTitle": "Impossible d'envoyer l'e-mail de test",
+      "failedBody": "Vérifiez le fournisseur d'e-mail sous Fournisseurs et réessayez."
+    },
+    "toolbar": {
+      "bold": "Gras",
+      "italic": "Italique",
+      "underline": "Souligné",
+      "strike": "Barré",
+      "link": "Lien",
+      "linkUrl": "Adresse du lien",
+      "linkPlaceholder": "https://... ou {share_link}",
+      "linkHint": "Une adresse web, ou une balise comme {share_link} qui est remplie à l'envoi de l'e-mail.",
+      "linkApply": "Appliquer",
+      "linkRemove": "Retirer le lien",
+      "bulletList": "Liste à puces",
+      "orderedList": "Liste numérotée",
+      "align": {
+        "left": "Aligner à gauche",
+        "center": "Centrer",
+        "right": "Aligner à droite"
+      },
+      "size": {
+        "label": "Taille du texte",
+        "default": "Taille par défaut",
+        "small": "Petit",
+        "normal": "Normal",
+        "large": "Grand",
+        "extraLarge": "Très grand"
+      },
+      "color": {
+        "label": "Couleur du texte",
+        "text": "Couleur de texte du thème",
+        "muted": "Couleur atténuée du thème",
+        "primary": "Couleur principale du thème",
+        "default": "Par défaut",
+        "hex": "Couleur hexadécimale",
+        "apply": "Appliquer"
+      },
+      "clear": "Effacer la mise en forme"
+    },
+    "logo": {
+      "title": "Logo",
+      "empty": "Aucun logo importé. L'en-tête imprime le nom de l'atelier.",
+      "upload": "Importer un logo",
+      "replace": "Remplacer le logo",
+      "remove": "Retirer",
+      "uploading": "Import en cours…",
+      "width": "Largeur du logo",
+      "hint": "Indépendant du logo de l'entreprise. Il est rogné et préparé pour l'e-mail lors de l'import. PNG, JPEG ou WebP jusqu'à 4 Mo.",
+      "position": "Position",
+      "tooLarge": "L'image doit faire moins de 4 Mo",
+      "uploadFailed": "Impossible d'importer le logo"
+    },
+    "image": {
+      "empty": "Aucune image importée. Le bloc n'imprime rien tant qu'il n'y en a pas.",
+      "upload": "Importer une image",
+      "replace": "Remplacer l'image",
+      "remove": "Retirer",
+      "uploading": "Import en cours…",
+      "alt": "Description",
+      "altHint": "Lue à voix haute aux lecteurs qui ne peuvent pas voir l'image, et affichée pendant son chargement. PNG, JPEG ou WebP jusqu'à 10 Mo.",
+      "width": "Largeur",
+      "widthHint": "600 px correspond à la pleine largeur de l'e-mail. Les images plus étroites se placent selon la Position.",
+      "link": "Lien",
+      "linkHint": "Facultatif. S'ouvre au clic sur l'image ; une balise comme le lien de partage fonctionne aussi ici.",
+      "tooLarge": "L'image doit faire moins de 10 Mo",
+      "uploadFailed": "Impossible d'importer l'image"
+    },
+    "dragHandle": "Glisser pour réorganiser",
+    "dragHandleKeyboard": "Glisser, ou utiliser les touches fléchées, pour déplacer ce bloc",
+    "tagDoesNotFit": "La balise ne tient pas dans ce champ."
   }
 }

+ 6 - 0
messages/it/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Design del documento eliminato",
     "settings_applyDocumentDesign": "Design del documento predefinito impostato",
     "settings_setDocumentDesignRule": "Modificato quando viene usato un design",
+    "settings_saveEmailTemplate": "Modello email salvato",
+    "settings_deleteEmailTemplate": "Modello email eliminato",
+    "settings_applyEmailTemplate": "Modello email predefinito impostato",
     "settings_freezeInvoices": "Fatture precedenti bloccate",
     "inspection_reminders_sent": "Promemoria revisione inviati",
     "inspection_reminders_send": "Promemoria revisione inviati",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "Ha eliminato il design del documento {name}",
     "settings_applyDocumentDesign": "Ha impostato {name} come design del documento predefinito",
     "settings_setDocumentDesignRule": "Modificato quando viene usato il design del documento {name}",
+    "settings_saveEmailTemplate": "Ha salvato il modello email {name}",
+    "settings_deleteEmailTemplate": "Ha eliminato il modello email {name}",
+    "settings_applyEmailTemplate": "Ha impostato {name} come modello per le email {kind}",
     "settings_freezeInvoices": "Ha bloccato {count, plural, one {# fattura} other {# fatture}} precedenti al blocco con il design attuale",
     "inspection_reminders_sent": "Inviati {count} promemoria revisione",
     "service_videoCall_send": "Ha inviato il link della videochiamata via {channels}",

+ 51 - 0
messages/it/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Fattura",
+      "subject": "Fattura {document_number} da {workshop_name}",
+      "heading": "Fattura {document_number}",
+      "intro": "Gentile {customer_name},\n\ngrazie per averci scelto. La sua fattura è pronta.",
+      "button": "Vedi fattura",
+      "outro": "Per qualsiasi domanda, basta rispondere a questa email."
+    },
+    "quote_sent": {
+      "name": "Preventivo",
+      "subject": "Preventivo {document_number} da {workshop_name}",
+      "heading": "Preventivo {document_number}",
+      "intro": "Gentile {customer_name},\n\necco il preventivo che ci ha richiesto. Ci faccia sapere se desidera procedere.",
+      "button": "Vedi preventivo",
+      "outro": "Per qualsiasi domanda, basta rispondere a questa email."
+    },
+    "inspection_sent": {
+      "name": "Rapporto di ispezione",
+      "subject": "Rapporto di ispezione da {workshop_name}",
+      "heading": "Rapporto di ispezione del veicolo",
+      "intro": "Gentile {customer_name},\n\nabbiamo completato l'ispezione. Il rapporto è pronto per lei.",
+      "button": "Vedi rapporto",
+      "outro": "Per qualsiasi domanda, basta rispondere a questa email."
+    },
+    "message": {
+      "name": "Messaggio al cliente",
+      "subject": "Messaggio da {workshop_name}",
+      "intro": "Gentile {customer_name},",
+      "outro": "Per qualsiasi domanda, basta rispondere a questa email."
+    },
+    "portal_signin": {
+      "name": "Accesso al portale",
+      "subject": "Accedi a {workshop_name}",
+      "heading": "Accedi al tuo portale clienti",
+      "intro": "Gentile {customer_name},\n\nusi il pulsante qui sotto per accedere al portale clienti di {workshop_name}.",
+      "button": "Accedi",
+      "linkFallback": "Se il pulsante non funziona, copi questo link nel suo browser:\n{signin_link}",
+      "outro": "Questo link scade tra 15 minuti. Se non l'ha richiesto, può ignorare questa email."
+    }
+  },
+  "attachmentNote": "Una copia in PDF è allegata a questa email.",
+  "summary": {
+    "reference": "Riferimento",
+    "vehicle": "Veicolo",
+    "total": "Totale",
+    "balance": "Saldo dovuto",
+    "due": "Scadenza"
+  }
+}

+ 326 - 0
messages/it/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "Promemoria revisione",
         "description": "Link di prenotazione, durata e preavviso"
+      },
+      "emailTemplates": {
+        "title": "Modelli email",
+        "description": "Cosa dicono le email di fattura, preventivo e rapporto"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "Il fuso orario è su automatico in Localizzazione, quindi il server usa {zone}, l'ultimo visto in un browser che ha salvato le impostazioni. Scegli esplicitamente il fuso dell'officina così gli orari di prenotazione non dipendono mai da chi ha salvato per ultimo.",
     "timeZoneExplicit": "Orari di prenotazione e di apertura sono letti in {zone}.",
     "timeZoneChange": "Cambia in Localizzazione"
+  },
+  "emailTemplates": {
+    "title": "Modelli email",
+    "description": "Cosa dicono le email inviate dalla tua officina e che aspetto hanno. Ogni tipo di email ha un modello integrato; crea il tuo per cambiare il testo, i colori e i blocchi che lo compongono.",
+    "lockedDescription": "Progetta le email inviate dalla tua officina: cambia il testo, aggiungi o togli blocchi e abbina i colori al tuo marchio.",
+    "groups": {
+      "documents": "Documenti",
+      "messages": "Messaggi",
+      "portal": "Portale clienti"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Fattura inviata",
+        "description": "Inviata quando una fattura va a un cliente via email, con il PDF allegato o un link per visualizzarla."
+      },
+      "quote_sent": {
+        "name": "Preventivo inviato",
+        "description": "Inviata quando un preventivo va a un cliente via email."
+      },
+      "inspection_sent": {
+        "name": "Rapporto di ispezione inviato",
+        "description": "Inviata quando un rapporto di ispezione va a un cliente via email."
+      },
+      "message": {
+        "name": "Messaggio al cliente",
+        "description": "Un messaggio scritto a un cliente: una notifica da un ordine di lavoro, un rapporto di stato, un invito a una videochiamata o un promemoria programmato."
+      },
+      "portal_signin": {
+        "name": "Accesso al portale",
+        "description": "Il link di accesso che un cliente richiede sul portale clienti."
+      }
+    },
+    "blocks": {
+      "header": "Testata",
+      "heading": "Titolo",
+      "paragraph": "Paragrafo",
+      "callout": "Riquadro",
+      "button": "Pulsante",
+      "document_summary": "Riepilogo documento",
+      "attachment_note": "Nota allegato",
+      "image": "Immagine",
+      "divider": "Separatore",
+      "spacer": "Spazio",
+      "contact_footer": "Piè di pagina contatti"
+    },
+    "blockNotes": {
+      "header": "Il tuo logo, oppure il nome dell'officina quando non c'è un logo o è disattivato. Carica e ridimensiona il logo qui; colori e caratteri si trovano in Oggetto e tema.",
+      "contact_footer": "Nome, indirizzo, telefono ed email della tua officina, presi dalle impostazioni Azienda. Compilali lì per cambiare cosa viene stampato qui.",
+      "divider": "Una linea sottile tra due blocchi. Viene omessa quando aprirebbe o chiuderebbe l'email.",
+      "callout": "Un pannello evidenziato con un bordo colorato. Ideale per le parole che contano di più, come il messaggio stesso.",
+      "attachment_note": "Stampata solo quando all'email è allegato un PDF.",
+      "button": "Il link di solito è un tag, come il link di condivisione. Un pulsante il cui link non ha valore viene omesso dall'email.",
+      "document_summary": "Un pannello con le cifre chiave del documento. Le righe senza nulla da mostrare vengono omesse.",
+      "image": "Le immagini vengono compresse e ridimensionate per l'email al caricamento. Le immagini non più usate vengono rimosse al salvataggio del modello.",
+      "summaryUnused": "Questo tipo di email non ha un documento, quindi il riepilogo non stampa nulla."
+    },
+    "tagGroups": {
+      "workshop": "Officina",
+      "customer": "Cliente",
+      "vehicle": "Veicolo",
+      "document": "Documento",
+      "message": "Messaggio",
+      "portal": "Portale"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Nome officina",
+        "description": "Il nome della tua officina."
+      },
+      "workshop_phone": {
+        "label": "Telefono officina",
+        "description": "Il numero di telefono dalle impostazioni Azienda."
+      },
+      "workshop_email": {
+        "label": "Email officina",
+        "description": "L'indirizzo email dalle impostazioni Azienda."
+      },
+      "workshop_address": {
+        "label": "Indirizzo officina",
+        "description": "L'indirizzo dalle impostazioni Azienda, su tutte le righe che ha."
+      },
+      "current_user": {
+        "label": "Nome mittente",
+        "description": "Il nome della persona che invia l'email."
+      },
+      "customer_name": {
+        "label": "Nome cliente",
+        "description": "Il nome del cliente a cui va l'email."
+      },
+      "vehicle": {
+        "label": "Veicolo",
+        "description": "Anno, marca e modello del veicolo."
+      },
+      "plate": {
+        "label": "Targa",
+        "description": "Il numero di targa del veicolo."
+      },
+      "mileage": {
+        "label": "Chilometraggio",
+        "description": "Il chilometraggio registrato sul veicolo."
+      },
+      "document_number": {
+        "label": "Numero documento",
+        "description": "Il numero della fattura o del preventivo."
+      },
+      "document_title": {
+        "label": "Titolo documento",
+        "description": "Il titolo del lavoro o del documento."
+      },
+      "total": {
+        "label": "Totale",
+        "description": "Il totale del documento, nella sua valuta."
+      },
+      "balance_due": {
+        "label": "Saldo dovuto",
+        "description": "Quanto resta da pagare sul documento dopo i pagamenti."
+      },
+      "due_date": {
+        "label": "Data di scadenza",
+        "description": "La data entro cui va effettuato il pagamento."
+      },
+      "share_link": {
+        "label": "Link di condivisione",
+        "description": "Un link che il cliente può aprire per vedere il documento online."
+      },
+      "message": {
+        "label": "Messaggio",
+        "description": "Il messaggio scritto per questa email, o la nota aggiunta al documento."
+      },
+      "signin_link": {
+        "label": "Link di accesso",
+        "description": "Il link che fa accedere il cliente al portale. Scade dopo poco tempo."
+      },
+      "portal_link": {
+        "label": "Link portale",
+        "description": "Un link al portale clienti."
+      }
+    },
+    "optionalTag": "Non tutte le email hanno un valore per questo tag. Quando è vuoto, le parole intorno vengono sistemate.",
+    "summaryRows": {
+      "reference": "Riferimento",
+      "vehicle": "Veicolo",
+      "total": "Totale",
+      "balance": "Saldo dovuto",
+      "due": "Data di scadenza"
+    },
+    "builtIn": "Integrato",
+    "inUse": "In uso",
+    "useBuiltIn": "Usa quello integrato",
+    "setDefault": "Imposta come predefinito",
+    "defaultSet": "Ora si invia con «{name}»",
+    "couldNotSetDefault": "Impossibile cambiare il modello in uso",
+    "updatedOn": "Aggiornato il {date}",
+    "delete": "Elimina",
+    "deleteTitle": "Elimina modello",
+    "deleteConfirm": "Eliminare il modello «{name}»? Se è in uso, le email tornano al modello integrato.",
+    "deleteTemplate": "Elimina questo modello",
+    "deleted": "«{name}» eliminato",
+    "couldNotDelete": "Impossibile eliminare il modello",
+    "backToSettings": "Torna alle impostazioni",
+    "unsaved": "(non salvato)",
+    "save": "Salva",
+    "saving": "Salvataggio…",
+    "savedState": "Salvato",
+    "saved": "«{name}» salvato",
+    "couldNotSave": "Impossibile salvare",
+    "saveAsNew": "Salva come nuovo",
+    "discard": "Scarta le modifiche",
+    "sendTest": "Invia test",
+    "cancel": "Annulla",
+    "problems": "{count, plural, one {# problema} other {# problemi}}",
+    "unknownTags": "Questi tag non esistono per questo tipo di email: {tags}",
+    "missingTags": "Questa email deve includere {tags}",
+    "subjectRequired": "L'oggetto non può essere vuoto",
+    "invalidTemplate": "Un testo è troppo lungo o un campo ha un valore non valido.",
+    "leaveTitle": "Uscire senza salvare?",
+    "leaveBody": "Le modifiche a questo modello non sono state salvate. Andranno perse.",
+    "leaveConfirm": "Esci",
+    "subjectAndTheme": "Oggetto e tema",
+    "settingsSubtitle": "Vale per l'intera email",
+    "blocksTitle": "Blocchi",
+    "addBlock": "Aggiungi blocco",
+    "removeBlock": "Rimuovi blocco",
+    "removeBlockLast": "L'ultimo blocco non può essere rimosso",
+    "blockEmpty": "vuoto",
+    "blockEmptyHint": "Viene mostrato, ma non ha ancora nulla da stampare.",
+    "blockShownHint": "Visibile nell'email. Fai clic per nasconderlo.",
+    "blockHiddenHint": "Nascosto dall'email. Fai clic per mostrarlo.",
+    "railHint": "Trascina un blocco dalla sua maniglia per spostarlo. La linea mostra dove verrà posizionato. Fai clic su un blocco per modificarne il testo a destra.",
+    "resizeHint": "Trascina per ridimensionare il pannello. Fai doppio clic per ripristinarlo.",
+    "desktop": "Desktop",
+    "mobile": "Mobile",
+    "htmlView": "HTML",
+    "textView": "Testo semplice",
+    "desktopTooltip": "Anteprima a larghezza desktop",
+    "mobileTooltip": "Anteprima a larghezza telefono",
+    "htmlViewTooltip": "Mostra l'email HTML",
+    "previewWidth": "Larghezza dell'anteprima",
+    "previewView": "Vista dell'anteprima",
+    "textViewTooltip": "Mostra la versione in testo semplice",
+    "inboxNow": "Adesso",
+    "inboxNoSender": "La tua officina",
+    "inboxNoSubject": "(nessun oggetto)",
+    "subject": "Oggetto",
+    "text": "Testo",
+    "buttonLabel": "Testo del pulsante",
+    "buttonHref": "Link",
+    "spacerHeight": "Altezza",
+    "tagsTitle": "Tag",
+    "tagsHint": "Fai clic su un tag per inserirlo dove si trova il cursore nell'ultimo campo in cui hai scritto.",
+    "theme": {
+      "colors": "Colori",
+      "primaryColor": "Principale",
+      "textColor": "Testo",
+      "mutedColor": "Testo attenuato",
+      "backgroundColor": "Sfondo",
+      "panelColor": "Pannelli",
+      "invalidColor": "Un colore non è un valore esadecimale valido",
+      "hexLabel": "{label} in esadecimale",
+      "typography": "Tipografia",
+      "font": "Carattere",
+      "fontHint": "Solo caratteri che ogni client di posta ha già. I web font non vengono caricati dalla maggior parte di essi.",
+      "fonts": {
+        "system": "Sistema",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Forme",
+      "buttonRadius": "Angoli del pulsante",
+      "showLogo": "Mostra logo",
+      "showLogoHint": "Con il logo disattivato, la testata stampa invece il nome dell'officina."
+    },
+    "nameDialog": {
+      "title": "Salva questo modello",
+      "body": "Dai un nome al modello per ritrovarlo nella galleria. Salvandolo diventa anche quello con cui si invia questo tipo di email.",
+      "placeholder": "Nome del modello",
+      "nameExists": "Esiste già un modello con questo nome. Scegli un altro nome.",
+      "save": "Salva modello",
+      "update": "Aggiorna modello"
+    },
+    "testDialog": {
+      "title": "Invia un'email di prova",
+      "body": "L'email viene inviata con dati di esempio, così puoi vederla in un vero client di posta.",
+      "placeholder": "Indirizzo email",
+      "send": "Invia",
+      "sending": "Invio…",
+      "sentTitle": "Email di prova inviata",
+      "sentBody": "Controlla la casella di {email}. Potrebbe volerci un minuto.",
+      "failedTitle": "Impossibile inviare l'email di prova",
+      "failedBody": "Controlla il provider email in Provider e riprova."
+    },
+    "toolbar": {
+      "bold": "Grassetto",
+      "italic": "Corsivo",
+      "underline": "Sottolineato",
+      "strike": "Barrato",
+      "link": "Link",
+      "linkUrl": "Indirizzo del link",
+      "linkPlaceholder": "https://... o {share_link}",
+      "linkHint": "Un indirizzo web, oppure un tag come {share_link} che viene compilato all'invio dell'email.",
+      "linkApply": "Applica",
+      "linkRemove": "Rimuovi link",
+      "bulletList": "Elenco puntato",
+      "orderedList": "Elenco numerato",
+      "align": {
+        "left": "Allinea a sinistra",
+        "center": "Centra",
+        "right": "Allinea a destra"
+      },
+      "size": {
+        "label": "Dimensione del testo",
+        "default": "Dimensione predefinita",
+        "small": "Piccolo",
+        "normal": "Normale",
+        "large": "Grande",
+        "extraLarge": "Molto grande"
+      },
+      "color": {
+        "label": "Colore del testo",
+        "text": "Colore testo del tema",
+        "muted": "Colore attenuato del tema",
+        "primary": "Colore principale del tema",
+        "default": "Predefinito",
+        "hex": "Colore esadecimale",
+        "apply": "Applica"
+      },
+      "clear": "Rimuovi formattazione"
+    },
+    "logo": {
+      "title": "Logo",
+      "empty": "Nessun logo caricato. La testata stampa il nome dell'officina.",
+      "upload": "Carica logo",
+      "replace": "Sostituisci logo",
+      "remove": "Rimuovi",
+      "uploading": "Caricamento…",
+      "width": "Larghezza del logo",
+      "hint": "Separato dal logo aziendale. Al caricamento viene ritagliato e preparato per l'email. PNG, JPEG o WebP fino a 4 MB.",
+      "position": "Posizione",
+      "tooLarge": "L'immagine deve essere inferiore a 4 MB",
+      "uploadFailed": "Impossibile caricare il logo"
+    },
+    "image": {
+      "empty": "Nessuna immagine caricata. Il blocco non stampa nulla finché non ce n'è una.",
+      "upload": "Carica immagine",
+      "replace": "Sostituisci immagine",
+      "remove": "Rimuovi",
+      "uploading": "Caricamento…",
+      "alt": "Descrizione",
+      "altHint": "Letta ad alta voce ai lettori che non possono vedere l'immagine e mostrata durante il caricamento. PNG, JPEG o WebP fino a 10 MB.",
+      "width": "Larghezza",
+      "widthHint": "600 px è la larghezza piena dell'email. Le immagini più strette si collocano dove indica Posizione.",
+      "link": "Link",
+      "linkHint": "Facoltativo. Si apre al clic sull'immagine; qui funziona anche un tag come il link di condivisione.",
+      "tooLarge": "L'immagine deve essere inferiore a 10 MB",
+      "uploadFailed": "Impossibile caricare l'immagine"
+    },
+    "dragHandle": "Trascina per riordinare",
+    "dragHandleKeyboard": "Trascina, o usa i tasti freccia, per spostare questo blocco",
+    "tagDoesNotFit": "Il tag non entra in questo campo."
   }
 }

+ 6 - 0
messages/lt/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Ištrintas dokumento dizainas",
     "settings_applyDocumentDesign": "Nustatytas numatytasis dokumento dizainas",
     "settings_setDocumentDesignRule": "Pakeista, kada naudojamas dizainas",
+    "settings_saveEmailTemplate": "Išsaugotas el. laiško šablonas",
+    "settings_deleteEmailTemplate": "Ištrintas el. laiško šablonas",
+    "settings_applyEmailTemplate": "Nustatytas numatytasis el. laiško šablonas",
     "settings_freezeInvoices": "Užrakintos senesnės sąskaitos",
     "inspection_reminders_sent": "Išsiųsti apžiūros priminimai",
     "inspection_reminders_send": "Išsiųsti apžiūros priminimai",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "Ištrynė dokumento dizainą {name}",
     "settings_applyDocumentDesign": "Nustatė {name} kaip numatytąjį dokumento dizainą",
     "settings_setDocumentDesignRule": "Pakeista, kada naudojamas dokumento dizainas {name}",
+    "settings_saveEmailTemplate": "Išsaugojo el. laiško šabloną {name}",
+    "settings_deleteEmailTemplate": "Ištrynė el. laiško šabloną {name}",
+    "settings_applyEmailTemplate": "Nustatė {name} kaip {kind} el. laiškų šabloną",
     "settings_freezeInvoices": "Užrakino {count, plural, one {# sąskaitą} few {# sąskaitas} other {# sąskaitų}} iki užrakinimo su dabartiniu dizainu",
     "inspection_reminders_sent": "Išsiųsta {count} apžiūros priminimų",
     "service_videoCall_send": "Išsiuntė vaizdo skambučio nuorodą per {channels}",

+ 51 - 0
messages/lt/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Sąskaita",
+      "subject": "Sąskaita {document_number} iš {workshop_name}",
+      "heading": "Sąskaita {document_number}",
+      "intro": "Sveiki, {customer_name},\n\ndėkojame, kad pasirinkote mus. Jūsų sąskaita paruošta.",
+      "button": "Peržiūrėti sąskaitą",
+      "outro": "Jei turite klausimų, tiesiog atsakykite į šį laišką."
+    },
+    "quote_sent": {
+      "name": "Pasiūlymas",
+      "subject": "Pasiūlymas {document_number} iš {workshop_name}",
+      "heading": "Pasiūlymas {document_number}",
+      "intro": "Sveiki, {customer_name},\n\nsiunčiame jūsų prašytą pasiūlymą. Praneškite, jei norite, kad pradėtume darbus.",
+      "button": "Peržiūrėti pasiūlymą",
+      "outro": "Jei turite klausimų, tiesiog atsakykite į šį laišką."
+    },
+    "inspection_sent": {
+      "name": "Apžiūros ataskaita",
+      "subject": "Apžiūros ataskaita iš {workshop_name}",
+      "heading": "Transporto priemonės apžiūros ataskaita",
+      "intro": "Sveiki, {customer_name},\n\napžiūrą baigėme. Ataskaita jums paruošta.",
+      "button": "Peržiūrėti ataskaitą",
+      "outro": "Jei turite klausimų, tiesiog atsakykite į šį laišką."
+    },
+    "message": {
+      "name": "Žinutė klientui",
+      "subject": "Žinutė iš {workshop_name}",
+      "intro": "Sveiki, {customer_name},",
+      "outro": "Jei turite klausimų, tiesiog atsakykite į šį laišką."
+    },
+    "portal_signin": {
+      "name": "Prisijungimas prie portalo",
+      "subject": "Prisijunkite prie {workshop_name}",
+      "heading": "Prisijunkite prie savo klientų portalo",
+      "intro": "Sveiki, {customer_name},\n\nspustelėkite žemiau esantį mygtuką, kad prisijungtumėte prie {workshop_name} klientų portalo.",
+      "button": "Prisijungti",
+      "linkFallback": "Jei mygtukas neveikia, nukopijuokite šią nuorodą į savo naršyklę:\n{signin_link}",
+      "outro": "Ši nuoroda galioja 15 minučių. Jei jos neprašėte, galite nekreipti dėmesio į šį laišką."
+    }
+  },
+  "attachmentNote": "Prie šio laiško pridėta PDF kopija.",
+  "summary": {
+    "reference": "Nuoroda",
+    "vehicle": "Transporto priemonė",
+    "total": "Iš viso",
+    "balance": "Mokėtinas likutis",
+    "due": "Apmokėti iki"
+  }
+}

+ 326 - 0
messages/lt/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "Apžiūros priminimai",
         "description": "Rezervacijos nuoroda, trukmė ir terminas"
+      },
+      "emailTemplates": {
+        "title": "El. laiškų šablonai",
+        "description": "Ką rašo sąskaitų, pasiūlymų ir ataskaitų laiškai"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "Laiko juosta nustatyta automatiškai skiltyje Lokalizacija, todėl serveris naudoja {zone} – paskutinę naršyklėje matytą juostą išsaugant nustatymus. Pasirinkite dirbtuvių juostą aiškiai, kad rezervacijos laikai niekada nepriklausytų nuo to, kas išsaugojo paskutinis.",
     "timeZoneExplicit": "Rezervacijos ir darbo laikai skaitomi {zone} juostoje.",
     "timeZoneChange": "Keisti skiltyje Lokalizacija"
+  },
+  "emailTemplates": {
+    "title": "El. laiškų šablonai",
+    "description": "Ką rašo jūsų dirbtuvių siunčiami laiškai ir kaip jie atrodo. Kiekvienas laiško tipas turi įtaisytą šabloną; sukurkite savą, kad pakeistumėte tekstą, spalvas ir blokus, iš kurių jis sudarytas.",
+    "lockedDescription": "Kurkite dirbtuvių siunčiamus laiškus: keiskite tekstą, pridėkite ar šalinkite blokus ir priderinkite spalvas prie savo prekės ženklo.",
+    "groups": {
+      "documents": "Dokumentai",
+      "messages": "Žinutės",
+      "portal": "Klientų portalas"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Sąskaita išsiųsta",
+        "description": "Siunčiamas, kai sąskaita klientui išsiunčiama el. paštu, su pridėtu PDF arba nuoroda peržiūrėti."
+      },
+      "quote_sent": {
+        "name": "Pasiūlymas išsiųstas",
+        "description": "Siunčiamas, kai pasiūlymas klientui išsiunčiamas el. paštu."
+      },
+      "inspection_sent": {
+        "name": "Apžiūros ataskaita išsiųsta",
+        "description": "Siunčiamas, kai apžiūros ataskaita klientui išsiunčiama el. paštu."
+      },
+      "message": {
+        "name": "Žinutė klientui",
+        "description": "Klientui rašoma žinutė: pranešimas iš darbo užsakymo, būsenos ataskaita, kvietimas į vaizdo skambutį arba suplanuotas priminimas."
+      },
+      "portal_signin": {
+        "name": "Prisijungimas prie portalo",
+        "description": "Prisijungimo nuoroda, kurios klientas paprašo klientų portale."
+      }
+    },
+    "blocks": {
+      "header": "Antraštinė dalis",
+      "heading": "Antraštė",
+      "paragraph": "Pastraipa",
+      "callout": "Išskirtas laukas",
+      "button": "Mygtukas",
+      "document_summary": "Dokumento santrauka",
+      "attachment_note": "Pastaba apie priedą",
+      "image": "Paveikslėlis",
+      "divider": "Skirtukas",
+      "spacer": "Tarpas",
+      "contact_footer": "Kontaktų poraštė"
+    },
+    "blockNotes": {
+      "header": "Jūsų logotipas arba dirbtuvių pavadinimas, kai logotipo nėra arba jis išjungtas. Įkelkite logotipą ir nustatykite jo dydį čia; spalvos ir šriftai yra skiltyje Tema ir dizainas.",
+      "contact_footer": "Jūsų dirbtuvių pavadinimas, adresas, telefonas ir el. paštas iš skilties Įmonė nustatymų. Užpildykite juos ten, kad pakeistumėte, kas čia spausdinama.",
+      "divider": "Plona linija tarp dviejų blokų. Praleidžiama, jei ji pradėtų ar užbaigtų laišką.",
+      "callout": "Išskirtas laukas su spalvotu kraštu. Tinka svarbiausiems žodžiams, pavyzdžiui, pačiai žinutei.",
+      "attachment_note": "Spausdinama tik tada, kai prie laiško pridėtas PDF.",
+      "button": "Nuoroda paprastai yra žymė, pavyzdžiui, bendrinimo nuoroda. Mygtukas, kurio nuoroda neturi reikšmės, laiške praleidžiamas.",
+      "document_summary": "Laukas su pagrindiniais dokumento skaičiais. Eilutės, kuriose nėra ko rodyti, praleidžiamos.",
+      "image": "Įkeliami paveikslėliai suspaudžiami ir sumažinami el. laiškui. Nebenaudojami paveikslėliai pašalinami išsaugant šabloną.",
+      "summaryUnused": "Šio tipo laiškas neturi dokumento, todėl santrauka nieko nespausdina."
+    },
+    "tagGroups": {
+      "workshop": "Dirbtuvės",
+      "customer": "Klientas",
+      "vehicle": "Transporto priemonė",
+      "document": "Dokumentas",
+      "message": "Žinutė",
+      "portal": "Portalas"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Dirbtuvių pavadinimas",
+        "description": "Jūsų dirbtuvių pavadinimas."
+      },
+      "workshop_phone": {
+        "label": "Dirbtuvių telefonas",
+        "description": "Telefono numeris iš skilties Įmonė nustatymų."
+      },
+      "workshop_email": {
+        "label": "Dirbtuvių el. paštas",
+        "description": "El. pašto adresas iš skilties Įmonė nustatymų."
+      },
+      "workshop_address": {
+        "label": "Dirbtuvių adresas",
+        "description": "Adresas iš skilties Įmonė nustatymų, tiek eilučių, kiek jis turi."
+      },
+      "current_user": {
+        "label": "Siuntėjo vardas",
+        "description": "Laišką siunčiančio asmens vardas."
+      },
+      "customer_name": {
+        "label": "Kliento vardas",
+        "description": "Kliento, kuriam siunčiamas laiškas, vardas."
+      },
+      "vehicle": {
+        "label": "Transporto priemonė",
+        "description": "Transporto priemonės metai, markė ir modelis."
+      },
+      "plate": {
+        "label": "Valstybinis numeris",
+        "description": "Transporto priemonės registracijos numeris."
+      },
+      "mileage": {
+        "label": "Rida",
+        "description": "Transporto priemonės užfiksuota rida."
+      },
+      "document_number": {
+        "label": "Dokumento numeris",
+        "description": "Sąskaitos arba pasiūlymo numeris."
+      },
+      "document_title": {
+        "label": "Dokumento pavadinimas",
+        "description": "Darbo arba dokumento pavadinimas."
+      },
+      "total": {
+        "label": "Iš viso",
+        "description": "Dokumento suma jo valiuta."
+      },
+      "balance_due": {
+        "label": "Likutis",
+        "description": "Kiek pagal dokumentą dar liko sumokėti po mokėjimų."
+      },
+      "due_date": {
+        "label": "Apmokėjimo data",
+        "description": "Data, iki kurios reikia sumokėti."
+      },
+      "share_link": {
+        "label": "Bendrinimo nuoroda",
+        "description": "Nuoroda, kurią klientas gali atidaryti ir peržiūrėti dokumentą internete."
+      },
+      "message": {
+        "label": "Žinutė",
+        "description": "Šiam laiškui parašyta žinutė arba prie dokumento pridėta pastaba."
+      },
+      "signin_link": {
+        "label": "Prisijungimo nuoroda",
+        "description": "Nuoroda, kuri prijungia klientą prie portalo. Ji greitai nustoja galioti."
+      },
+      "portal_link": {
+        "label": "Portalo nuoroda",
+        "description": "Nuoroda į klientų portalą."
+      }
+    },
+    "optionalTag": "Ne kiekvienas laiškas turi šios žymės reikšmę. Kai ji tuščia, aplinkiniai žodžiai sutvarkomi.",
+    "summaryRows": {
+      "reference": "Nuoroda",
+      "vehicle": "Transporto priemonė",
+      "total": "Iš viso",
+      "balance": "Likutis",
+      "due": "Apmokėjimo data"
+    },
+    "builtIn": "Įtaisytas",
+    "inUse": "Naudojamas",
+    "useBuiltIn": "Naudoti įtaisytą",
+    "setDefault": "Nustatyti numatytuoju",
+    "defaultSet": "Dabar siunčiama su „{name}“",
+    "couldNotSetDefault": "Nepavyko pakeisti naudojamo šablono",
+    "updatedOn": "Atnaujinta {date}",
+    "delete": "Ištrinti",
+    "deleteTitle": "Ištrinti šabloną",
+    "deleteConfirm": "Ištrinti šabloną „{name}“? Jei jis naudojamas, laiškai grįžta prie įtaisyto šablono.",
+    "deleteTemplate": "Ištrinti šį šabloną",
+    "deleted": "Ištrinta „{name}“",
+    "couldNotDelete": "Nepavyko ištrinti šablono",
+    "backToSettings": "Grįžti į nustatymus",
+    "unsaved": "(neišsaugota)",
+    "save": "Išsaugoti",
+    "saving": "Išsaugoma…",
+    "savedState": "Išsaugota",
+    "saved": "Išsaugota „{name}“",
+    "couldNotSave": "Nepavyko išsaugoti",
+    "saveAsNew": "Išsaugoti kaip naują",
+    "discard": "Atmesti pakeitimus",
+    "sendTest": "Siųsti bandomąjį",
+    "cancel": "Atšaukti",
+    "problems": "{count, plural, one {# problema} few {# problemos} many {# problemos} other {# problemų}}",
+    "unknownTags": "Šios žymės šio tipo laiškui neegzistuoja: {tags}",
+    "missingTags": "Šiame laiške turi būti {tags}",
+    "subjectRequired": "Tema negali būti tuščia",
+    "invalidTemplate": "Kai kuris tekstas per ilgas arba laukelyje netinkama reikšmė.",
+    "leaveTitle": "Išeiti neišsaugojus?",
+    "leaveBody": "Jūsų šio šablono pakeitimai neišsaugoti. Jie bus prarasti.",
+    "leaveConfirm": "Išeiti",
+    "subjectAndTheme": "Tema ir dizainas",
+    "settingsSubtitle": "Taikoma visam laiškui",
+    "blocksTitle": "Blokai",
+    "addBlock": "Pridėti bloką",
+    "removeBlock": "Šalinti bloką",
+    "removeBlockLast": "Paskutinio bloko pašalinti negalima",
+    "blockEmpty": "tuščias",
+    "blockEmptyHint": "Rodomas, bet jame kol kas nėra ko spausdinti.",
+    "blockShownHint": "Matomas laiške. Spustelėkite, kad paslėptumėte.",
+    "blockHiddenHint": "Paslėptas laiške. Spustelėkite, kad parodytumėte.",
+    "railHint": "Tempkite bloką už rankenėlės, kad jį perkeltumėte. Linija rodo, kur jis atsidurs. Spustelėkite bloką, kad dešinėje redaguotumėte jo tekstą.",
+    "resizeHint": "Tempkite, kad pakeistumėte skydelio dydį. Spustelėkite du kartus, kad atstatytumėte.",
+    "desktop": "Kompiuteris",
+    "mobile": "Mobilusis",
+    "htmlView": "HTML",
+    "textView": "Paprastas tekstas",
+    "desktopTooltip": "Peržiūra kompiuterio pločiu",
+    "mobileTooltip": "Peržiūra telefono pločiu",
+    "htmlViewTooltip": "Rodyti HTML laišką",
+    "previewWidth": "Peržiūros plotis",
+    "previewView": "Peržiūros rodinys",
+    "textViewTooltip": "Rodyti paprasto teksto versiją",
+    "inboxNow": "Dabar",
+    "inboxNoSender": "Jūsų dirbtuvės",
+    "inboxNoSubject": "(be temos)",
+    "subject": "Tema",
+    "text": "Tekstas",
+    "buttonLabel": "Mygtuko tekstas",
+    "buttonHref": "Nuoroda",
+    "spacerHeight": "Aukštis",
+    "tagsTitle": "Žymės",
+    "tagsHint": "Spustelėkite žymę, kad įterptumėte ją ten, kur yra žymeklis lauke, kuriame paskutinį kartą rašėte.",
+    "theme": {
+      "colors": "Spalvos",
+      "primaryColor": "Pagrindinė",
+      "textColor": "Tekstas",
+      "mutedColor": "Prislopintas tekstas",
+      "backgroundColor": "Fonas",
+      "panelColor": "Laukai",
+      "invalidColor": "Spalva nėra tinkama šešioliktainė reikšmė",
+      "hexLabel": "{label} šešioliktaine reikšme",
+      "typography": "Tipografika",
+      "font": "Šriftas",
+      "fontHint": "Tik šriftai, kuriuos jau turi kiekviena pašto programa. Dauguma jų neįkelia žiniatinklio šriftų.",
+      "fonts": {
+        "system": "Sisteminis",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Formos",
+      "buttonRadius": "Mygtuko kampai",
+      "showLogo": "Rodyti logotipą",
+      "showLogoHint": "Išjungus logotipą, antraštinėje dalyje vietoj jo spausdinamas dirbtuvių pavadinimas."
+    },
+    "nameDialog": {
+      "title": "Išsaugoti šį šabloną",
+      "body": "Suteikite šablonui pavadinimą, pagal kurį rasite jį galerijoje. Išsaugojus jis taip pat tampa šablonu, su kuriuo siunčiamas šio tipo laiškas.",
+      "placeholder": "Šablono pavadinimas",
+      "nameExists": "Šablonas tokiu pavadinimu jau yra. Pasirinkite kitą pavadinimą.",
+      "save": "Išsaugoti šabloną",
+      "update": "Atnaujinti šabloną"
+    },
+    "testDialog": {
+      "title": "Siųsti bandomąjį laišką",
+      "body": "Laiškas siunčiamas su pavyzdiniais duomenimis, kad galėtumėte pamatyti jį tikroje pašto programoje.",
+      "placeholder": "El. pašto adresas",
+      "send": "Siųsti",
+      "sending": "Siunčiama…",
+      "sentTitle": "Bandomasis laiškas išsiųstas",
+      "sentBody": "Patikrinkite {email} pašto dėžutę. Laiškas gali ateiti per minutę.",
+      "failedTitle": "Nepavyko išsiųsti bandomojo laiško",
+      "failedBody": "Patikrinkite el. pašto tiekėją skiltyje Tiekėjai ir bandykite dar kartą."
+    },
+    "toolbar": {
+      "bold": "Paryškintas",
+      "italic": "Kursyvas",
+      "underline": "Pabrauktas",
+      "strike": "Perbrauktas",
+      "link": "Nuoroda",
+      "linkUrl": "Nuorodos adresas",
+      "linkPlaceholder": "https://... arba {share_link}",
+      "linkHint": "Žiniatinklio adresas arba žymė, pvz. {share_link}, kuri užpildoma siunčiant laišką.",
+      "linkApply": "Taikyti",
+      "linkRemove": "Šalinti nuorodą",
+      "bulletList": "Sąrašas su ženkleliais",
+      "orderedList": "Numeruotas sąrašas",
+      "align": {
+        "left": "Lygiuoti kairėje",
+        "center": "Centruoti",
+        "right": "Lygiuoti dešinėje"
+      },
+      "size": {
+        "label": "Teksto dydis",
+        "default": "Numatytasis dydis",
+        "small": "Mažas",
+        "normal": "Įprastas",
+        "large": "Didelis",
+        "extraLarge": "Labai didelis"
+      },
+      "color": {
+        "label": "Teksto spalva",
+        "text": "Temos teksto spalva",
+        "muted": "Temos prislopinta spalva",
+        "primary": "Temos pagrindinė spalva",
+        "default": "Numatytoji",
+        "hex": "Šešioliktainė spalva",
+        "apply": "Taikyti"
+      },
+      "clear": "Šalinti formatavimą"
+    },
+    "logo": {
+      "title": "Logotipas",
+      "empty": "Logotipas neįkeltas. Antraštinėje dalyje spausdinamas dirbtuvių pavadinimas.",
+      "upload": "Įkelti logotipą",
+      "replace": "Pakeisti logotipą",
+      "remove": "Šalinti",
+      "uploading": "Įkeliama…",
+      "width": "Logotipo plotis",
+      "hint": "Atskiras nuo įmonės logotipo. Įkeliant jis apkarpomas ir paruošiamas el. laiškui. PNG, JPEG arba WebP iki 4 MB.",
+      "position": "Padėtis",
+      "tooLarge": "Paveikslėlis turi būti mažesnis nei 4 MB",
+      "uploadFailed": "Nepavyko įkelti logotipo"
+    },
+    "image": {
+      "empty": "Paveikslėlis neįkeltas. Blokas nieko nespausdina, kol jo nėra.",
+      "upload": "Įkelti paveikslėlį",
+      "replace": "Pakeisti paveikslėlį",
+      "remove": "Šalinti",
+      "uploading": "Įkeliama…",
+      "alt": "Aprašymas",
+      "altHint": "Perskaitomas balsu skaitytojams, kurie negali matyti paveikslėlio, ir rodomas, kol jis įkeliamas. PNG, JPEG arba WebP iki 10 MB.",
+      "width": "Plotis",
+      "widthHint": "600 px yra visas el. laiško plotis. Siauresni paveikslėliai dedami ten, kur nurodo Padėtis.",
+      "link": "Nuoroda",
+      "linkHint": "Neprivaloma. Atidaroma spustelėjus paveikslėlį; čia tinka ir žymė, pavyzdžiui, bendrinimo nuoroda.",
+      "tooLarge": "Paveikslėlis turi būti mažesnis nei 10 MB",
+      "uploadFailed": "Nepavyko įkelti paveikslėlio"
+    },
+    "dragHandle": "Tempkite, kad pertvarkytumėte",
+    "dragHandleKeyboard": "Tempkite arba naudokite rodyklių klavišus, kad perkeltumėte šį bloką",
+    "tagDoesNotFit": "Žymė netelpa į šį laukelį."
   }
 }

+ 6 - 0
messages/nb/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Slettet dokumentdesign",
     "settings_applyDocumentDesign": "Satte standard dokumentdesign",
     "settings_setDocumentDesignRule": "Endret når et design brukes",
+    "settings_saveEmailTemplate": "Lagret e-postmal",
+    "settings_deleteEmailTemplate": "Slettet e-postmal",
+    "settings_applyEmailTemplate": "Satte standard e-postmal",
     "settings_freezeInvoices": "Låste eldre fakturaer",
     "inspection_reminders_sent": "Sendte påminnelser om EU-kontroll",
     "inspection_reminders_send": "Sendte påminnelser om EU-kontroll",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "Slettet dokumentdesignet {name}",
     "settings_applyDocumentDesign": "Gjorde {name} til standard dokumentdesign",
     "settings_setDocumentDesignRule": "Endret når dokumentdesignet {name} brukes",
+    "settings_saveEmailTemplate": "Lagret e-postmalen {name}",
+    "settings_deleteEmailTemplate": "Slettet e-postmalen {name}",
+    "settings_applyEmailTemplate": "Gjorde {name} til mal for {kind}-e-poster",
     "settings_freezeInvoices": "Låste {count, plural, one {# faktura} other {# fakturaer}} fra før låsing med gjeldende design",
     "inspection_reminders_sent": "Sendte {count} påminnelser om EU-kontroll",
     "service_videoCall_send": "Sendte lenken til videosamtalen via {channels}",

+ 51 - 0
messages/nb/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Faktura",
+      "subject": "Faktura {document_number} fra {workshop_name}",
+      "heading": "Faktura {document_number}",
+      "intro": "Hei {customer_name},\n\nTakk for at du valgte oss. Fakturaen din er klar.",
+      "button": "Se faktura",
+      "outro": "Har du spørsmål, er det bare å svare på denne e-posten."
+    },
+    "quote_sent": {
+      "name": "Tilbud",
+      "subject": "Tilbud {document_number} fra {workshop_name}",
+      "heading": "Tilbud {document_number}",
+      "intro": "Hei {customer_name},\n\nHer er tilbudet du ba om. Si fra om du vil at vi setter i gang.",
+      "button": "Se tilbud",
+      "outro": "Har du spørsmål, er det bare å svare på denne e-posten."
+    },
+    "inspection_sent": {
+      "name": "Inspeksjonsrapport",
+      "subject": "Inspeksjonsrapport fra {workshop_name}",
+      "heading": "Inspeksjonsrapport for kjøretøyet",
+      "intro": "Hei {customer_name},\n\nVi er ferdige med inspeksjonen. Rapporten er klar til deg.",
+      "button": "Se rapport",
+      "outro": "Har du spørsmål, er det bare å svare på denne e-posten."
+    },
+    "message": {
+      "name": "Kundemelding",
+      "subject": "Melding fra {workshop_name}",
+      "intro": "Hei {customer_name},",
+      "outro": "Har du spørsmål, er det bare å svare på denne e-posten."
+    },
+    "portal_signin": {
+      "name": "Portalinnlogging",
+      "subject": "Logg inn hos {workshop_name}",
+      "heading": "Logg inn på kundeportalen din",
+      "intro": "Hei {customer_name},\n\nBruk knappen under for å logge inn på kundeportalen til {workshop_name}.",
+      "button": "Logg inn",
+      "linkFallback": "Hvis knappen ikke virker, kan du kopiere denne lenken inn i nettleseren din:\n{signin_link}",
+      "outro": "Lenken utløper om 15 minutter. Hvis du ikke ba om den, kan du se bort fra denne e-posten."
+    }
+  },
+  "attachmentNote": "En PDF-kopi er vedlagt denne e-posten.",
+  "summary": {
+    "reference": "Referanse",
+    "vehicle": "Kjøretøy",
+    "total": "Totalt",
+    "balance": "Å betale",
+    "due": "Forfall"
+  }
+}

+ 326 - 0
messages/nb/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "EU-kontroll-påminnelser",
         "description": "Bestillingslenke, varighet og frist"
+      },
+      "emailTemplates": {
+        "title": "E-postmaler",
+        "description": "Hva e-postene for faktura, tilbud og rapport sier"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "Tidssone står på automatisk under Lokalisering, så serveren bruker {zone}, sonen sist sett i en nettleser som lagret innstillinger. Velg verkstedets sone eksplisitt så bestillingstider aldri avhenger av hvem som lagret sist.",
     "timeZoneExplicit": "Bestillingstider og åpningstider leses i {zone}.",
     "timeZoneChange": "Endre under Lokalisering"
+  },
+  "emailTemplates": {
+    "title": "E-postmaler",
+    "description": "Hva e-postene verkstedet sender sier, og hvordan de ser ut. Hver type e-post har en innebygd mal; lag din egen for å endre ordlyden, fargene og blokkene den er satt sammen av.",
+    "lockedDescription": "Utform e-postene verkstedet sender: endre ordlyden, legg til eller fjern blokker, og tilpass fargene til merkevaren din.",
+    "groups": {
+      "documents": "Dokumenter",
+      "messages": "Meldinger",
+      "portal": "Kundeportal"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Faktura sendt",
+        "description": "Sendes når en faktura går til en kunde på e-post, med PDF-en vedlagt eller en lenke for å se den."
+      },
+      "quote_sent": {
+        "name": "Tilbud sendt",
+        "description": "Sendes når et tilbud går til en kunde på e-post."
+      },
+      "inspection_sent": {
+        "name": "Inspeksjonsrapport sendt",
+        "description": "Sendes når en inspeksjonsrapport går til en kunde på e-post."
+      },
+      "message": {
+        "name": "Kundemelding",
+        "description": "En melding skrevet til en kunde: et varsel fra en arbeidsordre, en statusrapport, en videosamtaleinvitasjon eller en planlagt påminnelse."
+      },
+      "portal_signin": {
+        "name": "Portalinnlogging",
+        "description": "Innloggingslenken en kunde ber om på kundeportalen."
+      }
+    },
+    "blocks": {
+      "header": "Topptekst",
+      "heading": "Overskrift",
+      "paragraph": "Avsnitt",
+      "callout": "Uthevet felt",
+      "button": "Knapp",
+      "document_summary": "Dokumentsammendrag",
+      "attachment_note": "Vedleggsmerknad",
+      "image": "Bilde",
+      "divider": "Skillelinje",
+      "spacer": "Mellomrom",
+      "contact_footer": "Kontaktbunntekst"
+    },
+    "blockNotes": {
+      "header": "Logoen din, eller verkstedsnavnet når det ikke finnes noen logo eller den er slått av. Last opp og velg størrelse på logoen her; farger og skrifter finner du under Emne og tema.",
+      "contact_footer": "Verkstedets navn, adresse, telefon og e-post, hentet fra innstillingene under Bedrift. Fyll dem inn der for å endre hva som skrives ut her.",
+      "divider": "En tynn linje mellom to blokker. Den utelates når den ville åpnet eller avsluttet e-posten.",
+      "callout": "Et uthevet felt med farget kant. Passer for ordene som betyr mest, som selve meldingen.",
+      "attachment_note": "Skrives bare ut når en PDF er vedlagt e-posten.",
+      "button": "Lenken er vanligvis en tagg, som delingslenken. En knapp uten verdi i lenken utelates fra e-posten.",
+      "document_summary": "Et felt med nøkkeltallene i dokumentet. Rader uten noe å vise utelates.",
+      "image": "Bilder komprimeres og skaleres for e-post når de lastes opp. Bilder som ikke lenger brukes, fjernes når malen lagres.",
+      "summaryUnused": "Denne typen e-post har ikke noe dokument, så sammendraget skriver ikke ut noe."
+    },
+    "tagGroups": {
+      "workshop": "Verksted",
+      "customer": "Kunde",
+      "vehicle": "Kjøretøy",
+      "document": "Dokument",
+      "message": "Melding",
+      "portal": "Portal"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Verkstedsnavn",
+        "description": "Navnet på verkstedet ditt."
+      },
+      "workshop_phone": {
+        "label": "Verkstedets telefon",
+        "description": "Telefonnummeret fra innstillingene under Bedrift."
+      },
+      "workshop_email": {
+        "label": "Verkstedets e-post",
+        "description": "E-postadressen fra innstillingene under Bedrift."
+      },
+      "workshop_address": {
+        "label": "Verkstedets adresse",
+        "description": "Adressen fra innstillingene under Bedrift, på så mange linjer som den har."
+      },
+      "current_user": {
+        "label": "Avsendernavn",
+        "description": "Navnet på personen som sender e-posten."
+      },
+      "customer_name": {
+        "label": "Kundenavn",
+        "description": "Navnet på kunden e-posten går til."
+      },
+      "vehicle": {
+        "label": "Kjøretøy",
+        "description": "Årsmodell, merke og modell på kjøretøyet."
+      },
+      "plate": {
+        "label": "Registreringsnummer",
+        "description": "Kjøretøyets registreringsnummer."
+      },
+      "mileage": {
+        "label": "Kilometerstand",
+        "description": "Kilometerstanden registrert på kjøretøyet."
+      },
+      "document_number": {
+        "label": "Dokumentnummer",
+        "description": "Nummeret på fakturaen eller tilbudet."
+      },
+      "document_title": {
+        "label": "Dokumenttittel",
+        "description": "Tittelen på jobben eller dokumentet."
+      },
+      "total": {
+        "label": "Totalt",
+        "description": "Dokumentets totalsum, i dokumentets valuta."
+      },
+      "balance_due": {
+        "label": "Utestående",
+        "description": "Det som fortsatt skyldes på dokumentet etter betalinger."
+      },
+      "due_date": {
+        "label": "Forfallsdato",
+        "description": "Datoen betalingen forfaller."
+      },
+      "share_link": {
+        "label": "Delingslenke",
+        "description": "En lenke kunden kan åpne for å se dokumentet på nett."
+      },
+      "message": {
+        "label": "Melding",
+        "description": "Meldingen skrevet for denne e-posten, eller notatet lagt til dokumentet."
+      },
+      "signin_link": {
+        "label": "Innloggingslenke",
+        "description": "Lenken som logger kunden inn på portalen. Den utløper etter kort tid."
+      },
+      "portal_link": {
+        "label": "Portallenke",
+        "description": "En lenke til kundeportalen."
+      }
+    },
+    "optionalTag": "Ikke alle e-poster har en verdi for denne taggen. Når den er tom, ryddes ordene rundt den opp.",
+    "summaryRows": {
+      "reference": "Referanse",
+      "vehicle": "Kjøretøy",
+      "total": "Totalt",
+      "balance": "Utestående",
+      "due": "Forfallsdato"
+    },
+    "builtIn": "Innebygd",
+    "inUse": "I bruk",
+    "useBuiltIn": "Bruk innebygd",
+    "setDefault": "Sett som standard",
+    "defaultSet": "Sender nå med «{name}»",
+    "couldNotSetDefault": "Kunne ikke endre malen i bruk",
+    "updatedOn": "Oppdatert {date}",
+    "delete": "Slett",
+    "deleteTitle": "Slett mal",
+    "deleteConfirm": "Slette malen «{name}»? Hvis den er i bruk, går e-postene tilbake til den innebygde malen.",
+    "deleteTemplate": "Slett denne malen",
+    "deleted": "Slettet «{name}»",
+    "couldNotDelete": "Kunne ikke slette malen",
+    "backToSettings": "Tilbake til innstillinger",
+    "unsaved": "(ikke lagret)",
+    "save": "Lagre",
+    "saving": "Lagrer…",
+    "savedState": "Lagret",
+    "saved": "Lagret «{name}»",
+    "couldNotSave": "Kunne ikke lagre",
+    "saveAsNew": "Lagre som ny",
+    "discard": "Forkast endringer",
+    "sendTest": "Send test",
+    "cancel": "Avbryt",
+    "problems": "{count, plural, one {# problem} other {# problemer}}",
+    "unknownTags": "Disse taggene finnes ikke for denne typen e-post: {tags}",
+    "missingTags": "Denne e-posten må inneholde {tags}",
+    "subjectRequired": "Emnet kan ikke være tomt",
+    "invalidTemplate": "En tekst er for lang, eller et felt har en ugyldig verdi.",
+    "leaveTitle": "Forlate uten å lagre?",
+    "leaveBody": "Endringene dine i denne malen er ikke lagret. De går tapt.",
+    "leaveConfirm": "Forlat",
+    "subjectAndTheme": "Emne og tema",
+    "settingsSubtitle": "Gjelder hele e-posten",
+    "blocksTitle": "Blokker",
+    "addBlock": "Legg til blokk",
+    "removeBlock": "Fjern blokk",
+    "removeBlockLast": "Den siste blokken kan ikke fjernes",
+    "blockEmpty": "tom",
+    "blockEmptyHint": "Vises, men det er ingenting i den å skrive ut ennå.",
+    "blockShownHint": "Vises i e-posten. Klikk for å skjule den.",
+    "blockHiddenHint": "Skjult fra e-posten. Klikk for å vise den.",
+    "railHint": "Dra en blokk i håndtaket for å flytte den. Linjen viser hvor den havner. Klikk på en blokk for å redigere teksten til høyre.",
+    "resizeHint": "Dra for å endre størrelsen på panelet. Dobbeltklikk for å tilbakestille.",
+    "desktop": "Skrivebord",
+    "mobile": "Mobil",
+    "htmlView": "HTML",
+    "textView": "Ren tekst",
+    "desktopTooltip": "Forhåndsvis i skrivebordsbredde",
+    "mobileTooltip": "Forhåndsvis i mobilbredde",
+    "htmlViewTooltip": "Vis HTML-e-posten",
+    "previewWidth": "Bredde på forhåndsvisning",
+    "previewView": "Visning av forhåndsvisning",
+    "textViewTooltip": "Vis ren tekst-versjonen",
+    "inboxNow": "Nå",
+    "inboxNoSender": "Verkstedet ditt",
+    "inboxNoSubject": "(uten emne)",
+    "subject": "Emne",
+    "text": "Tekst",
+    "buttonLabel": "Knappetekst",
+    "buttonHref": "Lenke",
+    "spacerHeight": "Høyde",
+    "tagsTitle": "Tagger",
+    "tagsHint": "Klikk på en tagg for å sette den inn der markøren står i feltet du sist skrev i.",
+    "theme": {
+      "colors": "Farger",
+      "primaryColor": "Primær",
+      "textColor": "Tekst",
+      "mutedColor": "Dempet tekst",
+      "backgroundColor": "Bakgrunn",
+      "panelColor": "Felt",
+      "invalidColor": "En farge er ikke en gyldig hex-verdi",
+      "hexLabel": "{label} som hex",
+      "typography": "Typografi",
+      "font": "Skrift",
+      "fontHint": "Bare skrifter alle e-postklienter allerede har. De fleste laster ikke webfonter.",
+      "fonts": {
+        "system": "System",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Former",
+      "buttonRadius": "Knappehjørner",
+      "showLogo": "Vis logo",
+      "showLogoHint": "Med logoen av skriver toppteksten ut verkstedsnavnet i stedet."
+    },
+    "nameDialog": {
+      "title": "Lagre denne malen",
+      "body": "Gi malen et navn å finne den igjen på i galleriet. Lagring gjør den også til malen denne typen e-post sendes med.",
+      "placeholder": "Navn på mal",
+      "nameExists": "En mal med dette navnet finnes allerede. Velg et annet navn.",
+      "save": "Lagre mal",
+      "update": "Oppdater mal"
+    },
+    "testDialog": {
+      "title": "Send en test-e-post",
+      "body": "E-posten sendes med eksempeldata, så du kan se den i en ekte e-postklient.",
+      "placeholder": "E-postadresse",
+      "send": "Send",
+      "sending": "Sender…",
+      "sentTitle": "Test-e-post sendt",
+      "sentBody": "Sjekk innboksen til {email}. Det kan ta et minutt før den kommer.",
+      "failedTitle": "Kunne ikke sende test-e-posten",
+      "failedBody": "Sjekk e-postleverandøren under Leverandører og prøv igjen."
+    },
+    "toolbar": {
+      "bold": "Fet",
+      "italic": "Kursiv",
+      "underline": "Understreket",
+      "strike": "Gjennomstreket",
+      "link": "Lenke",
+      "linkUrl": "Lenkeadresse",
+      "linkPlaceholder": "https://... eller {share_link}",
+      "linkHint": "En nettadresse, eller en tagg som {share_link} som fylles inn når e-posten sendes.",
+      "linkApply": "Bruk",
+      "linkRemove": "Fjern lenke",
+      "bulletList": "Punktliste",
+      "orderedList": "Nummerert liste",
+      "align": {
+        "left": "Venstrejuster",
+        "center": "Midtstill",
+        "right": "Høyrejuster"
+      },
+      "size": {
+        "label": "Tekststørrelse",
+        "default": "Standardstørrelse",
+        "small": "Liten",
+        "normal": "Normal",
+        "large": "Stor",
+        "extraLarge": "Ekstra stor"
+      },
+      "color": {
+        "label": "Tekstfarge",
+        "text": "Temaets tekstfarge",
+        "muted": "Temaets dempede farge",
+        "primary": "Temaets primærfarge",
+        "default": "Standard",
+        "hex": "Hex-farge",
+        "apply": "Bruk"
+      },
+      "clear": "Fjern formatering"
+    },
+    "logo": {
+      "title": "Logo",
+      "empty": "Ingen logo lastet opp. Toppteksten skriver ut verkstedsnavnet.",
+      "upload": "Last opp logo",
+      "replace": "Bytt logo",
+      "remove": "Fjern",
+      "uploading": "Laster opp…",
+      "width": "Logobredde",
+      "hint": "Uavhengig av firmalogoen. Den beskjæres og klargjøres for e-post når den lastes opp. PNG, JPEG eller WebP på inntil 4 MB.",
+      "position": "Plassering",
+      "tooLarge": "Bildet må være under 4 MB",
+      "uploadFailed": "Kunne ikke laste opp logoen"
+    },
+    "image": {
+      "empty": "Ingen bilde lastet opp. Blokken skriver ikke ut noe før det finnes ett.",
+      "upload": "Last opp bilde",
+      "replace": "Bytt bilde",
+      "remove": "Fjern",
+      "uploading": "Laster opp…",
+      "alt": "Beskrivelse",
+      "altHint": "Leses opp for lesere som ikke kan se bildet, og vises mens det lastes. PNG, JPEG eller WebP på inntil 10 MB.",
+      "width": "Bredde",
+      "widthHint": "600 px er hele bredden på e-posten. Smalere bilder plasseres der Plassering sier.",
+      "link": "Lenke",
+      "linkHint": "Valgfritt. Åpnes når bildet klikkes; en tagg som delingslenken fungerer også her.",
+      "tooLarge": "Bildet må være under 10 MB",
+      "uploadFailed": "Kunne ikke laste opp bildet"
+    },
+    "dragHandle": "Dra for å endre rekkefølge",
+    "dragHandleKeyboard": "Dra, eller bruk piltastene, for å flytte denne blokken",
+    "tagDoesNotFit": "Taggen får ikke plass i dette feltet."
   }
 }

+ 6 - 0
messages/nl/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Documentontwerp verwijderd",
     "settings_applyDocumentDesign": "Standaard documentontwerp ingesteld",
     "settings_setDocumentDesignRule": "Gewijzigd wanneer een ontwerp wordt gebruikt",
+    "settings_saveEmailTemplate": "E-mailsjabloon opgeslagen",
+    "settings_deleteEmailTemplate": "E-mailsjabloon verwijderd",
+    "settings_applyEmailTemplate": "Standaard e-mailsjabloon ingesteld",
     "settings_freezeInvoices": "Oudere facturen vergrendeld",
     "inspection_reminders_sent": "Keuringsherinneringen verstuurd",
     "inspection_reminders_send": "Keuringsherinneringen verstuurd",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "Verwijderde het documentontwerp {name}",
     "settings_applyDocumentDesign": "Maakte {name} het standaard documentontwerp",
     "settings_setDocumentDesignRule": "Gewijzigd wanneer het documentontwerp {name} wordt gebruikt",
+    "settings_saveEmailTemplate": "Sloeg het e-mailsjabloon {name} op",
+    "settings_deleteEmailTemplate": "Verwijderde het e-mailsjabloon {name}",
+    "settings_applyEmailTemplate": "Stelde {name} in als sjabloon voor {kind}-e-mails",
     "settings_freezeInvoices": "Vergrendelde {count, plural, one {# factuur} other {# facturen}} van vóór het vergrendelen met het huidige ontwerp",
     "inspection_reminders_sent": "{count} keuringsherinneringen verstuurd",
     "service_videoCall_send": "Heeft de link van het videogesprek verstuurd via {channels}",

+ 51 - 0
messages/nl/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Factuur",
+      "subject": "Factuur {document_number} van {workshop_name}",
+      "heading": "Factuur {document_number}",
+      "intro": "Beste {customer_name},\n\nBedankt dat u voor ons heeft gekozen. Uw factuur staat klaar.",
+      "button": "Factuur bekijken",
+      "outro": "Heeft u vragen? Reageer dan gewoon op deze e-mail."
+    },
+    "quote_sent": {
+      "name": "Offerte",
+      "subject": "Offerte {document_number} van {workshop_name}",
+      "heading": "Offerte {document_number}",
+      "intro": "Beste {customer_name},\n\nHierbij de offerte waar u om vroeg. Laat het ons weten als u wilt dat we aan de slag gaan.",
+      "button": "Offerte bekijken",
+      "outro": "Heeft u vragen? Reageer dan gewoon op deze e-mail."
+    },
+    "inspection_sent": {
+      "name": "Inspectierapport",
+      "subject": "Inspectierapport van {workshop_name}",
+      "heading": "Inspectierapport van uw voertuig",
+      "intro": "Beste {customer_name},\n\nWe hebben de inspectie afgerond. Het rapport staat voor u klaar.",
+      "button": "Rapport bekijken",
+      "outro": "Heeft u vragen? Reageer dan gewoon op deze e-mail."
+    },
+    "message": {
+      "name": "Klantbericht",
+      "subject": "Bericht van {workshop_name}",
+      "intro": "Beste {customer_name},",
+      "outro": "Heeft u vragen? Reageer dan gewoon op deze e-mail."
+    },
+    "portal_signin": {
+      "name": "Portaalaanmelding",
+      "subject": "Aanmelden bij {workshop_name}",
+      "heading": "Meld u aan bij uw klantportaal",
+      "intro": "Beste {customer_name},\n\nGebruik de knop hieronder om u aan te melden bij het klantportaal van {workshop_name}.",
+      "button": "Aanmelden",
+      "linkFallback": "Werkt de knop niet? Kopieer dan deze link in uw browser:\n{signin_link}",
+      "outro": "Deze link verloopt over 15 minuten. Heeft u hier niet om gevraagd, dan kunt u deze e-mail negeren."
+    }
+  },
+  "attachmentNote": "Een PDF-kopie is als bijlage bij deze e-mail gevoegd.",
+  "summary": {
+    "reference": "Referentie",
+    "vehicle": "Voertuig",
+    "total": "Totaal",
+    "balance": "Openstaand bedrag",
+    "due": "Vervaldatum"
+  }
+}

+ 326 - 0
messages/nl/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "Keuringsherinneringen",
         "description": "Boekingslink, duur en aanlooptijd"
+      },
+      "emailTemplates": {
+        "title": "E-mailsjablonen",
+        "description": "Wat je e-mails voor factuur, offerte en rapport zeggen"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "De tijdzone staat op automatisch onder Lokalisatie, dus de server gebruikt {zone}, de zone die het laatst is gezien in een browser die instellingen opsloeg. Kies de zone van de werkplaats expliciet zodat boekingstijden nooit afhangen van wie het laatst opsloeg.",
     "timeZoneExplicit": "Boekingstijden en openingstijden worden gelezen in {zone}.",
     "timeZoneChange": "Wijzigen onder Lokalisatie"
+  },
+  "emailTemplates": {
+    "title": "E-mailsjablonen",
+    "description": "Wat de e-mails van uw werkplaats zeggen en hoe ze eruitzien. Elk soort e-mail heeft een ingebouwd sjabloon; ontwerp uw eigen sjabloon om de tekst, de kleuren en de blokken waaruit het bestaat te wijzigen.",
+    "lockedDescription": "Ontwerp de e-mails die uw werkplaats verstuurt: wijzig de tekst, voeg blokken toe of verwijder ze, en stem de kleuren af op uw huisstijl.",
+    "groups": {
+      "documents": "Documenten",
+      "messages": "Berichten",
+      "portal": "Klantportaal"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Factuur verzonden",
+        "description": "Wordt verzonden wanneer een factuur per e-mail naar een klant gaat, met de pdf als bijlage of een link om hem te bekijken."
+      },
+      "quote_sent": {
+        "name": "Offerte verzonden",
+        "description": "Wordt verzonden wanneer een offerte per e-mail naar een klant gaat."
+      },
+      "inspection_sent": {
+        "name": "Inspectierapport verzonden",
+        "description": "Wordt verzonden wanneer een inspectierapport per e-mail naar een klant gaat."
+      },
+      "message": {
+        "name": "Klantbericht",
+        "description": "Een bericht aan een klant: een melding vanuit een werkorder, een statusrapport, een uitnodiging voor een videogesprek of een geplande herinnering."
+      },
+      "portal_signin": {
+        "name": "Portaalaanmelding",
+        "description": "De aanmeldlink die een klant aanvraagt op het klantportaal."
+      }
+    },
+    "blocks": {
+      "header": "Koptekst",
+      "heading": "Kop",
+      "paragraph": "Alinea",
+      "callout": "Kader",
+      "button": "Knop",
+      "document_summary": "Documentoverzicht",
+      "attachment_note": "Bijlagenotitie",
+      "image": "Afbeelding",
+      "divider": "Scheidingslijn",
+      "spacer": "Witruimte",
+      "contact_footer": "Contactvoettekst"
+    },
+    "blockNotes": {
+      "header": "Uw logo, of de naam van de werkplaats wanneer er geen logo is of het is uitgeschakeld. Upload het logo en stel de grootte hier in; kleuren en lettertypen staan onder Onderwerp en thema.",
+      "contact_footer": "De naam, het adres, het telefoonnummer en het e-mailadres van uw werkplaats, overgenomen uit de instellingen onder Bedrijf. Vul ze daar in om te wijzigen wat hier wordt afgedrukt.",
+      "divider": "Een dunne lijn tussen twee blokken. Hij wordt weggelaten als hij de e-mail zou openen of afsluiten.",
+      "callout": "Een uitgelicht kader met een gekleurde rand. Geschikt voor de woorden die het meest tellen, zoals het bericht zelf.",
+      "attachment_note": "Wordt alleen afgedrukt wanneer een pdf aan de e-mail is toegevoegd.",
+      "button": "De link is meestal een tag, zoals de deellink. Een knop waarvan de link geen waarde heeft, wordt uit de e-mail weggelaten.",
+      "document_summary": "Een kader met de kerngetallen van het document. Rijen zonder iets om te tonen worden weggelaten.",
+      "image": "Afbeeldingen worden bij het uploaden gecomprimeerd en verkleind voor e-mail. Afbeeldingen die niet meer worden gebruikt, worden verwijderd wanneer de sjabloon wordt opgeslagen.",
+      "summaryUnused": "Dit soort e-mail heeft geen document, dus het overzicht drukt niets af."
+    },
+    "tagGroups": {
+      "workshop": "Werkplaats",
+      "customer": "Klant",
+      "vehicle": "Voertuig",
+      "document": "Document",
+      "message": "Bericht",
+      "portal": "Portaal"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Naam werkplaats",
+        "description": "De naam van uw werkplaats."
+      },
+      "workshop_phone": {
+        "label": "Telefoon werkplaats",
+        "description": "Het telefoonnummer uit de instellingen onder Bedrijf."
+      },
+      "workshop_email": {
+        "label": "E-mail werkplaats",
+        "description": "Het e-mailadres uit de instellingen onder Bedrijf."
+      },
+      "workshop_address": {
+        "label": "Adres werkplaats",
+        "description": "Het adres uit de instellingen onder Bedrijf, op zoveel regels als het heeft."
+      },
+      "current_user": {
+        "label": "Naam afzender",
+        "description": "De naam van de persoon die de e-mail verstuurt."
+      },
+      "customer_name": {
+        "label": "Naam klant",
+        "description": "De naam van de klant aan wie de e-mail is gericht."
+      },
+      "vehicle": {
+        "label": "Voertuig",
+        "description": "Bouwjaar, merk en model van het voertuig."
+      },
+      "plate": {
+        "label": "Kenteken",
+        "description": "Het kenteken van het voertuig."
+      },
+      "mileage": {
+        "label": "Kilometerstand",
+        "description": "De kilometerstand die bij het voertuig is vastgelegd."
+      },
+      "document_number": {
+        "label": "Documentnummer",
+        "description": "Het nummer van de factuur of offerte."
+      },
+      "document_title": {
+        "label": "Documenttitel",
+        "description": "De titel van de opdracht of het document."
+      },
+      "total": {
+        "label": "Totaal",
+        "description": "Het totaal van het document, in zijn valuta."
+      },
+      "balance_due": {
+        "label": "Openstaand bedrag",
+        "description": "Wat na betalingen nog openstaat op het document."
+      },
+      "due_date": {
+        "label": "Vervaldatum",
+        "description": "De datum waarop de betaling verschuldigd is."
+      },
+      "share_link": {
+        "label": "Deellink",
+        "description": "Een link die de klant kan openen om het document online te bekijken."
+      },
+      "message": {
+        "label": "Bericht",
+        "description": "Het bericht dat voor deze e-mail is geschreven, of de notitie die aan het document is toegevoegd."
+      },
+      "signin_link": {
+        "label": "Aanmeldlink",
+        "description": "De link waarmee de klant zich aanmeldt op het portaal. Hij verloopt na korte tijd."
+      },
+      "portal_link": {
+        "label": "Portaallink",
+        "description": "Een link naar het klantportaal."
+      }
+    },
+    "optionalTag": "Niet elke e-mail heeft een waarde voor deze tag. Als hij leeg is, worden de woorden eromheen opgeruimd.",
+    "summaryRows": {
+      "reference": "Referentie",
+      "vehicle": "Voertuig",
+      "total": "Totaal",
+      "balance": "Openstaand bedrag",
+      "due": "Vervaldatum"
+    },
+    "builtIn": "Ingebouwd",
+    "inUse": "In gebruik",
+    "useBuiltIn": "Ingebouwd sjabloon gebruiken",
+    "setDefault": "Als standaard instellen",
+    "defaultSet": "Verzendt nu met \"{name}\"",
+    "couldNotSetDefault": "Het sjabloon in gebruik kon niet worden gewijzigd",
+    "updatedOn": "Bijgewerkt {date}",
+    "delete": "Verwijderen",
+    "deleteTitle": "Sjabloon verwijderen",
+    "deleteConfirm": "Het sjabloon \"{name}\" verwijderen? Als het in gebruik is, vallen e-mails terug op het ingebouwde sjabloon.",
+    "deleteTemplate": "Dit sjabloon verwijderen",
+    "deleted": "\"{name}\" verwijderd",
+    "couldNotDelete": "Het sjabloon kon niet worden verwijderd",
+    "backToSettings": "Terug naar instellingen",
+    "unsaved": "(niet opgeslagen)",
+    "save": "Opslaan",
+    "saving": "Opslaan…",
+    "savedState": "Opgeslagen",
+    "saved": "\"{name}\" opgeslagen",
+    "couldNotSave": "Opslaan mislukt",
+    "saveAsNew": "Opslaan als nieuw",
+    "discard": "Wijzigingen verwerpen",
+    "sendTest": "Test verzenden",
+    "cancel": "Annuleren",
+    "problems": "{count, plural, one {# probleem} other {# problemen}}",
+    "unknownTags": "Deze tags bestaan niet voor dit soort e-mail: {tags}",
+    "missingTags": "Deze e-mail moet {tags} bevatten",
+    "subjectRequired": "Het onderwerp mag niet leeg zijn",
+    "invalidTemplate": "Een tekst is te lang of een veld heeft een ongeldige waarde.",
+    "leaveTitle": "Verlaten zonder op te slaan?",
+    "leaveBody": "Uw wijzigingen aan dit sjabloon zijn niet opgeslagen. Ze gaan verloren.",
+    "leaveConfirm": "Verlaten",
+    "subjectAndTheme": "Onderwerp en thema",
+    "settingsSubtitle": "Geldt voor de hele e-mail",
+    "blocksTitle": "Blokken",
+    "addBlock": "Blok toevoegen",
+    "removeBlock": "Blok verwijderen",
+    "removeBlockLast": "Het laatste blok kan niet worden verwijderd",
+    "blockEmpty": "leeg",
+    "blockEmptyHint": "Wordt getoond, maar er staat nog niets in om af te drukken.",
+    "blockShownHint": "Zichtbaar in de e-mail. Klik om te verbergen.",
+    "blockHiddenHint": "Verborgen in de e-mail. Klik om te tonen.",
+    "railHint": "Sleep een blok aan de greep om het te verplaatsen. De lijn laat zien waar het terechtkomt. Klik op een blok om rechts de tekst te bewerken.",
+    "resizeHint": "Sleep om het paneel te vergroten of te verkleinen. Dubbelklik om te herstellen.",
+    "desktop": "Desktop",
+    "mobile": "Mobiel",
+    "htmlView": "HTML",
+    "textView": "Platte tekst",
+    "desktopTooltip": "Voorbeeld op desktopbreedte",
+    "mobileTooltip": "Voorbeeld op telefoonbreedte",
+    "htmlViewTooltip": "De HTML-e-mail tonen",
+    "previewWidth": "Voorbeeldbreedte",
+    "previewView": "Voorbeeldweergave",
+    "textViewTooltip": "De platte-tekstversie tonen",
+    "inboxNow": "Nu",
+    "inboxNoSender": "Uw werkplaats",
+    "inboxNoSubject": "(geen onderwerp)",
+    "subject": "Onderwerp",
+    "text": "Tekst",
+    "buttonLabel": "Knoptekst",
+    "buttonHref": "Link",
+    "spacerHeight": "Hoogte",
+    "tagsTitle": "Tags",
+    "tagsHint": "Klik op een tag om hem in te voegen op de cursorpositie in het veld waarin u het laatst typte.",
+    "theme": {
+      "colors": "Kleuren",
+      "primaryColor": "Primair",
+      "textColor": "Tekst",
+      "mutedColor": "Gedempte tekst",
+      "backgroundColor": "Achtergrond",
+      "panelColor": "Kaders",
+      "invalidColor": "Een kleur is geen geldige hexwaarde",
+      "hexLabel": "{label} als hex",
+      "typography": "Typografie",
+      "font": "Lettertype",
+      "fontHint": "Alleen lettertypen die elk e-mailprogramma al heeft. Webfonts worden door de meeste niet geladen.",
+      "fonts": {
+        "system": "Systeem",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Vormen",
+      "buttonRadius": "Knophoeken",
+      "showLogo": "Logo tonen",
+      "showLogoHint": "Met het logo uit drukt de koptekst in plaats daarvan de naam van de werkplaats af."
+    },
+    "nameDialog": {
+      "title": "Dit sjabloon opslaan",
+      "body": "Geef het sjabloon een naam om het in de galerij terug te vinden. Opslaan maakt het ook het sjabloon waarmee dit soort e-mail wordt verzonden.",
+      "placeholder": "Naam van het sjabloon",
+      "nameExists": "Er bestaat al een sjabloon met deze naam. Kies een andere naam.",
+      "save": "Sjabloon opslaan",
+      "update": "Sjabloon bijwerken"
+    },
+    "testDialog": {
+      "title": "Een test-e-mail verzenden",
+      "body": "De e-mail wordt verzonden met voorbeeldgegevens, zodat u hem in een echt e-mailprogramma kunt bekijken.",
+      "placeholder": "E-mailadres",
+      "send": "Verzenden",
+      "sending": "Verzenden…",
+      "sentTitle": "Test-e-mail verzonden",
+      "sentBody": "Controleer het postvak van {email}. Het kan een minuut duren voordat hij aankomt.",
+      "failedTitle": "De test-e-mail kon niet worden verzonden",
+      "failedBody": "Controleer de e-mailprovider onder Providers en probeer het opnieuw."
+    },
+    "toolbar": {
+      "bold": "Vet",
+      "italic": "Cursief",
+      "underline": "Onderstrepen",
+      "strike": "Doorhalen",
+      "link": "Link",
+      "linkUrl": "Linkadres",
+      "linkPlaceholder": "https://... of {share_link}",
+      "linkHint": "Een webadres, of een tag zoals {share_link} die wordt ingevuld wanneer de e-mail wordt verzonden.",
+      "linkApply": "Toepassen",
+      "linkRemove": "Link verwijderen",
+      "bulletList": "Opsommingslijst",
+      "orderedList": "Genummerde lijst",
+      "align": {
+        "left": "Links uitlijnen",
+        "center": "Centreren",
+        "right": "Rechts uitlijnen"
+      },
+      "size": {
+        "label": "Tekstgrootte",
+        "default": "Standaardgrootte",
+        "small": "Klein",
+        "normal": "Normaal",
+        "large": "Groot",
+        "extraLarge": "Extra groot"
+      },
+      "color": {
+        "label": "Tekstkleur",
+        "text": "Tekstkleur van het thema",
+        "muted": "Gedempte kleur van het thema",
+        "primary": "Primaire kleur van het thema",
+        "default": "Standaard",
+        "hex": "Hexkleur",
+        "apply": "Toepassen"
+      },
+      "clear": "Opmaak wissen"
+    },
+    "logo": {
+      "title": "Logo",
+      "empty": "Geen logo geüpload. De koptekst drukt de naam van de werkplaats af.",
+      "upload": "Logo uploaden",
+      "replace": "Logo vervangen",
+      "remove": "Verwijderen",
+      "uploading": "Uploaden…",
+      "width": "Logobreedte",
+      "hint": "Los van het bedrijfslogo. Het wordt bij het uploaden bijgesneden en voor e-mail voorbereid. PNG, JPEG of WebP tot 4 MB.",
+      "position": "Positie",
+      "tooLarge": "De afbeelding moet kleiner zijn dan 4 MB",
+      "uploadFailed": "Kon het logo niet uploaden"
+    },
+    "image": {
+      "empty": "Geen afbeelding geüpload. Het blok drukt niets af totdat er een is.",
+      "upload": "Afbeelding uploaden",
+      "replace": "Afbeelding vervangen",
+      "remove": "Verwijderen",
+      "uploading": "Uploaden…",
+      "alt": "Beschrijving",
+      "altHint": "Wordt voorgelezen aan lezers die de afbeelding niet kunnen zien en getoond terwijl hij laadt. PNG, JPEG of WebP tot 10 MB.",
+      "width": "Breedte",
+      "widthHint": "600 px is de volledige breedte van de e-mail. Smallere afbeeldingen staan waar Positie aangeeft.",
+      "link": "Link",
+      "linkHint": "Optioneel. Wordt geopend wanneer op de afbeelding wordt geklikt; een tag zoals de deellink werkt hier ook.",
+      "tooLarge": "De afbeelding moet kleiner zijn dan 10 MB",
+      "uploadFailed": "Kon de afbeelding niet uploaden"
+    },
+    "dragHandle": "Sleep om te herschikken",
+    "dragHandleKeyboard": "Sleep, of gebruik de pijltjestoetsen, om dit blok te verplaatsen",
+    "tagDoesNotFit": "De tag past niet in dit veld."
   }
 }

+ 6 - 0
messages/pl/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Usunięto projekt dokumentu",
     "settings_applyDocumentDesign": "Ustawiono domyślny projekt dokumentu",
     "settings_setDocumentDesignRule": "Zmieniono, kiedy używany jest projekt",
+    "settings_saveEmailTemplate": "Zapisano szablon e-maila",
+    "settings_deleteEmailTemplate": "Usunięto szablon e-maila",
+    "settings_applyEmailTemplate": "Ustawiono domyślny szablon e-maila",
     "settings_freezeInvoices": "Zablokowano starsze faktury",
     "inspection_reminders_sent": "Wysłano przypomnienia o przeglądzie",
     "inspection_reminders_send": "Wysłano przypomnienia o przeglądzie",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "Usunął projekt dokumentu {name}",
     "settings_applyDocumentDesign": "Ustawił {name} jako domyślny projekt dokumentu",
     "settings_setDocumentDesignRule": "Zmieniono, kiedy używany jest projekt dokumentu {name}",
+    "settings_saveEmailTemplate": "Zapisał szablon e-maila {name}",
+    "settings_deleteEmailTemplate": "Usunął szablon e-maila {name}",
+    "settings_applyEmailTemplate": "Ustawił {name} jako szablon dla e-maili {kind}",
     "settings_freezeInvoices": "Zablokował {count, plural, one {# fakturę} few {# faktury} many {# faktur} other {# faktury}} sprzed blokady z bieżącym projektem",
     "inspection_reminders_sent": "Wysłano {count} przypomnień o przeglądzie",
     "service_videoCall_send": "Wysłano link do rozmowy wideo przez {channels}",

+ 51 - 0
messages/pl/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Faktura",
+      "subject": "Faktura {document_number} od {workshop_name}",
+      "heading": "Faktura {document_number}",
+      "intro": "Dzień dobry, {customer_name},\n\ndziękujemy za skorzystanie z naszych usług. Faktura jest gotowa.",
+      "button": "Zobacz fakturę",
+      "outro": "W razie pytań wystarczy odpowiedzieć na tę wiadomość."
+    },
+    "quote_sent": {
+      "name": "Wycena",
+      "subject": "Wycena {document_number} od {workshop_name}",
+      "heading": "Wycena {document_number}",
+      "intro": "Dzień dobry, {customer_name},\n\nprzesyłamy wycenę, o którą Państwo prosili. Prosimy o informację, czy mamy przystąpić do pracy.",
+      "button": "Zobacz wycenę",
+      "outro": "W razie pytań wystarczy odpowiedzieć na tę wiadomość."
+    },
+    "inspection_sent": {
+      "name": "Raport z przeglądu",
+      "subject": "Raport z przeglądu od {workshop_name}",
+      "heading": "Raport z przeglądu pojazdu",
+      "intro": "Dzień dobry, {customer_name},\n\nzakończyliśmy przegląd. Raport jest już gotowy.",
+      "button": "Zobacz raport",
+      "outro": "W razie pytań wystarczy odpowiedzieć na tę wiadomość."
+    },
+    "message": {
+      "name": "Wiadomość do klienta",
+      "subject": "Wiadomość od {workshop_name}",
+      "intro": "Dzień dobry, {customer_name},",
+      "outro": "W razie pytań wystarczy odpowiedzieć na tę wiadomość."
+    },
+    "portal_signin": {
+      "name": "Logowanie do portalu",
+      "subject": "Zaloguj się do {workshop_name}",
+      "heading": "Zaloguj się do swojego portalu klienta",
+      "intro": "Dzień dobry, {customer_name},\n\nprosimy użyć przycisku poniżej, aby zalogować się do portalu klienta {workshop_name}.",
+      "button": "Zaloguj się",
+      "linkFallback": "Jeśli przycisk nie działa, prosimy skopiować ten link do przeglądarki:\n{signin_link}",
+      "outro": "Ten link wygasa za 15 minut. Jeśli nie prosili Państwo o niego, można zignorować tę wiadomość."
+    }
+  },
+  "attachmentNote": "Kopia PDF jest załączona do tej wiadomości.",
+  "summary": {
+    "reference": "Numer",
+    "vehicle": "Pojazd",
+    "total": "Razem",
+    "balance": "Do zapłaty",
+    "due": "Termin płatności"
+  }
+}

+ 326 - 0
messages/pl/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "Przypomnienia o przeglądzie",
         "description": "Link do rezerwacji, czas trwania i wyprzedzenie"
+      },
+      "emailTemplates": {
+        "title": "Szablony e-mail",
+        "description": "Co mówią e-maile z fakturą, ofertą i raportem"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "Strefa czasowa jest ustawiona na automatyczną w Lokalizacji, więc serwer używa {zone}, strefy ostatnio widzianej w przeglądarce, która zapisała ustawienia. Wybierz strefę warsztatu jawnie, aby godziny rezerwacji nigdy nie zależały od tego, kto zapisał ostatni.",
     "timeZoneExplicit": "Godziny rezerwacji i otwarcia są odczytywane w strefie {zone}.",
     "timeZoneChange": "Zmień w Lokalizacji"
+  },
+  "emailTemplates": {
+    "title": "Szablony e-mail",
+    "description": "Co mówią e-maile wysyłane przez Twój warsztat i jak wyglądają. Każdy rodzaj e-maila ma wbudowany szablon; zaprojektuj własny, aby zmienić treść, kolory i bloki, z których się składa.",
+    "lockedDescription": "Projektuj e-maile wysyłane przez Twój warsztat: zmieniaj treść, dodawaj lub usuwaj bloki i dopasuj kolory do swojej marki.",
+    "groups": {
+      "documents": "Dokumenty",
+      "messages": "Wiadomości",
+      "portal": "Portal klienta"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Faktura wysłana",
+        "description": "Wysyłany, gdy faktura trafia do klienta e-mailem, z załączonym PDF lub linkiem do jej podglądu."
+      },
+      "quote_sent": {
+        "name": "Wycena wysłana",
+        "description": "Wysyłany, gdy wycena trafia do klienta e-mailem."
+      },
+      "inspection_sent": {
+        "name": "Raport z przeglądu wysłany",
+        "description": "Wysyłany, gdy raport z przeglądu trafia do klienta e-mailem."
+      },
+      "message": {
+        "name": "Wiadomość do klienta",
+        "description": "Wiadomość do klienta: powiadomienie ze zlecenia, raport o stanie, zaproszenie na rozmowę wideo lub zaplanowane przypomnienie."
+      },
+      "portal_signin": {
+        "name": "Logowanie do portalu",
+        "description": "Link logowania, o który klient prosi w portalu klienta."
+      }
+    },
+    "blocks": {
+      "header": "Nagłówek strony",
+      "heading": "Nagłówek",
+      "paragraph": "Akapit",
+      "callout": "Wyróżnienie",
+      "button": "Przycisk",
+      "document_summary": "Podsumowanie dokumentu",
+      "attachment_note": "Uwaga o załączniku",
+      "image": "Obraz",
+      "divider": "Linia oddzielająca",
+      "spacer": "Odstęp",
+      "contact_footer": "Stopka kontaktowa"
+    },
+    "blockNotes": {
+      "header": "Twoje logo lub nazwa warsztatu, gdy nie ma logo albo jest wyłączone. Prześlij logo i ustaw jego rozmiar tutaj; kolory i czcionki znajdziesz w sekcji Temat i motyw.",
+      "contact_footer": "Nazwa, adres, telefon i e-mail Twojego warsztatu, pobrane z ustawień w sekcji Firma. Uzupełnij je tam, aby zmienić, co jest tu drukowane.",
+      "divider": "Cienka linia między dwoma blokami. Jest pomijana, gdy otwierałaby lub zamykała e-mail.",
+      "callout": "Wyróżniony panel z kolorową krawędzią. Dobry na najważniejsze słowa, na przykład samą wiadomość.",
+      "attachment_note": "Drukowana tylko wtedy, gdy do e-maila dołączony jest PDF.",
+      "button": "Link to zwykle tag, na przykład link do udostępnienia. Przycisk, którego link nie ma wartości, jest pomijany w e-mailu.",
+      "document_summary": "Panel z kluczowymi kwotami dokumentu. Wiersze bez niczego do pokazania są pomijane.",
+      "image": "Obrazy są przy przesyłaniu kompresowane i skalowane do wiadomości e-mail. Obrazy, które nie są już używane, są usuwane przy zapisie szablonu.",
+      "summaryUnused": "Ten rodzaj e-maila nie ma dokumentu, więc podsumowanie niczego nie drukuje."
+    },
+    "tagGroups": {
+      "workshop": "Warsztat",
+      "customer": "Klient",
+      "vehicle": "Pojazd",
+      "document": "Dokument",
+      "message": "Wiadomość",
+      "portal": "Portal"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Nazwa warsztatu",
+        "description": "Nazwa Twojego warsztatu."
+      },
+      "workshop_phone": {
+        "label": "Telefon warsztatu",
+        "description": "Numer telefonu z ustawień w sekcji Firma."
+      },
+      "workshop_email": {
+        "label": "E-mail warsztatu",
+        "description": "Adres e-mail z ustawień w sekcji Firma."
+      },
+      "workshop_address": {
+        "label": "Adres warsztatu",
+        "description": "Adres z ustawień w sekcji Firma, w tylu wierszach, ile ma."
+      },
+      "current_user": {
+        "label": "Nazwa nadawcy",
+        "description": "Imię i nazwisko osoby wysyłającej e-mail."
+      },
+      "customer_name": {
+        "label": "Nazwa klienta",
+        "description": "Nazwa klienta, do którego trafia e-mail."
+      },
+      "vehicle": {
+        "label": "Pojazd",
+        "description": "Rocznik, marka i model pojazdu."
+      },
+      "plate": {
+        "label": "Numer rejestracyjny",
+        "description": "Numer rejestracyjny pojazdu."
+      },
+      "mileage": {
+        "label": "Przebieg",
+        "description": "Przebieg zapisany przy pojeździe."
+      },
+      "document_number": {
+        "label": "Numer dokumentu",
+        "description": "Numer faktury lub wyceny."
+      },
+      "document_title": {
+        "label": "Tytuł dokumentu",
+        "description": "Tytuł zlecenia lub dokumentu."
+      },
+      "total": {
+        "label": "Razem",
+        "description": "Suma dokumentu w jego walucie."
+      },
+      "balance_due": {
+        "label": "Do zapłaty",
+        "description": "Ile pozostaje do zapłaty z dokumentu po wpłatach."
+      },
+      "due_date": {
+        "label": "Termin płatności",
+        "description": "Data, do której należy zapłacić."
+      },
+      "share_link": {
+        "label": "Link do udostępnienia",
+        "description": "Link, który klient może otworzyć, aby zobaczyć dokument online."
+      },
+      "message": {
+        "label": "Wiadomość",
+        "description": "Wiadomość napisana do tego e-maila lub notatka dodana do dokumentu."
+      },
+      "signin_link": {
+        "label": "Link logowania",
+        "description": "Link, który loguje klienta do portalu. Wygasa po krótkim czasie."
+      },
+      "portal_link": {
+        "label": "Link do portalu",
+        "description": "Link do portalu klienta."
+      }
+    },
+    "optionalTag": "Nie każdy e-mail ma wartość dla tego tagu. Gdy jest pusty, słowa wokół niego są porządkowane.",
+    "summaryRows": {
+      "reference": "Numer",
+      "vehicle": "Pojazd",
+      "total": "Razem",
+      "balance": "Do zapłaty",
+      "due": "Termin płatności"
+    },
+    "builtIn": "Wbudowany",
+    "inUse": "W użyciu",
+    "useBuiltIn": "Użyj wbudowanego",
+    "setDefault": "Ustaw jako domyślny",
+    "defaultSet": "Wysyłanie odbywa się teraz z „{name}”",
+    "couldNotSetDefault": "Nie udało się zmienić używanego szablonu",
+    "updatedOn": "Zaktualizowano {date}",
+    "delete": "Usuń",
+    "deleteTitle": "Usuń szablon",
+    "deleteConfirm": "Usunąć szablon „{name}”? Jeśli jest w użyciu, e-maile wracają do wbudowanego szablonu.",
+    "deleteTemplate": "Usuń ten szablon",
+    "deleted": "Usunięto „{name}”",
+    "couldNotDelete": "Nie udało się usunąć szablonu",
+    "backToSettings": "Wróć do ustawień",
+    "unsaved": "(niezapisane)",
+    "save": "Zapisz",
+    "saving": "Zapisywanie…",
+    "savedState": "Zapisano",
+    "saved": "Zapisano „{name}”",
+    "couldNotSave": "Nie udało się zapisać",
+    "saveAsNew": "Zapisz jako nowy",
+    "discard": "Odrzuć zmiany",
+    "sendTest": "Wyślij test",
+    "cancel": "Anuluj",
+    "problems": "{count, plural, one {# problem} few {# problemy} many {# problemów} other {# problemu}}",
+    "unknownTags": "Te tagi nie istnieją dla tego rodzaju e-maila: {tags}",
+    "missingTags": "Ten e-mail musi zawierać {tags}",
+    "subjectRequired": "Temat nie może być pusty",
+    "invalidTemplate": "Jakiś tekst jest za długi lub pole ma nieprawidłową wartość.",
+    "leaveTitle": "Wyjść bez zapisywania?",
+    "leaveBody": "Twoje zmiany w tym szablonie nie zostały zapisane. Zostaną utracone.",
+    "leaveConfirm": "Wyjdź",
+    "subjectAndTheme": "Temat i motyw",
+    "settingsSubtitle": "Dotyczy całego e-maila",
+    "blocksTitle": "Bloki",
+    "addBlock": "Dodaj blok",
+    "removeBlock": "Usuń blok",
+    "removeBlockLast": "Ostatniego bloku nie można usunąć",
+    "blockEmpty": "pusty",
+    "blockEmptyHint": "Jest pokazywany, ale nie ma w nim jeszcze nic do wydrukowania.",
+    "blockShownHint": "Widoczny w e-mailu. Kliknij, aby go ukryć.",
+    "blockHiddenHint": "Ukryty w e-mailu. Kliknij, aby go pokazać.",
+    "railHint": "Przeciągnij blok za uchwyt, aby go przenieść. Linia pokazuje, gdzie trafi. Kliknij blok, aby edytować jego treść po prawej.",
+    "resizeHint": "Przeciągnij, aby zmienić rozmiar panelu. Kliknij dwukrotnie, aby przywrócić.",
+    "desktop": "Komputer",
+    "mobile": "Telefon",
+    "htmlView": "HTML",
+    "textView": "Zwykły tekst",
+    "desktopTooltip": "Podgląd w szerokości komputera",
+    "mobileTooltip": "Podgląd w szerokości telefonu",
+    "htmlViewTooltip": "Pokaż wiadomość HTML",
+    "previewWidth": "Szerokość podglądu",
+    "previewView": "Widok podglądu",
+    "textViewTooltip": "Pokaż wersję w zwykłym tekście",
+    "inboxNow": "Teraz",
+    "inboxNoSender": "Twój warsztat",
+    "inboxNoSubject": "(brak tematu)",
+    "subject": "Temat",
+    "text": "Tekst",
+    "buttonLabel": "Tekst przycisku",
+    "buttonHref": "Link",
+    "spacerHeight": "Wysokość",
+    "tagsTitle": "Tagi",
+    "tagsHint": "Kliknij tag, aby wstawić go w miejscu kursora w polu, w którym ostatnio pisałeś.",
+    "theme": {
+      "colors": "Kolory",
+      "primaryColor": "Główny",
+      "textColor": "Tekst",
+      "mutedColor": "Tekst wyciszony",
+      "backgroundColor": "Tło",
+      "panelColor": "Panele",
+      "invalidColor": "Kolor nie jest prawidłową wartością szesnastkową",
+      "hexLabel": "{label} szesnastkowo",
+      "typography": "Typografia",
+      "font": "Czcionka",
+      "fontHint": "Tylko czcionki, które każdy program pocztowy już ma. Większość z nich nie ładuje czcionek webowych.",
+      "fonts": {
+        "system": "Systemowa",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Kształty",
+      "buttonRadius": "Rogi przycisku",
+      "showLogo": "Pokaż logo",
+      "showLogoHint": "Przy wyłączonym logo nagłówek drukuje zamiast niego nazwę warsztatu."
+    },
+    "nameDialog": {
+      "title": "Zapisz ten szablon",
+      "body": "Nadaj szablonowi nazwę, po której znajdziesz go w galerii. Zapisanie sprawia też, że ten rodzaj e-maila będzie z nim wysyłany.",
+      "placeholder": "Nazwa szablonu",
+      "nameExists": "Szablon o tej nazwie już istnieje. Wybierz inną nazwę.",
+      "save": "Zapisz szablon",
+      "update": "Zaktualizuj szablon"
+    },
+    "testDialog": {
+      "title": "Wyślij testowy e-mail",
+      "body": "E-mail jest wysyłany z przykładowymi danymi, aby zobaczyć go w prawdziwym programie pocztowym.",
+      "placeholder": "Adres e-mail",
+      "send": "Wyślij",
+      "sending": "Wysyłanie…",
+      "sentTitle": "Testowy e-mail wysłany",
+      "sentBody": "Sprawdź skrzynkę {email}. Dotarcie może zająć minutę.",
+      "failedTitle": "Nie udało się wysłać testowego e-maila",
+      "failedBody": "Sprawdź dostawcę e-mail w sekcji Dostawcy i spróbuj ponownie."
+    },
+    "toolbar": {
+      "bold": "Pogrubienie",
+      "italic": "Kursywa",
+      "underline": "Podkreślenie",
+      "strike": "Przekreślenie",
+      "link": "Link",
+      "linkUrl": "Adres linku",
+      "linkPlaceholder": "https://... lub {share_link}",
+      "linkHint": "Adres internetowy lub tag taki jak {share_link}, który jest wypełniany przy wysyłce wiadomości.",
+      "linkApply": "Zastosuj",
+      "linkRemove": "Usuń link",
+      "bulletList": "Lista punktowana",
+      "orderedList": "Lista numerowana",
+      "align": {
+        "left": "Wyrównaj do lewej",
+        "center": "Wyśrodkuj",
+        "right": "Wyrównaj do prawej"
+      },
+      "size": {
+        "label": "Rozmiar tekstu",
+        "default": "Rozmiar domyślny",
+        "small": "Mały",
+        "normal": "Normalny",
+        "large": "Duży",
+        "extraLarge": "Bardzo duży"
+      },
+      "color": {
+        "label": "Kolor tekstu",
+        "text": "Kolor tekstu motywu",
+        "muted": "Kolor wyciszony motywu",
+        "primary": "Kolor główny motywu",
+        "default": "Domyślny",
+        "hex": "Kolor szesnastkowy",
+        "apply": "Zastosuj"
+      },
+      "clear": "Wyczyść formatowanie"
+    },
+    "logo": {
+      "title": "Logo",
+      "empty": "Nie przesłano logo. Nagłówek drukuje nazwę warsztatu.",
+      "upload": "Prześlij logo",
+      "replace": "Zastąp logo",
+      "remove": "Usuń",
+      "uploading": "Przesyłanie…",
+      "width": "Szerokość logo",
+      "hint": "Niezależne od logo firmy. Przy przesyłaniu jest przycinane i przygotowywane do wiadomości e-mail. PNG, JPEG lub WebP do 4 MB.",
+      "position": "Położenie",
+      "tooLarge": "Obraz musi mieć mniej niż 4 MB",
+      "uploadFailed": "Nie udało się przesłać logo"
+    },
+    "image": {
+      "empty": "Nie przesłano obrazu. Blok nic nie drukuje, dopóki go nie ma.",
+      "upload": "Prześlij obraz",
+      "replace": "Zastąp obraz",
+      "remove": "Usuń",
+      "uploading": "Przesyłanie…",
+      "alt": "Opis",
+      "altHint": "Odczytywany na głos czytelnikom, którzy nie widzą obrazu, i wyświetlany podczas jego ładowania. PNG, JPEG lub WebP do 10 MB.",
+      "width": "Szerokość",
+      "widthHint": "600 px to pełna szerokość wiadomości e-mail. Węższe obrazy ustawiają się tam, gdzie wskazuje Położenie.",
+      "link": "Link",
+      "linkHint": "Opcjonalnie. Otwiera się po kliknięciu obrazu; działa tu też tag, na przykład link do udostępnienia.",
+      "tooLarge": "Obraz musi mieć mniej niż 10 MB",
+      "uploadFailed": "Nie udało się przesłać obrazu"
+    },
+    "dragHandle": "Przeciągnij, aby zmienić kolejność",
+    "dragHandleKeyboard": "Przeciągnij lub użyj klawiszy strzałek, aby przenieść ten blok",
+    "tagDoesNotFit": "Tag nie mieści się w tym polu."
   }
 }

+ 6 - 0
messages/pt-BR/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Design de documento excluído",
     "settings_applyDocumentDesign": "Design de documento padrão definido",
     "settings_setDocumentDesignRule": "Alterou quando um design é usado",
+    "settings_saveEmailTemplate": "Modelo de e-mail salvo",
+    "settings_deleteEmailTemplate": "Modelo de e-mail excluído",
+    "settings_applyEmailTemplate": "Modelo de e-mail padrão definido",
     "settings_freezeInvoices": "Faturas anteriores travadas",
     "inspection_reminders_sent": "Enviou lembretes de inspeção",
     "inspection_reminders_send": "Enviou lembretes de inspeção",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "Excluiu o design de documento {name}",
     "settings_applyDocumentDesign": "Definiu {name} como design de documento padrão",
     "settings_setDocumentDesignRule": "Alterou quando o design de documento {name} é usado",
+    "settings_saveEmailTemplate": "Salvou o modelo de e-mail {name}",
+    "settings_deleteEmailTemplate": "Excluiu o modelo de e-mail {name}",
+    "settings_applyEmailTemplate": "Definiu {name} como modelo para e-mails de {kind}",
     "settings_freezeInvoices": "Travou {count, plural, one {# fatura} other {# faturas}} anteriores ao bloqueio com o design atual",
     "inspection_reminders_sent": "Enviou {count} lembretes de inspeção",
     "service_videoCall_send": "Enviou o link da videochamada por {channels}",

+ 51 - 0
messages/pt-BR/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Fatura",
+      "subject": "Fatura {document_number} de {workshop_name}",
+      "heading": "Fatura {document_number}",
+      "intro": "Olá, {customer_name},\n\nObrigado por escolher a gente. Sua fatura está pronta.",
+      "button": "Ver fatura",
+      "outro": "Se tiver alguma dúvida, é só responder a este e-mail."
+    },
+    "quote_sent": {
+      "name": "Orçamento",
+      "subject": "Orçamento {document_number} de {workshop_name}",
+      "heading": "Orçamento {document_number}",
+      "intro": "Olá, {customer_name},\n\nAqui está o orçamento que você pediu. É só avisar se quiser que a gente siga em frente.",
+      "button": "Ver orçamento",
+      "outro": "Se tiver alguma dúvida, é só responder a este e-mail."
+    },
+    "inspection_sent": {
+      "name": "Relatório de inspeção",
+      "subject": "Relatório de inspeção de {workshop_name}",
+      "heading": "Relatório de inspeção do veículo",
+      "intro": "Olá, {customer_name},\n\nConcluímos a inspeção. O relatório já está disponível para você.",
+      "button": "Ver relatório",
+      "outro": "Se tiver alguma dúvida, é só responder a este e-mail."
+    },
+    "message": {
+      "name": "Mensagem ao cliente",
+      "subject": "Mensagem de {workshop_name}",
+      "intro": "Olá, {customer_name},",
+      "outro": "Se tiver alguma dúvida, é só responder a este e-mail."
+    },
+    "portal_signin": {
+      "name": "Acesso ao portal",
+      "subject": "Entre em {workshop_name}",
+      "heading": "Entre no seu portal do cliente",
+      "intro": "Olá, {customer_name},\n\nUse o botão abaixo para entrar no portal do cliente de {workshop_name}.",
+      "button": "Entrar",
+      "linkFallback": "Se o botão não funcionar, copie este link no seu navegador:\n{signin_link}",
+      "outro": "Este link expira em 15 minutos. Se você não o solicitou, pode ignorar este e-mail."
+    }
+  },
+  "attachmentNote": "Uma cópia em PDF está anexada a este e-mail.",
+  "summary": {
+    "reference": "Referência",
+    "vehicle": "Veículo",
+    "total": "Total",
+    "balance": "Saldo a pagar",
+    "due": "Vencimento"
+  }
+}

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

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "Lembretes de inspeção",
         "description": "Link de agendamento, duração e antecedência"
+      },
+      "emailTemplates": {
+        "title": "Modelos de e-mail",
+        "description": "O que dizem seus e-mails de fatura, orçamento e relatório"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "O fuso horário está em automático em Localização, então o servidor usa {zone}, o último visto em um navegador que salvou configurações. Escolha o fuso da oficina explicitamente para que os horários de agendamento nunca dependam de quem salvou por último.",
     "timeZoneExplicit": "Horários de agendamento e de funcionamento são lidos em {zone}.",
     "timeZoneChange": "Alterar em Localização"
+  },
+  "emailTemplates": {
+    "title": "Modelos de e-mail",
+    "description": "O que dizem os e-mails que sua oficina envia e como eles ficam. Cada tipo de e-mail tem um modelo integrado; crie o seu para mudar o texto, as cores e os blocos que o compõem.",
+    "lockedDescription": "Crie os e-mails que sua oficina envia: mude o texto, adicione ou remova blocos e combine as cores com a sua marca.",
+    "groups": {
+      "documents": "Documentos",
+      "messages": "Mensagens",
+      "portal": "Portal do cliente"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Fatura enviada",
+        "description": "Enviado quando uma fatura vai para um cliente por e-mail, com o PDF anexado ou um link para visualizá-la."
+      },
+      "quote_sent": {
+        "name": "Orçamento enviado",
+        "description": "Enviado quando um orçamento vai para um cliente por e-mail."
+      },
+      "inspection_sent": {
+        "name": "Relatório de inspeção enviado",
+        "description": "Enviado quando um relatório de inspeção vai para um cliente por e-mail."
+      },
+      "message": {
+        "name": "Mensagem ao cliente",
+        "description": "Uma mensagem escrita a um cliente: uma notificação de uma ordem de serviço, um relatório de status, um convite para videochamada ou um lembrete agendado."
+      },
+      "portal_signin": {
+        "name": "Acesso ao portal",
+        "description": "O link de acesso que um cliente solicita no portal do cliente."
+      }
+    },
+    "blocks": {
+      "header": "Cabeçalho",
+      "heading": "Título",
+      "paragraph": "Parágrafo",
+      "callout": "Destaque",
+      "button": "Botão",
+      "document_summary": "Resumo do documento",
+      "attachment_note": "Nota de anexo",
+      "image": "Imagem",
+      "divider": "Divisor",
+      "spacer": "Espaço",
+      "contact_footer": "Rodapé de contato"
+    },
+    "blockNotes": {
+      "header": "Seu logotipo, ou o nome da oficina quando não há logotipo ou ele está desativado. Envie e dimensione o logotipo aqui; as cores e as fontes ficam em Assunto e tema.",
+      "contact_footer": "Nome, endereço, telefone e e-mail da sua oficina, tirados das configurações de Empresa. Preencha-os lá para mudar o que é impresso aqui.",
+      "divider": "Uma linha fina entre dois blocos. É omitida quando abriria ou fecharia o e-mail.",
+      "callout": "Um painel em destaque com uma borda colorida. Bom para as palavras que mais importam, como a própria mensagem.",
+      "attachment_note": "Impressa apenas quando um PDF é anexado ao e-mail.",
+      "button": "O link costuma ser uma tag, como o link de compartilhamento. Um botão cujo link não tem valor é omitido do e-mail.",
+      "document_summary": "Um painel com os números principais do documento. Linhas sem nada a mostrar são omitidas.",
+      "image": "As imagens são comprimidas e redimensionadas para e-mail ao serem enviadas. Imagens que não são mais usadas são removidas quando o modelo é salvo.",
+      "summaryUnused": "Este tipo de e-mail não tem documento, então o resumo não imprime nada."
+    },
+    "tagGroups": {
+      "workshop": "Oficina",
+      "customer": "Cliente",
+      "vehicle": "Veículo",
+      "document": "Documento",
+      "message": "Mensagem",
+      "portal": "Portal"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Nome da oficina",
+        "description": "O nome da sua oficina."
+      },
+      "workshop_phone": {
+        "label": "Telefone da oficina",
+        "description": "O número de telefone das configurações de Empresa."
+      },
+      "workshop_email": {
+        "label": "E-mail da oficina",
+        "description": "O endereço de e-mail das configurações de Empresa."
+      },
+      "workshop_address": {
+        "label": "Endereço da oficina",
+        "description": "O endereço das configurações de Empresa, em quantas linhas ele tiver."
+      },
+      "current_user": {
+        "label": "Nome do remetente",
+        "description": "O nome da pessoa que envia o e-mail."
+      },
+      "customer_name": {
+        "label": "Nome do cliente",
+        "description": "O nome do cliente a quem o e-mail vai."
+      },
+      "vehicle": {
+        "label": "Veículo",
+        "description": "Ano, marca e modelo do veículo."
+      },
+      "plate": {
+        "label": "Placa",
+        "description": "O número da placa do veículo."
+      },
+      "mileage": {
+        "label": "Quilometragem",
+        "description": "A quilometragem registrada no veículo."
+      },
+      "document_number": {
+        "label": "Número do documento",
+        "description": "O número da fatura ou do orçamento."
+      },
+      "document_title": {
+        "label": "Título do documento",
+        "description": "O título do serviço ou do documento."
+      },
+      "total": {
+        "label": "Total",
+        "description": "O total do documento, na moeda dele."
+      },
+      "balance_due": {
+        "label": "Saldo devedor",
+        "description": "O que ainda falta pagar do documento após os pagamentos."
+      },
+      "due_date": {
+        "label": "Data de vencimento",
+        "description": "A data em que o pagamento vence."
+      },
+      "share_link": {
+        "label": "Link de compartilhamento",
+        "description": "Um link que o cliente pode abrir para ver o documento online."
+      },
+      "message": {
+        "label": "Mensagem",
+        "description": "A mensagem escrita para este e-mail, ou a nota adicionada ao documento."
+      },
+      "signin_link": {
+        "label": "Link de acesso",
+        "description": "O link que conecta o cliente ao portal. Ele expira em pouco tempo."
+      },
+      "portal_link": {
+        "label": "Link do portal",
+        "description": "Um link para o portal do cliente."
+      }
+    },
+    "optionalTag": "Nem todo e-mail tem um valor para esta tag. Quando ela fica vazia, as palavras ao redor são ajustadas.",
+    "summaryRows": {
+      "reference": "Referência",
+      "vehicle": "Veículo",
+      "total": "Total",
+      "balance": "Saldo devedor",
+      "due": "Data de vencimento"
+    },
+    "builtIn": "Integrado",
+    "inUse": "Em uso",
+    "useBuiltIn": "Usar o integrado",
+    "setDefault": "Definir como padrão",
+    "defaultSet": "Agora enviando com \"{name}\"",
+    "couldNotSetDefault": "Não foi possível mudar o modelo em uso",
+    "updatedOn": "Atualizado em {date}",
+    "delete": "Excluir",
+    "deleteTitle": "Excluir modelo",
+    "deleteConfirm": "Excluir o modelo \"{name}\"? Se estiver em uso, os e-mails voltam ao modelo integrado.",
+    "deleteTemplate": "Excluir este modelo",
+    "deleted": "\"{name}\" excluído",
+    "couldNotDelete": "Não foi possível excluir o modelo",
+    "backToSettings": "Voltar às configurações",
+    "unsaved": "(não salvo)",
+    "save": "Salvar",
+    "saving": "Salvando…",
+    "savedState": "Salvo",
+    "saved": "\"{name}\" salvo",
+    "couldNotSave": "Não foi possível salvar",
+    "saveAsNew": "Salvar como novo",
+    "discard": "Descartar alterações",
+    "sendTest": "Enviar teste",
+    "cancel": "Cancelar",
+    "problems": "{count, plural, one {# problema} other {# problemas}}",
+    "unknownTags": "Estas tags não existem para este tipo de e-mail: {tags}",
+    "missingTags": "Este e-mail precisa incluir {tags}",
+    "subjectRequired": "O assunto não pode ficar vazio",
+    "invalidTemplate": "Algum texto é longo demais ou um campo tem um valor inválido.",
+    "leaveTitle": "Sair sem salvar?",
+    "leaveBody": "Suas alterações neste modelo não foram salvas. Elas serão perdidas.",
+    "leaveConfirm": "Sair",
+    "subjectAndTheme": "Assunto e tema",
+    "settingsSubtitle": "Vale para o e-mail inteiro",
+    "blocksTitle": "Blocos",
+    "addBlock": "Adicionar bloco",
+    "removeBlock": "Remover bloco",
+    "removeBlockLast": "O último bloco não pode ser removido",
+    "blockEmpty": "vazio",
+    "blockEmptyHint": "Aparece, mas ainda não há nada nele para imprimir.",
+    "blockShownHint": "Visível no e-mail. Clique para ocultar.",
+    "blockHiddenHint": "Oculto no e-mail. Clique para mostrar.",
+    "railHint": "Arraste um bloco pela alça para movê-lo. A linha mostra onde ele vai ficar. Clique em um bloco para editar o texto dele à direita.",
+    "resizeHint": "Arraste para redimensionar o painel. Clique duas vezes para redefinir.",
+    "desktop": "Computador",
+    "mobile": "Celular",
+    "htmlView": "HTML",
+    "textView": "Texto simples",
+    "desktopTooltip": "Pré-visualizar na largura de computador",
+    "mobileTooltip": "Pré-visualizar na largura de celular",
+    "htmlViewTooltip": "Mostrar o e-mail HTML",
+    "previewWidth": "Largura da pré-visualização",
+    "previewView": "Modo de pré-visualização",
+    "textViewTooltip": "Mostrar a versão em texto simples",
+    "inboxNow": "Agora",
+    "inboxNoSender": "Sua oficina",
+    "inboxNoSubject": "(sem assunto)",
+    "subject": "Assunto",
+    "text": "Texto",
+    "buttonLabel": "Texto do botão",
+    "buttonHref": "Link",
+    "spacerHeight": "Altura",
+    "tagsTitle": "Tags",
+    "tagsHint": "Clique em uma tag para inseri-la onde está o cursor no campo em que você digitou por último.",
+    "theme": {
+      "colors": "Cores",
+      "primaryColor": "Principal",
+      "textColor": "Texto",
+      "mutedColor": "Texto suave",
+      "backgroundColor": "Fundo",
+      "panelColor": "Painéis",
+      "invalidColor": "Uma cor não é um valor hexadecimal válido",
+      "hexLabel": "{label} em hexadecimal",
+      "typography": "Tipografia",
+      "font": "Fonte",
+      "fontHint": "Apenas fontes que todo cliente de e-mail já tem. A maioria não carrega fontes da web.",
+      "fonts": {
+        "system": "Sistema",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Formas",
+      "buttonRadius": "Cantos do botão",
+      "showLogo": "Mostrar logotipo",
+      "showLogoHint": "Com o logotipo desativado, o cabeçalho imprime o nome da oficina no lugar."
+    },
+    "nameDialog": {
+      "title": "Salvar este modelo",
+      "body": "Dê um nome ao modelo para encontrá-lo na galeria. Salvar também faz dele o modelo com que este tipo de e-mail é enviado.",
+      "placeholder": "Nome do modelo",
+      "nameExists": "Já existe um modelo com este nome. Escolha outro nome.",
+      "save": "Salvar modelo",
+      "update": "Atualizar modelo"
+    },
+    "testDialog": {
+      "title": "Enviar um e-mail de teste",
+      "body": "O e-mail é enviado com dados de exemplo, para você vê-lo em um cliente de e-mail de verdade.",
+      "placeholder": "Endereço de e-mail",
+      "send": "Enviar",
+      "sending": "Enviando…",
+      "sentTitle": "E-mail de teste enviado",
+      "sentBody": "Verifique a caixa de entrada de {email}. Pode levar um minuto para chegar.",
+      "failedTitle": "Não foi possível enviar o e-mail de teste",
+      "failedBody": "Verifique o provedor de e-mail em Provedores e tente novamente."
+    },
+    "toolbar": {
+      "bold": "Negrito",
+      "italic": "Itálico",
+      "underline": "Sublinhado",
+      "strike": "Tachado",
+      "link": "Link",
+      "linkUrl": "Endereço do link",
+      "linkPlaceholder": "https://... ou {share_link}",
+      "linkHint": "Um endereço web ou uma tag como {share_link}, preenchida quando o e-mail é enviado.",
+      "linkApply": "Aplicar",
+      "linkRemove": "Remover link",
+      "bulletList": "Lista com marcadores",
+      "orderedList": "Lista numerada",
+      "align": {
+        "left": "Alinhar à esquerda",
+        "center": "Centralizar",
+        "right": "Alinhar à direita"
+      },
+      "size": {
+        "label": "Tamanho do texto",
+        "default": "Tamanho padrão",
+        "small": "Pequeno",
+        "normal": "Normal",
+        "large": "Grande",
+        "extraLarge": "Extragrande"
+      },
+      "color": {
+        "label": "Cor do texto",
+        "text": "Cor de texto do tema",
+        "muted": "Cor suave do tema",
+        "primary": "Cor principal do tema",
+        "default": "Padrão",
+        "hex": "Cor hexadecimal",
+        "apply": "Aplicar"
+      },
+      "clear": "Limpar formatação"
+    },
+    "logo": {
+      "title": "Logotipo",
+      "empty": "Nenhum logotipo enviado. O cabeçalho imprime o nome da oficina.",
+      "upload": "Enviar logotipo",
+      "replace": "Substituir logotipo",
+      "remove": "Remover",
+      "uploading": "Enviando…",
+      "width": "Largura do logotipo",
+      "hint": "Separado do logotipo da empresa. Ao ser enviado, ele é recortado e preparado para e-mail. PNG, JPEG ou WebP de até 4 MB.",
+      "position": "Posição",
+      "tooLarge": "A imagem deve ter menos de 4 MB",
+      "uploadFailed": "Não foi possível enviar o logotipo"
+    },
+    "image": {
+      "empty": "Nenhuma imagem enviada. O bloco não imprime nada até que haja uma.",
+      "upload": "Enviar imagem",
+      "replace": "Substituir imagem",
+      "remove": "Remover",
+      "uploading": "Enviando…",
+      "alt": "Descrição",
+      "altHint": "Lida em voz alta para leitores que não conseguem ver a imagem e exibida enquanto ela carrega. PNG, JPEG ou WebP de até 10 MB.",
+      "width": "Largura",
+      "widthHint": "600 px é a largura total do e-mail. Imagens mais estreitas ficam onde Posição indica.",
+      "link": "Link",
+      "linkHint": "Opcional. Abre ao clicar na imagem; uma tag como o link de compartilhamento também funciona aqui.",
+      "tooLarge": "A imagem deve ter menos de 10 MB",
+      "uploadFailed": "Não foi possível enviar a imagem"
+    },
+    "dragHandle": "Arraste para reordenar",
+    "dragHandleKeyboard": "Arraste, ou use as teclas de seta, para mover este bloco",
+    "tagDoesNotFit": "A tag não cabe neste campo."
   }
 }

+ 6 - 0
messages/ru/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Удалён дизайн документа",
     "settings_applyDocumentDesign": "Установлен дизайн документа по умолчанию",
     "settings_setDocumentDesignRule": "Изменено, когда используется дизайн",
+    "settings_saveEmailTemplate": "Сохранён шаблон письма",
+    "settings_deleteEmailTemplate": "Удалён шаблон письма",
+    "settings_applyEmailTemplate": "Задан шаблон письма по умолчанию",
     "settings_freezeInvoices": "Зафиксированы старые счета",
     "inspection_reminders_sent": "Отправлены напоминания о техосмотре",
     "inspection_reminders_send": "Отправлены напоминания о техосмотре",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "Удалил дизайн документа {name}",
     "settings_applyDocumentDesign": "Сделал {name} дизайном документа по умолчанию",
     "settings_setDocumentDesignRule": "Изменено, когда используется дизайн документа {name}",
+    "settings_saveEmailTemplate": "Сохранил шаблон письма {name}",
+    "settings_deleteEmailTemplate": "Удалил шаблон письма {name}",
+    "settings_applyEmailTemplate": "Сделал {name} шаблоном для писем {kind}",
     "settings_freezeInvoices": "Зафиксировал {count, plural, one {# счёт} few {# счёта} many {# счетов} other {# счёта}} до появления блокировки с текущим дизайном",
     "inspection_reminders_sent": "Отправлено напоминаний о техосмотре: {count}",
     "service_videoCall_send": "Отправил(а) ссылку на видеозвонок через {channels}",

+ 51 - 0
messages/ru/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Счёт",
+      "subject": "Счёт {document_number} от {workshop_name}",
+      "heading": "Счёт {document_number}",
+      "intro": "Здравствуйте, {customer_name}!\n\nСпасибо, что выбрали нас. Ваш счёт готов.",
+      "button": "Открыть счёт",
+      "outro": "Если у вас есть вопросы, просто ответьте на это письмо."
+    },
+    "quote_sent": {
+      "name": "Предложение",
+      "subject": "Предложение {document_number} от {workshop_name}",
+      "heading": "Предложение {document_number}",
+      "intro": "Здравствуйте, {customer_name}!\n\nВысылаем предложение, которое вы запрашивали. Сообщите нам, если хотите, чтобы мы приступили к работе.",
+      "button": "Открыть предложение",
+      "outro": "Если у вас есть вопросы, просто ответьте на это письмо."
+    },
+    "inspection_sent": {
+      "name": "Отчёт об осмотре",
+      "subject": "Отчёт об осмотре от {workshop_name}",
+      "heading": "Отчёт об осмотре автомобиля",
+      "intro": "Здравствуйте, {customer_name}!\n\nМы завершили осмотр. Отчёт готов для вас.",
+      "button": "Открыть отчёт",
+      "outro": "Если у вас есть вопросы, просто ответьте на это письмо."
+    },
+    "message": {
+      "name": "Сообщение клиенту",
+      "subject": "Сообщение от {workshop_name}",
+      "intro": "Здравствуйте, {customer_name}!",
+      "outro": "Если у вас есть вопросы, просто ответьте на это письмо."
+    },
+    "portal_signin": {
+      "name": "Вход в портал",
+      "subject": "Вход в {workshop_name}",
+      "heading": "Войдите в свой клиентский портал",
+      "intro": "Здравствуйте, {customer_name}!\n\nНажмите кнопку ниже, чтобы войти в клиентский портал {workshop_name}.",
+      "button": "Войти",
+      "linkFallback": "Если кнопка не работает, скопируйте эту ссылку в адресную строку браузера:\n{signin_link}",
+      "outro": "Ссылка действует 15 минут. Если вы её не запрашивали, просто проигнорируйте это письмо."
+    }
+  },
+  "attachmentNote": "Копия в формате PDF приложена к этому письму.",
+  "summary": {
+    "reference": "Номер",
+    "vehicle": "Автомобиль",
+    "total": "Итого",
+    "balance": "К оплате",
+    "due": "Срок оплаты"
+  }
+}

+ 326 - 0
messages/ru/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "Напоминания о техосмотре",
         "description": "Ссылка на запись, длительность и срок"
+      },
+      "emailTemplates": {
+        "title": "Шаблоны писем",
+        "description": "Что говорят письма о счёте, предложении и отчёте"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "Часовой пояс стоит на автоматическом в разделе Локализация, поэтому сервер использует {zone} — пояс, последним увиденный в браузере, сохранившем настройки. Выберите пояс мастерской явно, чтобы время записи никогда не зависело от того, кто сохранил последним.",
     "timeZoneExplicit": "Время записи и часы работы читаются в поясе {zone}.",
     "timeZoneChange": "Изменить в Локализации"
+  },
+  "emailTemplates": {
+    "title": "Шаблоны писем",
+    "description": "Что говорят письма, которые отправляет ваша мастерская, и как они выглядят. У каждого вида письма есть встроенный шаблон; создайте свой, чтобы изменить текст, цвета и блоки, из которых оно состоит.",
+    "lockedDescription": "Оформляйте письма, которые отправляет ваша мастерская: меняйте текст, добавляйте или убирайте блоки и подбирайте цвета под свой бренд.",
+    "groups": {
+      "documents": "Документы",
+      "messages": "Сообщения",
+      "portal": "Портал клиента"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Счёт отправлен",
+        "description": "Отправляется, когда счёт уходит клиенту по почте, с вложенным PDF или ссылкой для просмотра."
+      },
+      "quote_sent": {
+        "name": "Предложение отправлено",
+        "description": "Отправляется, когда предложение уходит клиенту по почте."
+      },
+      "inspection_sent": {
+        "name": "Отчёт об осмотре отправлен",
+        "description": "Отправляется, когда отчёт об осмотре уходит клиенту по почте."
+      },
+      "message": {
+        "name": "Сообщение клиенту",
+        "description": "Сообщение клиенту: уведомление из заказа, отчёт о статусе, приглашение на видеозвонок или запланированное напоминание."
+      },
+      "portal_signin": {
+        "name": "Вход в портал",
+        "description": "Ссылка для входа, которую клиент запрашивает на портале клиента."
+      }
+    },
+    "blocks": {
+      "header": "Шапка",
+      "heading": "Заголовок",
+      "paragraph": "Абзац",
+      "callout": "Выноска",
+      "button": "Кнопка",
+      "document_summary": "Сводка документа",
+      "attachment_note": "Примечание о вложении",
+      "image": "Изображение",
+      "divider": "Разделитель",
+      "spacer": "Отступ",
+      "contact_footer": "Подвал с контактами"
+    },
+    "blockNotes": {
+      "header": "Ваш логотип или название мастерской, если логотипа нет или он выключен. Загрузите логотип и задайте его размер здесь; цвета и шрифты находятся в разделе Тема и оформление.",
+      "contact_footer": "Название, адрес, телефон и почта вашей мастерской из настроек раздела Компания. Заполните их там, чтобы изменить то, что печатается здесь.",
+      "divider": "Тонкая линия между двумя блоками. Пропускается, если бы она открывала или закрывала письмо.",
+      "callout": "Выделенная панель с цветной кромкой. Подходит для самых важных слов, например для самого сообщения.",
+      "attachment_note": "Печатается только тогда, когда к письму приложен PDF.",
+      "button": "Ссылка обычно является тегом, например ссылкой для просмотра. Кнопка, у ссылки которой нет значения, в письмо не попадает.",
+      "document_summary": "Панель с ключевыми цифрами документа. Строки, в которых нечего показать, пропускаются.",
+      "image": "При загрузке изображения сжимаются и уменьшаются для письма. Изображения, которые больше не используются, удаляются при сохранении шаблона.",
+      "summaryUnused": "У этого вида письма нет документа, поэтому сводка ничего не печатает."
+    },
+    "tagGroups": {
+      "workshop": "Мастерская",
+      "customer": "Клиент",
+      "vehicle": "Автомобиль",
+      "document": "Документ",
+      "message": "Сообщение",
+      "portal": "Портал"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Название мастерской",
+        "description": "Название вашей мастерской."
+      },
+      "workshop_phone": {
+        "label": "Телефон мастерской",
+        "description": "Номер телефона из настроек раздела Компания."
+      },
+      "workshop_email": {
+        "label": "Почта мастерской",
+        "description": "Адрес электронной почты из настроек раздела Компания."
+      },
+      "workshop_address": {
+        "label": "Адрес мастерской",
+        "description": "Адрес из настроек раздела Компания, на стольких строках, сколько в нём есть."
+      },
+      "current_user": {
+        "label": "Имя отправителя",
+        "description": "Имя человека, который отправляет письмо."
+      },
+      "customer_name": {
+        "label": "Имя клиента",
+        "description": "Имя клиента, которому уходит письмо."
+      },
+      "vehicle": {
+        "label": "Автомобиль",
+        "description": "Год, марка и модель автомобиля."
+      },
+      "plate": {
+        "label": "Госномер",
+        "description": "Регистрационный номер автомобиля."
+      },
+      "mileage": {
+        "label": "Пробег",
+        "description": "Пробег, записанный у автомобиля."
+      },
+      "document_number": {
+        "label": "Номер документа",
+        "description": "Номер счёта или предложения."
+      },
+      "document_title": {
+        "label": "Название документа",
+        "description": "Название работы или документа."
+      },
+      "total": {
+        "label": "Итого",
+        "description": "Итоговая сумма документа в его валюте."
+      },
+      "balance_due": {
+        "label": "К оплате",
+        "description": "Сколько ещё причитается по документу после оплат."
+      },
+      "due_date": {
+        "label": "Срок оплаты",
+        "description": "Дата, к которой нужно оплатить."
+      },
+      "share_link": {
+        "label": "Ссылка для просмотра",
+        "description": "Ссылка, по которой клиент может открыть документ онлайн."
+      },
+      "message": {
+        "label": "Сообщение",
+        "description": "Сообщение, написанное для этого письма, или примечание, добавленное к документу."
+      },
+      "signin_link": {
+        "label": "Ссылка для входа",
+        "description": "Ссылка, которая выполняет вход клиента в портал. Она быстро истекает."
+      },
+      "portal_link": {
+        "label": "Ссылка на портал",
+        "description": "Ссылка на портал клиента."
+      }
+    },
+    "optionalTag": "Не у каждого письма есть значение для этого тега. Когда он пуст, слова вокруг него приводятся в порядок.",
+    "summaryRows": {
+      "reference": "Номер",
+      "vehicle": "Автомобиль",
+      "total": "Итого",
+      "balance": "К оплате",
+      "due": "Срок оплаты"
+    },
+    "builtIn": "Встроенный",
+    "inUse": "Используется",
+    "useBuiltIn": "Использовать встроенный",
+    "setDefault": "Сделать основным",
+    "defaultSet": "Теперь отправляется с «{name}»",
+    "couldNotSetDefault": "Не удалось сменить используемый шаблон",
+    "updatedOn": "Обновлено {date}",
+    "delete": "Удалить",
+    "deleteTitle": "Удалить шаблон",
+    "deleteConfirm": "Удалить шаблон «{name}»? Если он используется, письма вернутся к встроенному шаблону.",
+    "deleteTemplate": "Удалить этот шаблон",
+    "deleted": "Удалён «{name}»",
+    "couldNotDelete": "Не удалось удалить шаблон",
+    "backToSettings": "Назад к настройкам",
+    "unsaved": "(не сохранено)",
+    "save": "Сохранить",
+    "saving": "Сохранение…",
+    "savedState": "Сохранено",
+    "saved": "Сохранён «{name}»",
+    "couldNotSave": "Не удалось сохранить",
+    "saveAsNew": "Сохранить как новый",
+    "discard": "Отменить изменения",
+    "sendTest": "Отправить тест",
+    "cancel": "Отмена",
+    "problems": "{count, plural, one {# проблема} few {# проблемы} many {# проблем} other {# проблемы}}",
+    "unknownTags": "Этих тегов нет для этого вида письма: {tags}",
+    "missingTags": "Это письмо должно содержать {tags}",
+    "subjectRequired": "Тема не может быть пустой",
+    "invalidTemplate": "Какой-то текст слишком длинный, или в поле указано недопустимое значение.",
+    "leaveTitle": "Выйти без сохранения?",
+    "leaveBody": "Ваши изменения в этом шаблоне не сохранены. Они будут потеряны.",
+    "leaveConfirm": "Выйти",
+    "subjectAndTheme": "Тема и оформление",
+    "settingsSubtitle": "Применяется ко всему письму",
+    "blocksTitle": "Блоки",
+    "addBlock": "Добавить блок",
+    "removeBlock": "Убрать блок",
+    "removeBlockLast": "Последний блок нельзя убрать",
+    "blockEmpty": "пусто",
+    "blockEmptyHint": "Показывается, но печатать в нём пока нечего.",
+    "blockShownHint": "Виден в письме. Нажмите, чтобы скрыть.",
+    "blockHiddenHint": "Скрыт из письма. Нажмите, чтобы показать.",
+    "railHint": "Перетащите блок за ручку, чтобы переместить его. Линия показывает, куда он встанет. Нажмите на блок, чтобы отредактировать его текст справа.",
+    "resizeHint": "Перетащите, чтобы изменить размер панели. Двойной щелчок сбрасывает размер.",
+    "desktop": "Компьютер",
+    "mobile": "Телефон",
+    "htmlView": "HTML",
+    "textView": "Обычный текст",
+    "desktopTooltip": "Предпросмотр в ширине компьютера",
+    "mobileTooltip": "Предпросмотр в ширине телефона",
+    "htmlViewTooltip": "Показать HTML-письмо",
+    "previewWidth": "Ширина предпросмотра",
+    "previewView": "Вид предпросмотра",
+    "textViewTooltip": "Показать текстовую версию",
+    "inboxNow": "Сейчас",
+    "inboxNoSender": "Ваша мастерская",
+    "inboxNoSubject": "(без темы)",
+    "subject": "Тема",
+    "text": "Текст",
+    "buttonLabel": "Текст кнопки",
+    "buttonHref": "Ссылка",
+    "spacerHeight": "Высота",
+    "tagsTitle": "Теги",
+    "tagsHint": "Нажмите на тег, чтобы вставить его в позицию курсора в поле, в котором вы печатали последним.",
+    "theme": {
+      "colors": "Цвета",
+      "primaryColor": "Основной",
+      "textColor": "Текст",
+      "mutedColor": "Приглушённый текст",
+      "backgroundColor": "Фон",
+      "panelColor": "Панели",
+      "invalidColor": "Один из цветов не является корректным hex-значением",
+      "hexLabel": "{label} в формате hex",
+      "typography": "Типографика",
+      "font": "Шрифт",
+      "fontHint": "Только шрифты, которые уже есть в каждом почтовом клиенте. Веб-шрифты большинство из них не загружает.",
+      "fonts": {
+        "system": "Системный",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Формы",
+      "buttonRadius": "Углы кнопки",
+      "showLogo": "Показывать логотип",
+      "showLogoHint": "При выключенном логотипе шапка печатает вместо него название мастерской."
+    },
+    "nameDialog": {
+      "title": "Сохранить этот шаблон",
+      "body": "Дайте шаблону название, по которому вы найдёте его в галерее. Сохранение также делает его шаблоном, с которым отправляется этот вид письма.",
+      "placeholder": "Название шаблона",
+      "nameExists": "Шаблон с таким названием уже есть. Выберите другое название.",
+      "save": "Сохранить шаблон",
+      "update": "Обновить шаблон"
+    },
+    "testDialog": {
+      "title": "Отправить тестовое письмо",
+      "body": "Письмо отправляется с примерными данными, чтобы вы увидели его в настоящем почтовом клиенте.",
+      "placeholder": "Адрес электронной почты",
+      "send": "Отправить",
+      "sending": "Отправка…",
+      "sentTitle": "Тестовое письмо отправлено",
+      "sentBody": "Проверьте почтовый ящик {email}. Письмо может идти около минуты.",
+      "failedTitle": "Не удалось отправить тестовое письмо",
+      "failedBody": "Проверьте почтового провайдера в разделе Провайдеры и попробуйте снова."
+    },
+    "toolbar": {
+      "bold": "Жирный",
+      "italic": "Курсив",
+      "underline": "Подчёркнутый",
+      "strike": "Зачёркнутый",
+      "link": "Ссылка",
+      "linkUrl": "Адрес ссылки",
+      "linkPlaceholder": "https://... или {share_link}",
+      "linkHint": "Веб-адрес или тег, например {share_link}, который заполняется при отправке письма.",
+      "linkApply": "Применить",
+      "linkRemove": "Убрать ссылку",
+      "bulletList": "Маркированный список",
+      "orderedList": "Нумерованный список",
+      "align": {
+        "left": "По левому краю",
+        "center": "По центру",
+        "right": "По правому краю"
+      },
+      "size": {
+        "label": "Размер текста",
+        "default": "Размер по умолчанию",
+        "small": "Мелкий",
+        "normal": "Обычный",
+        "large": "Крупный",
+        "extraLarge": "Очень крупный"
+      },
+      "color": {
+        "label": "Цвет текста",
+        "text": "Цвет текста темы",
+        "muted": "Приглушённый цвет темы",
+        "primary": "Основной цвет темы",
+        "default": "По умолчанию",
+        "hex": "Hex-цвет",
+        "apply": "Применить"
+      },
+      "clear": "Очистить форматирование"
+    },
+    "logo": {
+      "title": "Логотип",
+      "empty": "Логотип не загружен. Шапка печатает название мастерской.",
+      "upload": "Загрузить логотип",
+      "replace": "Заменить логотип",
+      "remove": "Убрать",
+      "uploading": "Загрузка…",
+      "width": "Ширина логотипа",
+      "hint": "Не зависит от логотипа компании. При загрузке обрезается и подготавливается для письма. PNG, JPEG или WebP до 4 МБ.",
+      "position": "Положение",
+      "tooLarge": "Изображение должно быть меньше 4 МБ",
+      "uploadFailed": "Не удалось загрузить логотип"
+    },
+    "image": {
+      "empty": "Изображение не загружено. Блок ничего не печатает, пока его нет.",
+      "upload": "Загрузить изображение",
+      "replace": "Заменить изображение",
+      "remove": "Убрать",
+      "uploading": "Загрузка…",
+      "alt": "Описание",
+      "altHint": "Зачитывается читателям, которые не видят изображение, и показывается, пока оно загружается. PNG, JPEG или WebP до 10 МБ.",
+      "width": "Ширина",
+      "widthHint": "600 px соответствует полной ширине письма. Более узкие изображения размещаются так, как указано в поле Положение.",
+      "link": "Ссылка",
+      "linkHint": "Необязательно. Открывается при нажатии на изображение; здесь подойдёт и тег, например ссылка для просмотра.",
+      "tooLarge": "Изображение должно быть меньше 10 МБ",
+      "uploadFailed": "Не удалось загрузить изображение"
+    },
+    "dragHandle": "Перетащите, чтобы изменить порядок",
+    "dragHandleKeyboard": "Перетащите этот блок или переместите его клавишами со стрелками",
+    "tagDoesNotFit": "Тег не помещается в это поле."
   }
 }

+ 6 - 0
messages/tr/audit.json

@@ -161,6 +161,9 @@
     "settings_deleteDocumentDesign": "Belge tasarımı silindi",
     "settings_applyDocumentDesign": "Varsayılan belge tasarımı ayarlandı",
     "settings_setDocumentDesignRule": "Bir tasarımın ne zaman kullanılacağı değiştirildi",
+    "settings_saveEmailTemplate": "E-posta şablonu kaydedildi",
+    "settings_deleteEmailTemplate": "E-posta şablonu silindi",
+    "settings_applyEmailTemplate": "Varsayılan e-posta şablonu ayarlandı",
     "settings_freezeInvoices": "Eski faturalar kilitlendi",
     "inspection_reminders_sent": "Muayene hatırlatmaları gönderildi",
     "inspection_reminders_send": "Muayene hatırlatmaları gönderildi",
@@ -301,6 +304,9 @@
     "settings_deleteDocumentDesign": "{name} belge tasarımını sildi",
     "settings_applyDocumentDesign": "{name} tasarımını varsayılan belge tasarımı yaptı",
     "settings_setDocumentDesignRule": "{name} belge tasarımının ne zaman kullanılacağı değiştirildi",
+    "settings_saveEmailTemplate": "{name} e-posta şablonunu kaydetti",
+    "settings_deleteEmailTemplate": "{name} e-posta şablonunu sildi",
+    "settings_applyEmailTemplate": "{name} şablonunu {kind} e-postaları için şablon yaptı",
     "settings_freezeInvoices": "Kilitlemeden önceki {count, plural, one {# faturayı} other {# faturayı}} mevcut tasarımla kilitledi",
     "inspection_reminders_sent": "{count} muayene hatırlatması gönderildi",
     "service_videoCall_send": "Görüntülü görüşme bağlantısını {channels} ile gönderdi",

+ 51 - 0
messages/tr/email.json

@@ -0,0 +1,51 @@
+{
+  "presets": {
+    "invoice_sent": {
+      "name": "Fatura",
+      "subject": "{workshop_name} tarafından {document_number} numaralı fatura",
+      "heading": "Fatura {document_number}",
+      "intro": "Merhaba {customer_name},\n\nBizi tercih ettiğiniz için teşekkür ederiz. Faturanız hazır.",
+      "button": "Faturayı görüntüle",
+      "outro": "Sorularınız olursa bu e-postayı yanıtlamanız yeterli."
+    },
+    "quote_sent": {
+      "name": "Teklif",
+      "subject": "{workshop_name} tarafından {document_number} numaralı teklif",
+      "heading": "Teklif {document_number}",
+      "intro": "Merhaba {customer_name},\n\nİstediğiniz teklif ektedir. Devam etmemizi isterseniz bize haber verin.",
+      "button": "Teklifi görüntüle",
+      "outro": "Sorularınız olursa bu e-postayı yanıtlamanız yeterli."
+    },
+    "inspection_sent": {
+      "name": "Muayene raporu",
+      "subject": "{workshop_name} tarafından muayene raporu",
+      "heading": "Araç muayene raporu",
+      "intro": "Merhaba {customer_name},\n\nMuayeneyi tamamladık. Raporunuz hazır.",
+      "button": "Raporu görüntüle",
+      "outro": "Sorularınız olursa bu e-postayı yanıtlamanız yeterli."
+    },
+    "message": {
+      "name": "Müşteri mesajı",
+      "subject": "{workshop_name} tarafından mesaj",
+      "intro": "Merhaba {customer_name},",
+      "outro": "Sorularınız olursa bu e-postayı yanıtlamanız yeterli."
+    },
+    "portal_signin": {
+      "name": "Portal girişi",
+      "subject": "{workshop_name} hesabınıza giriş yapın",
+      "heading": "Müşteri portalınıza giriş yapın",
+      "intro": "Merhaba {customer_name},\n\n{workshop_name} müşteri portalına giriş yapmak için aşağıdaki düğmeyi kullanın.",
+      "button": "Giriş yap",
+      "linkFallback": "Düğme çalışmazsa bu bağlantıyı tarayıcınıza kopyalayın:\n{signin_link}",
+      "outro": "Bu bağlantı 15 dakika içinde geçerliliğini yitirir. Bu isteği siz yapmadıysanız bu e-postayı yok sayabilirsiniz."
+    }
+  },
+  "attachmentNote": "Bu e-postaya bir PDF kopyası eklenmiştir.",
+  "summary": {
+    "reference": "Referans",
+    "vehicle": "Araç",
+    "total": "Toplam",
+    "balance": "Kalan borç",
+    "due": "Son ödeme"
+  }
+}

+ 326 - 0
messages/tr/settings.json

@@ -138,6 +138,10 @@
       "inspectionReminders": {
         "title": "Muayene hatırlatmaları",
         "description": "Randevu bağlantısı, süre ve öncelik"
+      },
+      "emailTemplates": {
+        "title": "E-posta şablonları",
+        "description": "Fatura, teklif ve rapor e-postalarınızın metni"
       }
     },
     "pro": "PRO"
@@ -2019,5 +2023,327 @@
     "timeZoneDetected": "Saat dilimi Yerelleştirme altında otomatik olduğundan sunucu, ayarları kaydeden bir tarayıcıda son görülen {zone} dilimini kullanıyor. Randevu saatlerinin son kaydeden kişiye bağlı olmaması için atölyenin dilimini açıkça seçin.",
     "timeZoneExplicit": "Randevu saatleri ve çalışma saatleri {zone} diliminde okunur.",
     "timeZoneChange": "Yerelleştirme altında değiştir"
+  },
+  "emailTemplates": {
+    "title": "E-posta şablonları",
+    "description": "Atölyenizin gönderdiği e-postaların metni ve görünümü. Her e-posta türünün yerleşik bir şablonu vardır; metni, renkleri ve oluştuğu blokları değiştirmek için kendi şablonunuzu tasarlayın.",
+    "lockedDescription": "Atölyenizin gönderdiği e-postaları tasarlayın: metni değiştirin, blok ekleyin veya kaldırın ve renkleri markanıza uydurun.",
+    "groups": {
+      "documents": "Belgeler",
+      "messages": "Mesajlar",
+      "portal": "Müşteri portalı"
+    },
+    "kinds": {
+      "invoice_sent": {
+        "name": "Fatura gönderildi",
+        "description": "Bir fatura e-postayla müşteriye gittiğinde, PDF ekli veya görüntüleme bağlantısıyla gönderilir."
+      },
+      "quote_sent": {
+        "name": "Teklif gönderildi",
+        "description": "Bir teklif e-postayla müşteriye gittiğinde gönderilir."
+      },
+      "inspection_sent": {
+        "name": "Muayene raporu gönderildi",
+        "description": "Bir muayene raporu e-postayla müşteriye gittiğinde gönderilir."
+      },
+      "message": {
+        "name": "Müşteri mesajı",
+        "description": "Bir müşteriye yazılan mesaj: bir iş emrinden bildirim, durum raporu, görüntülü görüşme daveti veya planlanmış hatırlatma."
+      },
+      "portal_signin": {
+        "name": "Portal girişi",
+        "description": "Müşterinin müşteri portalında istediği giriş bağlantısı."
+      }
+    },
+    "blocks": {
+      "header": "Üst bilgi",
+      "heading": "Başlık",
+      "paragraph": "Paragraf",
+      "callout": "Vurgu kutusu",
+      "button": "Düğme",
+      "document_summary": "Belge özeti",
+      "attachment_note": "Ek notu",
+      "image": "Görsel",
+      "divider": "Ayırıcı",
+      "spacer": "Boşluk",
+      "contact_footer": "İletişim alt bilgisi"
+    },
+    "blockNotes": {
+      "header": "Logonuz veya logo yoksa ya da kapatılmışsa atölye adı. Logoyu buradan yükleyin ve boyutlandırın; renkler ve yazı tipleri Konu ve tema altındadır.",
+      "contact_footer": "Şirket ayarlarından alınan atölye adınız, adresiniz, telefonunuz ve e-postanız. Burada yazdırılanı değiştirmek için bunları orada doldurun.",
+      "divider": "İki blok arasında ince bir çizgi. E-postayı açacak veya kapatacaksa atlanır.",
+      "callout": "Renkli kenarlı vurgulu bir panel. Mesajın kendisi gibi en önemli sözler için uygundur.",
+      "attachment_note": "Yalnızca e-postaya bir PDF eklendiğinde yazdırılır.",
+      "button": "Bağlantı genellikle paylaşım bağlantısı gibi bir etikettir. Bağlantısında değer olmayan düğme e-postadan çıkarılır.",
+      "document_summary": "Belgenin temel rakamlarını içeren bir panel. Gösterecek bir şeyi olmayan satırlar atlanır.",
+      "image": "Görseller yüklenirken e-posta için sıkıştırılır ve yeniden boyutlandırılır. Artık kullanılmayan görseller şablon kaydedildiğinde kaldırılır.",
+      "summaryUnused": "Bu e-posta türünün belgesi yoktur, bu yüzden özet hiçbir şey yazdırmaz."
+    },
+    "tagGroups": {
+      "workshop": "Atölye",
+      "customer": "Müşteri",
+      "vehicle": "Araç",
+      "document": "Belge",
+      "message": "Mesaj",
+      "portal": "Portal"
+    },
+    "tags": {
+      "workshop_name": {
+        "label": "Atölye adı",
+        "description": "Atölyenizin adı."
+      },
+      "workshop_phone": {
+        "label": "Atölye telefonu",
+        "description": "Şirket ayarlarındaki telefon numarası."
+      },
+      "workshop_email": {
+        "label": "Atölye e-postası",
+        "description": "Şirket ayarlarındaki e-posta adresi."
+      },
+      "workshop_address": {
+        "label": "Atölye adresi",
+        "description": "Şirket ayarlarındaki adres, kaç satırsa o kadar satırda."
+      },
+      "current_user": {
+        "label": "Gönderen adı",
+        "description": "E-postayı gönderen kişinin adı."
+      },
+      "customer_name": {
+        "label": "Müşteri adı",
+        "description": "E-postanın gittiği müşterinin adı."
+      },
+      "vehicle": {
+        "label": "Araç",
+        "description": "Aracın yılı, markası ve modeli."
+      },
+      "plate": {
+        "label": "Plaka",
+        "description": "Aracın plaka numarası."
+      },
+      "mileage": {
+        "label": "Kilometre",
+        "description": "Araçta kayıtlı kilometre."
+      },
+      "document_number": {
+        "label": "Belge numarası",
+        "description": "Faturanın veya teklifin numarası."
+      },
+      "document_title": {
+        "label": "Belge başlığı",
+        "description": "İşin veya belgenin başlığı."
+      },
+      "total": {
+        "label": "Toplam",
+        "description": "Belgenin kendi para birimindeki toplamı."
+      },
+      "balance_due": {
+        "label": "Kalan bakiye",
+        "description": "Ödemelerden sonra belgede hâlâ borç olan tutar."
+      },
+      "due_date": {
+        "label": "Vade tarihi",
+        "description": "Ödemenin vadesinin dolduğu tarih."
+      },
+      "share_link": {
+        "label": "Paylaşım bağlantısı",
+        "description": "Müşterinin belgeyi çevrimiçi görüntülemek için açabileceği bağlantı."
+      },
+      "message": {
+        "label": "Mesaj",
+        "description": "Bu e-posta için yazılan mesaj veya belgeye eklenen not."
+      },
+      "signin_link": {
+        "label": "Giriş bağlantısı",
+        "description": "Müşteriyi portala giriş yaptıran bağlantı. Kısa süre sonra geçerliliğini yitirir."
+      },
+      "portal_link": {
+        "label": "Portal bağlantısı",
+        "description": "Müşteri portalına bağlantı."
+      }
+    },
+    "optionalTag": "Her e-postada bu etiket için bir değer yoktur. Boş olduğunda çevresindeki sözcükler düzenlenir.",
+    "summaryRows": {
+      "reference": "Referans",
+      "vehicle": "Araç",
+      "total": "Toplam",
+      "balance": "Kalan bakiye",
+      "due": "Vade tarihi"
+    },
+    "builtIn": "Yerleşik",
+    "inUse": "Kullanımda",
+    "useBuiltIn": "Yerleşik olanı kullan",
+    "setDefault": "Varsayılan yap",
+    "defaultSet": "Artık \"{name}\" ile gönderiliyor",
+    "couldNotSetDefault": "Kullanımdaki şablon değiştirilemedi",
+    "updatedOn": "{date} tarihinde güncellendi",
+    "delete": "Sil",
+    "deleteTitle": "Şablonu sil",
+    "deleteConfirm": "\"{name}\" şablonu silinsin mi? Kullanımdaysa e-postalar yerleşik şablona döner.",
+    "deleteTemplate": "Bu şablonu sil",
+    "deleted": "\"{name}\" silindi",
+    "couldNotDelete": "Şablon silinemedi",
+    "backToSettings": "Ayarlara dön",
+    "unsaved": "(kaydedilmedi)",
+    "save": "Kaydet",
+    "saving": "Kaydediliyor…",
+    "savedState": "Kaydedildi",
+    "saved": "\"{name}\" kaydedildi",
+    "couldNotSave": "Kaydedilemedi",
+    "saveAsNew": "Yeni olarak kaydet",
+    "discard": "Değişiklikleri yoksay",
+    "sendTest": "Test gönder",
+    "cancel": "İptal",
+    "problems": "{count, plural, one {# sorun} other {# sorun}}",
+    "unknownTags": "Bu etiketler bu e-posta türü için yok: {tags}",
+    "missingTags": "Bu e-posta {tags} içermeli",
+    "subjectRequired": "Konu boş olamaz",
+    "invalidTemplate": "Bir metin çok uzun ya da bir alanda geçersiz bir değer var.",
+    "leaveTitle": "Kaydetmeden çıkılsın mı?",
+    "leaveBody": "Bu şablondaki değişiklikleriniz kaydedilmedi. Kaybolacaklar.",
+    "leaveConfirm": "Çık",
+    "subjectAndTheme": "Konu ve tema",
+    "settingsSubtitle": "Tüm e-posta için geçerlidir",
+    "blocksTitle": "Bloklar",
+    "addBlock": "Blok ekle",
+    "removeBlock": "Bloğu kaldır",
+    "removeBlockLast": "Son blok kaldırılamaz",
+    "blockEmpty": "boş",
+    "blockEmptyHint": "Gösteriliyor, ancak içinde henüz yazdırılacak bir şey yok.",
+    "blockShownHint": "E-postada görünüyor. Gizlemek için tıklayın.",
+    "blockHiddenHint": "E-postada gizli. Göstermek için tıklayın.",
+    "railHint": "Bir bloğu taşımak için tutamacından sürükleyin. Çizgi, nereye yerleşeceğini gösterir. Metnini sağda düzenlemek için bir bloğa tıklayın.",
+    "resizeHint": "Panelin boyutunu değiştirmek için sürükleyin. Sıfırlamak için çift tıklayın.",
+    "desktop": "Masaüstü",
+    "mobile": "Mobil",
+    "htmlView": "HTML",
+    "textView": "Düz metin",
+    "desktopTooltip": "Masaüstü genişliğinde önizle",
+    "mobileTooltip": "Telefon genişliğinde önizle",
+    "htmlViewTooltip": "HTML e-postayı göster",
+    "previewWidth": "Önizleme genişliği",
+    "previewView": "Önizleme görünümü",
+    "textViewTooltip": "Düz metin sürümünü göster",
+    "inboxNow": "Şimdi",
+    "inboxNoSender": "Atölyeniz",
+    "inboxNoSubject": "(konu yok)",
+    "subject": "Konu",
+    "text": "Metin",
+    "buttonLabel": "Düğme metni",
+    "buttonHref": "Bağlantı",
+    "spacerHeight": "Yükseklik",
+    "tagsTitle": "Etiketler",
+    "tagsHint": "Bir etiketi, en son yazdığınız alanda imlecin bulunduğu yere eklemek için tıklayın.",
+    "theme": {
+      "colors": "Renkler",
+      "primaryColor": "Ana renk",
+      "textColor": "Metin",
+      "mutedColor": "Soluk metin",
+      "backgroundColor": "Arka plan",
+      "panelColor": "Paneller",
+      "invalidColor": "Bir renk geçerli bir onaltılık değer değil",
+      "hexLabel": "{label} onaltılık olarak",
+      "typography": "Tipografi",
+      "font": "Yazı tipi",
+      "fontHint": "Yalnızca her e-posta istemcisinde zaten bulunan yazı tipleri. Çoğu web yazı tiplerini yüklemez.",
+      "fonts": {
+        "system": "Sistem",
+        "arial": "Arial",
+        "georgia": "Georgia",
+        "verdana": "Verdana",
+        "trebuchet": "Trebuchet MS",
+        "courier": "Courier New"
+      },
+      "shapes": "Şekiller",
+      "buttonRadius": "Düğme köşeleri",
+      "showLogo": "Logoyu göster",
+      "showLogoHint": "Logo kapalıyken üst bilgi bunun yerine atölye adını yazdırır."
+    },
+    "nameDialog": {
+      "title": "Bu şablonu kaydet",
+      "body": "Şablona galeride bulabileceğiniz bir ad verin. Kaydetmek onu aynı zamanda bu e-posta türünün gönderildiği şablon yapar.",
+      "placeholder": "Şablon adı",
+      "nameExists": "Bu adda bir şablon zaten var. Başka bir ad seçin.",
+      "save": "Şablonu kaydet",
+      "update": "Şablonu güncelle"
+    },
+    "testDialog": {
+      "title": "Test e-postası gönder",
+      "body": "E-posta örnek verilerle gönderilir, böylece onu gerçek bir e-posta istemcisinde görebilirsiniz.",
+      "placeholder": "E-posta adresi",
+      "send": "Gönder",
+      "sending": "Gönderiliyor…",
+      "sentTitle": "Test e-postası gönderildi",
+      "sentBody": "{email} gelen kutusunu kontrol edin. Ulaşması bir dakika sürebilir.",
+      "failedTitle": "Test e-postası gönderilemedi",
+      "failedBody": "Sağlayıcılar altındaki e-posta sağlayıcısını kontrol edin ve yeniden deneyin."
+    },
+    "toolbar": {
+      "bold": "Kalın",
+      "italic": "İtalik",
+      "underline": "Altı çizili",
+      "strike": "Üstü çizili",
+      "link": "Bağlantı",
+      "linkUrl": "Bağlantı adresi",
+      "linkPlaceholder": "https://... veya {share_link}",
+      "linkHint": "Bir web adresi veya e-posta gönderilirken doldurulan {share_link} gibi bir etiket.",
+      "linkApply": "Uygula",
+      "linkRemove": "Bağlantıyı kaldır",
+      "bulletList": "Madde işaretli liste",
+      "orderedList": "Numaralı liste",
+      "align": {
+        "left": "Sola hizala",
+        "center": "Ortala",
+        "right": "Sağa hizala"
+      },
+      "size": {
+        "label": "Metin boyutu",
+        "default": "Varsayılan boyut",
+        "small": "Küçük",
+        "normal": "Normal",
+        "large": "Büyük",
+        "extraLarge": "Çok büyük"
+      },
+      "color": {
+        "label": "Metin rengi",
+        "text": "Temanın metin rengi",
+        "muted": "Temanın soluk rengi",
+        "primary": "Temanın ana rengi",
+        "default": "Varsayılan",
+        "hex": "Onaltılık renk",
+        "apply": "Uygula"
+      },
+      "clear": "Biçimlendirmeyi temizle"
+    },
+    "logo": {
+      "title": "Logo",
+      "empty": "Logo yüklenmedi. Üst bilgi atölye adını yazdırır.",
+      "upload": "Logo yükle",
+      "replace": "Logoyu değiştir",
+      "remove": "Kaldır",
+      "uploading": "Yükleniyor…",
+      "width": "Logo genişliği",
+      "hint": "Şirket logosundan bağımsızdır. Yüklenirken kırpılır ve e-posta için hazırlanır. En fazla 4 MB PNG, JPEG veya WebP.",
+      "position": "Konum",
+      "tooLarge": "Görsel 4 MB'den küçük olmalıdır",
+      "uploadFailed": "Logo yüklenemedi"
+    },
+    "image": {
+      "empty": "Görsel yüklenmedi. Bir görsel eklenene kadar blok hiçbir şey yazdırmaz.",
+      "upload": "Görsel yükle",
+      "replace": "Görseli değiştir",
+      "remove": "Kaldır",
+      "uploading": "Yükleniyor…",
+      "alt": "Açıklama",
+      "altHint": "Görseli göremeyen okuyuculara sesli okunur ve görsel yüklenirken gösterilir. En fazla 10 MB PNG, JPEG veya WebP.",
+      "width": "Genişlik",
+      "widthHint": "600 px e-postanın tam genişliğidir. Daha dar görseller Konum alanında belirtilen yere yerleşir.",
+      "link": "Bağlantı",
+      "linkHint": "İsteğe bağlı. Görsele tıklandığında açılır; paylaşım bağlantısı gibi bir etiket de burada kullanılabilir.",
+      "tooLarge": "Görsel 10 MB'den küçük olmalıdır",
+      "uploadFailed": "Görsel yüklenemedi"
+    },
+    "dragHandle": "Yeniden sıralamak için sürükleyin",
+    "dragHandleKeyboard": "Bu bloğu taşımak için sürükleyin ya da ok tuşlarını kullanın",
+    "tagDoesNotFit": "Etiket bu alana sığmıyor."
   }
 }

+ 20 - 0
prisma/migrations/20260908180000_email_templates/migration.sql

@@ -0,0 +1,20 @@
+-- CreateTable
+CREATE TABLE "email_templates" (
+    "id" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "kind" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "subject" TEXT NOT NULL,
+    "blocks" JSONB NOT NULL,
+    "theme" JSONB NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "email_templates_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "email_templates_organizationId_kind_idx" ON "email_templates"("organizationId", "kind");
+
+-- AddForeignKey
+ALTER TABLE "email_templates" ADD CONSTRAINT "email_templates_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 32 - 0
prisma/schema/email-templates.prisma

@@ -0,0 +1,32 @@
+/// A template for one kind of mail the workshop sends, saved under a name.
+///
+/// The same idea as DocumentDesign: a workshop keeps several templates for a
+/// kind, and the one that sends is named by the AppSetting
+/// `email.template.<kind>` as `design:<id>`, the way `invoice.activeDesign`
+/// names the sheet. No setting means the built-in preset. Blocks and a theme
+/// rather than a body of HTML, because the renderer turns them into markup a
+/// mail client will honour and a workshop should never have to think about
+/// tables and inline styles.
+///
+/// Nothing is snapshotted. An issued invoice has to reprint identically for
+/// years, which is why its design is frozen; a sent email is already in
+/// somebody's inbox and is never rendered again.
+model EmailTemplate {
+  id             String       @id @default(cuid())
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+  /// One of EMAIL_KINDS in src/features/email/Lib/emailKinds.ts.
+  kind           String
+  name           String
+  /// The subject line, tags and all.
+  subject        String
+  /// EmailBlock[] as edited.
+  blocks         Json
+  /// EmailTheme: colours, typeface, button shape, logo on or off.
+  theme          Json
+  createdAt      DateTime     @default(now())
+  updatedAt      DateTime     @updatedAt
+
+  @@index([organizationId, kind])
+  @@map("email_templates")
+}

+ 1 - 0
prisma/schema/organization.prisma

@@ -49,6 +49,7 @@ model Organization {
   laborPresets                LaborPreset[]
   customFieldDefinitions      CustomFieldDefinition[]
   settings                    AppSetting[]
+  emailTemplates              EmailTemplate[]
   subscription                Subscription?
   invitations                 TeamInvitation[]
   inspectionTemplates         InspectionTemplate[]

+ 7 - 84
src/__tests__/features/email/document-email.test.ts

@@ -1,101 +1,24 @@
 import { describe, expect, it } from 'vitest'
-import {
-  attachPdfDefault,
-  buildDocumentEmailHtml,
-  resolveAttachPdf,
-} from '@/features/email/Lib/documentEmail'
+import { attachPdfDefault, resolveAttachPdf } from '@/features/email/Lib/documentEmail'
 
-/**
- * A workshop that wants to know whether its invoice was read has to send the
- * link, because a PDF in the mail is read without ever touching the link and
- * the viewed counter stays at zero.
- */
 describe('attachPdfDefault', () => {
-  it('attaches when the workshop has never chosen', () => {
+  it('attaches when nothing has been decided, as it always did', () => {
     expect(attachPdfDefault({})).toBe(true)
   })
 
-  it('attaches when the setting says so', () => {
-    expect(attachPdfDefault({ 'email.attachPdf': 'true' })).toBe(true)
-  })
-
   it('sends the link when the setting says so', () => {
     expect(attachPdfDefault({ 'email.attachPdf': 'false' })).toBe(false)
   })
 })
 
 describe('resolveAttachPdf', () => {
-  it('follows the setting when the send does not say', () => {
-    expect(resolveAttachPdf({ 'email.attachPdf': 'false' }, undefined)).toBe(false)
-    expect(resolveAttachPdf({}, undefined)).toBe(true)
-  })
-
-  it('lets one send overrule the setting either way', () => {
+  it('lets the sender override the workshop default either way', () => {
     expect(resolveAttachPdf({ 'email.attachPdf': 'false' }, true)).toBe(true)
-    expect(resolveAttachPdf({ 'email.attachPdf': 'true' }, false)).toBe(false)
-  })
-})
-
-describe('buildDocumentEmailHtml', () => {
-  const base = {
-    heading: 'Invoice INV-1042',
-    subject: 'invoice',
-    linkLabel: 'View Invoice Online',
-    fromName: 'Bergen Bil',
-  }
-
-  it('says the document is attached when it is', () => {
-    const html = buildDocumentEmailHtml({ ...base, attached: true, link: null })
-    expect(html).toContain('Please find the invoice attached.')
+    expect(resolveAttachPdf({}, false)).toBe(false)
   })
 
-  it('points at the link when the PDF is not attached', () => {
-    const html = buildDocumentEmailHtml({
-      ...base,
-      attached: false,
-      link: 'https://workshop.example.com/share/invoice/org/token',
-    })
-    expect(html).not.toContain('attached')
-    expect(html).toContain('href="https://workshop.example.com/share/invoice/org/token"')
-    expect(html).toContain('View Invoice Online')
-  })
-
-  it('carries the link alongside the attachment, as it always did', () => {
-    const html = buildDocumentEmailHtml({
-      ...base,
-      attached: true,
-      link: 'https://workshop.example.com/share/invoice/org/token',
-    })
-    expect(html).toContain('attached')
-    expect(html).toContain('View Invoice Online')
-  })
-
-  it('still reads sensibly with neither an attachment nor a link', () => {
-    const html = buildDocumentEmailHtml({ ...base, attached: false, link: null })
-    expect(html).toContain('The invoice is ready.')
-    expect(html).not.toContain('<a href')
-  })
-
-  it('includes the sender note and the phone when there is one', () => {
-    const html = buildDocumentEmailHtml({
-      ...base,
-      attached: true,
-      link: null,
-      message: 'Ready for pickup on Friday',
-      phone: '+47 55 00 00 00',
-    })
-    expect(html).toContain('Ready for pickup on Friday')
-    expect(html).toContain('+47 55 00 00 00')
-  })
-
-  it('escapes what the sender typed rather than pasting it into the mail', () => {
-    const html = buildDocumentEmailHtml({
-      ...base,
-      attached: true,
-      link: null,
-      message: '<script>alert(1)</script>',
-    })
-    expect(html).not.toContain('<script>')
-    expect(html).toContain('&lt;script&gt;')
+  it('falls back to the workshop default when the sender did not choose', () => {
+    expect(resolveAttachPdf({ 'email.attachPdf': 'false' }, undefined)).toBe(false)
+    expect(resolveAttachPdf({}, undefined)).toBe(true)
   })
 })

+ 656 - 0
src/__tests__/features/email/email-template-engine.test.ts

@@ -0,0 +1,656 @@
+import { describe, expect, it } from 'vitest'
+import en from '../../../../messages/en/email.json'
+import { buildEmailSpec } from '@/features/email/Lib/buildEmailSpec'
+import {
+  sampleContextFor,
+  sampleValuesFor,
+  summaryRowsFor,
+  tagValuesFor,
+} from '@/features/email/Lib/emailContext'
+import { EMAIL_KINDS, kindSpec } from '@/features/email/Lib/emailKinds'
+import { type EmailMessages, presetTemplate } from '@/features/email/Lib/emailPresets'
+import { activeTemplateId, templateText } from '@/features/email/Lib/emailTemplate'
+import { fillTags, missingTags, tagsUsed, unknownTags } from '@/features/email/Lib/tags'
+import { renderEmailHtml } from '@/features/email/Render/renderEmailHtml'
+import { renderEmailText } from '@/features/email/Render/renderEmailText'
+import { readStoredTemplate } from '@/features/email/Schema/emailTemplateSchema'
+import {
+  joinSplitTags,
+  plainToRich,
+  richDocSchema,
+  richToPlain,
+} from '@/features/email/Lib/richText'
+import { emailLogoPublicPath, templateAssets } from '@/features/email/Lib/emailTemplate'
+import { normalizeHref } from '@/features/email/Lib/links'
+
+const messages = en as EmailMessages
+const preset = (kind: (typeof EMAIL_KINDS)[number]) => presetTemplate(kind, messages)
+
+describe('tags', () => {
+  it('fills what it has', () => {
+    expect(fillTags('Hi {customer_name}', { customer_name: 'Alex' })).toBe('Hi Alex')
+  })
+
+  it('renders a known tag with no value as nothing, and tidies the punctuation', () => {
+    // "Hi ," is what a blank tag leaves behind; nobody should read that.
+    expect(fillTags('Hi {customer_name},', {})).toBe('Hi,')
+    expect(fillTags('for your {vehicle} today', {})).toBe('for your today')
+  })
+
+  it('leaves a tag it does not know as typed, so a typo is visible', () => {
+    expect(fillTags('Hi {custmer_name}', { customer_name: 'Alex' })).toBe('Hi {custmer_name}')
+  })
+
+  it('accepts the older SMS spellings', () => {
+    expect(fillTags('{company_name}', { workshop_name: 'Bergen Auto' })).toBe('Bergen Auto')
+  })
+
+  it('lists the tags a template uses, once each', () => {
+    expect(tagsUsed('{a} then {b} then {a}')).toEqual(['a', 'b'])
+  })
+
+  it('names the tags a kind does not offer', () => {
+    const tags = kindSpec('invoice_sent').tags
+    expect(unknownTags('{customer_name} {tyre_pressure}', tags)).toEqual(['tyre_pressure'])
+    expect(unknownTags('{signin_link}', tags)).toEqual(['signin_link'])
+    expect(unknownTags('{company_name}', tags)).toEqual([])
+  })
+
+  it('names a required tag that is missing', () => {
+    expect(missingTags('Sign in soon', ['signin_link'])).toEqual(['signin_link'])
+    expect(missingTags('Sign in: {signin_link}', ['signin_link'])).toEqual([])
+  })
+})
+
+describe('presets', () => {
+  it.each(EMAIL_KINDS)('%s uses only the tags its kind offers and carries what it must', (kind) => {
+    const template = preset(kind)
+    const text = templateText(template)
+    expect(unknownTags(text, kindSpec(kind).tags)).toEqual([])
+    expect(missingTags(text, kindSpec(kind).required)).toEqual([])
+  })
+
+  it('reads a stored row leniently, dropping what it cannot understand', () => {
+    const stored = readStoredTemplate({
+      kind: 'invoice_sent',
+      name: 'Mine',
+      subject: 'Hello',
+      blocks: [
+        { id: 'p', type: 'paragraph', text: 'Hi' },
+        { id: 'x', type: 'hologram', text: 'from the future' },
+      ],
+      theme: { primaryColor: '#123456', textColor: 'red' },
+    })
+    expect(stored?.blocks.map((b) => b.type)).toEqual(['paragraph'])
+    expect(stored?.theme.primaryColor).toBe('#123456')
+    // A colour that is not a colour costs that colour, not the whole theme.
+    expect(stored?.theme.textColor).toBe('#111827')
+  })
+
+  it('gives up on a row it cannot use at all, so the preset answers', () => {
+    expect(
+      readStoredTemplate({
+        kind: 'invoice_sent',
+        name: 'x',
+        subject: 's',
+        blocks: 'nope',
+        theme: {},
+      })
+    ).toBeNull()
+    expect(
+      readStoredTemplate({ kind: 'hologram', name: 'x', subject: 's', blocks: [], theme: {} })
+    ).toBeNull()
+    expect(
+      readStoredTemplate({ kind: 'invoice_sent', name: 'x', subject: 's', blocks: [], theme: null })
+    ).toBeNull()
+  })
+
+  it('knows what the active-template setting points at', () => {
+    expect(activeTemplateId('design:abc')).toBe('abc')
+    expect(activeTemplateId('preset')).toBeNull()
+    expect(activeTemplateId(undefined)).toBeNull()
+  })
+})
+
+const workshop = {
+  name: 'Bergen Bil',
+  phone: '+47 55 00 00 00',
+  email: 'post@bergenbil.no',
+  address: 'Havnegata 3\n5003 Bergen',
+}
+
+const invoice = {
+  customerName: 'Alex Carter',
+  vehicle: { year: 2019, make: 'Volvo', model: 'V70' },
+  document: { number: 'INV-1042', total: 4200, paid: 1000, currencyCode: 'NOK' },
+  shareLink: 'https://shop.example.com/share/invoice/org/tok',
+}
+
+const invoiceInput = {
+  values: tagValuesFor('invoice_sent', invoice, { workshop }),
+  summary: summaryRowsFor('invoice_sent', invoice, messages.summary),
+}
+
+describe('buildEmailSpec', () => {
+  it('fills the subject and the blocks from one set of values', () => {
+    const spec = buildEmailSpec(preset('invoice_sent'), invoiceInput)
+    expect(spec.subject).toBe('Invoice INV-1042 from Bergen Bil')
+    const paragraph = spec.blocks.find((b) => b.type === 'paragraph')
+    expect(paragraph && 'text' in paragraph && paragraph.text).toContain('Alex Carter')
+  })
+
+  it('never sends a literal tag when the send has no value for it', () => {
+    const spec = buildEmailSpec(preset('invoice_sent'), {
+      values: tagValuesFor('invoice_sent', { document: { number: 'INV-1' } }, { workshop }),
+    })
+    const html = renderEmailHtml(spec)
+    expect(html).not.toMatch(/\{\w+\}/)
+    expect(spec.subject).toBe('Invoice INV-1 from Bergen Bil')
+  })
+
+  it('drops a button whose link has no value', () => {
+    const spec = buildEmailSpec(preset('invoice_sent'), {
+      values: { ...invoiceInput.values, share_link: undefined },
+    })
+    expect(spec.blocks.some((b) => b.type === 'button')).toBe(false)
+  })
+
+  it('drops the summary panel when there is nothing to summarise', () => {
+    const spec = buildEmailSpec(preset('invoice_sent'), { values: invoiceInput.values })
+    expect(spec.blocks.some((b) => b.type === 'document_summary')).toBe(false)
+  })
+
+  it('shows only the summary rows the block asks for', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [{ id: 's', type: 'document_summary', rows: ['total'] }]
+    const spec = buildEmailSpec(template, invoiceInput)
+    const summary = spec.blocks[0]
+    expect(summary.type === 'document_summary' && summary.rows.map((r) => r.key)).toEqual(['total'])
+  })
+
+  it('mentions the attachment only when there is one', () => {
+    const attached = buildEmailSpec(preset('invoice_sent'), { ...invoiceInput, attached: true })
+    const linked = buildEmailSpec(preset('invoice_sent'), invoiceInput)
+    expect(attached.blocks.some((b) => b.type === 'attachment_note')).toBe(true)
+    expect(linked.blocks.some((b) => b.type === 'attachment_note')).toBe(false)
+  })
+
+  it('shows the logo only when the theme wants it', () => {
+    const template = preset('invoice_sent')
+    const withLogo = buildEmailSpec(template, { ...invoiceInput, logoUrl: 'https://x/logo.png' })
+    const header = withLogo.blocks[0]
+    expect(header.type === 'header' && header.logoUrl).toBe('https://x/logo.png')
+    template.theme = { ...template.theme, showLogo: false }
+    const without = buildEmailSpec(template, { ...invoiceInput, logoUrl: 'https://x/logo.png' })
+    expect(without.blocks[0].type === 'header' && without.blocks[0].logoUrl).toBeUndefined()
+  })
+
+  it('does not leave a rule or a gap where a dropped block was', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [
+      { id: 'd1', type: 'divider' },
+      { id: 'p', type: 'paragraph', text: 'Hello' },
+      { id: 'd2', type: 'divider' },
+      { id: 'd3', type: 'divider' },
+    ]
+    const spec = buildEmailSpec(template, invoiceInput)
+    expect(spec.blocks.map((b) => b.type)).toEqual(['paragraph'])
+  })
+})
+
+describe('renderEmailHtml', () => {
+  const spec = buildEmailSpec(preset('invoice_sent'), invoiceInput)
+  const html = renderEmailHtml(spec)
+
+  it('builds a table layout rather than anything a mail client will drop', () => {
+    expect(html).toContain('<table')
+    expect(html).not.toContain('display:flex')
+    expect(html).not.toContain('display:grid')
+    expect(html).not.toContain('<style')
+  })
+
+  it('puts the link behind the button', () => {
+    expect(html).toContain('href="https://shop.example.com/share/invoice/org/tok"')
+  })
+
+  it('escapes what a person typed instead of pasting it into the markup', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [{ id: 'p', type: 'paragraph', text: '<script>alert(1)</script>' }]
+    const out = renderEmailHtml(buildEmailSpec(template, invoiceInput))
+    expect(out).not.toContain('<script>')
+    expect(out).toContain('&lt;script&gt;')
+  })
+
+  it('refuses a link that is not a real address', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [
+      // eslint-disable-next-line no-script-url
+      { id: 'b', type: 'button', label: 'Click', href: 'javascript:alert(1)' },
+    ]
+    const out = renderEmailHtml(buildEmailSpec(template, invoiceInput))
+    expect(out).not.toContain('javascript:')
+  })
+
+  it('refuses a theme value that could break out of an attribute', () => {
+    const template = preset('invoice_sent')
+    template.theme = { ...template.theme, primaryColor: '"><script>alert(1)</script>' }
+    const out = renderEmailHtml(buildEmailSpec(template, invoiceInput))
+    expect(out).not.toContain('<script>')
+    expect(out).toContain('#d97706')
+  })
+
+  it('marks every row with its block only when asked, for the designer', () => {
+    const sent = renderEmailHtml(spec)
+    const marked = renderEmailHtml(spec, { marked: true })
+    expect(sent).not.toContain('data-block')
+    expect(marked).toContain('<style')
+    for (const block of spec.blocks) expect(marked).toContain(`data-block="${block.id}"`)
+  })
+
+  it('keeps a typed line break as a break', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [{ id: 'p', type: 'paragraph', text: 'One\nTwo' }]
+    expect(renderEmailHtml(buildEmailSpec(template, invoiceInput))).toContain('One<br />Two')
+  })
+})
+
+describe('renderEmailText', () => {
+  const spec = buildEmailSpec(preset('invoice_sent'), invoiceInput)
+  const text = renderEmailText(spec)
+
+  it('says the same things as the HTML, without the markup', () => {
+    expect(text).toContain('Alex Carter')
+    expect(text).toContain('INV-1042')
+    expect(text).not.toContain('<')
+  })
+
+  it('spells out a button, since there is nothing to click', () => {
+    expect(text).toContain('View invoice: https://shop.example.com/share/invoice/org/tok')
+  })
+})
+
+describe('emailContext', () => {
+  it('works out the balance rather than asking for it', () => {
+    const values = tagValuesFor('invoice_sent', invoice, { workshop })
+    expect(values.total).toContain('4')
+    expect(values.balance_due).toContain('3')
+  })
+
+  it('leaves out what the workshop has not filled in', () => {
+    const values = tagValuesFor('invoice_sent', invoice, {
+      workshop: { name: 'Bergen Bil', phone: '', email: null },
+    })
+    expect('workshop_phone' in values).toBe(false)
+    expect('workshop_email' in values).toBe(false)
+  })
+
+  it('names the vehicle the way a person would', () => {
+    expect(tagValuesFor('invoice_sent', invoice, { workshop }).vehicle).toBe('2019 Volvo V70')
+  })
+
+  it('fills only the tags the kind offers', () => {
+    const values = tagValuesFor('portal_signin', { signinLink: 'https://x/s' }, { workshop })
+    expect(values.signin_link).toBe('https://x/s')
+    expect('share_link' in values).toBe(false)
+  })
+
+  it('summarises without repeating the total as the balance', () => {
+    const paid = summaryRowsFor(
+      'invoice_sent',
+      { ...invoice, document: { ...invoice.document, paid: 0 } },
+      messages.summary
+    )
+    expect(paid.map((r) => r.key)).not.toContain('balance')
+    expect(summaryRowsFor('invoice_sent', invoice, messages.summary).map((r) => r.key)).toContain(
+      'balance'
+    )
+  })
+
+  it('previews with the real letterhead and an invented job', () => {
+    const values = sampleValuesFor('invoice_sent', { name: 'Bergen Bil', phone: '' })
+    expect(values.workshop_name).toBe('Bergen Bil')
+    expect(values.workshop_phone).toBeUndefined()
+    expect(values.customer_name).toBeTruthy()
+    expect(sampleValuesFor('invoice_sent', {}).workshop_name).toBeTruthy()
+  })
+
+  it.each(EMAIL_KINDS)('%s renders its preset from sample data without a literal tag', (kind) => {
+    const context = sampleContextFor(kind)
+    const spec = buildEmailSpec(preset(kind), {
+      values: tagValuesFor(kind, context, { workshop }),
+      summary: summaryRowsFor(kind, context, messages.summary),
+      attached: true,
+    })
+    expect(renderEmailHtml(spec)).not.toMatch(/\{\w+\}/)
+    expect(renderEmailText(spec)).not.toMatch(/\{\w+\}/)
+  })
+})
+
+describe('rich text', () => {
+  const doc = {
+    type: 'doc' as const,
+    content: [
+      {
+        type: 'paragraph' as const,
+        content: [
+          { type: 'text' as const, text: 'Hi ' },
+          { type: 'text' as const, text: '{customer_name}', marks: [{ type: 'bold' as const }] },
+          { type: 'hardBreak' as const },
+          {
+            type: 'text' as const,
+            text: 'book here',
+            marks: [{ type: 'link' as const, attrs: { href: 'https://x.example/book' } }],
+          },
+        ],
+      },
+      {
+        type: 'bulletList' as const,
+        content: [
+          {
+            type: 'listItem' as const,
+            content: [
+              { type: 'paragraph' as const, content: [{ type: 'text' as const, text: 'Oil' }] },
+            ],
+          },
+          {
+            type: 'listItem' as const,
+            content: [
+              { type: 'paragraph' as const, content: [{ type: 'text' as const, text: 'Brakes' }] },
+            ],
+          },
+        ],
+      },
+    ],
+  }
+
+  it('is accepted by the schema and rejected when it carries what mail cannot show', () => {
+    expect(richDocSchema.safeParse(doc).success).toBe(true)
+    const bad = { type: 'doc', content: [{ type: 'iframe', src: 'x' }] }
+    expect(richDocSchema.safeParse(bad).success).toBe(false)
+  })
+
+  it('fills tags inside formatted text and keeps the formatting', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [{ id: 'p', type: 'paragraph', content: doc }]
+    const html = renderEmailHtml(buildEmailSpec(template, invoiceInput))
+    expect(html).toContain('<span style="font-weight:700;">Alex Carter</span>')
+    expect(html).toContain('href="https://x.example/book"')
+    expect(html).toContain('<li')
+    expect(html).not.toContain('{customer_name}')
+  })
+
+  it('reads the same words as plain text, lists included', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [{ id: 'p', type: 'paragraph', content: doc }]
+    const text = renderEmailText(buildEmailSpec(template, invoiceInput))
+    expect(text).toContain('Hi Alex Carter\nbook here')
+    expect(text).toContain('- Oil\n- Brakes')
+  })
+
+  it('fills a tag used as a link address, and drops the link when it has no value', () => {
+    const linked = (href: string) => ({
+      type: 'doc' as const,
+      content: [
+        {
+          type: 'paragraph' as const,
+          content: [
+            {
+              type: 'text' as const,
+              text: 'Open',
+              marks: [{ type: 'link' as const, attrs: { href } }],
+            },
+          ],
+        },
+      ],
+    })
+    const template = preset('invoice_sent')
+    template.blocks = [{ id: 'p', type: 'paragraph', content: linked('{share_link}') }]
+    expect(renderEmailHtml(buildEmailSpec(template, invoiceInput))).toContain(
+      'href="https://shop.example.com/share/invoice/org/tok"'
+    )
+    const without = buildEmailSpec(template, {
+      values: { ...invoiceInput.values, share_link: undefined },
+    })
+    const html = renderEmailHtml(without)
+    expect(html).toContain('Open')
+    expect(html).not.toContain('<a ')
+    expect(unknownTags(templateText(template), kindSpec('portal_signin').tags)).toContain(
+      'share_link'
+    )
+  })
+
+  it('drops a formatted block that fills to nothing', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [
+      {
+        id: 'p',
+        type: 'paragraph',
+        content: {
+          type: 'doc',
+          content: [{ type: 'paragraph', content: [{ type: 'text', text: '{mileage}' }] }],
+        },
+      },
+    ]
+    expect(buildEmailSpec(template, invoiceInput).blocks).toEqual([])
+  })
+
+  it('refuses a link that is not an address', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [
+      {
+        id: 'p',
+        type: 'paragraph',
+        content: {
+          type: 'doc',
+          content: [
+            {
+              type: 'paragraph',
+              content: [
+                // eslint-disable-next-line no-script-url
+                {
+                  type: 'text',
+                  text: 'x',
+                  marks: [{ type: 'link', attrs: { href: 'javascript:1' } }],
+                },
+              ],
+            },
+          ],
+        },
+      },
+    ]
+    expect(renderEmailHtml(buildEmailSpec(template, invoiceInput))).not.toContain('javascript:')
+  })
+
+  it('turns plain text into a document and back without loss', () => {
+    const plain = 'Hi {customer_name},\n\nThank you.\nSee you soon.'
+    expect(richToPlain(plainToRich(plain))).toBe(plain)
+  })
+})
+
+describe('email logo', () => {
+  it('serves only its own uploads through the public route', () => {
+    expect(emailLogoPublicPath('/api/protected/files/org1/email-logos/abc.png')).toBe(
+      '/api/public/email-logo/org1/abc.png'
+    )
+    expect(emailLogoPublicPath('/api/protected/files/org1/logos/abc.png')).toBeUndefined()
+    expect(emailLogoPublicPath('https://evil.example/x.png')).toBeUndefined()
+    expect(emailLogoPublicPath('')).toBeUndefined()
+  })
+
+  it('places the header and the button where the block says', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [
+      { id: 'h', type: 'header', align: 'center' },
+      { id: 'b', type: 'button', label: 'Go', href: '{share_link}', align: 'right' },
+    ]
+    const html = renderEmailHtml(
+      buildEmailSpec(template, { ...invoiceInput, logoUrl: 'https://x/l.png' })
+    )
+    expect(html).toContain('<td align="center" style="padding:0 0 20px 0;text-align:center;">')
+    expect(html).toContain('<td align="right" style="padding:6px 0 20px 0;text-align:right;">')
+  })
+
+  it('draws the logo at the width the theme asks for', () => {
+    const template = preset('invoice_sent')
+    template.theme = { ...template.theme, logoWidth: 200 }
+    const html = renderEmailHtml(
+      buildEmailSpec(template, { ...invoiceInput, logoUrl: 'https://x/l.png' })
+    )
+    expect(html).toContain('width="200"')
+  })
+})
+
+describe('link addresses', () => {
+  it('guesses https for an address typed without a scheme', () => {
+    expect(normalizeHref('example.com/book')).toBe('https://example.com/book')
+    expect(normalizeHref('www.example.com')).toBe('https://www.example.com')
+    expect(normalizeHref('post@example.com')).toBe('mailto:post@example.com')
+    expect(normalizeHref('https://example.com')).toBe('https://example.com')
+    expect(normalizeHref('{share_link}')).toBe('{share_link}')
+    expect(normalizeHref('not a link')).toBe('not a link')
+  })
+
+  it('keeps a scheme-less link in the mail', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [
+      { id: 'b', type: 'button', label: 'Book', href: 'example.com/book' },
+      {
+        id: 'p',
+        type: 'paragraph',
+        content: {
+          type: 'doc',
+          content: [
+            {
+              type: 'paragraph',
+              content: [
+                {
+                  type: 'text',
+                  text: 'Book',
+                  marks: [{ type: 'link', attrs: { href: 'example.com/book' } }],
+                },
+              ],
+            },
+          ],
+        },
+      },
+    ]
+    const html = renderEmailHtml(buildEmailSpec(template, invoiceInput))
+    expect(html.match(/href="https:\/\/example.com\/book"/g)).toHaveLength(2)
+  })
+})
+
+describe('image block', () => {
+  const stored = '/api/protected/files/org1/email-images/pic.jpg'
+
+  it('draws the picture at its width and position, linked when asked', () => {
+    const template = preset('invoice_sent')
+    template.blocks = [
+      {
+        id: 'i',
+        type: 'image',
+        src: stored,
+        alt: 'Our workshop',
+        width: 300,
+        align: 'center',
+        href: '{share_link}',
+      },
+    ]
+    const html = renderEmailHtml(buildEmailSpec(template, invoiceInput))
+    expect(html).toContain('src="/api/public/email-image/org1/pic.jpg"')
+    expect(html).toContain('alt="Our workshop"')
+    expect(html).toContain('width="300"')
+    expect(html).toContain('<td align="center"')
+    expect(html).toContain('href="https://shop.example.com/share/invoice/org/tok"')
+    expect(renderEmailText(buildEmailSpec(template, invoiceInput))).toContain(
+      'Our workshop: https://'
+    )
+  })
+
+  it("takes the sender's addresses and drops a picture whose file is gone", () => {
+    const template = preset('invoice_sent')
+    template.blocks = [{ id: 'i', type: 'image', src: stored }]
+    const sent = buildEmailSpec(template, {
+      ...invoiceInput,
+      assets: { [stored]: 'https://app/x.jpg' },
+    })
+    expect(sent.blocks[0]).toMatchObject({ type: 'image', src: 'https://app/x.jpg' })
+    const gone = buildEmailSpec(template, { ...invoiceInput, assets: { [stored]: undefined } })
+    expect(gone.blocks).toEqual([])
+  })
+
+  it('lists every upload a template refers to, for the sweep', () => {
+    const template = preset('invoice_sent')
+    template.theme = { ...template.theme, logoUrl: '/api/protected/files/org1/email-logos/l.png' }
+    template.blocks = [
+      { id: 'i', type: 'image', src: stored },
+      { id: 'j', type: 'image', src: stored },
+      { id: 'k', type: 'image', src: 'https://elsewhere.example/x.png' },
+    ]
+    expect(templateAssets(template)).toEqual([
+      '/api/protected/files/org1/email-logos/l.png',
+      stored,
+    ])
+  })
+})
+
+describe('tags split across formatting', () => {
+  const split = {
+    type: 'doc' as const,
+    content: [
+      {
+        type: 'paragraph' as const,
+        content: [
+          { type: 'text' as const, text: 'Hi {custo', marks: [{ type: 'bold' as const }] },
+          { type: 'text' as const, text: 'mer_name}, welcome' },
+        ],
+      },
+    ],
+  }
+
+  it('makes the tag whole before filling', () => {
+    const joined = joinSplitTags(split)
+    expect(richToPlain(joined)).toBe('Hi {customer_name}, welcome')
+    const template = preset('invoice_sent')
+    template.blocks = [{ id: 'p', type: 'paragraph', content: split }]
+    const html = renderEmailHtml(buildEmailSpec(template, invoiceInput))
+    expect(html).toContain('Alex Carter')
+    expect(html).not.toContain('{custo')
+  })
+
+  it('fills a required link that was typed across two formattings', () => {
+    const template = preset('portal_signin')
+    template.blocks = [
+      {
+        id: 'p',
+        type: 'paragraph',
+        content: {
+          type: 'doc',
+          content: [
+            {
+              type: 'paragraph',
+              content: [
+                { type: 'text', text: '{signin', marks: [{ type: 'italic' }] },
+                { type: 'text', text: '_link}' },
+              ],
+            },
+          ],
+        },
+      },
+    ]
+    const values = tagValuesFor('portal_signin', { signinLink: 'https://x/s' }, { workshop })
+    const text = renderEmailText(buildEmailSpec(template, { values }))
+    expect(text).toContain('https://x/s')
+    expect(text).not.toContain('{signin')
+  })
+})
+
+describe('button address', () => {
+  it('refuses an address that is not a link, in both halves of the mail', () => {
+    const template = preset('invoice_sent')
+    // eslint-disable-next-line no-script-url
+    template.blocks = [{ id: 'b', type: 'button', label: 'Go', href: 'javascript:alert(1)' }]
+    const spec = buildEmailSpec(template, invoiceInput)
+    expect(spec.blocks).toEqual([])
+    expect(renderEmailText(spec)).not.toContain('javascript')
+  })
+})

+ 126 - 0
src/__tests__/features/email/send-templated-mail.test.ts

@@ -0,0 +1,126 @@
+/**
+ * The one door a customer mail leaves through.
+ *
+ * The free-text mails used to be built by hand at each send site, and the
+ * notification one pasted the body straight into HTML without escaping it.
+ * These tests pin what the helper has to do for every caller: fill the
+ * workshop's template, escape what the sender typed, produce a text half,
+ * and let a caller who knows better name the subject.
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const appSetting = {
+  findMany: vi.fn().mockResolvedValue([]),
+  findUnique: vi.fn().mockResolvedValue(null),
+}
+const organization = {
+  findUnique: vi.fn().mockResolvedValue({ name: 'Bergen Auto' }),
+}
+const emailTemplate = { findFirst: vi.fn().mockResolvedValue(null) }
+vi.mock('@/lib/db', () => ({ db: { appSetting, organization, emailTemplate } }))
+
+const sendOrgMail = vi.fn().mockResolvedValue(undefined)
+const getOrgFromAddress = vi.fn().mockResolvedValue('Bergen Auto <post@bergen.example>')
+vi.mock('@/lib/email', () => ({
+  sendOrgMail: (...args: unknown[]) => sendOrgMail(...args),
+  getOrgFromAddress: (...args: unknown[]) => getOrgFromAddress(...args),
+}))
+
+const resolveCustomerLocale = vi.fn().mockResolvedValue('en')
+vi.mock('@/i18n/locale-from-request', () => ({
+  resolveCustomerLocale: (...args: unknown[]) => resolveCustomerLocale(...args),
+}))
+
+const { sendTemplatedMail } = await import('@/features/email/Lib/sendTemplatedMail')
+
+type SentMail = {
+  from: string
+  to: string
+  subject: string
+  html: string
+  text?: string
+}
+
+function lastMail(): SentMail {
+  const call = sendOrgMail.mock.calls.at(-1)
+  if (!call) throw new Error('nothing was sent')
+  return call[1] as SentMail
+}
+
+beforeEach(() => {
+  sendOrgMail.mockClear()
+  getOrgFromAddress.mockClear()
+  resolveCustomerLocale.mockClear()
+})
+
+describe('sendTemplatedMail', () => {
+  it('escapes what the sender typed and carries it in both halves', async () => {
+    await sendTemplatedMail('org', {
+      kind: 'message',
+      to: 'ola@example.com',
+      context: { message: 'Your car is ready. <b>Not bold</b>', customerName: 'Ola' },
+    })
+
+    const mail = lastMail()
+    expect(mail.to).toBe('ola@example.com')
+    expect(mail.from).toBe('Bergen Auto <post@bergen.example>')
+    // The old notification mail pasted the body into HTML as it was, so a
+    // customer note could carry markup. It must read as typed.
+    expect(mail.html).not.toContain('<b>Not bold</b>')
+    expect(mail.html).toContain('&lt;b&gt;Not bold&lt;/b&gt;')
+    expect(mail.html).toContain('Your car is ready.')
+    expect(mail.text).toContain('Your car is ready. <b>Not bold</b>')
+    expect(mail.html).toContain('Ola')
+  })
+
+  it('uses the template subject when the caller has none', async () => {
+    const result = await sendTemplatedMail('org', {
+      kind: 'message',
+      to: 'ola@example.com',
+      context: { message: 'Hello' },
+    })
+    // The preset says "Message from {workshop_name}", and the workshop is
+    // named from the organization row.
+    expect(result.subject).toBe('Message from Bergen Auto')
+    expect(lastMail().subject).toBe('Message from Bergen Auto')
+  })
+
+  it('lets a caller who knows better name the subject', async () => {
+    const result = await sendTemplatedMail('org', {
+      kind: 'message',
+      to: 'ola@example.com',
+      subject: 'Status update: 2020 Ford Focus',
+      context: { message: 'Hello' },
+    })
+    expect(result.subject).toBe('Status update: 2020 Ford Focus')
+    expect(lastMail().subject).toBe('Status update: 2020 Ford Focus')
+  })
+
+  it('treats a blank subject override as no override', async () => {
+    const result = await sendTemplatedMail('org', {
+      kind: 'message',
+      to: 'ola@example.com',
+      subject: '   ',
+      context: { message: 'Hello' },
+    })
+    expect(result.subject).toBe('Message from Bergen Auto')
+  })
+
+  it('asks for the reader language only when the caller gave none', async () => {
+    await sendTemplatedMail('org', {
+      kind: 'message',
+      to: 'ola@example.com',
+      locale: 'nb',
+      context: { message: 'Hei' },
+    })
+    expect(resolveCustomerLocale).not.toHaveBeenCalled()
+
+    await sendTemplatedMail('org', {
+      kind: 'message',
+      to: 'ola@example.com',
+      context: { message: 'Hi' },
+    })
+    expect(resolveCustomerLocale).toHaveBeenCalledWith('org', null)
+  })
+})

+ 1 - 0
src/__tests__/i18n/audit-summaries.test.ts

@@ -94,6 +94,7 @@ describe('audit summaries', () => {
     // production because nobody tests in Lithuanian.
     const sample: Record<string, string | number> = {
       name: 'X',
+      kind: 'invoice',
       id: 'X',
       ref: 'X',
       code: 'X',

+ 298 - 0
src/app/(authenticated)/settings/email-templates/email-template-gallery.tsx

@@ -0,0 +1,298 @@
+'use client'
+
+import { FileText, KeyRound, Loader2, MessageSquare, Trash2 } from 'lucide-react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useFormatter, useTranslations } from 'next-intl'
+import { useEffect, useState, useTransition } from 'react'
+import { toast } from 'sonner'
+import { AppCard } from '@/components/app-card'
+import { useConfirm } from '@/components/confirm-dialog'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  applyEmailTemplate,
+  deleteEmailTemplate,
+} from '@/features/email/Actions/emailTemplateActions'
+import { type EmailSampleData, EmailThumbnail } from '@/features/email/Components/EmailThumbnail'
+import {
+  EMAIL_KIND_GROUPS,
+  type EmailKind,
+  type EmailKindGroup,
+  kindsInGroup,
+} from '@/features/email/Lib/emailKinds'
+import type { EmailTemplate, SavedEmailTemplate } from '@/features/email/Lib/emailTemplate'
+import { cn } from '@/lib/utils'
+
+const GROUP_ICONS: Record<EmailKindGroup, typeof FileText> = {
+  documents: FileText,
+  messages: MessageSquare,
+  portal: KeyRound,
+}
+
+/**
+ * One section per group of mails, one row per kind, and a card for every
+ * template the kind can send with: the built-in preset first, then whatever
+ * the workshop has saved. The card in use wears the pill; the others offer
+ * to take over. Clicking a card opens the designer on it in a new tab, the
+ * way the document designer opens from the templates page.
+ */
+export function EmailTemplateGallery({
+  presets,
+  samples,
+  saved,
+  active,
+}: {
+  presets: Record<EmailKind, EmailTemplate>
+  samples: Record<EmailKind, EmailSampleData>
+  saved: SavedEmailTemplate[]
+  active: Record<EmailKind, string | null>
+}) {
+  const t = useTranslations('settings.emailTemplates')
+
+  return (
+    <div className="space-y-6">
+      <div>
+        <h1 className="text-lg font-semibold">{t('title')}</h1>
+        <p className="text-sm text-muted-foreground">{t('description')}</p>
+      </div>
+
+      {EMAIL_KIND_GROUPS.map((group) => (
+        <AppCard
+          key={group}
+          icon={GROUP_ICONS[group]}
+          title={t(`groups.${group}`)}
+          contentClassName="space-y-6"
+        >
+          {kindsInGroup(group).map((kind) => (
+            <KindRow
+              key={kind}
+              kind={kind}
+              preset={presets[kind]}
+              sample={samples[kind]}
+              saved={saved.filter((template) => template.kind === kind)}
+              activeId={active[kind]}
+            />
+          ))}
+        </AppCard>
+      ))}
+    </div>
+  )
+}
+
+function KindRow({
+  kind,
+  preset,
+  sample,
+  saved,
+  activeId,
+}: {
+  kind: EmailKind
+  preset: EmailTemplate
+  sample: EmailSampleData
+  saved: SavedEmailTemplate[]
+  activeId: string | null
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  const format = useFormatter()
+  const router = useRouter()
+  const confirm = useConfirm()
+  // The card whose request is out, then the card whose answer the page is
+  // still catching up with: a card stays dimmed from the click until the
+  // refreshed props have arrived, so nothing on it can be pressed twice.
+  const [busy, setBusy] = useState<string | null>(null)
+  const [settling, setSettling] = useState<string | null>(null)
+  const [refreshing, startRefresh] = useTransition()
+  // Deleted rows leave the grid at once rather than when the refresh lands.
+  const [removed, setRemoved] = useState<string[]>([])
+
+  useEffect(() => {
+    if (!refreshing) setSettling(null)
+  }, [refreshing])
+
+  const shown = saved.filter((template) => !removed.includes(template.id))
+  const working = (key: string) => busy === key || (refreshing && settling === key)
+
+  // A saved id that no longer matches a row sends with the preset, which is
+  // what the resolver does too; the preset card says so.
+  const presetInUse = activeId === null || !shown.some((template) => template.id === activeId)
+
+  const refresh = (key: string) => {
+    setSettling(key)
+    startRefresh(() => router.refresh())
+  }
+
+  const apply = async (id: string | null, name: string) => {
+    const key = id ?? 'preset'
+    setBusy(key)
+    try {
+      const result = await applyEmailTemplate(kind, id)
+      if (!result.success) throw new Error(result.error)
+      toast.success(t('defaultSet', { name }))
+      refresh(key)
+    } catch {
+      toast.error(t('couldNotSetDefault'))
+    } finally {
+      setBusy(null)
+    }
+  }
+
+  const remove = async (template: SavedEmailTemplate) => {
+    const ok = await confirm({
+      title: t('deleteTitle'),
+      description: t('deleteConfirm', { name: template.name }),
+      confirmLabel: t('delete'),
+      destructive: true,
+    })
+    if (!ok) return
+    setBusy(template.id)
+    try {
+      const result = await deleteEmailTemplate(template.id)
+      if (!result.success) throw new Error(result.error)
+      toast.success(t('deleted', { name: template.name }))
+      setRemoved((prev) => [...prev, template.id])
+      refresh(template.id)
+    } catch {
+      toast.error(t('couldNotDelete'))
+    } finally {
+      setBusy(null)
+    }
+  }
+
+  return (
+    <section>
+      <h3 className="text-sm font-medium">{t(`kinds.${kind}.name`)}</h3>
+      <p className="mb-3 text-xs text-muted-foreground">{t(`kinds.${kind}.description`)}</p>
+
+      <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
+        <TemplateCard
+          href={`/email-designer?kind=${kind}&preset=1`}
+          template={preset}
+          sample={sample}
+          name={t('builtIn')}
+          meta={preset.name}
+          inUse={presetInUse}
+          pending={working('preset')}
+          action={
+            !presetInUse && (
+              <Button
+                type="button"
+                variant="outline"
+                size="sm"
+                className="mt-2 h-7 text-xs"
+                disabled={working('preset')}
+                onClick={() => void apply(null, t('builtIn'))}
+              >
+                {working('preset') && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
+                {t('useBuiltIn')}
+              </Button>
+            )
+          }
+        />
+
+        {shown.map((template) => {
+          const inUse = activeId === template.id
+          const pending = working(template.id)
+          return (
+            <TemplateCard
+              key={template.id}
+              href={`/email-designer?kind=${kind}&template=${template.id}`}
+              template={template}
+              sample={sample}
+              name={template.name}
+              meta={t('updatedOn', {
+                date: format.dateTime(new Date(template.updatedAt), { dateStyle: 'medium' }),
+              })}
+              inUse={inUse}
+              pending={pending}
+              onDelete={() => void remove(template)}
+              action={
+                !inUse && (
+                  <Button
+                    type="button"
+                    variant="outline"
+                    size="sm"
+                    className="mt-2 h-7 text-xs"
+                    disabled={pending}
+                    onClick={() => void apply(template.id, template.name)}
+                  >
+                    {pending && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
+                    {t('setDefault')}
+                  </Button>
+                )
+              }
+            />
+          )
+        })}
+      </div>
+    </section>
+  )
+}
+
+function TemplateCard({
+  href,
+  template,
+  sample,
+  name,
+  meta,
+  inUse,
+  pending = false,
+  action,
+  onDelete,
+}: {
+  href: string
+  template: EmailTemplate
+  sample: EmailSampleData
+  name: string
+  meta: string
+  inUse: boolean
+  /** A request about this card is out, or the page has not caught up with its answer yet. */
+  pending?: boolean
+  action?: React.ReactNode
+  onDelete?: () => void
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  return (
+    <div
+      aria-busy={pending || undefined}
+      className={cn(
+        'relative flex flex-col rounded-lg border p-3 text-left transition-[opacity,background-color] hover:bg-muted',
+        inUse && 'border-primary',
+        pending && 'pointer-events-none opacity-50'
+      )}
+    >
+      {inUse && (
+        <Badge className="absolute right-2 top-2 z-10 text-[10px] font-semibold">
+          {t('inUse')}
+        </Badge>
+      )}
+      {onDelete && (
+        <Button
+          type="button"
+          variant="outline"
+          size="icon-xs"
+          onClick={onDelete}
+          disabled={pending}
+          title={t('delete')}
+          aria-label={t('delete')}
+          className="absolute left-2 top-2 z-10 size-7 rounded-full text-destructive shadow hover:bg-destructive/10 hover:text-destructive"
+        >
+          {pending ? <Loader2 className="animate-spin" /> : <Trash2 />}
+        </Button>
+      )}
+      <Link
+        href={href}
+        target="_blank"
+        rel="noopener"
+        tabIndex={pending ? -1 : undefined}
+        aria-disabled={pending || undefined}
+        className="block"
+      >
+        <EmailThumbnail template={template} sample={sample} className="border border-border" />
+        <p className="mt-2 truncate text-xs font-medium">{name}</p>
+        <p className="truncate text-[11px] leading-tight text-muted-foreground">{meta}</p>
+      </Link>
+      {action}
+    </div>
+  )
+}

+ 97 - 0
src/app/(authenticated)/settings/email-templates/page.tsx

@@ -0,0 +1,97 @@
+import { redirect } from 'next/navigation'
+import { getLocale, getTranslations } from 'next-intl/server'
+import { db } from '@/lib/db'
+import { getFeatures, isCloudMode } from '@/lib/features'
+import { getLayoutData } from '@/lib/get-layout-data'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import {
+  getActiveEmailTemplates,
+  listEmailTemplates,
+} from '@/features/email/Actions/emailTemplateActions'
+import type { EmailSampleData } from '@/features/email/Components/EmailThumbnail'
+import { sampleSummaryRows, sampleValuesFor } from '@/features/email/Lib/emailContext'
+import { EMAIL_KINDS, type EmailKind } from '@/features/email/Lib/emailKinds'
+import { loadEmailMessages } from '@/features/email/Lib/emailMessages.server'
+import { presetTemplate } from '@/features/email/Lib/emailPresets'
+import type { EmailTemplate } from '@/features/email/Lib/emailTemplate'
+import { FeatureLockedMessage } from '../feature-locked-message'
+import { EmailTemplateGallery } from './email-template-gallery'
+
+/**
+ * Every kind of mail with the templates saved for it, resolved on the server
+ * so the first paint already shows what a workshop has rather than the
+ * preset flashing first.
+ */
+export default async function EmailTemplatesPage() {
+  const data = await getLayoutData()
+  if (data.status === 'unauthenticated') redirect('/auth/sign-in')
+  if (data.status === 'no-organization') redirect('/onboarding')
+
+  const [features, t] = await Promise.all([
+    getFeatures(data.organizationId),
+    getTranslations('settings.emailTemplates'),
+  ])
+
+  if (!features.customTemplates) {
+    return (
+      <FeatureLockedMessage
+        feature={t('title')}
+        description={t('lockedDescription')}
+        isCloud={isCloudMode()}
+      />
+    )
+  }
+
+  const [settingsResult, organization, templatesResult, activeResult, messages] = await Promise.all(
+    [
+      getSettings([
+        SETTING_KEYS.WORKSHOP_PHONE,
+        SETTING_KEYS.WORKSHOP_EMAIL,
+        SETTING_KEYS.WORKSHOP_ADDRESS,
+      ]),
+      db.organization.findUnique({
+        where: { id: data.organizationId },
+        select: { name: true },
+      }),
+      listEmailTemplates(),
+      getActiveEmailTemplates(),
+      loadEmailMessages(await getLocale()),
+    ]
+  )
+
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const workshop = {
+    name: organization?.name,
+    phone: settings[SETTING_KEYS.WORKSHOP_PHONE],
+    email: settings[SETTING_KEYS.WORKSHOP_EMAIL],
+    address: settings[SETTING_KEYS.WORKSHOP_ADDRESS],
+  }
+
+  const presets = {} as Record<EmailKind, EmailTemplate>
+  const samples = {} as Record<EmailKind, EmailSampleData>
+  for (const kind of EMAIL_KINDS) {
+    presets[kind] = presetTemplate(kind, messages)
+    samples[kind] = {
+      values: sampleValuesFor(kind, workshop),
+      summary: sampleSummaryRows(kind, messages.summary),
+    }
+  }
+
+  const active = activeResult.success && activeResult.data ? activeResult.data : null
+
+  return (
+    <EmailTemplateGallery
+      presets={presets}
+      samples={samples}
+      saved={templatesResult.success && templatesResult.data ? templatesResult.data : []}
+      active={active ?? emptyActive()}
+    />
+  )
+}
+
+function emptyActive(): Record<EmailKind, string | null> {
+  const active = {} as Record<EmailKind, string | null>
+  for (const kind of EMAIL_KINDS) active[kind] = null
+  return active
+}

+ 2 - 0
src/app/(authenticated)/settings/settings-nav.tsx

@@ -26,6 +26,7 @@ import {
   Info,
   Key,
   Layout,
+  Mail,
   MessagesSquare,
   Percent,
   UserCog,
@@ -82,6 +83,7 @@ const settingsCategories: SettingsCategory[] = [
     key: 'communications',
     items: [
       { key: 'providers', href: '/settings/providers', icon: MessagesSquare },
+      { key: 'emailTemplates', href: '/settings/email-templates', icon: Mail },
       {
         key: 'customerPortal',
         href: '/settings/customer-portal',

+ 3 - 0
src/app/(authenticated)/work-orders/work-orders-client.tsx

@@ -175,6 +175,7 @@ export function WorkOrdersClient({
     email: string | null
     phone: string | null
   } | null>(null)
+  const [notifyVehicle, setNotifyVehicle] = useState<WorkOrder['vehicle']>(null)
   const [notifyMessage, setNotifyMessage] = useState('')
   const [notifyStatus, setNotifyStatus] = useState('')
 
@@ -256,6 +257,7 @@ export function WorkOrdersClient({
         current_user: tplData?.currentUser || '',
       })
       setNotifyCustomer(notifyTarget)
+      setNotifyVehicle(workOrder.vehicle)
       setNotifyMessage(message)
       setNotifyStatus(newStatus)
       setShowNotifyDialog(true)
@@ -621,6 +623,7 @@ export function WorkOrdersClient({
           open={showNotifyDialog}
           onOpenChange={setShowNotifyDialog}
           customer={notifyCustomer}
+          vehicle={notifyVehicle}
           defaultMessage={notifyMessage}
           emailSubject={t('emailSubject', { status: notifyStatus })}
           smsEnabled={smsEnabled}

+ 95 - 0
src/app/(designer)/email-designer/page.tsx

@@ -0,0 +1,95 @@
+import { redirect } from 'next/navigation'
+import { getLocale } from 'next-intl/server'
+import { db } from '@/lib/db'
+import { getFeatures } from '@/lib/features'
+import { getLayoutData } from '@/lib/get-layout-data'
+import { getSettings } from '@/features/settings/Actions/settingsActions'
+import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import {
+  getActiveEmailTemplates,
+  listEmailTemplates,
+} from '@/features/email/Actions/emailTemplateActions'
+import { EmailDesigner } from '@/features/email/Components/EmailDesigner'
+import { sampleSummaryRows, sampleValuesFor } from '@/features/email/Lib/emailContext'
+import { isEmailKind } from '@/features/email/Lib/emailKinds'
+import { loadEmailMessages } from '@/features/email/Lib/emailMessages.server'
+import { presetTemplate } from '@/features/email/Lib/emailPresets'
+
+const SETTINGS_PAGE = '/settings/email-templates'
+
+/**
+ * The email designer, on one kind of mail at a time.
+ *
+ * Arrives with ?kind= and either ?template=<id> for a saved template or
+ * ?preset=1 for the built-in one. A link that names nothing usable goes back
+ * to the gallery, which is where every link here comes from anyway.
+ */
+export default async function EmailDesignerPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ kind?: string; template?: string; preset?: string }>
+}) {
+  const data = await getLayoutData()
+  if (data.status === 'unauthenticated') redirect('/auth/sign-in')
+  if (data.status === 'no-organization') redirect('/onboarding')
+
+  const features = await getFeatures(data.organizationId)
+  if (!features.customTemplates) redirect(SETTINGS_PAGE)
+
+  const { kind, template: templateId } = await searchParams
+  if (!kind || !isEmailKind(kind)) redirect(SETTINGS_PAGE)
+
+  const [settingsResult, organization, user, templatesResult, activeResult, messages] =
+    await Promise.all([
+      getSettings([
+        SETTING_KEYS.WORKSHOP_PHONE,
+        SETTING_KEYS.WORKSHOP_EMAIL,
+        SETTING_KEYS.WORKSHOP_ADDRESS,
+      ]),
+      db.organization.findUnique({
+        where: { id: data.organizationId },
+        select: { name: true },
+      }),
+      db.user.findUnique({ where: { id: data.userId }, select: { email: true } }),
+      listEmailTemplates(),
+      getActiveEmailTemplates(),
+      loadEmailMessages(await getLocale()),
+    ])
+
+  const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {}
+  const workshop = {
+    name: organization?.name,
+    phone: settings[SETTING_KEYS.WORKSHOP_PHONE],
+    email: settings[SETTING_KEYS.WORKSHOP_EMAIL],
+    address: settings[SETTING_KEYS.WORKSHOP_ADDRESS],
+  }
+
+  const saved = (
+    templatesResult.success && templatesResult.data ? templatesResult.data : []
+  ).filter((template) => template.kind === kind)
+  const initial = templateId ? saved.find((template) => template.id === templateId) : undefined
+  // A stale link to a template that has since been deleted lands on the
+  // gallery rather than silently opening the preset under the wrong name.
+  if (templateId && !initial) redirect(SETTINGS_PAGE)
+
+  const active = activeResult.success && activeResult.data ? activeResult.data[kind] : null
+
+  // The designer layout paints the document designer's own grey; this tool
+  // is dressed in the app's theme, so it brings its own ground.
+  return (
+    <div className="h-screen bg-background text-foreground">
+      <EmailDesigner
+        kind={kind}
+        preset={presetTemplate(kind, messages)}
+        initialSaved={initial ?? null}
+        savedNames={saved.map((template) => ({ id: template.id, name: template.name }))}
+        activeId={active}
+        sample={{
+          values: sampleValuesFor(kind, workshop),
+          summary: sampleSummaryRows(kind, messages.summary),
+        }}
+        userEmail={user?.email ?? ''}
+      />
+    </div>
+  )
+}

+ 10 - 0
src/app/api/protected/backup/export/route.ts

@@ -92,6 +92,16 @@ export async function POST(request: NextRequest) {
     )
   }
 
+  if (options.settings) {
+    queries.push(
+      db.emailTemplate
+        .findMany({ where: { organizationId: ctx.organizationId } })
+        .then((result) => {
+          data.emailTemplates = result
+        })
+    )
+  }
+
   if (options.customers) {
     queries.push(
       db.customer.findMany({ where: { organizationId: ctx.organizationId } }).then((result) => {

+ 39 - 0
src/app/api/protected/backup/import/route.ts

@@ -440,6 +440,7 @@ export async function POST(request: NextRequest) {
         ReportSchedule: () => tx.reportSchedule.deleteMany({ where: { organizationId } }),
         AppSetting: () => tx.appSetting.deleteMany({ where: { organizationId } }),
         DocumentDesign: () => tx.documentDesign.deleteMany({ where: { organizationId } }),
+        EmailTemplate: () => tx.emailTemplate.deleteMany({ where: { organizationId } }),
         DocumentDesignSnapshot: () =>
           tx.documentDesignSnapshot.deleteMany({ where: { organizationId } }),
         DocumentAssetSnapshot: () =>
@@ -493,6 +494,44 @@ export async function POST(request: NextRequest) {
         })
         for (const d of rows) designIds.add(d.id as string)
       }
+      // Email templates: the setting that names the one in use came back with
+      // settings, so the rows it points at have to come back with their ids.
+      if (data.emailTemplates?.length) {
+        const rows = (data.emailTemplates as Record<string, unknown>[]).filter(
+          (t) =>
+            typeof t.id === 'string' && typeof t.name === 'string' && typeof t.kind === 'string'
+        )
+        // Uploads travel under this organisation's id, so every stored
+        // upload URL is rewritten the way logo settings are.
+        const rewriteAssets = (value: unknown): unknown => {
+          if (typeof value === 'string' && value.startsWith('/api/protected/files/')) {
+            return rewriteFileUrl(value, ctx.organizationId) ?? value
+          }
+          if (Array.isArray(value)) return value.map(rewriteAssets)
+          if (value && typeof value === 'object') {
+            return Object.fromEntries(
+              Object.entries(value as Record<string, unknown>).map(([k, v]) => [
+                k,
+                rewriteAssets(v),
+              ])
+            )
+          }
+          return value
+        }
+        await tx.emailTemplate.createMany({
+          data: rows.map((t) => ({
+            id: t.id as string,
+            organizationId: ctx.organizationId,
+            kind: t.kind as string,
+            name: t.name as string,
+            subject: (t.subject as string) || '',
+            blocks: rewriteAssets(t.blocks ?? []) as Prisma.InputJsonValue,
+            theme: rewriteAssets(t.theme ?? {}) as Prisma.InputJsonValue,
+            createdAt: toSafeDate(t.createdAt as string),
+            updatedAt: toSafeDate(t.updatedAt as string),
+          })),
+        })
+      }
       const designSnapshotIds = new Set<string>()
       if (data.documentDesignSnapshots?.length) {
         const rows = (data.documentDesignSnapshots as Record<string, unknown>[]).filter(

+ 2 - 0
src/app/api/protected/files/[...path]/route.ts

@@ -48,6 +48,8 @@ export async function GET(
     'inventory',
     'services',
     'logos',
+    'email-logos',
+    'email-images',
     'quotes',
     'portal',
     'tire-hotel',

+ 114 - 0
src/app/api/protected/upload/email-image/route.ts

@@ -0,0 +1,114 @@
+import { randomUUID } from 'crypto'
+import { mkdir, writeFile } from 'fs/promises'
+import { NextResponse } from 'next/server'
+import path from 'path'
+import sharp from 'sharp'
+import { EMAIL_IMAGE_CATEGORY, EMAIL_IMAGE_MAX_WIDTH } from '@/features/email/Lib/emailTemplate'
+import { guardEmailUpload } from '@/features/email/Lib/emailUploadAccess.server'
+
+/**
+ * A picture for an image block.
+ *
+ * Mail has no image pipeline of its own: whatever is uploaded is what every
+ * reader downloads, on a phone, over whatever connection they have. So the
+ * upload is made small here, once. Fitted to twice the mail's width so it
+ * stays sharp on a high-density screen, saved as JPEG unless it has
+ * transparency, and squeezed until it is under the byte cap. The template
+ * stores the protected URL; the public route serves the file to mail
+ * clients, and the asset sweep removes it once no template refers to it.
+ */
+
+const MAX_UPLOAD_BYTES = 10 * 1024 * 1024
+const MAX_OUTPUT_BYTES = 600 * 1024
+const MAX_PIXEL_WIDTH = EMAIL_IMAGE_MAX_WIDTH * 2
+const MAX_PIXEL_HEIGHT = 1600
+
+/** More pixels than this and the decode alone would eat a gigabyte; no email needs it. */
+const MAX_INPUT_PIXELS = 40_000_000
+
+export async function POST(request: Request) {
+  const guard = await guardEmailUpload(request)
+  if ('response' in guard) return guard.response
+  const { ctx } = guard
+
+  const formData = await request.formData()
+  const file = formData.get('file')
+  if (!(file instanceof File)) {
+    return NextResponse.json({ error: 'No file provided' }, { status: 400 })
+  }
+  if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') {
+    return NextResponse.json({ error: 'Upload a PNG, JPEG or WebP image' }, { status: 400 })
+  }
+  if (file.size > MAX_UPLOAD_BYTES) {
+    return NextResponse.json({ error: 'File size must be under 10MB' }, { status: 400 })
+  }
+
+  try {
+    const source = Buffer.from(await file.arrayBuffer())
+    const image = sharp(source, { failOn: 'error', limitInputPixels: MAX_INPUT_PIXELS }).rotate()
+    const meta = await image.metadata()
+    if (!meta.width || !meta.height) {
+      return NextResponse.json({ error: 'Not an image we can read' }, { status: 400 })
+    }
+
+    const fitted = image.resize({
+      width: MAX_PIXEL_WIDTH,
+      height: MAX_PIXEL_HEIGHT,
+      fit: 'inside',
+      withoutEnlargement: true,
+    })
+
+    // Transparency needs PNG; everything else is smaller as JPEG. Either way,
+    // step the quality down until the file is under the cap, and when quality
+    // alone will not do it, halve the size and try again.
+    const encode = async (width: number) => {
+      const base = fitted.clone().resize({ width, withoutEnlargement: true })
+      if (meta.hasAlpha) {
+        const out = await base
+          .png({ compressionLevel: 9, palette: true })
+          .toBuffer({ resolveWithObject: true })
+        // A transparent picture cannot trade quality for bytes; it can only
+        // shrink. Over the cap at full size means try again at half.
+        if (out.data.length <= MAX_OUTPUT_BYTES || width < MAX_PIXEL_WIDTH)
+          return { ext: 'png', out }
+        return null
+      }
+      for (const quality of [82, 72, 62, 52]) {
+        const out = await base
+          .jpeg({ quality, mozjpeg: true, progressive: true })
+          .toBuffer({ resolveWithObject: true })
+        if (out.data.length <= MAX_OUTPUT_BYTES) return { ext: 'jpg', out }
+      }
+      return null
+    }
+
+    let result = await encode(MAX_PIXEL_WIDTH)
+    if (!result || result.out.data.length > MAX_OUTPUT_BYTES) {
+      result = await encode(Math.round(MAX_PIXEL_WIDTH / 2))
+    }
+    if (!result) {
+      return NextResponse.json({ error: 'Could not make the image small enough' }, { status: 400 })
+    }
+
+    const fileName = `${randomUUID()}.${result.ext}`
+    const dir = path.join(
+      process.cwd(),
+      'data',
+      'uploads',
+      ctx.organizationId,
+      EMAIL_IMAGE_CATEGORY
+    )
+    await mkdir(dir, { recursive: true })
+    await writeFile(path.join(dir, fileName), result.out.data)
+
+    return NextResponse.json({
+      url: `/api/protected/files/${ctx.organizationId}/${EMAIL_IMAGE_CATEGORY}/${fileName}`,
+      width: result.out.info.width,
+      height: result.out.info.height,
+      bytes: result.out.data.length,
+    })
+  } catch (error) {
+    console.error('[Email image upload] Error:', error)
+    return NextResponse.json({ error: 'Could not process the image' }, { status: 500 })
+  }
+}

+ 73 - 0
src/app/api/protected/upload/email-logo/route.ts

@@ -0,0 +1,73 @@
+import { randomUUID } from 'crypto'
+import { mkdir, writeFile } from 'fs/promises'
+import { NextResponse } from 'next/server'
+import path from 'path'
+import sharp from 'sharp'
+import { EMAIL_LOGO_CATEGORY, EMAIL_LOGO_MAX_WIDTH } from '@/features/email/Lib/emailTemplate'
+import { guardEmailUpload } from '@/features/email/Lib/emailUploadAccess.server'
+
+/**
+ * A logo for the email templates, uploaded on its own rather than borrowed
+ * from the company settings.
+ *
+ * Mail clients are unkind to logos: no scaling to fit, no SVG, and Outlook
+ * draws an image at the pixel size it was saved at. So the upload is made
+ * clean here once: trimmed of transparent margins, fitted to twice the
+ * largest width a template can ask for (so it stays sharp on a phone), and
+ * written as PNG so transparency survives. What the template stores is the
+ * protected URL; the public route serves the same file to mail clients.
+ */
+/** More pixels than this and the decode alone would eat a gigabyte; no email needs it. */
+const MAX_INPUT_PIXELS = 40_000_000
+
+export async function POST(request: Request) {
+  const guard = await guardEmailUpload(request)
+  if ('response' in guard) return guard.response
+  const { ctx } = guard
+
+  const formData = await request.formData()
+  const file = formData.get('file')
+  if (!(file instanceof File)) {
+    return NextResponse.json({ error: 'No file provided' }, { status: 400 })
+  }
+  if (!file.type.startsWith('image/') || file.type === 'image/svg+xml') {
+    return NextResponse.json({ error: 'Upload a PNG, JPEG or WebP image' }, { status: 400 })
+  }
+  if (file.size > 4 * 1024 * 1024) {
+    return NextResponse.json({ error: 'File size must be under 4MB' }, { status: 400 })
+  }
+
+  try {
+    const source = Buffer.from(await file.arrayBuffer())
+    const image = sharp(source, { failOn: 'error', limitInputPixels: MAX_INPUT_PIXELS }).rotate()
+    const meta = await image.metadata()
+    if (!meta.width || !meta.height) {
+      return NextResponse.json({ error: 'Not an image we can read' }, { status: 400 })
+    }
+
+    const png = await image
+      .trim({ threshold: 8 })
+      .resize({
+        width: EMAIL_LOGO_MAX_WIDTH * 2,
+        height: 400,
+        fit: 'inside',
+        withoutEnlargement: true,
+      })
+      .png({ compressionLevel: 9 })
+      .toBuffer({ resolveWithObject: true })
+
+    const fileName = `${randomUUID()}.png`
+    const dir = path.join(process.cwd(), 'data', 'uploads', ctx.organizationId, EMAIL_LOGO_CATEGORY)
+    await mkdir(dir, { recursive: true })
+    await writeFile(path.join(dir, fileName), png.data)
+
+    return NextResponse.json({
+      url: `/api/protected/files/${ctx.organizationId}/${EMAIL_LOGO_CATEGORY}/${fileName}`,
+      width: png.info.width,
+      height: png.info.height,
+    })
+  } catch (error) {
+    console.error('[Email logo upload] Error:', error)
+    return NextResponse.json({ error: 'Could not process the image' }, { status: 500 })
+  }
+}

+ 45 - 0
src/app/api/public/email-image/[orgId]/[file]/route.ts

@@ -0,0 +1,45 @@
+import { readFile, stat } from 'fs/promises'
+import { NextResponse } from 'next/server'
+import path from 'path'
+import { EMAIL_IMAGE_CATEGORY } from '@/features/email/Lib/emailTemplate'
+
+const MIME_TYPES: Record<string, string> = {
+  png: 'image/png',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  webp: 'image/webp',
+}
+
+const FILE_NAME = /^[A-Za-z0-9_-]+\.(png|jpe?g|webp)$/
+
+/**
+ * Serves an image block's picture to mail clients, which carry no session.
+ *
+ * Public by nature: the picture is in every copy of the mail. The file name
+ * is a random id, so nothing can be enumerated, and the path is built from
+ * checked parts only, so nothing can be traversed.
+ */
+export async function GET(
+  _request: Request,
+  { params }: { params: Promise<{ orgId: string; file: string }> }
+) {
+  const { orgId, file } = await params
+  if (!/^[A-Za-z0-9_-]+$/.test(orgId) || !FILE_NAME.test(file)) {
+    return NextResponse.json({ error: 'Not found' }, { status: 404 })
+  }
+
+  const filePath = path.join(process.cwd(), 'data', 'uploads', orgId, EMAIL_IMAGE_CATEGORY, file)
+  try {
+    await stat(filePath)
+  } catch {
+    return NextResponse.json({ error: 'Not found' }, { status: 404 })
+  }
+
+  const ext = file.split('.').pop()?.toLowerCase() ?? 'jpg'
+  return new NextResponse(await readFile(filePath), {
+    headers: {
+      'Content-Type': MIME_TYPES[ext] ?? 'image/jpeg',
+      'Cache-Control': 'public, max-age=31536000, immutable',
+    },
+  })
+}

+ 46 - 0
src/app/api/public/email-logo/[orgId]/[file]/route.ts

@@ -0,0 +1,46 @@
+import { readFile, stat } from 'fs/promises'
+import { NextResponse } from 'next/server'
+import path from 'path'
+import { EMAIL_LOGO_CATEGORY } from '@/features/email/Lib/emailTemplate'
+
+const MIME_TYPES: Record<string, string> = {
+  png: 'image/png',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  webp: 'image/webp',
+}
+
+const FILE_NAME = /^[A-Za-z0-9_-]+\.(png|jpe?g|webp)$/
+
+/**
+ * Serves an email logo to mail clients, which carry no session.
+ *
+ * Public by nature: the same image is at the top of every mail the workshop
+ * sends. The file name is a random id, so nothing can be enumerated, and the
+ * path is built from checked parts only, so nothing can be traversed.
+ */
+export async function GET(
+  _request: Request,
+  { params }: { params: Promise<{ orgId: string; file: string }> }
+) {
+  const { orgId, file } = await params
+  if (!/^[A-Za-z0-9_-]+$/.test(orgId) || !FILE_NAME.test(file)) {
+    return NextResponse.json({ error: 'Not found' }, { status: 404 })
+  }
+
+  const filePath = path.join(process.cwd(), 'data', 'uploads', orgId, EMAIL_LOGO_CATEGORY, file)
+  try {
+    await stat(filePath)
+  } catch {
+    return NextResponse.json({ error: 'Not found' }, { status: 404 })
+  }
+
+  const ext = file.split('.').pop()?.toLowerCase() ?? 'png'
+  return new NextResponse(await readFile(filePath), {
+    headers: {
+      'Content-Type': MIME_TYPES[ext] ?? 'image/png',
+      // The name is unique per upload, so a client may keep it for good.
+      'Cache-Control': 'public, max-age=31536000, immutable',
+    },
+  })
+}

+ 9 - 26
src/app/api/public/portal/[orgId]/auth/request/route.ts

@@ -2,20 +2,12 @@ import { NextResponse } from 'next/server'
 import { randomBytes } from 'crypto'
 import { db } from '@/lib/db'
 import { rateLimit } from '@/lib/rate-limit'
-import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { MAGIC_LINK_DURATION } from '@/lib/customer-session'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { getAppBaseUrl } from '@/lib/app-url'
-
-function escapeHtml(value: string): string {
-  return value
-    .replace(/&/g, '&amp;')
-    .replace(/</g, '&lt;')
-    .replace(/>/g, '&gt;')
-    .replace(/"/g, '&quot;')
-    .replace(/'/g, '&#39;')
-}
+import { sendTemplatedMail } from '@/features/email/Lib/sendTemplatedMail'
+import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 
 export async function POST(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
   const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000, anonymous: true })
@@ -84,25 +76,16 @@ export async function POST(request: Request, { params }: { params: Promise<{ org
     const appUrl = getAppBaseUrl()
 
     const magicLinkUrl = `${appUrl}/portal/${orgParam}/auth/verify?token=${token}`
-    const fromAddress = await getOrgFromAddress(orgId)
 
-    // Plain, left-aligned email. We include the magic link both as a
-    // clickable anchor and as a raw URL on its own line so customers can
-    // copy/paste it if the auto-link does not work in their mail client.
-    const escapedName = escapeHtml(customer.name)
-    const escapedOrg = escapeHtml(org.name)
-    const escapedUrl = escapeHtml(magicLinkUrl)
+    // The customer has no workshop cookie to read a language from, so the
+    // browser that asked for the link decides, unless the workshop forces one.
+    const locale = await resolveCustomerLocale(orgId, request.headers.get('accept-language'))
 
-    await sendOrgMail(orgId, {
-      from: fromAddress,
+    await sendTemplatedMail(orgId, {
+      kind: 'portal_signin',
       to: email,
-      subject: `Sign in to ${org.name} portal`,
-      html: `<div style="font-family: -apple-system, Segoe UI, Helvetica, Arial, sans-serif; font-size: 15px; color: #0f172a; line-height: 1.55;">
-<p>Hi ${escapedName},</p>
-<p>Use the link below to sign in to the ${escapedOrg} customer portal:</p>
-<p><a href="${escapedUrl}">${escapedUrl}</a></p>
-<p>This link expires in 15 minutes. If you didn't request this, you can ignore this email.</p>
-</div>`,
+      locale,
+      context: { customerName: customer.name, signinLink: magicLinkUrl },
     })
 
     return NextResponse.json({ success: true })

+ 22 - 0
src/components/notify-customer-dialog.tsx

@@ -27,6 +27,17 @@ interface NotifyCustomerDialogProps {
     email: string | null
     phone: string | null
   }
+  /**
+   * The car the message is about, when there is one. The email template can
+   * name it; a counter sale has none and the template leaves the gap.
+   */
+  vehicle?: {
+    year?: number | null
+    make?: string | null
+    model?: string | null
+    licensePlate?: string | null
+    mileage?: number | null
+  } | null
   defaultMessage: string
   emailSubject: string
   smsEnabled: boolean
@@ -39,6 +50,7 @@ export function NotifyCustomerDialog({
   open,
   onOpenChange,
   customer,
+  vehicle,
   defaultMessage,
   emailSubject,
   smsEnabled,
@@ -92,6 +104,16 @@ export function NotifyCustomerDialog({
         recipientEmail: customer.email!,
         subject: emailSubject,
         body: message,
+        customerName: customer.name,
+        vehicle: vehicle
+          ? {
+              year: vehicle.year,
+              make: vehicle.make,
+              model: vehicle.model,
+              licensePlate: vehicle.licensePlate,
+              mileage: vehicle.mileage,
+            }
+          : null,
       })
       if (res.success) results.push(t('emailSent'))
       else toast.error(res.error || t('failedEmail'))

+ 80 - 90
src/features/email/Actions/emailActions.ts

@@ -2,7 +2,6 @@
 
 import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { db } from '@/lib/db'
-import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
 import { withAuth } from '@/lib/with-auth'
 import { renderToBuffer } from '@react-pdf/renderer'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
@@ -28,7 +27,15 @@ import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getAppBaseUrl } from '@/lib/app-url'
 import { randomUUID } from 'crypto'
-import { buildDocumentEmailHtml, resolveAttachPdf } from '@/features/email/Lib/documentEmail'
+import { resolveAttachPdf } from '@/features/email/Lib/documentEmail'
+import type { VehicleContext } from '@/features/email/Lib/emailContext'
+import { sendTemplatedMail } from '@/features/email/Lib/sendTemplatedMail'
+
+/** Whoever pressed send, for a template that signs off with a name. */
+async function senderName(userId: string): Promise<string | null> {
+  const user = await db.user.findUnique({ where: { id: userId }, select: { name: true } })
+  return user?.name ?? null
+}
 
 async function getWorkshopSettings(organizationId: string) {
   const [settings, org] = await Promise.all([
@@ -46,7 +53,6 @@ async function getWorkshopSettings(organizationId: string) {
             'quote.logo',
             'workshop.currencyCode',
             'workshop.currencyFormat',
-            'workshop.emailFromName',
             'workshop.emailEnabled',
             'invoice.primaryColor',
             'invoice.backgroundColor',
@@ -119,7 +125,7 @@ export async function sendQuoteEmail(input: {
   attachPdf?: boolean
 }) {
   return withAuth(
-    async ({ organizationId }) => {
+    async ({ organizationId, userId }) => {
       demoGuard()
       await requireFeature(organizationId, 'smtp')
 
@@ -150,7 +156,6 @@ export async function sendQuoteEmail(input: {
       const currencyCode = settings['workshop.currencyCode'] || 'USD'
       const currencyFormat: 'symbol' | 'code' =
         settings['workshop.currencyFormat'] === 'code' ? 'code' : 'symbol'
-      const fromName = settings['workshop.emailFromName'] || settings['workshop.name'] || 'Workshop'
 
       const pick = (key: string) => settings[`quote.${key}`] || settings[`invoice.${key}`]
       const template = {
@@ -209,30 +214,25 @@ export async function sendQuoteEmail(input: {
       }
       const publicLink = token ? `${getAppBaseUrl()}/share/quote/${organizationId}/${token}` : null
 
-      const from = await getOrgFromAddress(organizationId)
-
-      await sendOrgMail(organizationId, {
-        from,
+      await sendTemplatedMail(organizationId, {
+        kind: 'quote_sent',
         to: recipientEmail,
-        subject: `Quote ${quoteNum} - ${quote.title}`,
-        html: buildDocumentEmailHtml({
-          heading: `Quote ${quoteNum}`,
-          subject: 'quote',
-          link: publicLink,
-          linkLabel: 'View Quote Online',
-          attached: attachPdf,
+        attached: attachPdf,
+        attachments: pdfBuffer ? [{ filename: `${quoteNum}.pdf`, content: pdfBuffer }] : undefined,
+        context: {
+          customerName: quote.customer?.name,
+          vehicle: quote.vehicle,
+          currentUser: await senderName(userId),
           message,
-          fromName,
-          phone: settings['workshop.phone'],
-        }),
-        attachments: pdfBuffer
-          ? [
-              {
-                filename: `${quoteNum}.pdf`,
-                content: pdfBuffer,
-              },
-            ]
-          : undefined,
+          document: {
+            number: quoteNum,
+            title: quote.title,
+            total: quote.totalAmount,
+            currencyCode,
+            currencyFormat,
+          },
+          shareLink: publicLink,
+        },
       })
 
       // Stamps sentAt and moves a draft to "sent" (accepted and converted
@@ -254,12 +254,21 @@ export async function sendQuoteEmail(input: {
   )
 }
 
+/**
+ * A free-text message to a customer: a status update, a report link, a video
+ * call invitation. The words are the caller's; the mail around them is the
+ * workshop's "message" template. The customer and vehicle are optional
+ * because not every caller has them, and a template that names the car
+ * simply leaves the gap when they are absent.
+ */
 export async function sendNotificationEmail(input: {
   recipientEmail: string
   subject: string
   body: string
+  customerName?: string | null
+  vehicle?: VehicleContext | null
 }) {
-  return withAuth(async ({ organizationId }) => {
+  return withAuth(async ({ organizationId, userId }) => {
     demoGuard()
     await requireFeature(organizationId, 'smtp')
 
@@ -268,22 +277,16 @@ export async function sendNotificationEmail(input: {
       throw new Error('Email sending is disabled. Enable it in Settings.')
     }
 
-    const fromName = settings['workshop.emailFromName'] || settings['workshop.name'] || 'Workshop'
-    const from = await getOrgFromAddress(organizationId)
-
-    await sendOrgMail(organizationId, {
-      from,
+    await sendTemplatedMail(organizationId, {
+      kind: 'message',
       to: input.recipientEmail,
       subject: input.subject,
-      html: `
-        <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
-          <p style="white-space: pre-line;">${input.body}</p>
-          <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
-          <p style="color: #666; font-size: 14px;">
-            ${fromName}${settings['workshop.phone'] ? ` · ${settings['workshop.phone']}` : ''}
-          </p>
-        </div>
-      `,
+      context: {
+        message: input.body,
+        customerName: input.customerName,
+        vehicle: input.vehicle,
+        currentUser: await senderName(userId),
+      },
     })
 
     return { sent: true }
@@ -298,7 +301,7 @@ export async function sendInvoiceEmail(input: {
   attachPdf?: boolean
 }) {
   return withAuth(
-    async ({ organizationId }) => {
+    async ({ organizationId, userId }) => {
       demoGuard()
       await requireFeature(organizationId, 'smtp')
 
@@ -323,11 +326,11 @@ export async function sendInvoiceEmail(input: {
       if (!assembly) throw new Error('Service record not found')
       const { record } = assembly
 
-      const fromName = settings['workshop.emailFromName'] || settings['workshop.name'] || 'Workshop'
+      // One language for the PDF's labels and the mail around it.
+      const locale = await resolveCustomerLocale(organizationId, null)
 
       let pdfBuffer: Buffer | null = null
       if (attachPdf) {
-        const locale = await resolveCustomerLocale(organizationId, null)
         const labels = await loadPrintLabels(locale, assembly.labelSettings)
         const element = React.createElement(InvoicePDF, {
           data: assembly.data,
@@ -356,30 +359,30 @@ export async function sendInvoiceEmail(input: {
         ? `${getAppBaseUrl()}/share/invoice/${organizationId}/${token}`
         : null
 
-      const from = await getOrgFromAddress(organizationId)
-
-      await sendOrgMail(organizationId, {
-        from,
+      await sendTemplatedMail(organizationId, {
+        kind: 'invoice_sent',
         to: recipientEmail,
-        subject: `Invoice ${invoiceNum} - ${record.title}`,
-        html: buildDocumentEmailHtml({
-          heading: `Invoice ${invoiceNum}`,
-          subject: 'invoice',
-          link: publicLink,
-          linkLabel: 'View Invoice Online',
-          attached: attachPdf,
-          message,
-          fromName,
-          phone: settings['workshop.phone'],
-        }),
+        locale,
+        attached: attachPdf,
         attachments: pdfBuffer
-          ? [
-              {
-                filename: `${invoiceNum}.pdf`,
-                content: pdfBuffer,
-              },
-            ]
+          ? [{ filename: `${invoiceNum}.pdf`, content: pdfBuffer }]
           : undefined,
+        context: {
+          customerName: assembly.data.customer?.name ?? assembly.data.vehicle?.customer?.name,
+          vehicle: assembly.data.vehicle,
+          currentUser: await senderName(userId),
+          message,
+          document: {
+            number: invoiceNum,
+            title: record.title,
+            total: record.totalAmount > 0 ? record.totalAmount : record.cost,
+            paid: assembly.paymentSummary?.totalPaid,
+            dueDate: record.invoiceDueDate,
+            currencyCode: assembly.invoiceSettings.currencyCode,
+            currencyFormat: assembly.invoiceSettings.currencyFormat,
+          },
+          shareLink: publicLink,
+        },
       })
 
       await markInvoiceSent(serviceRecordId, organizationId, { alreadyIssued: true })
@@ -412,7 +415,7 @@ export async function sendInspectionEmail(input: {
   attachPdf?: boolean
 }) {
   return withAuth(
-    async ({ organizationId }) => {
+    async ({ organizationId, userId }) => {
       demoGuard()
       await requireFeature(organizationId, 'smtp')
 
@@ -451,7 +454,6 @@ export async function sendInspectionEmail(input: {
       const attachPdf = resolveAttachPdf(settings, input.attachPdf)
 
       const logoDataUri = await loadLogoDataUri(settings['workshop.logo'])
-      const fromName = settings['workshop.emailFromName'] || settings['workshop.name'] || 'Workshop'
 
       const template = {
         primaryColor: settings['invoice.primaryColor'] || '#d97706',
@@ -510,30 +512,18 @@ export async function sendInspectionEmail(input: {
         ? `${getAppBaseUrl()}/share/inspection/${organizationId}/${token}`
         : null
 
-      const from = await getOrgFromAddress(organizationId)
-
-      await sendOrgMail(organizationId, {
-        from,
+      await sendTemplatedMail(organizationId, {
+        kind: 'inspection_sent',
         to: recipientEmail,
-        subject: `Vehicle Inspection - ${vehicleName}`,
-        html: buildDocumentEmailHtml({
-          heading: 'Vehicle Inspection Report',
-          subject: `inspection report for your ${vehicleName}`,
-          link: publicLink,
-          linkLabel: 'View Inspection Online',
-          attached: attachPdf,
+        attached: attachPdf,
+        attachments: pdfBuffer ? [{ filename: fileName, content: pdfBuffer }] : undefined,
+        context: {
+          customerName: inspection.vehicle.customer?.name,
+          vehicle: inspection.vehicle,
+          currentUser: await senderName(userId),
           message,
-          fromName,
-          phone: settings['workshop.phone'],
-        }),
-        attachments: pdfBuffer
-          ? [
-              {
-                filename: fileName,
-                content: pdfBuffer,
-              },
-            ]
-          : undefined,
+          shareLink: publicLink,
+        },
       })
 
       return { sent: true, inspectionId, recipientEmail }

+ 336 - 0
src/features/email/Actions/emailTemplateActions.ts

@@ -0,0 +1,336 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { z } from 'zod'
+import { db } from '@/lib/db'
+import { demoGuard } from '@/lib/demo'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { withAuth } from '@/lib/with-auth'
+import type { Prisma } from '@/generated/prisma/client'
+import { EMAIL_KINDS, type EmailKind, kindSpec } from '../Lib/emailKinds'
+import {
+  activeTemplateId,
+  activeTemplateSettingKey,
+  parseEmailAssetUrl,
+  type SavedEmailTemplate,
+  templateAssets,
+  templateText,
+} from '../Lib/emailTemplate'
+import { getOrgFromAddress, sendOrgMail } from '@/lib/email'
+import { missingTags, unknownTags } from '../Lib/tags'
+import { buildTemplatedMail } from '../Lib/sendTemplatedMail'
+import { sampleContextFor } from '../Lib/emailContext'
+import { missingAssets, sweepEmailAssets } from '../Lib/emailAssets.server'
+import { requireFeature } from '@/lib/features'
+import {
+  emailKindSchema,
+  type SaveEmailTemplateInput,
+  saveEmailTemplateSchema,
+  savedTemplateFromRow,
+} from '../Schema/emailTemplateSchema'
+
+const SETTINGS_PAGE = '/settings/email-templates'
+const READ = [{ action: PermissionAction.READ, subject: PermissionSubject.SETTINGS }]
+const MANAGE = [{ action: PermissionAction.UPDATE, subject: PermissionSubject.SETTINGS }]
+
+/** Every template the workshop saved, newest first. */
+export async function listEmailTemplates() {
+  return withAuth(
+    async ({ organizationId }): Promise<SavedEmailTemplate[]> => {
+      const rows = await db.emailTemplate.findMany({
+        where: { organizationId },
+        orderBy: { updatedAt: 'desc' },
+      })
+      return rows.map(savedTemplateFromRow).filter((t): t is SavedEmailTemplate => t !== null)
+    },
+    { requiredPermissions: READ }
+  )
+}
+
+/** Which template each kind sends with: a saved id, or null for the preset. */
+export async function getActiveEmailTemplates() {
+  return withAuth(
+    async ({ organizationId }): Promise<Record<EmailKind, string | null>> => {
+      const rows = await db.appSetting.findMany({
+        where: { organizationId, key: { in: EMAIL_KINDS.map(activeTemplateSettingKey) } },
+        select: { key: true, value: true },
+      })
+      const byKey = new Map(rows.map((row) => [row.key, row.value]))
+      const active = {} as Record<EmailKind, string | null>
+      for (const kind of EMAIL_KINDS) {
+        active[kind] = activeTemplateId(byKey.get(activeTemplateSettingKey(kind)))
+      }
+      return active
+    },
+    { requiredPermissions: READ }
+  )
+}
+
+/**
+ * What is wrong with a template, in the workshop's terms.
+ *
+ * A tag this kind does not offer is a typo, and a required tag that is
+ * missing is a mail that cannot do its job: a sign-in mail with no sign-in
+ * link is undeliverable in the only sense that matters. The designer runs
+ * the same checks as it goes, so this is the last line, not the first.
+ */
+export async function validateEmailTemplate(input: SaveEmailTemplateInput) {
+  return withAuth(
+    async () => {
+      const data = saveEmailTemplateSchema.parse(input)
+      return problemsWith(data)
+    },
+    { requiredPermissions: READ }
+  )
+}
+
+function problemsWith(data: SaveEmailTemplateInput): {
+  unknown: string[]
+  missing: string[]
+} {
+  const text = templateText(data)
+  const spec = kindSpec(data.kind)
+  return {
+    unknown: unknownTags(text, spec.tags),
+    missing: missingTags(text, spec.required),
+  }
+}
+
+/**
+ * Saves a template under its name, and makes it the one that sends for its
+ * kind. Without an id, the same name means the same template: saving again
+ * updates it in place rather than filling the gallery with near-copies,
+ * which is what the invoice designer has always done.
+ *
+ * Applying on save is deliberate. A workshop that edits a template means to
+ * use it; the gallery is where a saved template is set aside, not the
+ * designer.
+ */
+export async function saveEmailTemplate(input: SaveEmailTemplateInput) {
+  return withAuth(
+    async ({ userId, organizationId }): Promise<SavedEmailTemplate> => {
+      demoGuard()
+      const data = saveEmailTemplateSchema.parse(input)
+      const problems = problemsWith(data)
+      if (problems.unknown.length) {
+        throw new Error(
+          `This template uses tags that do not exist here: ${problems.unknown.map((t) => `{${t}}`).join(', ')}`
+        )
+      }
+      if (problems.missing.length) {
+        throw new Error(
+          `This template has to include ${problems.missing.map((t) => `{${t}}`).join(', ')}`
+        )
+      }
+
+      // A template may only point at this organisation's own uploads.
+      const foreign = templateAssets(data).find(
+        (asset) => parseEmailAssetUrl(asset)?.organizationId !== organizationId
+      )
+      if (foreign) throw new Error('This template refers to an upload that is not yours')
+      // An upload that has gone, swept or never finished, must not be saved
+      // into a template that would then send without it.
+      const missing = await missingAssets(organizationId, data)
+      if (missing.length)
+        throw new Error('A picture in this template is no longer available. Upload it again.')
+
+      const byName = await db.emailTemplate.findFirst({
+        where: {
+          organizationId,
+          kind: data.kind,
+          name: { equals: data.name, mode: 'insensitive' },
+        },
+        select: { id: true },
+      })
+      let target = data.id
+        ? await db.emailTemplate.findFirst({
+            where: { id: data.id, organizationId, kind: data.kind },
+            select: { id: true },
+          })
+        : null
+      // Renaming one template onto another's name would leave two with the
+      // same name and the gallery unable to tell them apart.
+      if (target && byName && byName.id !== target.id) {
+        throw new Error('A template with this name already exists')
+      }
+      if (!target) target = byName
+
+      const blocks = data.blocks as unknown as Prisma.InputJsonValue
+      const theme = data.theme as Prisma.InputJsonValue
+      const row = target
+        ? await db.emailTemplate.update({
+            where: { id: target.id },
+            data: { name: data.name, subject: data.subject, blocks, theme },
+          })
+        : await db.emailTemplate.create({
+            data: {
+              organizationId,
+              kind: data.kind,
+              name: data.name,
+              subject: data.subject,
+              blocks,
+              theme,
+            },
+          })
+
+      const key = activeTemplateSettingKey(data.kind)
+      await db.appSetting.upsert({
+        where: { organizationId_key: { organizationId, key } },
+        update: { value: `design:${row.id}` },
+        create: { userId, organizationId, key, value: `design:${row.id}` },
+      })
+
+      const saved = savedTemplateFromRow(row)
+      if (!saved) throw new Error('Template could not be read back')
+      // Pictures replaced or removed on the way to this save are now unreferenced.
+      await sweepEmailAssets(organizationId)
+      revalidatePath(SETTINGS_PAGE)
+      return saved
+    },
+    {
+      requiredPermissions: MANAGE,
+      audit: ({ result }) => ({
+        action: 'settings.saveEmailTemplate',
+        entity: 'EmailTemplate',
+        entityId: result.id,
+        details: { key: 'settings_saveEmailTemplate', params: { name: result.name } },
+      }),
+    }
+  )
+}
+
+/**
+ * Makes a saved template the one its kind sends with, or with a null id
+ * puts the kind back on the built-in preset.
+ */
+export async function applyEmailTemplate(kind: string, id: string | null) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      demoGuard()
+      const parsedKind = emailKindSchema.parse(kind)
+      if (id !== null) rowIdSchema.parse(id)
+      const key = activeTemplateSettingKey(parsedKind)
+
+      if (id === null) {
+        await db.appSetting.deleteMany({ where: { organizationId, key } })
+        revalidatePath(SETTINGS_PAGE)
+        return { id: null, kind: parsedKind, name: 'preset' }
+      }
+
+      const row = await db.emailTemplate.findFirst({
+        where: { id, organizationId, kind: parsedKind },
+        select: { id: true, name: true },
+      })
+      if (!row) throw new Error('Template not found')
+
+      await db.appSetting.upsert({
+        where: { organizationId_key: { organizationId, key } },
+        update: { value: `design:${row.id}` },
+        create: { userId, organizationId, key, value: `design:${row.id}` },
+      })
+      revalidatePath(SETTINGS_PAGE)
+      return { id: row.id, kind: parsedKind, name: row.name }
+    },
+    {
+      requiredPermissions: MANAGE,
+      audit: ({ result }) => ({
+        action: 'settings.applyEmailTemplate',
+        entity: 'EmailTemplate',
+        entityId: result.id ?? undefined,
+        details: {
+          key: 'settings_applyEmailTemplate',
+          params: { name: result.name, kind: result.kind },
+        },
+      }),
+    }
+  )
+}
+
+/**
+ * Removes a template. If it was the one that sent, the kind falls back to
+ * the built-in preset, which is what the resolver does when the setting
+ * points at nothing.
+ */
+export async function deleteEmailTemplate(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      demoGuard()
+      rowIdSchema.parse(id)
+      const row = await db.emailTemplate.findFirst({
+        where: { id, organizationId },
+        select: { id: true, name: true, kind: true },
+      })
+      if (!row) throw new Error('Template not found')
+
+      await db.$transaction([
+        db.emailTemplate.delete({ where: { id: row.id } }),
+        db.appSetting.deleteMany({
+          where: {
+            organizationId,
+            key: activeTemplateSettingKey(row.kind as EmailKind),
+            value: `design:${row.id}`,
+          },
+        }),
+      ])
+      await sweepEmailAssets(organizationId)
+      revalidatePath(SETTINGS_PAGE)
+      return { id: row.id, name: row.name }
+    },
+    {
+      requiredPermissions: MANAGE,
+      audit: ({ result }) => ({
+        action: 'settings.deleteEmailTemplate',
+        entity: 'EmailTemplate',
+        entityId: result.id,
+        details: { key: 'settings_deleteEmailTemplate', params: { name: result.name } },
+      }),
+    }
+  )
+}
+
+const testRecipientSchema = z.string().trim().email()
+/** Prisma drops an undefined filter, which would make findFirst return any row. */
+const rowIdSchema = z.string().min(1).max(64)
+
+/**
+ * Sends the template being edited to an address of the editor's choosing,
+ * filled with the same stand-in data the preview uses. The one control that
+ * answers the question an editor cannot: what it looks like in a real mail
+ * client.
+ */
+export async function sendTestEmail(input: SaveEmailTemplateInput, recipientEmail: string) {
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      demoGuard()
+      // The same gates as a real send: the plan allows email, and the
+      // workshop has not switched it off.
+      await requireFeature(organizationId, 'smtp')
+      const enabled = await db.appSetting.findUnique({
+        where: { organizationId_key: { organizationId, key: 'workshop.emailEnabled' } },
+        select: { value: true },
+      })
+      if (enabled?.value === 'false')
+        throw new Error('Email sending is disabled. Enable it in Settings.')
+      const data = saveEmailTemplateSchema.parse(input)
+      const to = testRecipientSchema.parse(recipientEmail)
+      const sender = await db.user.findUnique({ where: { id: userId }, select: { name: true } })
+
+      const mail = await buildTemplatedMail(organizationId, {
+        kind: data.kind,
+        to,
+        context: sampleContextFor(data.kind, sender?.name),
+        template: data,
+        attached: kindSpec(data.kind).hasAttachment,
+      })
+      await sendOrgMail(organizationId, {
+        from: await getOrgFromAddress(organizationId),
+        to,
+        subject: `[Test] ${mail.subject}`,
+        html: mail.html,
+        text: mail.text,
+      })
+      return { sent: true, recipientEmail: to }
+    },
+    { requiredPermissions: MANAGE }
+  )
+}

+ 752 - 0
src/features/email/Components/EmailDesigner.tsx

@@ -0,0 +1,752 @@
+'use client'
+
+import {
+  AlertTriangle,
+  ArrowLeft,
+  Code,
+  Copy,
+  FileText,
+  Monitor,
+  RotateCcw,
+  Save,
+  Send,
+  Smartphone,
+  Trash2,
+} from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
+import { toast } from 'sonner'
+import { useConfirm } from '@/components/confirm-dialog'
+import { useGlassModal } from '@/components/glass-modal'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Separator } from '@/components/ui/separator'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import {
+  deleteEmailTemplate,
+  saveEmailTemplate,
+  sendTestEmail,
+} from '../Actions/emailTemplateActions'
+import { buildEmailSpec } from '../Lib/buildEmailSpec'
+import { type EmailKind, kindSpec } from '../Lib/emailKinds'
+import {
+  EMAIL_BLOCK_TYPES,
+  EMAIL_IMAGE_MAX_WIDTH,
+  type EmailBlock,
+  type EmailBlockType,
+  type EmailTemplate,
+  type EmailTheme,
+  emailLogoPublicPath,
+  type SavedEmailTemplate,
+  templateText,
+} from '../Lib/emailTemplate'
+import { missingTags, unknownTags } from '../Lib/tags'
+import { renderEmailHtml } from '../Render/renderEmailHtml'
+import { renderEmailText } from '../Render/renderEmailText'
+import { type SaveEmailTemplateInput, saveEmailTemplateSchema } from '../Schema/emailTemplateSchema'
+import { HEX_COLOR } from './EmailDesignerControls'
+import { EmailDesignerInspector } from './EmailDesignerInspector'
+import { EmailDesignerRail } from './EmailDesignerRail'
+import { EmailPreview, type PreviewView, type PreviewWidth } from './EmailPreview'
+import type { EmailSampleData } from './EmailThumbnail'
+
+const SETTINGS_PAGE = '/settings/email-templates'
+
+/** Blocks a mail has at most one of; the rest can repeat. */
+const ONCE: ReadonlySet<EmailBlockType> = new Set([
+  'header',
+  'contact_footer',
+  'document_summary',
+  'attachment_note',
+])
+
+const THEME_COLORS: (keyof EmailTheme)[] = [
+  'primaryColor',
+  'textColor',
+  'mutedColor',
+  'backgroundColor',
+  'panelColor',
+]
+
+function newBlockId(type: EmailBlockType): string {
+  const suffix =
+    typeof crypto !== 'undefined' && 'randomUUID' in crypto
+      ? crypto.randomUUID().slice(0, 8)
+      : Date.now().toString(36)
+  return `${type}-${suffix}`
+}
+
+const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
+const same = (a: EmailTemplate, b: EmailTemplate) => JSON.stringify(a) === JSON.stringify(b)
+
+/**
+ * The save action's own sentences, written for the workshop; anything else
+ * it answers with is a validation path or an internal message and is shown
+ * as a plain "could not save".
+ */
+const HUMAN_SAVE_ERROR = /^(?:This template|A template|A picture)\b/
+
+/**
+ * The email designer: one kind of mail, its blocks on the left, the mail in
+ * the middle, the words and the look on the right.
+ *
+ * Everything is edited as one template object and rendered on every change
+ * through the same builder and renderer a real send uses, so what the preview
+ * shows is what arrives. Save writes the template and makes it the one this
+ * kind sends with; the gallery is where a saved template is set aside.
+ */
+export function EmailDesigner({
+  kind,
+  preset,
+  initialSaved,
+  savedNames,
+  activeId: initialActiveId,
+  sample,
+  userEmail,
+}: {
+  kind: EmailKind
+  /** The built-in template for this kind, in the workshop's language. */
+  preset: EmailTemplate
+  /** The saved template this tab opened on, or null for the preset. */
+  initialSaved: SavedEmailTemplate | null
+  /** The names already taken for this kind, so the dialog can say a save will update. */
+  savedNames: { id: string; name: string }[]
+  /** Which template the kind sends with right now. */
+  activeId: string | null
+  sample: EmailSampleData
+  userEmail: string
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  const router = useRouter()
+  const confirm = useConfirm()
+  const openModal = useGlassModal((state) => state.open)
+  const spec = kindSpec(kind)
+
+  const [saved, setSaved] = useState<SavedEmailTemplate | null>(initialSaved)
+  const [baseline, setBaseline] = useState<EmailTemplate>(() =>
+    clone(initialSaved ? stripSaved(initialSaved) : preset)
+  )
+  const [template, setTemplate] = useState<EmailTemplate>(() => clone(baseline))
+  const [selected, setSelected] = useState<string | null>(null)
+  const [width, setWidth] = useState<PreviewWidth>('desktop')
+  const [view, setView] = useState<PreviewView>('html')
+  const [saving, setSaving] = useState(false)
+  const [names, setNames] = useState(savedNames)
+  // Every save makes its row the one this kind sends with, so the badge
+  // follows the last save rather than what the page opened on.
+  const [activeId, setActiveId] = useState(initialActiveId)
+
+  const [naming, setNaming] = useState<'first' | 'copy' | null>(null)
+  const [name, setName] = useState('')
+  const [testOpen, setTestOpen] = useState(false)
+  const [testEmail, setTestEmail] = useState(userEmail)
+  const [sending, setSending] = useState(false)
+
+  const dirty = !same(template, baseline)
+
+  // A closed tab or a reload throws the work away; the browser asks first.
+  useEffect(() => {
+    if (!dirty) return
+    const warn = (event: BeforeUnloadEvent) => {
+      event.preventDefault()
+      event.returnValue = ''
+    }
+    window.addEventListener('beforeunload', warn)
+    return () => window.removeEventListener('beforeunload', warn)
+  }, [dirty])
+
+  // The preview, built the way a send is, filled with the sample data. The
+  // logo is the template's own, fetched from the public route as a mail
+  // client would fetch it.
+  const { html, text, subject } = useMemo(() => {
+    const built = buildEmailSpec(template, {
+      values: sample.values,
+      summary: sample.summary,
+      logoUrl: emailLogoPublicPath(template.theme.logoUrl),
+      attached: spec.hasAttachment,
+    })
+    return {
+      html: renderEmailHtml(built, { marked: true }),
+      text: renderEmailText(built),
+      subject: built.subject,
+    }
+  }, [template, sample, spec.hasAttachment])
+
+  // The first line of the mail after the sender's name, which is what an
+  // inbox shows beside the subject.
+  const previewLine = useMemo(() => {
+    const lines = text.split('\n').map((line) => line.trim())
+    const start = lines[0] === (sample.values.workshop_name ?? '').trim() ? 1 : 0
+    return lines.slice(start).find(Boolean) ?? ''
+  }, [text, sample.values.workshop_name])
+
+  // Everything that keeps the template from being saved, worded for the
+  // panel and for the pill beside Save.
+  const problems = useMemo(() => {
+    const list: string[] = []
+    const all = templateText(template)
+    const unknown = unknownTags(all, spec.tags)
+    const missing = missingTags(all, spec.required)
+    if (unknown.length) {
+      list.push(t('unknownTags', { tags: unknown.map((tag) => `{${tag}}`).join(', ') }))
+    }
+    if (missing.length) {
+      list.push(t('missingTags', { tags: missing.map((tag) => `{${tag}}`).join(', ') }))
+    }
+    if (!template.subject.trim()) list.push(t('subjectRequired'))
+    if (THEME_COLORS.some((key) => !HEX_COLOR.test(String(template.theme[key])))) {
+      list.push(t('theme.invalidColor'))
+    }
+    // The same check the server runs, so a field over its limit disables
+    // Save here rather than coming back as a raw validation path. Faults
+    // the lines above already name are not counted twice.
+    const parsed = saveEmailTemplateSchema.safeParse({
+      kind,
+      name: template.name || preset.name,
+      subject: template.subject,
+      blocks: template.blocks,
+      theme: template.theme,
+    })
+    if (!parsed.success) {
+      const explained = parsed.error.issues.every(
+        (issue) =>
+          (issue.path[0] === 'subject' && !template.subject.trim()) ||
+          (issue.path[0] === 'theme' &&
+            THEME_COLORS.includes(issue.path[1] as keyof EmailTheme) &&
+            issue.path.length === 2)
+      )
+      if (!explained) list.push(t('invalidTemplate'))
+    }
+    return list
+  }, [template, spec, t, kind, preset.name])
+
+  const patchBlock = useCallback((id: string, patch: Partial<EmailBlock>) => {
+    setTemplate((prev) => ({
+      ...prev,
+      blocks: prev.blocks.map((block) => (block.id === id ? { ...block, ...patch } : block)),
+    }))
+  }, [])
+
+  // Shown is the absence of the key, not `visible: true`: hiding a block and
+  // showing it again has to read as no change against the baseline.
+  const toggleBlock = useCallback((id: string) => {
+    setTemplate((prev) => ({
+      ...prev,
+      blocks: prev.blocks.map((block) => {
+        if (block.id !== id) return block
+        const { visible, ...rest } = block
+        return visible === false ? rest : { ...rest, visible: false }
+      }),
+    }))
+  }, [])
+
+  const moveBlock = useCallback((draggedId: string, toIndex: number) => {
+    setTemplate((prev) => {
+      const blocks = [...prev.blocks]
+      const from = blocks.findIndex((block) => block.id === draggedId)
+      if (from === -1) return prev
+      const [moved] = blocks.splice(from, 1)
+      blocks.splice(Math.max(0, Math.min(blocks.length, toIndex)), 0, moved)
+      return { ...prev, blocks }
+    })
+  }, [])
+
+  const removeBlock = useCallback(
+    (id: string) => {
+      setTemplate((prev) =>
+        prev.blocks.length > 1
+          ? { ...prev, blocks: prev.blocks.filter((block) => block.id !== id) }
+          : prev
+      )
+      if (selected === id) setSelected(null)
+    },
+    [selected]
+  )
+
+  /**
+   * A new block lands after the selected one, or above the footer when
+   * nothing is selected: the end of the mail is the sign-off, not where
+   * new words go.
+   */
+  const addBlock = useCallback(
+    (type: EmailBlockType) => {
+      const block: EmailBlock = { id: newBlockId(type), type }
+      if (type === 'attachment_note') {
+        block.text = preset.blocks.find((b) => b.type === 'attachment_note')?.text ?? ''
+      } else if (type === 'heading' || type === 'paragraph' || type === 'callout') {
+        block.text = ''
+      } else if (type === 'button') {
+        block.label = ''
+        block.href = spec.tags.includes('share_link')
+          ? '{share_link}'
+          : spec.tags.includes('signin_link')
+            ? '{signin_link}'
+            : '{portal_link}'
+      } else if (type === 'spacer') {
+        block.height = 16
+      } else if (type === 'image') {
+        block.align = 'center'
+        block.width = EMAIL_IMAGE_MAX_WIDTH
+        block.alt = ''
+      }
+      setTemplate((prev) => {
+        const blocks = [...prev.blocks]
+        const after = selected ? blocks.findIndex((b) => b.id === selected) : -1
+        const footer = blocks.findIndex((b) => b.type === 'contact_footer')
+        const at = after >= 0 ? after + 1 : footer >= 0 ? footer : blocks.length
+        blocks.splice(at, 0, block)
+        return { ...prev, blocks }
+      })
+      setSelected(block.id)
+    },
+    [preset.blocks, selected, spec.tags]
+  )
+
+  const addable = useMemo(() => {
+    const present = new Set(template.blocks.map((block) => block.type))
+    return EMAIL_BLOCK_TYPES.filter((type) => {
+      if (ONCE.has(type) && present.has(type)) return false
+      if (type === 'document_summary' && !spec.hasSummary) return false
+      if (type === 'attachment_note' && !spec.hasAttachment) return false
+      return true
+    })
+  }, [template.blocks, spec])
+
+  const inputFor = (templateName: string, id?: string): SaveEmailTemplateInput => ({
+    id,
+    kind,
+    name: templateName,
+    subject: template.subject,
+    blocks: template.blocks,
+    theme: template.theme,
+  })
+
+  /**
+   * From the preset, Save asks for a name and writes a new row; from a saved
+   * template it updates that row in place. Either way the row becomes the
+   * one this kind sends with, which is what the server does on save.
+   */
+  const save = async (mode: 'update' | 'first' | 'copy', nameOverride?: string) => {
+    if (problems.length || saving) return
+    if (mode !== 'update' && !nameOverride) {
+      setName(mode === 'first' ? template.name || preset.name : '')
+      setNaming(mode)
+      return
+    }
+    const id = mode === 'update' ? saved?.id : undefined
+    const templateName = mode === 'update' ? (saved?.name ?? template.name) : (nameOverride ?? '')
+
+    // What went over the wire. Anything typed while the request was out is
+    // newer than the answer and stays; only an untouched template takes the
+    // saved copy, so the editor and the template never disagree.
+    const sent = template
+    setSaving(true)
+    try {
+      const result = await saveEmailTemplate(inputFor(templateName, id))
+      if (!result.success || !result.data) {
+        throw new Error(result.success ? 'No template returned' : result.error)
+      }
+      const row = result.data
+      const next = stripSaved(row)
+      setSaved(row)
+      setActiveId(row.id)
+      setBaseline(clone(next))
+      setTemplate((prev) => (prev === sent ? clone(next) : prev))
+      setNames((prev) => [{ id: row.id, name: row.name }, ...prev.filter((n) => n.id !== row.id)])
+      setNaming(null)
+      if (row.id !== saved?.id) {
+        router.replace(`/email-designer?kind=${kind}&template=${row.id}`)
+      }
+      toast.success(t('saved', { name: row.name }))
+    } catch (error) {
+      const message = error instanceof Error ? error.message : ''
+      toast.error(HUMAN_SAVE_ERROR.test(message) ? message : t('couldNotSave'))
+    } finally {
+      setSaving(false)
+    }
+  }
+
+  const discard = () => {
+    setTemplate(clone(baseline))
+    setSelected(null)
+  }
+
+  const leave = async () => {
+    if (dirty) {
+      const go = await confirm({
+        title: t('leaveTitle'),
+        description: t('leaveBody'),
+        confirmLabel: t('leaveConfirm'),
+      })
+      if (!go) return
+    }
+    router.push(SETTINGS_PAGE)
+  }
+
+  const remove = async () => {
+    if (!saved) return
+    const ok = await confirm({
+      title: t('deleteTitle'),
+      description: t('deleteConfirm', { name: saved.name }),
+      confirmLabel: t('delete'),
+      destructive: true,
+    })
+    if (!ok) return
+    const result = await deleteEmailTemplate(saved.id)
+    if (!result.success) {
+      toast.error(t('couldNotDelete'))
+      return
+    }
+    // Nothing left to keep editing, and the beforeunload guard has nothing
+    // left to protect: the baseline is what the row was.
+    setBaseline(template)
+    toast.success(t('deleted', { name: saved.name }))
+    router.push(SETTINGS_PAGE)
+  }
+
+  const sendTest = async () => {
+    setSending(true)
+    try {
+      const result = await sendTestEmail(inputFor(saved?.name ?? template.name), testEmail)
+      if (!result.success) throw new Error(result.error)
+      setTestOpen(false)
+      openModal(
+        'success',
+        t('testDialog.sentTitle'),
+        t('testDialog.sentBody', { email: testEmail })
+      )
+    } catch (error) {
+      openModal(
+        'error',
+        t('testDialog.failedTitle'),
+        error instanceof Error && error.message ? error.message : t('testDialog.failedBody')
+      )
+    } finally {
+      setSending(false)
+    }
+  }
+
+  const selectedBlock = selected
+    ? (template.blocks.find((block) => block.id === selected) ?? null)
+    : null
+  const nameTaken = names.some((n) => n.name.trim().toLowerCase() === name.trim().toLowerCase())
+  const canSave = dirty && problems.length === 0 && !saving
+  const inUse = saved ? activeId === saved.id : activeId === null
+
+  return (
+    <div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
+      <TooltipProvider delayDuration={400}>
+        <header className="grid h-[52px] flex-none grid-cols-[1fr_auto_1fr] items-center gap-3 border-b border-border bg-card px-3">
+          <div className="flex min-w-0 items-center gap-2">
+            <Button type="button" variant="ghost" size="sm" onClick={() => void leave()}>
+              <ArrowLeft /> {t('backToSettings')}
+            </Button>
+            <Separator orientation="vertical" className="h-5!" />
+            <div className="flex min-w-0 items-center gap-2 text-[13px]">
+              <span className="flex-none text-muted-foreground">{t(`kinds.${kind}.name`)}</span>
+              <span className="flex-none text-muted-foreground/60">/</span>
+              <span className="truncate font-semibold">{saved ? saved.name : t('builtIn')}</span>
+              {dirty && <span className="flex-none text-muted-foreground">{t('unsaved')}</span>}
+              {inUse && !dirty && (
+                <Badge className="flex-none text-[10.5px] font-semibold">{t('inUse')}</Badge>
+              )}
+            </div>
+          </div>
+
+          <div className="flex items-center gap-2">
+            <IconToggleGroup
+              label={t('previewWidth')}
+              value={width}
+              onChange={setWidth}
+              options={[
+                { value: 'desktop', label: t('desktopTooltip'), icon: <Monitor /> },
+                { value: 'mobile', label: t('mobileTooltip'), icon: <Smartphone /> },
+              ]}
+            />
+            <IconToggleGroup
+              label={t('previewView')}
+              value={view}
+              onChange={setView}
+              options={[
+                { value: 'html', label: t('htmlViewTooltip'), icon: <Code /> },
+                { value: 'text', label: t('textViewTooltip'), icon: <FileText /> },
+              ]}
+            />
+          </div>
+
+          <div className="flex items-center justify-end gap-1.5">
+            {problems.length > 0 && (
+              <Tooltip>
+                <TooltipTrigger asChild>
+                  <Badge variant="destructive" className="mr-1 h-8 gap-1.5 px-2.5 text-xs">
+                    <AlertTriangle /> {t('problems', { count: problems.length })}
+                  </Badge>
+                </TooltipTrigger>
+                <TooltipContent side="bottom" className="max-w-[320px] whitespace-pre-line">
+                  {problems.join('\n')}
+                </TooltipContent>
+              </Tooltip>
+            )}
+
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              disabled={saving || problems.length > 0}
+              onClick={() => {
+                setTestEmail(userEmail)
+                setTestOpen(true)
+              }}
+            >
+              <Send /> {t('sendTest')}
+            </Button>
+
+            {saved && (
+              <Button
+                type="button"
+                variant="ghost"
+                size="sm"
+                title={t('deleteTemplate')}
+                disabled={saving}
+                onClick={() => void remove()}
+                className="text-destructive hover:bg-destructive/10 hover:text-destructive"
+              >
+                <Trash2 /> {t('delete')}
+              </Button>
+            )}
+
+            <Separator orientation="vertical" className="mx-1 h-5!" />
+
+            <Button
+              type="button"
+              variant="outline"
+              size="sm"
+              disabled={!dirty || saving}
+              onClick={discard}
+            >
+              <RotateCcw /> {t('discard')}
+            </Button>
+
+            {saved && (
+              <Button
+                type="button"
+                variant="outline"
+                size="sm"
+                disabled={saving || problems.length > 0}
+                onClick={() => void save('copy')}
+              >
+                <Copy /> {t('saveAsNew')}
+              </Button>
+            )}
+
+            <Button
+              type="button"
+              size="sm"
+              disabled={!canSave}
+              onClick={() => void save(saved ? 'update' : 'first')}
+            >
+              <Save /> {saving ? t('saving') : dirty ? t('save') : t('savedState')}
+            </Button>
+          </div>
+        </header>
+      </TooltipProvider>
+
+      <div className="relative flex min-h-0 flex-1">
+        <EmailDesignerRail
+          blocks={template.blocks}
+          selected={selected}
+          addable={addable}
+          canRemove={template.blocks.length > 1}
+          onSelect={setSelected}
+          onToggle={toggleBlock}
+          onMove={moveBlock}
+          onAdd={addBlock}
+          onRemove={removeBlock}
+        />
+
+        <EmailPreview
+          from={sample.values.workshop_name ?? ''}
+          subject={subject}
+          previewLine={previewLine}
+          html={html}
+          text={text}
+          width={width}
+          view={view}
+          selected={selected}
+          onSelect={setSelected}
+        />
+
+        <EmailDesignerInspector
+          kind={kind}
+          subject={template.subject}
+          theme={template.theme}
+          selected={selectedBlock}
+          problems={problems}
+          canRemove={template.blocks.length > 1}
+          onSubject={(value) => setTemplate((prev) => ({ ...prev, subject: value }))}
+          onTheme={(patch) =>
+            setTemplate((prev) => ({ ...prev, theme: { ...prev.theme, ...patch } }))
+          }
+          onBlock={patchBlock}
+          onRemove={removeBlock}
+        />
+      </div>
+
+      {/* The dialog stays open until the save has answered: a failed save
+          keeps the typed name where it was, a successful one closes it. */}
+      <Dialog
+        open={naming !== null}
+        onOpenChange={(open) => {
+          if (!open && !saving) setNaming(null)
+        }}
+      >
+        <DialogContent className="sm:max-w-sm">
+          <DialogHeader>
+            <DialogTitle>{t('nameDialog.title')}</DialogTitle>
+            <DialogDescription>{t('nameDialog.body')}</DialogDescription>
+          </DialogHeader>
+          <form
+            onSubmit={(e) => {
+              e.preventDefault()
+              const mode = naming
+              if (!mode || !name.trim() || nameTaken || saving) return
+              void save(mode, name.trim())
+            }}
+          >
+            <Input
+              value={name}
+              onChange={(e) => setName(e.target.value)}
+              placeholder={t('nameDialog.placeholder')}
+              maxLength={60}
+              aria-invalid={nameTaken}
+              autoFocus
+            />
+            {nameTaken && (
+              <p role="alert" className="mt-2 text-xs text-destructive">
+                {t('nameDialog.nameExists')}
+              </p>
+            )}
+            <DialogFooter className="mt-4">
+              <Button
+                type="button"
+                variant="outline"
+                disabled={saving}
+                onClick={() => setNaming(null)}
+              >
+                {t('cancel')}
+              </Button>
+              <Button type="submit" disabled={!name.trim() || nameTaken || saving}>
+                {saving ? t('saving') : t('nameDialog.save')}
+              </Button>
+            </DialogFooter>
+          </form>
+        </DialogContent>
+      </Dialog>
+
+      <Dialog open={testOpen} onOpenChange={setTestOpen}>
+        <DialogContent className="sm:max-w-sm">
+          <DialogHeader>
+            <DialogTitle>{t('testDialog.title')}</DialogTitle>
+            <DialogDescription>{t('testDialog.body')}</DialogDescription>
+          </DialogHeader>
+          <form
+            onSubmit={(e) => {
+              e.preventDefault()
+              if (!testEmail.trim()) return
+              void sendTest()
+            }}
+          >
+            <Input
+              type="email"
+              value={testEmail}
+              onChange={(e) => setTestEmail(e.target.value)}
+              placeholder={t('testDialog.placeholder')}
+              autoFocus
+            />
+            <DialogFooter className="mt-4">
+              <Button type="button" variant="outline" onClick={() => setTestOpen(false)}>
+                {t('cancel')}
+              </Button>
+              <Button type="submit" disabled={sending || !testEmail.trim()}>
+                {sending ? t('testDialog.sending') : t('testDialog.send')}
+              </Button>
+            </DialogFooter>
+          </form>
+        </DialogContent>
+      </Dialog>
+    </div>
+  )
+}
+
+/**
+ * Two or three icons that pick one thing, the way a design tool's top bar
+ * does it: one pressed, a tooltip on each, the height of every other
+ * control on the bar.
+ */
+function IconToggleGroup<T extends string>({
+  label,
+  value,
+  onChange,
+  options,
+}: {
+  /** What the group as a whole chooses, for a screen reader. */
+  label: string
+  value: T
+  onChange: (value: T) => void
+  options: { value: T; label: string; icon: ReactNode }[]
+}) {
+  return (
+    <div
+      role="group"
+      aria-label={label}
+      className="flex h-8 items-center gap-0.5 rounded-md bg-muted p-0.5"
+    >
+      {options.map((option) => {
+        const active = option.value === value
+        return (
+          <Tooltip key={option.value}>
+            <TooltipTrigger asChild>
+              <button
+                type="button"
+                aria-label={option.label}
+                aria-pressed={active}
+                onClick={() => onChange(option.value)}
+                className={cn(
+                  'flex h-7 w-8 items-center justify-center rounded-[5px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-4',
+                  active
+                    ? 'bg-background text-foreground shadow-xs'
+                    : 'text-muted-foreground hover:text-foreground'
+                )}
+              >
+                {option.icon}
+              </button>
+            </TooltipTrigger>
+            <TooltipContent side="bottom">{option.label}</TooltipContent>
+          </Tooltip>
+        )
+      })}
+    </div>
+  )
+}
+
+/** A saved row as a bare template, for comparing and editing. */
+function stripSaved(row: SavedEmailTemplate): EmailTemplate {
+  return {
+    kind: row.kind,
+    name: row.name,
+    subject: row.subject,
+    blocks: row.blocks,
+    theme: row.theme,
+  }
+}

+ 166 - 0
src/features/email/Components/EmailDesignerControls.tsx

@@ -0,0 +1,166 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import type { ReactNode } from 'react'
+import { Input } from '@/components/ui/input'
+import { Slider as UiSlider } from '@/components/ui/slider'
+import { Switch } from '@/components/ui/switch'
+import { cn } from '@/lib/utils'
+
+/**
+ * The inspector's vocabulary, in the app's own visual language: hairline
+ * groups, one label per row, the shared switch and slider. Kept local rather
+ * than shared with the invoice inspector so the two tools can drift apart on
+ * purpose without one breaking the other.
+ */
+
+export function Group({ title, children }: { title: string; children: ReactNode }) {
+  return (
+    <div className="border-t border-border pt-2 first:border-t-0 first:pt-0">
+      <div className="pb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
+        {title}
+      </div>
+      <div className="space-y-2.5">{children}</div>
+    </div>
+  )
+}
+
+export function Row({ label, children }: { label: string; children: ReactNode }) {
+  return (
+    <div className="flex items-center justify-between gap-2">
+      <span className="text-[13px] font-medium text-foreground">{label}</span>
+      {children}
+    </div>
+  )
+}
+
+export function Toggle({
+  on,
+  onChange,
+  label,
+}: {
+  on: boolean
+  onChange: (on: boolean) => void
+  label: string
+}) {
+  return <Switch checked={on} onCheckedChange={onChange} aria-label={label} />
+}
+
+export function Slider({
+  label,
+  value,
+  min,
+  max,
+  suffix,
+  onChange,
+}: {
+  label: string
+  value: number
+  min: number
+  max: number
+  suffix: string
+  onChange: (value: number) => void
+}) {
+  return (
+    <div>
+      <div className="mb-2 flex justify-between text-[13px] font-medium text-foreground">
+        <span>{label}</span>
+        <span className="font-normal text-muted-foreground">
+          {value}
+          {suffix}
+        </span>
+      </div>
+      <UiSlider
+        min={min}
+        max={max}
+        value={[value]}
+        aria-label={label}
+        onValueChange={([next]) => {
+          if (next !== undefined) onChange(next)
+        }}
+      />
+    </div>
+  )
+}
+
+export function Choice<T extends string>({
+  label,
+  options,
+  value,
+  onChange,
+}: {
+  /** What the choice is about, for a screen reader; the options say the rest. */
+  label: string
+  options: { value: T; label: string }[]
+  value: T
+  onChange: (value: T) => void
+}) {
+  return (
+    <div role="group" aria-label={label} className="flex gap-0.5 rounded-md bg-muted p-0.5">
+      {options.map((option) => {
+        const active = value === option.value
+        return (
+          <button
+            key={option.value}
+            type="button"
+            aria-pressed={active}
+            onClick={() => onChange(option.value)}
+            className={cn(
+              'flex-1 rounded-[5px] px-2 py-1.5 text-[12.5px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring',
+              active
+                ? 'bg-background font-semibold text-foreground shadow-xs'
+                : 'text-muted-foreground hover:text-foreground'
+            )}
+          >
+            {option.label}
+          </button>
+        )
+      })}
+    </div>
+  )
+}
+
+export const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
+
+/**
+ * A colour as a swatch and as the hex beside it. The hex is editable because
+ * a brand colour arrives as text, not as something to eyeball in a picker.
+ * The swatch shows the last valid colour while the text is half typed.
+ */
+export function ColorField({
+  label,
+  value,
+  onChange,
+}: {
+  label: string
+  value: string
+  onChange: (value: string) => void
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  const valid = HEX_COLOR.test(value)
+  return (
+    <div className="flex items-center gap-2.5">
+      <input
+        type="color"
+        value={valid ? value : '#000000'}
+        aria-label={label}
+        onChange={(e) => onChange(e.target.value)}
+        className="h-[30px] w-[34px] cursor-pointer rounded-md border border-input bg-background p-0.5 outline-none focus-visible:ring-2 focus-visible:ring-ring"
+      />
+      <span className="flex-1 text-[13px] font-medium text-foreground">{label}</span>
+      <Input
+        value={value}
+        onChange={(e) => onChange(e.target.value.trim())}
+        maxLength={7}
+        spellCheck={false}
+        aria-label={t('theme.hexLabel', { label })}
+        aria-invalid={!valid}
+        className="h-7 w-[76px] px-1.5 font-mono text-[11.5px] md:text-[11.5px]"
+      />
+    </div>
+  )
+}
+
+export function Note({ children }: { children: ReactNode }) {
+  return <p className="text-xs leading-relaxed text-muted-foreground">{children}</p>
+}

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

@@ -0,0 +1,811 @@
+'use client'
+
+import { GripVertical, ImageOff, Loader2, Trash2, Upload } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import {
+  type PointerEvent as ReactPointerEvent,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
+} from 'react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { cn } from '@/lib/utils'
+import { type EmailKind, kindSpec } from '../Lib/emailKinds'
+import {
+  EMAIL_FONT_IDS,
+  EMAIL_IMAGE_MAX_WIDTH,
+  EMAIL_IMAGE_MIN_WIDTH,
+  EMAIL_LOGO_MAX_WIDTH,
+  EMAIL_LOGO_MIN_WIDTH,
+  BLOCK_ALIGNS,
+  type BlockAlign,
+  type EmailBlock,
+  type EmailFontId,
+  type EmailTheme,
+  emailImagePublicPath,
+  emailLogoPublicPath,
+  SUMMARY_ROWS,
+} from '../Lib/emailTemplate'
+import { plainToRich } from '../Lib/richText'
+import { Choice, ColorField, Group, Note, Row, Slider, Toggle } from './EmailDesignerControls'
+import { EmailRichEditor } from './EmailRichEditor'
+import { TagField, TagPicker, useTagTargets } from './TagPicker'
+
+export interface InspectorProps {
+  kind: EmailKind
+  subject: string
+  theme: EmailTheme
+  selected: EmailBlock | null
+  /** Everything wrong with the template right now, in the workshop's words. */
+  problems: string[]
+  canRemove: boolean
+  onSubject: (subject: string) => void
+  onTheme: (patch: Partial<EmailTheme>) => void
+  onBlock: (id: string, patch: Partial<EmailBlock>) => void
+  onRemove: (id: string) => void
+}
+
+const INSPECTOR_MIN_WIDTH = 300
+const INSPECTOR_MAX_WIDTH = 720
+const INSPECTOR_DEFAULT_WIDTH = 360
+const INSPECTOR_WIDTH_KEY = 'email-designer.inspector-width'
+
+const clampWidth = (value: number) =>
+  Math.min(INSPECTOR_MAX_WIDTH, Math.max(INSPECTOR_MIN_WIDTH, Math.round(value)))
+
+/**
+ * How wide the panel is, remembered per browser. Read after mount so the
+ * server and the first client paint agree; storage may be unavailable or
+ * full, and neither is worth more than the default width.
+ */
+function useInspectorWidth() {
+  const [width, setWidth] = useState(INSPECTOR_DEFAULT_WIDTH)
+
+  useEffect(() => {
+    try {
+      const stored = Number(window.localStorage.getItem(INSPECTOR_WIDTH_KEY))
+      if (Number.isFinite(stored) && stored > 0) setWidth(clampWidth(stored))
+    } catch {
+      // Storage refused; the default width is fine.
+    }
+  }, [])
+
+  const remember = useCallback((value: number | null) => {
+    try {
+      if (value === null) window.localStorage.removeItem(INSPECTOR_WIDTH_KEY)
+      else window.localStorage.setItem(INSPECTOR_WIDTH_KEY, String(value))
+    } catch {
+      // Storage refused; the width lasts for the tab.
+    }
+  }, [])
+
+  return { width, setWidth, remember }
+}
+
+/**
+ * The right-hand panel: the selected block's words, or the subject and the
+ * look when nothing is selected. Keyed on the selection so the tag picker's
+ * idea of the last-focused field starts over with each block.
+ *
+ * Its left edge is a handle: the panel can be dragged wider for a long
+ * paragraph and back for a wide preview, and the width is remembered.
+ */
+export function EmailDesignerInspector(props: InspectorProps) {
+  const t = useTranslations('settings.emailTemplates')
+  const { width, setWidth, remember } = useInspectorWidth()
+  const [dragging, setDragging] = useState(false)
+  const drag = useRef<{ startX: number; startWidth: number } | null>(null)
+
+  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
+    if (event.button !== 0) return
+    event.preventDefault()
+    drag.current = { startX: event.clientX, startWidth: width }
+    event.currentTarget.setPointerCapture(event.pointerId)
+    setDragging(true)
+  }
+
+  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
+    if (!drag.current) return
+    setWidth(clampWidth(drag.current.startWidth + (drag.current.startX - event.clientX)))
+  }
+
+  const onPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
+    if (!drag.current) return
+    drag.current = null
+    event.currentTarget.releasePointerCapture(event.pointerId)
+    setDragging(false)
+    remember(width)
+  }
+
+  const reset = () => {
+    setWidth(INSPECTOR_DEFAULT_WIDTH)
+    remember(null)
+  }
+
+  return (
+    <div
+      className="relative flex flex-none flex-col border-l border-border bg-card text-card-foreground"
+      style={{ width }}
+    >
+      {/* Something for the pointer to land on while it crosses the preview's frame. */}
+      {dragging && <div className="fixed inset-0 z-40 cursor-col-resize" />}
+      <div
+        role="separator"
+        aria-orientation="vertical"
+        aria-valuenow={width}
+        aria-valuemin={INSPECTOR_MIN_WIDTH}
+        aria-valuemax={INSPECTOR_MAX_WIDTH}
+        title={t('resizeHint')}
+        onPointerDown={onPointerDown}
+        onPointerMove={onPointerMove}
+        onPointerUp={onPointerUp}
+        onPointerCancel={onPointerUp}
+        onDoubleClick={reset}
+        className="group absolute inset-y-0 -left-1.5 z-50 w-3 cursor-col-resize"
+      >
+        <div
+          className={cn(
+            'absolute inset-y-0 left-[5px] w-px bg-border transition-colors group-hover:bg-primary',
+            dragging && 'bg-primary'
+          )}
+        />
+        {/* A grip in the middle of the edge, so the edge reads as something to take hold of. */}
+        <div
+          className={cn(
+            'absolute top-1/2 left-1/2 flex h-9 w-4 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-xs transition-colors group-hover:border-primary group-hover:text-primary',
+            dragging && 'border-primary text-primary'
+          )}
+        >
+          <GripVertical size={12} />
+        </div>
+      </div>
+      <div className="min-h-0 flex-1 overflow-y-auto">
+        <InspectorBody key={props.selected?.id ?? 'settings'} {...props} />
+      </div>
+    </div>
+  )
+}
+
+function InspectorBody({
+  kind,
+  subject,
+  theme,
+  selected,
+  problems,
+  canRemove,
+  onSubject,
+  onTheme,
+  onBlock,
+  onRemove,
+}: InspectorProps) {
+  const t = useTranslations('settings.emailTemplates')
+  const targets = useTagTargets()
+  const spec = kindSpec(kind)
+
+  const title = selected ? t(`blocks.${selected.type}`) : t('subjectAndTheme')
+  const hasTagFields =
+    !selected ||
+    ['heading', 'paragraph', 'callout', 'attachment_note', 'button', 'image'].includes(
+      selected.type
+    )
+
+  return (
+    <div className="space-y-4 p-3.5">
+      <div>
+        <div className="text-sm font-semibold text-foreground">{title}</div>
+        {!selected && <Note>{t('settingsSubtitle')}</Note>}
+      </div>
+
+      {problems.length > 0 && (
+        <div className="space-y-1 rounded-md border border-destructive/30 bg-destructive/10 px-2.5 py-2 text-xs leading-snug text-destructive">
+          {problems.map((problem) => (
+            <div key={problem}>{problem}</div>
+          ))}
+        </div>
+      )}
+
+      {selected ? (
+        <BlockFields
+          block={selected}
+          kind={kind}
+          theme={theme}
+          onTheme={onTheme}
+          targets={targets}
+          onBlock={onBlock}
+        />
+      ) : (
+        <>
+          <TagField
+            id="subject"
+            label={t('subject')}
+            value={subject}
+            onChange={onSubject}
+            targets={targets}
+            maxLength={200}
+          />
+          <ThemeFields theme={theme} onTheme={onTheme} />
+        </>
+      )}
+
+      {hasTagFields && (
+        <Group title={t('tagsTitle')}>
+          <Note>{t('tagsHint')}</Note>
+          <TagPicker tags={spec.tags} onInsert={targets.insert} />
+        </Group>
+      )}
+
+      {selected && (
+        <div className="border-t border-border pt-3">
+          <Button
+            type="button"
+            variant="ghost"
+            size="sm"
+            disabled={!canRemove}
+            onClick={() => onRemove(selected.id)}
+            className="text-destructive hover:bg-destructive/10 hover:text-destructive"
+          >
+            <Trash2 /> {t('removeBlock')}
+          </Button>
+        </div>
+      )}
+    </div>
+  )
+}
+
+function BlockFields({
+  block,
+  kind,
+  theme,
+  targets,
+  onBlock,
+  onTheme,
+}: {
+  block: EmailBlock
+  kind: EmailKind
+  theme: EmailTheme
+  targets: ReturnType<typeof useTagTargets>
+  onBlock: (id: string, patch: Partial<EmailBlock>) => void
+  onTheme: (patch: Partial<EmailTheme>) => void
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  const spec = kindSpec(kind)
+
+  switch (block.type) {
+    case 'header':
+      return (
+        <>
+          <LogoFields theme={theme} onTheme={onTheme} />
+          <Group title={t('logo.position')}>
+            <AlignChoice
+              value={block.align ?? 'left'}
+              onChange={(align) => onBlock(block.id, { align })}
+            />
+          </Group>
+          <Note>{t('blockNotes.header')}</Note>
+        </>
+      )
+
+    case 'contact_footer':
+      return <Note>{t('blockNotes.contact_footer')}</Note>
+
+    case 'divider':
+      return <Note>{t('blockNotes.divider')}</Note>
+
+    case 'heading':
+    case 'paragraph':
+    case 'callout':
+    case 'attachment_note':
+      return (
+        <div className="space-y-2">
+          <EmailRichEditor
+            id={`${block.id}-text`}
+            label={t('text')}
+            // A block written before formatting existed opens as its plain
+            // words; the first edit writes the tree and leaves `text` alone.
+            value={block.content ?? plainToRich(block.text ?? '')}
+            theme={theme}
+            targets={targets}
+            onChange={(content) => onBlock(block.id, { content })}
+          />
+          {block.type === 'attachment_note' && <Note>{t('blockNotes.attachment_note')}</Note>}
+          {block.type === 'callout' && <Note>{t('blockNotes.callout')}</Note>}
+        </div>
+      )
+
+    case 'button':
+      return (
+        <div className="space-y-2">
+          <Row label={t('logo.position')}>
+            <AlignChoice
+              value={block.align ?? 'left'}
+              onChange={(align) => onBlock(block.id, { align })}
+            />
+          </Row>
+          <TagField
+            id={`${block.id}-label`}
+            label={t('buttonLabel')}
+            value={block.label ?? ''}
+            onChange={(label) => onBlock(block.id, { label })}
+            targets={targets}
+            maxLength={120}
+          />
+          <TagField
+            id={`${block.id}-href`}
+            label={t('buttonHref')}
+            value={block.href ?? ''}
+            onChange={(href) => onBlock(block.id, { href })}
+            targets={targets}
+            maxLength={500}
+            mono
+          />
+          <Note>{t('blockNotes.button')}</Note>
+        </div>
+      )
+
+    case 'image':
+      return <ImageFields block={block} theme={theme} targets={targets} onBlock={onBlock} />
+
+    case 'spacer':
+      return (
+        <Slider
+          label={t('spacerHeight')}
+          value={block.height ?? 16}
+          min={0}
+          max={120}
+          suffix="px"
+          onChange={(height) => onBlock(block.id, { height })}
+        />
+      )
+
+    case 'document_summary': {
+      // No list means every row; the first change writes the list out.
+      const shown = new Set(block.rows ?? SUMMARY_ROWS)
+      return (
+        <div className="space-y-2">
+          <Note>{t('blockNotes.document_summary')}</Note>
+          {SUMMARY_ROWS.map((row) => (
+            <Row key={row} label={t(`summaryRows.${row}`)}>
+              <Toggle
+                label={t(`summaryRows.${row}`)}
+                on={shown.has(row)}
+                onChange={(on) => {
+                  const rows = SUMMARY_ROWS.filter((r) => (r === row ? on : shown.has(r)))
+                  onBlock(block.id, { rows })
+                }}
+              />
+            </Row>
+          ))}
+          {!spec.hasSummary && <Note>{t('blockNotes.summaryUnused')}</Note>}
+        </div>
+      )
+    }
+
+    default:
+      return null
+  }
+}
+
+function ThemeFields({
+  theme,
+  onTheme,
+}: {
+  theme: EmailTheme
+  onTheme: (patch: Partial<EmailTheme>) => void
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  return (
+    <>
+      <Group title={t('theme.colors')}>
+        <ColorField
+          label={t('theme.primaryColor')}
+          value={theme.primaryColor}
+          onChange={(primaryColor) => onTheme({ primaryColor })}
+        />
+        <ColorField
+          label={t('theme.textColor')}
+          value={theme.textColor}
+          onChange={(textColor) => onTheme({ textColor })}
+        />
+        <ColorField
+          label={t('theme.mutedColor')}
+          value={theme.mutedColor}
+          onChange={(mutedColor) => onTheme({ mutedColor })}
+        />
+        <ColorField
+          label={t('theme.backgroundColor')}
+          value={theme.backgroundColor}
+          onChange={(backgroundColor) => onTheme({ backgroundColor })}
+        />
+        <ColorField
+          label={t('theme.panelColor')}
+          value={theme.panelColor}
+          onChange={(panelColor) => onTheme({ panelColor })}
+        />
+      </Group>
+
+      <Group title={t('theme.typography')}>
+        <Row label={t('theme.font')}>
+          <Select
+            value={theme.fontFamily}
+            onValueChange={(value) => onTheme({ fontFamily: value as EmailFontId })}
+          >
+            <SelectTrigger size="sm" aria-label={t('theme.font')} className="text-xs">
+              <SelectValue />
+            </SelectTrigger>
+            <SelectContent>
+              {EMAIL_FONT_IDS.map((id) => (
+                <SelectItem key={id} value={id}>
+                  {t(`theme.fonts.${id}`)}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </Row>
+        <Note>{t('theme.fontHint')}</Note>
+      </Group>
+
+      <Group title={t('theme.shapes')}>
+        <Slider
+          label={t('theme.buttonRadius')}
+          value={theme.buttonRadius}
+          min={0}
+          max={32}
+          suffix="px"
+          onChange={(buttonRadius) => onTheme({ buttonRadius })}
+        />
+      </Group>
+    </>
+  )
+}
+
+const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp'
+const LOGO_MAX_BYTES = 4 * 1024 * 1024
+const IMAGE_ACCEPT = LOGO_ACCEPT
+const IMAGE_MAX_BYTES = 10 * 1024 * 1024
+
+/**
+ * An upload that outlives its panel. Discard, or picking another block,
+ * unmounts the fields while the request is out; the answer must then be
+ * dropped rather than written into whatever the template has become. The
+ * request itself is aborted, and `cancelled` covers the window between the
+ * cleanup and the rejection reaching the caller.
+ */
+function useUploadGuard() {
+  const cancelled = useRef(false)
+  const controller = useRef<AbortController | null>(null)
+
+  useEffect(() => {
+    cancelled.current = false
+    return () => {
+      cancelled.current = true
+      controller.current?.abort()
+    }
+  }, [])
+
+  /** A signal for the next upload; an earlier one still out is abandoned. */
+  const begin = useCallback(() => {
+    controller.current?.abort()
+    const next = new AbortController()
+    controller.current = next
+    cancelled.current = false
+    return next.signal
+  }, [])
+
+  return { begin, isCancelled: () => cancelled.current }
+}
+
+/** Sends one file to an upload route and returns the URL it was stored under. */
+async function postUpload(
+  route: string,
+  file: File,
+  signal: AbortSignal,
+  fallbackError: string
+): Promise<string> {
+  const body = new FormData()
+  body.append('file', file)
+  const response = await fetch(route, { method: 'POST', body, signal })
+  const json = (await response.json().catch(() => null)) as {
+    url?: string
+    error?: string
+  } | null
+  if (!response.ok || !json?.url) {
+    throw new Error(json?.error || fallbackError)
+  }
+  return json.url
+}
+
+/** Left, centre or right, for a block that sits across the mail. */
+function AlignChoice({
+  value,
+  onChange,
+}: {
+  value: BlockAlign
+  onChange: (value: BlockAlign) => void
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  return (
+    <Choice
+      label={t('logo.position')}
+      value={value}
+      onChange={onChange}
+      options={BLOCK_ALIGNS.map((align) => ({ value: align, label: t(`toolbar.align.${align}`) }))}
+    />
+  )
+}
+
+/**
+ * The logo the header prints: its own upload, not the company logo, so it
+ * can be trimmed and sized for a mail without touching the letterhead. The
+ * server does the trimming and resizing; this only sends the file and keeps
+ * the URL it answers with.
+ */
+function LogoFields({
+  theme,
+  onTheme,
+}: {
+  theme: EmailTheme
+  onTheme: (patch: Partial<EmailTheme>) => void
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  const input = useRef<HTMLInputElement>(null)
+  const [uploading, setUploading] = useState(false)
+  const guard = useUploadGuard()
+  const src = emailLogoPublicPath(theme.logoUrl)
+
+  const upload = async (file: File) => {
+    if (file.size > LOGO_MAX_BYTES) {
+      toast.error(t('logo.tooLarge'))
+      return
+    }
+    setUploading(true)
+    const signal = guard.begin()
+    try {
+      const url = await postUpload(
+        '/api/protected/upload/email-logo',
+        file,
+        signal,
+        t('logo.uploadFailed')
+      )
+      if (guard.isCancelled()) return
+      onTheme({ logoUrl: url, showLogo: true })
+    } catch (error) {
+      if (guard.isCancelled()) return
+      toast.error(error instanceof Error && error.message ? error.message : t('logo.uploadFailed'))
+    } finally {
+      setUploading(false)
+    }
+  }
+
+  return (
+    <Group title={t('logo.title')}>
+      <div
+        className="flex min-h-[88px] items-center justify-center overflow-hidden rounded-md border border-dashed border-border p-3"
+        style={{ background: theme.backgroundColor }}
+      >
+        {src ? (
+          <img
+            src={src}
+            alt={t('logo.title')}
+            style={{ width: theme.logoWidth, maxWidth: '100%', height: 'auto', display: 'block' }}
+          />
+        ) : (
+          <div className="flex flex-col items-center gap-1 text-xs text-muted-foreground">
+            <ImageOff size={18} />
+            <span>{t('logo.empty')}</span>
+          </div>
+        )}
+      </div>
+
+      <div className="flex flex-wrap items-center gap-1.5">
+        <input
+          ref={input}
+          type="file"
+          accept={LOGO_ACCEPT}
+          className="hidden"
+          onChange={(e) => {
+            const file = e.target.files?.[0]
+            e.target.value = ''
+            if (file) void upload(file)
+          }}
+        />
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          disabled={uploading}
+          onClick={() => input.current?.click()}
+        >
+          {uploading ? <Loader2 className="animate-spin" /> : <Upload />}
+          {uploading ? t('logo.uploading') : src ? t('logo.replace') : t('logo.upload')}
+        </Button>
+        {src && (
+          <Button
+            type="button"
+            variant="ghost"
+            size="sm"
+            disabled={uploading}
+            onClick={() => onTheme({ logoUrl: '' })}
+            className="text-destructive hover:bg-destructive/10 hover:text-destructive"
+          >
+            <Trash2 /> {t('logo.remove')}
+          </Button>
+        )}
+      </div>
+      <Note>{t('logo.hint')}</Note>
+
+      <Slider
+        label={t('logo.width')}
+        value={theme.logoWidth}
+        min={EMAIL_LOGO_MIN_WIDTH}
+        max={EMAIL_LOGO_MAX_WIDTH}
+        suffix="px"
+        onChange={(logoWidth) => onTheme({ logoWidth })}
+      />
+
+      <Row label={t('theme.showLogo')}>
+        <Toggle
+          label={t('theme.showLogo')}
+          on={theme.showLogo}
+          onChange={(showLogo) => onTheme({ showLogo })}
+        />
+      </Row>
+      <Note>{t('theme.showLogoHint')}</Note>
+    </Group>
+  )
+}
+
+/**
+ * A picture in the body of the mail. The server compresses and resizes what
+ * is uploaded; this sends the file, keeps the URL it answers with, and lets
+ * the picture be described, sized, placed and linked. Nothing is deleted
+ * from here: a picture no block refers to is swept once the template is
+ * saved, so removing one is only a matter of forgetting its URL.
+ */
+function ImageFields({
+  block,
+  theme,
+  targets,
+  onBlock,
+}: {
+  block: EmailBlock
+  theme: EmailTheme
+  targets: ReturnType<typeof useTagTargets>
+  onBlock: (id: string, patch: Partial<EmailBlock>) => void
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  const input = useRef<HTMLInputElement>(null)
+  const [uploading, setUploading] = useState(false)
+  const guard = useUploadGuard()
+  const src = emailImagePublicPath(block.src)
+  const width = block.width ?? EMAIL_IMAGE_MAX_WIDTH
+
+  const upload = async (file: File) => {
+    if (file.size > IMAGE_MAX_BYTES) {
+      toast.error(t('image.tooLarge'))
+      return
+    }
+    setUploading(true)
+    const signal = guard.begin()
+    try {
+      const url = await postUpload(
+        '/api/protected/upload/email-image',
+        file,
+        signal,
+        t('image.uploadFailed')
+      )
+      if (guard.isCancelled()) return
+      onBlock(block.id, { src: url })
+    } catch (error) {
+      if (guard.isCancelled()) return
+      toast.error(error instanceof Error && error.message ? error.message : t('image.uploadFailed'))
+    } finally {
+      setUploading(false)
+    }
+  }
+
+  return (
+    <div className="space-y-2.5">
+      <div
+        className="flex min-h-[88px] items-center justify-center overflow-hidden rounded-md border border-dashed border-border p-3"
+        style={{ background: theme.backgroundColor }}
+      >
+        {src ? (
+          <img
+            src={src}
+            alt={block.alt || t('blocks.image')}
+            style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
+          />
+        ) : (
+          <div className="flex flex-col items-center gap-1 text-center text-xs text-muted-foreground">
+            <ImageOff size={18} />
+            <span>{t('image.empty')}</span>
+          </div>
+        )}
+      </div>
+
+      <div className="flex flex-wrap items-center gap-1.5">
+        <input
+          ref={input}
+          type="file"
+          accept={IMAGE_ACCEPT}
+          className="hidden"
+          onChange={(e) => {
+            const file = e.target.files?.[0]
+            e.target.value = ''
+            if (file) void upload(file)
+          }}
+        />
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          disabled={uploading}
+          onClick={() => input.current?.click()}
+        >
+          {uploading ? <Loader2 className="animate-spin" /> : <Upload />}
+          {uploading ? t('image.uploading') : src ? t('image.replace') : t('image.upload')}
+        </Button>
+        {src && (
+          <Button
+            type="button"
+            variant="ghost"
+            size="sm"
+            disabled={uploading}
+            onClick={() => onBlock(block.id, { src: '' })}
+            className="text-destructive hover:bg-destructive/10 hover:text-destructive"
+          >
+            <Trash2 /> {t('image.remove')}
+          </Button>
+        )}
+      </div>
+
+      <TagField
+        id={`${block.id}-alt`}
+        label={t('image.alt')}
+        value={block.alt ?? ''}
+        onChange={(alt) => onBlock(block.id, { alt })}
+        targets={targets}
+        maxLength={200}
+      />
+      <Note>{t('image.altHint')}</Note>
+
+      <Slider
+        label={t('image.width')}
+        value={width}
+        min={EMAIL_IMAGE_MIN_WIDTH}
+        max={EMAIL_IMAGE_MAX_WIDTH}
+        suffix="px"
+        onChange={(next) => onBlock(block.id, { width: next })}
+      />
+      <Note>{t('image.widthHint')}</Note>
+
+      <Row label={t('logo.position')}>
+        <AlignChoice
+          value={block.align ?? 'center'}
+          onChange={(align) => onBlock(block.id, { align })}
+        />
+      </Row>
+
+      <TagField
+        id={`${block.id}-href`}
+        label={t('image.link')}
+        value={block.href ?? ''}
+        onChange={(href) => onBlock(block.id, { href })}
+        targets={targets}
+        maxLength={500}
+        mono
+      />
+      <Note>{t('image.linkHint')}</Note>
+      <Note>{t('blockNotes.image')}</Note>
+    </div>
+  )
+}

+ 403 - 0
src/features/email/Components/EmailDesignerRail.tsx

@@ -0,0 +1,403 @@
+'use client'
+
+import { Eye, EyeOff, GripVertical, Palette, Plus, Trash2 } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import {
+  type KeyboardEvent as ReactKeyboardEvent,
+  type PointerEvent as ReactPointerEvent,
+  useEffect,
+  useRef,
+  useState,
+} from 'react'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { cn } from '@/lib/utils'
+import { type EmailBlock, type EmailBlockType, blockWords } from '../Lib/emailTemplate'
+
+/** What a row says under its label: the first line of whatever the block holds. */
+function snippetOf(block: EmailBlock): string {
+  const source =
+    block.type === 'button'
+      ? (block.label ?? '')
+      : block.type === 'image'
+        ? imageSnippet(block)
+        : blockWords(block)
+  const line = source.split('\n').find((part) => part.trim()) ?? ''
+  return line.length > 48 ? `${line.slice(0, 48)}…` : line
+}
+
+/** An image row reads as its description, or failing that the file it was stored as. */
+function imageSnippet(block: EmailBlock): string {
+  if (block.alt?.trim()) return block.alt
+  return block.src?.split('/').pop() ?? ''
+}
+
+/** A visible block that would still print nothing with what it has. */
+function isEmpty(block: EmailBlock): boolean {
+  switch (block.type) {
+    case 'heading':
+    case 'paragraph':
+    case 'callout':
+    case 'attachment_note':
+      return !blockWords(block).trim()
+    case 'button':
+      return !block.label?.trim() || !block.href?.trim()
+    case 'image':
+      return !block.src
+    default:
+      return false
+  }
+}
+
+/**
+ * The blocks in the order the mail says them, with the same drag-to-reorder
+ * the document designer's rail has. Order is the whole layout of a mail, so
+ * this list is the canvas as much as the preview is.
+ */
+export function EmailDesignerRail({
+  blocks,
+  selected,
+  addable,
+  canRemove,
+  onSelect,
+  onToggle,
+  onMove,
+  onAdd,
+  onRemove,
+}: {
+  blocks: EmailBlock[]
+  selected: string | null
+  /** Block types the add menu offers, already filtered for this kind. */
+  addable: EmailBlockType[]
+  /** False when one block is all that is left; a mail cannot be empty. */
+  canRemove: boolean
+  onSelect: (id: string | null) => void
+  onToggle: (id: string) => void
+  /** Put the block at this position in the list, counted with the block itself taken out. */
+  onMove: (draggedId: string, toIndex: number) => void
+  onAdd: (type: EmailBlockType) => void
+  onRemove: (id: string) => void
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  const [adding, setAdding] = useState(false)
+  const list = useRef<HTMLDivElement>(null)
+
+  /**
+   * Dragging is done with pointer events rather than the browser's drag and
+   * drop, so the row can lift under the pointer and a line can show the slot
+   * it will snap into. `slot` is the gap the block would land in, counted in
+   * the list as it stands; the block is lifted from its own place only on
+   * release.
+   */
+  const [drag, setDrag] = useState<{ id: string; y: number; slot: number } | null>(null)
+  const dragRef = useRef<{ id: string; grabOffset: number } | null>(null)
+
+  const slotAt = (clientY: number): number => {
+    const rows = Array.from(list.current?.querySelectorAll<HTMLElement>('[data-rail-block]') ?? [])
+    let slot = 0
+    for (const row of rows) {
+      const rect = row.getBoundingClientRect()
+      if (clientY > rect.top + rect.height / 2) slot += 1
+    }
+    return slot
+  }
+
+  const onGrab = (event: ReactPointerEvent<HTMLElement>, id: string) => {
+    if (event.button !== 0) return
+    event.preventDefault()
+    event.stopPropagation()
+    const row = (event.currentTarget as HTMLElement).closest<HTMLElement>('[data-rail-block]')
+    const top = row?.getBoundingClientRect().top ?? event.clientY
+    dragRef.current = { id, grabOffset: event.clientY - top }
+    ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
+    setDrag({ id, y: event.clientY, slot: slotAt(event.clientY) })
+  }
+
+  const onDragMove = (event: ReactPointerEvent<HTMLElement>) => {
+    const current = dragRef.current
+    if (!current) return
+    // Keep the list scrolling when the pointer reaches its edges.
+    const box = list.current
+    if (box) {
+      const rect = box.getBoundingClientRect()
+      if (event.clientY < rect.top + 28) box.scrollTop -= 8
+      else if (event.clientY > rect.bottom - 28) box.scrollTop += 8
+    }
+    setDrag({ id: current.id, y: event.clientY, slot: slotAt(event.clientY) })
+  }
+
+  const onDrop = (event: ReactPointerEvent<HTMLElement>) => {
+    const current = dragRef.current
+    dragRef.current = null
+    ;(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId)
+    if (!current) return
+    const slot = slotAt(event.clientY)
+    const from = blocks.findIndex((block) => block.id === current.id)
+    const to = slot > from ? slot - 1 : slot
+    setDrag(null)
+    if (from !== -1 && to !== from) onMove(current.id, to)
+  }
+
+  const cancelDrag = () => {
+    dragRef.current = null
+    setDrag(null)
+  }
+
+  /**
+   * The keyboard's reorder: one step up or down from where the block is.
+   * `onMove` counts the target with the block taken out, so a step of one
+   * is a step of one there too.
+   */
+  const step = (id: string, index: number, direction: -1 | 1) => {
+    const to = index + direction
+    if (to < 0 || to >= blocks.length) return
+    onMove(id, to)
+  }
+
+  /** Alt with an arrow moves the block from anywhere in its row; the grip needs no Alt. */
+  const arrowOf = (event: ReactKeyboardEvent, needsAlt: boolean): -1 | 1 | 0 => {
+    if (needsAlt && !event.altKey) return 0
+    if (event.key === 'ArrowUp') return -1
+    if (event.key === 'ArrowDown') return 1
+    return 0
+  }
+
+  useEffect(() => {
+    if (!drag) return
+    const onKey = (event: KeyboardEvent) => {
+      if (event.key === 'Escape') cancelDrag()
+    }
+    window.addEventListener('keydown', onKey)
+    return () => window.removeEventListener('keydown', onKey)
+  }, [drag])
+
+  const dragged = drag ? blocks.find((block) => block.id === drag.id) : null
+  const dragFrom = drag ? blocks.findIndex((block) => block.id === drag.id) : -1
+  // A slot right beside the lifted row would put it back where it was.
+  const slotShown = drag && drag.slot !== dragFrom && drag.slot !== dragFrom + 1 ? drag.slot : null
+
+  // A block picked in the preview may be further down than the rail shows.
+  useEffect(() => {
+    if (!selected) return
+    const row = list.current?.querySelector<HTMLElement>(
+      `[data-rail-block="${CSS.escape(selected)}"]`
+    )
+    row?.scrollIntoView({ block: 'nearest' })
+  }, [selected])
+
+  return (
+    <div className="flex w-[252px] flex-none flex-col border-r border-border bg-card text-card-foreground">
+      <div className="px-2 pt-2.5">
+        <button
+          type="button"
+          onClick={() => onSelect(null)}
+          className={cn(
+            'flex w-full items-center gap-2 rounded-md px-2 py-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring',
+            selected === null
+              ? 'bg-primary/10 text-foreground ring-1 ring-primary/40'
+              : 'hover:bg-accent hover:text-accent-foreground'
+          )}
+        >
+          <Palette size={14} className="text-muted-foreground" />
+          <span className="flex-1 text-[13.5px] font-medium">{t('subjectAndTheme')}</span>
+        </button>
+      </div>
+
+      <div className="flex items-center justify-between px-3.5 pb-2 pt-3">
+        <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
+          {t('blocksTitle')}
+        </span>
+        <Popover open={adding} onOpenChange={setAdding}>
+          <PopoverTrigger asChild>
+            <Button type="button" variant="outline" size="xs" disabled={addable.length === 0}>
+              <Plus /> {t('addBlock')}
+            </Button>
+          </PopoverTrigger>
+          <PopoverContent align="end" className="w-52 p-1">
+            {addable.map((type) => (
+              <button
+                key={type}
+                type="button"
+                onClick={() => {
+                  onAdd(type)
+                  setAdding(false)
+                }}
+                className="flex w-full items-center rounded-md px-2 py-1.5 text-left text-[13px] outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground"
+              >
+                {t(`blocks.${type}`)}
+              </button>
+            ))}
+          </PopoverContent>
+        </Popover>
+      </div>
+
+      <div
+        ref={list}
+        className={cn(
+          'relative flex flex-1 flex-col gap-[3px] overflow-y-auto px-2 pb-3.5',
+          drag && 'select-none'
+        )}
+      >
+        {blocks.map((block, index) => {
+          const visible = block.visible !== false
+          const active = selected === block.id
+          const lifted = drag?.id === block.id
+          const snippet = snippetOf(block)
+          return (
+            <div key={block.id} className="relative">
+              {slotShown === index && <DropLine />}
+              <div
+                data-rail-block={block.id}
+                role="button"
+                tabIndex={0}
+                aria-pressed={active}
+                onClick={() => onSelect(block.id)}
+                onKeyDown={(event) => {
+                  const direction = arrowOf(event, true)
+                  if (direction) {
+                    event.preventDefault()
+                    step(block.id, index, direction)
+                    return
+                  }
+                  // Enter or Space on a button inside the row is that button's.
+                  if (event.target !== event.currentTarget) return
+                  if (event.key === 'Enter' || event.key === ' ') {
+                    event.preventDefault()
+                    onSelect(block.id)
+                  }
+                }}
+                className={cn(
+                  'group flex cursor-pointer items-center gap-1.5 rounded-md py-1.5 pr-1.5 pl-1 outline-none transition-[opacity,transform,background-color] duration-150 focus-visible:ring-2 focus-visible:ring-ring',
+                  active
+                    ? 'bg-primary/10 text-foreground ring-1 ring-primary/40'
+                    : 'hover:bg-accent hover:text-accent-foreground',
+                  !visible && 'opacity-45',
+                  lifted && 'opacity-30 ring-1 ring-dashed ring-border'
+                )}
+              >
+                <span
+                  role="button"
+                  tabIndex={0}
+                  aria-label={t('dragHandleKeyboard')}
+                  title={t('dragHandle')}
+                  onPointerDown={(event) => onGrab(event, block.id)}
+                  onPointerMove={onDragMove}
+                  onPointerUp={onDrop}
+                  onPointerCancel={cancelDrag}
+                  onLostPointerCapture={cancelDrag}
+                  onClick={(event) => event.stopPropagation()}
+                  onKeyDown={(event) => {
+                    const direction = arrowOf(event, false)
+                    if (!direction) return
+                    event.preventDefault()
+                    event.stopPropagation()
+                    step(block.id, index, direction)
+                  }}
+                  className={cn(
+                    'flex h-6 w-5 flex-none touch-none items-center justify-center rounded text-muted-foreground/50 outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',
+                    lifted ? 'cursor-grabbing' : 'cursor-grab'
+                  )}
+                >
+                  <GripVertical size={14} />
+                </span>
+                <span className="min-w-0 flex-1">
+                  <span className="block truncate text-[13.5px]">{t(`blocks.${block.type}`)}</span>
+                  {snippet && (
+                    <span className="block truncate text-[11px] text-muted-foreground">
+                      {snippet}
+                    </span>
+                  )}
+                </span>
+                {visible && isEmpty(block) && (
+                  <Badge
+                    variant="outline"
+                    className="px-1.5 py-0 text-[10px] text-muted-foreground"
+                    title={t('blockEmptyHint')}
+                  >
+                    {t('blockEmpty')}
+                  </Badge>
+                )}
+                <button
+                  type="button"
+                  onClick={(e) => {
+                    e.stopPropagation()
+                    onToggle(block.id)
+                  }}
+                  className={cn(
+                    'rounded px-0.5 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',
+                    visible ? 'text-muted-foreground' : 'text-muted-foreground/50'
+                  )}
+                  title={visible ? t('blockShownHint') : t('blockHiddenHint')}
+                  aria-label={visible ? t('blockShownHint') : t('blockHiddenHint')}
+                >
+                  {visible ? <Eye size={14} /> : <EyeOff size={14} />}
+                </button>
+                <button
+                  type="button"
+                  disabled={!canRemove}
+                  onClick={(e) => {
+                    e.stopPropagation()
+                    onRemove(block.id)
+                  }}
+                  className={cn(
+                    'rounded px-0.5 text-muted-foreground outline-none transition-opacity hover:text-destructive focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring disabled:hover:text-muted-foreground group-hover:opacity-100 disabled:group-hover:opacity-40',
+                    active ? 'opacity-100' : 'opacity-0'
+                  )}
+                  title={canRemove ? t('removeBlock') : t('removeBlockLast')}
+                  aria-label={t('removeBlock')}
+                >
+                  <Trash2 size={14} />
+                </button>
+              </div>
+            </div>
+          )
+        })}
+        {slotShown === blocks.length && (
+          <div className="relative h-0">
+            <DropLine />
+          </div>
+        )}
+
+        {/* The lifted row, following the pointer above the list. */}
+        {drag && dragged && (
+          <div
+            aria-hidden
+            className="pointer-events-none fixed z-50 flex items-center gap-1.5 rounded-md border border-primary/40 bg-card py-1.5 pr-3 pl-1 shadow-lg ring-1 ring-primary/20"
+            style={{
+              left: (list.current?.getBoundingClientRect().left ?? 0) + 8,
+              width: (list.current?.clientWidth ?? 240) - 16,
+              top: drag.y - (dragRef.current?.grabOffset ?? 12),
+            }}
+          >
+            <span className="flex h-6 w-5 items-center justify-center text-primary">
+              <GripVertical size={14} />
+            </span>
+            <span className="min-w-0 flex-1">
+              <span className="block truncate text-[13.5px]">{t(`blocks.${dragged.type}`)}</span>
+              {snippetOf(dragged) && (
+                <span className="block truncate text-[11px] text-muted-foreground">
+                  {snippetOf(dragged)}
+                </span>
+              )}
+            </span>
+          </div>
+        )}
+      </div>
+
+      <div className="border-t border-border px-3.5 py-2.5 text-[11.5px] leading-relaxed text-muted-foreground">
+        {t('railHint')}
+      </div>
+    </div>
+  )
+}
+
+/** The slot a dragged block will snap into: a line across the list with a dot at its start. */
+function DropLine() {
+  return (
+    <div className="pointer-events-none absolute inset-x-1 -top-[3px] z-10 flex items-center">
+      <span className="h-2 w-2 flex-none rounded-full bg-primary ring-2 ring-card" />
+      <span className="h-[2px] flex-1 rounded-full bg-primary" />
+    </div>
+  )
+}

+ 202 - 0
src/features/email/Components/EmailPreview.tsx

@@ -0,0 +1,202 @@
+'use client'
+
+import { useTranslations } from 'next-intl'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { EMAIL_DESIGN_WIDTH } from './EmailThumbnail'
+
+export type PreviewWidth = 'desktop' | 'mobile'
+export type PreviewView = 'html' | 'text'
+
+const WIDTHS: Record<PreviewWidth, number> = { desktop: EMAIL_DESIGN_WIDTH, mobile: 375 }
+
+/** How long a pause in typing is before the frame is rewritten. */
+const CANVAS_SETTLE_MS = 80
+
+/**
+ * The value once it has stopped changing for `delay`. The first value is
+ * taken at once, so the frame has a document from its first paint.
+ */
+function useSettled<T>(value: T, delay: number): T {
+  const [settled, setSettled] = useState(value)
+  useEffect(() => {
+    const timer = setTimeout(() => setSettled(value), delay)
+    return () => clearTimeout(timer)
+  }, [value, delay])
+  return settled
+}
+
+/**
+ * The mail as the customer meets it: a row in an inbox, then the message.
+ *
+ * The inbox row is there because the subject and the first line are what
+ * decide whether a mail is opened at all, and nothing else in the designer
+ * shows them side by side. The width is the closest a preview can get to a
+ * phone; the text view shows the part some readers see instead. Both are
+ * chosen in the designer's top bar.
+ *
+ * The mail itself is the canvas: the block selected in the rail is outlined
+ * here and scrolled into view, and a click on a block here selects it in the
+ * rail. The rendered HTML carries a data-block mark on every row for that,
+ * which only the preview asks the renderer for.
+ */
+export function EmailPreview({
+  from,
+  subject,
+  previewLine,
+  html,
+  text,
+  width,
+  view,
+  selected,
+  onSelect,
+}: {
+  from: string
+  subject: string
+  previewLine: string
+  /** Rendered with `marked`, so every row knows which block it is. */
+  html: string
+  text: string
+  width: PreviewWidth
+  view: PreviewView
+  selected: string | null
+  onSelect: (id: string | null) => void
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  const px = WIDTHS[width]
+  const initial = (from.trim()[0] ?? '?').toUpperCase()
+  // The inbox row and the text view follow every keystroke; the frame, which
+  // reloads a whole document on each change, waits for the typing to pause.
+  const canvasHtml = useSettled(html, CANVAS_SETTLE_MS)
+
+  return (
+    <div className="flex min-w-0 flex-1 flex-col overflow-hidden bg-background">
+      <div className="flex min-h-0 flex-1 justify-center overflow-auto px-6 py-6">
+        <div
+          className="flex max-w-full flex-col overflow-hidden rounded-[10px] border border-border bg-card text-card-foreground shadow-lg"
+          style={{ width: px, minHeight: 0 }}
+        >
+          <div className="flex flex-none items-center gap-3 border-b border-border px-4 py-3">
+            <div className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-primary text-[14px] font-semibold text-primary-foreground">
+              {initial}
+            </div>
+            <div className="min-w-0 flex-1">
+              <div className="flex items-baseline justify-between gap-3">
+                <span className="truncate text-[13.5px] font-semibold">
+                  {from || t('inboxNoSender')}
+                </span>
+                <span className="flex-none text-[11.5px] text-muted-foreground">
+                  {t('inboxNow')}
+                </span>
+              </div>
+              <div className="truncate text-[13px]">
+                <span className="font-medium">{subject || t('inboxNoSubject')}</span>
+                {previewLine && <span className="text-muted-foreground"> {previewLine}</span>}
+              </div>
+            </div>
+          </div>
+
+          {view === 'html' ? (
+            <MailCanvas
+              title={subject.trim() || t('htmlView')}
+              html={canvasHtml}
+              width={px}
+              selected={selected}
+              onSelect={onSelect}
+            />
+          ) : (
+            <pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words bg-muted p-5 font-mono text-[12.5px] leading-relaxed text-foreground">
+              {text}
+            </pre>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+
+/**
+ * The rendered mail in a frame the designer can reach into.
+ *
+ * Same origin, so the parent can outline a row and listen for clicks without
+ * a script inside the document; no scripts allowed, so nothing in the mail
+ * can run. Every edit rewrites the document, which would throw the scroll
+ * position away, so it is remembered and put back on each load: typing in
+ * the footer must not send the preview back to the top on every keystroke.
+ */
+function MailCanvas({
+  title,
+  html,
+  width,
+  selected,
+  onSelect,
+}: {
+  title: string
+  html: string
+  width: number
+  selected: string | null
+  onSelect: (id: string | null) => void
+}) {
+  const frame = useRef<HTMLIFrameElement>(null)
+  const scrollTop = useRef(0)
+  const [loads, setLoads] = useState(0)
+  // Which selection the frame has already scrolled to, so a reload caused by
+  // typing restores the scroll position rather than jumping to the block.
+  const scrolledTo = useRef<string | null>(null)
+
+  const frameDoc = useCallback(() => frame.current?.contentDocument ?? null, [])
+
+  // Clicks in the mail select the block they land on; a click beside the
+  // blocks selects nothing, which is the subject and theme in the rail.
+  useEffect(() => {
+    const doc = frameDoc()
+    if (!doc) return
+    const onClick = (event: MouseEvent) => {
+      event.preventDefault()
+      const target = event.target as HTMLElement | null
+      const row = target?.closest?.('[data-block]') as HTMLElement | null
+      onSelect(row?.dataset.block ?? null)
+    }
+    const onScroll = () => {
+      scrollTop.current = doc.documentElement.scrollTop || doc.body?.scrollTop || 0
+    }
+    doc.addEventListener('click', onClick)
+    doc.addEventListener('scroll', onScroll, { passive: true })
+    return () => {
+      doc.removeEventListener('click', onClick)
+      doc.removeEventListener('scroll', onScroll)
+    }
+  }, [loads, frameDoc, onSelect])
+
+  // Outline the selected block. After a reload, first put the scroll back
+  // where it was; only a fresh selection scrolls the mail.
+  useEffect(() => {
+    const doc = frameDoc()
+    if (!doc) return
+    const rows = Array.from(doc.querySelectorAll<HTMLElement>('[data-block]'))
+    let hit: HTMLElement | null = null
+    for (const row of rows) {
+      const isSelected = row.dataset.block === selected
+      row.classList.toggle('is-selected', isSelected)
+      if (isSelected) hit = row
+    }
+    if (scrolledTo.current !== selected) {
+      scrolledTo.current = selected
+      if (hit) hit.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
+    } else {
+      doc.documentElement.scrollTop = scrollTop.current
+    }
+  }, [loads, selected, frameDoc])
+
+  return (
+    <iframe
+      ref={frame}
+      title={title}
+      sandbox="allow-same-origin"
+      srcDoc={html}
+      tabIndex={-1}
+      onLoad={() => setLoads((n) => n + 1)}
+      className="min-h-0 flex-1"
+      style={{ width, border: 0, display: 'block' }}
+    />
+  )
+}

+ 675 - 0
src/features/email/Components/EmailRichEditor.tsx

@@ -0,0 +1,675 @@
+'use client'
+
+import { Extension, Mark, mergeAttributes } from '@tiptap/core'
+import Link from '@tiptap/extension-link'
+import Underline from '@tiptap/extension-underline'
+import { type Editor, EditorContent, useEditor, useEditorState } from '@tiptap/react'
+import StarterKit from '@tiptap/starter-kit'
+import {
+  AlignCenter,
+  AlignLeft,
+  AlignRight,
+  Baseline,
+  Bold,
+  Italic,
+  Link2,
+  List,
+  ListOrdered,
+  RemoveFormatting,
+  Strikethrough,
+  Underline as UnderlineIcon,
+} from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { type ReactNode, useEffect, useId, useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import type { EmailTheme } from '../Lib/emailTemplate'
+import {
+  RICH_FONT_SIZES,
+  type RichAlign,
+  type RichDoc,
+  type RichMark,
+  type RichNode,
+} from '../Lib/richText'
+import { normalizeHref } from '../Lib/links'
+import { HEX_COLOR } from './EmailDesignerControls'
+import type { TagTargets } from './TagPicker'
+
+/**
+ * The link strings quote a tag as an example. next-intl would read the
+ * braces as an argument, so the tag is handed in as its own text.
+ */
+const TAG_EXAMPLE = { share_link: '{share_link}' }
+
+type RichFontSize = (typeof RICH_FONT_SIZES)[number]
+
+const FONT_SIZE_KEYS: Record<RichFontSize, string> = {
+  '13px': 'small',
+  '15px': 'normal',
+  '18px': 'large',
+  '22px': 'extraLarge',
+}
+
+const ALIGNS: RichAlign[] = ['left', 'center', 'right']
+
+/** The size picker's word for "whatever the block's style says"; an item cannot be the empty string. */
+const DEFAULT_SIZE = 'default'
+
+/** A tag on its own, which the link field accepts as a destination. */
+const TAG_HREF = /^\{[a-z_]+\}$/
+
+/**
+ * Colour and size on a run of text, as one mark named the way the engine
+ * names it. The stock text-style and colour extensions are not installed,
+ * and this is all they would add: two attributes on a span.
+ */
+const TextStyle = Mark.create({
+  name: 'textStyle',
+  addAttributes() {
+    return {
+      color: {
+        default: null,
+        parseHTML: (element) => element.style.color || null,
+        renderHTML: (attrs) => (attrs.color ? { style: `color: ${attrs.color}` } : {}),
+      },
+      fontSize: {
+        default: null,
+        parseHTML: (element) => element.style.fontSize || null,
+        renderHTML: (attrs) => (attrs.fontSize ? { style: `font-size: ${attrs.fontSize}` } : {}),
+      },
+    }
+  },
+  parseHTML() {
+    return [{ tag: 'span', getAttrs: (element) => (element.hasAttribute('style') ? {} : false) }]
+  },
+  renderHTML({ HTMLAttributes }) {
+    return ['span', mergeAttributes(HTMLAttributes), 0]
+  },
+})
+
+/** Alignment as an attribute on the paragraph, which is where the engine reads it. */
+const TextAlign = Extension.create({
+  name: 'emailTextAlign',
+  addGlobalAttributes() {
+    return [
+      {
+        types: ['paragraph'],
+        attributes: {
+          textAlign: {
+            default: null,
+            parseHTML: (element) => element.style.textAlign || null,
+            renderHTML: (attrs) =>
+              attrs.textAlign ? { style: `text-align: ${attrs.textAlign}` } : {},
+          },
+        },
+      },
+    ]
+  },
+})
+
+const isFontSize = (value: unknown): value is RichFontSize =>
+  typeof value === 'string' && (RICH_FONT_SIZES as readonly string[]).includes(value)
+
+/**
+ * The editor's document as the engine's shape and nothing more: no null
+ * attributes, no list numbering, no link target. What is saved is what the
+ * schema names, and a block opened and left alone stays byte-for-byte the
+ * same.
+ */
+export function toRichDoc(json: Record<string, unknown>): RichDoc {
+  const content = Array.isArray(json.content) ? json.content : []
+  return { type: 'doc', content: content.map(toNode).filter((n): n is RichNode => n !== null) }
+}
+
+function toNode(raw: unknown): RichNode | null {
+  if (!raw || typeof raw !== 'object') return null
+  const node = raw as {
+    type?: string
+    text?: string
+    attrs?: Record<string, unknown>
+    marks?: unknown[]
+    content?: unknown[]
+  }
+  const children = () =>
+    (Array.isArray(node.content) ? node.content : [])
+      .map(toNode)
+      .filter((n): n is RichNode => n !== null)
+
+  switch (node.type) {
+    case 'text': {
+      const marks = (node.marks ?? []).map(toMark).filter((m): m is RichMark => m !== null)
+      const text: RichNode = { type: 'text', text: node.text ?? '' }
+      return marks.length ? { ...text, marks } : text
+    }
+    case 'hardBreak':
+      return { type: 'hardBreak' }
+    case 'paragraph': {
+      const align = node.attrs?.textAlign
+      const paragraph: RichNode = { type: 'paragraph', content: children() }
+      return align === 'center' || align === 'right'
+        ? { ...paragraph, attrs: { textAlign: align } }
+        : paragraph
+    }
+    case 'bulletList':
+    case 'orderedList':
+    case 'listItem':
+      return { type: node.type, content: children() }
+    default:
+      return null
+  }
+}
+
+function toMark(raw: unknown): RichMark | null {
+  if (!raw || typeof raw !== 'object') return null
+  const mark = raw as { type?: string; attrs?: Record<string, unknown> }
+  switch (mark.type) {
+    case 'bold':
+    case 'italic':
+    case 'underline':
+    case 'strike':
+      return { type: mark.type }
+    case 'link': {
+      const href = typeof mark.attrs?.href === 'string' ? mark.attrs.href : ''
+      return href ? { type: 'link', attrs: { href } } : null
+    }
+    case 'textStyle': {
+      const attrs: { color?: string; fontSize?: RichFontSize } = {}
+      const color = mark.attrs?.color
+      if (typeof color === 'string' && HEX_COLOR.test(color)) attrs.color = color
+      if (isFontSize(mark.attrs?.fontSize)) attrs.fontSize = mark.attrs.fontSize
+      return Object.keys(attrs).length ? { type: 'textStyle', attrs } : null
+    }
+    default:
+      return null
+  }
+}
+
+/**
+ * The words of a text block with their formatting.
+ *
+ * Tiptap, kept to the vocabulary the renderers know: paragraphs, lists,
+ * the inline marks, alignment on a paragraph. Everything else in the
+ * starter kit is switched off, so nothing can be typed that a mail client
+ * would not draw. Tags stay plain text inside the document; the picker
+ * writes them at the cursor like any other characters.
+ */
+export function EmailRichEditor({
+  id,
+  label,
+  value,
+  theme,
+  targets,
+  onChange,
+}: {
+  id: string
+  label: string
+  /** The document as opened; later changes are the editor's own. */
+  value: RichDoc
+  theme: EmailTheme
+  targets: TagTargets
+  onChange: (doc: RichDoc) => void
+}) {
+  const editor = useEditor({
+    immediatelyRender: false,
+    extensions: [
+      StarterKit.configure({
+        heading: false,
+        blockquote: false,
+        codeBlock: false,
+        code: false,
+        horizontalRule: false,
+        link: false,
+        underline: false,
+        // A list at the end of the block must not grow a blank paragraph
+        // after it, which the mail would print as an empty line.
+        trailingNode: false,
+      }),
+      Underline,
+      Link.configure({
+        openOnClick: false,
+        autolink: true,
+        linkOnPaste: true,
+        isAllowedUri: (url, ctx) => TAG_HREF.test(url) || ctx.defaultValidate(url),
+      }),
+      TextStyle,
+      TextAlign,
+    ],
+    content: value,
+    onUpdate: ({ editor }) => onChange(toRichDoc(editor.getJSON())),
+    onFocus: () => targets.setFocused(id),
+    editorProps: {
+      attributes: {
+        'aria-label': label,
+        class:
+          'tiptap-content email-rich-editor min-h-[140px] max-h-[48vh] overflow-y-auto px-2.5 py-2 text-[13px] leading-relaxed text-foreground outline-none [&_a]:text-primary [&_a]:underline',
+      },
+    },
+  })
+
+  useEffect(() => {
+    if (!editor) return
+    targets.register(id, { kind: 'editor', editor })
+    return () => targets.register(id, null)
+  }, [editor, id, targets])
+
+  return (
+    <div>
+      <div className="mb-1 block text-[13px] font-medium text-foreground">{label}</div>
+      <div className="rounded-md border border-input bg-background shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50">
+        {editor && <Toolbar editor={editor} theme={theme} />}
+        <EditorContent editor={editor} />
+      </div>
+    </div>
+  )
+}
+
+function Toolbar({ editor, theme }: { editor: Editor; theme: EmailTheme }) {
+  const t = useTranslations('settings.emailTemplates.toolbar')
+  // The editor does not re-render React on every transaction; the toolbar
+  // asks for the bits of state it paints and re-renders when those change.
+  const state = useEditorState({
+    editor,
+    selector: ({ editor }) => ({
+      bold: editor.isActive('bold'),
+      italic: editor.isActive('italic'),
+      underline: editor.isActive('underline'),
+      strike: editor.isActive('strike'),
+      link: editor.isActive('link'),
+      bulletList: editor.isActive('bulletList'),
+      orderedList: editor.isActive('orderedList'),
+      align: (editor.getAttributes('paragraph').textAlign as RichAlign | null) ?? 'left',
+      color: (editor.getAttributes('textStyle').color as string | null) ?? null,
+      fontSize: (editor.getAttributes('textStyle').fontSize as string | null) ?? null,
+      href: (editor.getAttributes('link').href as string | null) ?? '',
+    }),
+  })
+
+  const setStyle = (patch: { color?: string | null; fontSize?: string | null }) => {
+    const current = editor.getAttributes('textStyle')
+    const next = { color: current.color ?? null, fontSize: current.fontSize ?? null, ...patch }
+    const chain = editor.chain().focus()
+    if (!next.color && !next.fontSize) chain.unsetMark('textStyle').run()
+    else chain.setMark('textStyle', next).run()
+  }
+
+  const align = (value: RichAlign) =>
+    editor
+      .chain()
+      .focus()
+      .updateAttributes('paragraph', { textAlign: value === 'left' ? null : value })
+      .run()
+
+  const clear = () =>
+    editor.chain().focus().unsetAllMarks().updateAttributes('paragraph', { textAlign: null }).run()
+
+  const alignIcons = { left: AlignLeft, center: AlignCenter, right: AlignRight }
+
+  return (
+    <TooltipProvider delayDuration={400}>
+      <div className="flex flex-wrap items-center gap-0.5 border-b border-border px-1.5 py-1">
+        <ToolButton
+          label={t('bold')}
+          active={state.bold}
+          onClick={() => editor.chain().focus().toggleBold().run()}
+        >
+          <Bold size={14} />
+        </ToolButton>
+        <ToolButton
+          label={t('italic')}
+          active={state.italic}
+          onClick={() => editor.chain().focus().toggleItalic().run()}
+        >
+          <Italic size={14} />
+        </ToolButton>
+        <ToolButton
+          label={t('underline')}
+          active={state.underline}
+          onClick={() => editor.chain().focus().toggleUnderline().run()}
+        >
+          <UnderlineIcon size={14} />
+        </ToolButton>
+        <ToolButton
+          label={t('strike')}
+          active={state.strike}
+          onClick={() => editor.chain().focus().toggleStrike().run()}
+        >
+          <Strikethrough size={14} />
+        </ToolButton>
+        <LinkButton editor={editor} active={state.link} href={state.href} />
+
+        <Divider />
+
+        <ToolButton
+          label={t('bulletList')}
+          active={state.bulletList}
+          onClick={() => editor.chain().focus().toggleBulletList().run()}
+        >
+          <List size={14} />
+        </ToolButton>
+        <ToolButton
+          label={t('orderedList')}
+          active={state.orderedList}
+          onClick={() => editor.chain().focus().toggleOrderedList().run()}
+        >
+          <ListOrdered size={14} />
+        </ToolButton>
+
+        <Divider />
+
+        {ALIGNS.map((value) => {
+          const Icon = alignIcons[value]
+          return (
+            <ToolButton
+              key={value}
+              label={t(`align.${value}`)}
+              active={state.align === value}
+              onClick={() => align(value)}
+            >
+              <Icon size={14} />
+            </ToolButton>
+          )
+        })}
+
+        <Divider />
+
+        <Select
+          value={isFontSize(state.fontSize) ? state.fontSize : DEFAULT_SIZE}
+          onValueChange={(value) => setStyle({ fontSize: value === DEFAULT_SIZE ? null : value })}
+        >
+          <SelectTrigger
+            size="sm"
+            aria-label={t('size.label')}
+            title={t('size.label')}
+            className="h-7 border-transparent bg-transparent px-1.5 text-xs shadow-none hover:bg-muted dark:bg-transparent dark:hover:bg-muted"
+          >
+            <SelectValue />
+          </SelectTrigger>
+          <SelectContent
+            // Picking a size is a command on the selection; the editor gets
+            // focus back, not the trigger.
+            onCloseAutoFocus={(e) => {
+              e.preventDefault()
+              editor.commands.focus()
+            }}
+          >
+            <SelectItem value={DEFAULT_SIZE}>{t('size.default')}</SelectItem>
+            {RICH_FONT_SIZES.map((size) => (
+              <SelectItem key={size} value={size}>
+                {t(`size.${FONT_SIZE_KEYS[size]}`)}
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
+
+        <ColorButton theme={theme} color={state.color} onChange={(color) => setStyle({ color })} />
+
+        <Divider />
+
+        <ToolButton label={t('clear')} active={false} onClick={clear}>
+          <RemoveFormatting size={14} />
+        </ToolButton>
+      </div>
+    </TooltipProvider>
+  )
+}
+
+function Divider() {
+  return <span className="mx-0.5 h-4 w-px bg-border" />
+}
+
+/** The look of a toolbar button, pressed or not; the app's own editor uses the same. */
+const toolButtonClass = (active: boolean) =>
+  cn(
+    'flex h-7 w-7 items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring',
+    active
+      ? 'bg-primary/15 text-primary'
+      : 'text-muted-foreground hover:bg-muted hover:text-foreground'
+  )
+
+function ToolButton({
+  label,
+  active,
+  onClick,
+  children,
+}: {
+  label: string
+  active: boolean
+  onClick: () => void
+  children: ReactNode
+}) {
+  return (
+    <Tooltip>
+      <TooltipTrigger asChild>
+        <button
+          type="button"
+          aria-label={label}
+          aria-pressed={active}
+          // Mouse down would take focus from the editor and drop the
+          // selection the command is meant for; the click still fires.
+          onMouseDown={(e) => e.preventDefault()}
+          onClick={onClick}
+          className={toolButtonClass(active)}
+        >
+          {children}
+        </button>
+      </TooltipTrigger>
+      <TooltipContent side="top">{label}</TooltipContent>
+    </Tooltip>
+  )
+}
+
+/**
+ * A link on the selected words. The destination may be a tag such as
+ * {share_link}, which is what most links in a workshop mail are.
+ */
+function LinkButton({ editor, active, href }: { editor: Editor; active: boolean; href: string }) {
+  const t = useTranslations('settings.emailTemplates.toolbar')
+  const inputId = useId()
+  const [open, setOpen] = useState(false)
+  const [draft, setDraft] = useState('')
+
+  const apply = () => {
+    const url = normalizeHref(draft)
+    const chain = editor.chain().focus().extendMarkRange('link')
+    if (url) chain.setLink({ href: url }).run()
+    else chain.unsetLink().run()
+    setOpen(false)
+  }
+
+  return (
+    <Popover
+      open={open}
+      onOpenChange={(next) => {
+        if (next) setDraft(href)
+        setOpen(next)
+      }}
+    >
+      <Tooltip>
+        <TooltipTrigger asChild>
+          <PopoverTrigger asChild>
+            <button
+              type="button"
+              aria-label={t('link')}
+              aria-pressed={active}
+              onMouseDown={(e) => e.preventDefault()}
+              className={toolButtonClass(active)}
+            >
+              <Link2 size={14} />
+            </button>
+          </PopoverTrigger>
+        </TooltipTrigger>
+        <TooltipContent side="top">{t('link')}</TooltipContent>
+      </Tooltip>
+      <PopoverContent align="start" className="w-72 p-3">
+        <form
+          onSubmit={(e) => {
+            e.preventDefault()
+            apply()
+          }}
+          className="space-y-2"
+        >
+          <label htmlFor={inputId} className="block text-xs font-medium text-foreground">
+            {t('linkUrl')}
+          </label>
+          <Input
+            id={inputId}
+            value={draft}
+            onChange={(e) => setDraft(e.target.value)}
+            placeholder={t('linkPlaceholder', TAG_EXAMPLE)}
+            maxLength={500}
+            spellCheck={false}
+            autoFocus
+            className="h-8 font-mono text-xs md:text-xs"
+          />
+          <p className="text-[11.5px] leading-snug text-muted-foreground">
+            {t('linkHint', TAG_EXAMPLE)}
+          </p>
+          <div className="flex justify-end gap-1.5 pt-1">
+            {active && (
+              <Button
+                type="button"
+                variant="ghost"
+                size="xs"
+                onClick={() => {
+                  setDraft('')
+                  editor.chain().focus().extendMarkRange('link').unsetLink().run()
+                  setOpen(false)
+                }}
+                className="text-destructive hover:bg-destructive/10 hover:text-destructive"
+              >
+                {t('linkRemove')}
+              </Button>
+            )}
+            <Button type="submit" size="xs">
+              {t('linkApply')}
+            </Button>
+          </div>
+        </form>
+      </PopoverContent>
+    </Popover>
+  )
+}
+
+/**
+ * The colour of the selected words: the theme's own three first, since a
+ * mail that uses more than those rarely looks like it came from a business,
+ * then a hex field for the odd exception.
+ */
+function ColorButton({
+  theme,
+  color,
+  onChange,
+}: {
+  theme: EmailTheme
+  color: string | null
+  onChange: (color: string | null) => void
+}) {
+  const t = useTranslations('settings.emailTemplates.toolbar')
+  const [open, setOpen] = useState(false)
+  const [hex, setHex] = useState('')
+  const swatches = [
+    { value: theme.textColor, label: t('color.text') },
+    { value: theme.mutedColor, label: t('color.muted') },
+    { value: theme.primaryColor, label: t('color.primary') },
+  ]
+  const hexValid = HEX_COLOR.test(hex)
+
+  return (
+    <Popover
+      open={open}
+      onOpenChange={(next) => {
+        if (next) setHex(color ?? '')
+        setOpen(next)
+      }}
+    >
+      <Tooltip>
+        <TooltipTrigger asChild>
+          <PopoverTrigger asChild>
+            <button
+              type="button"
+              aria-label={t('color.label')}
+              onMouseDown={(e) => e.preventDefault()}
+              className={cn(toolButtonClass(false), 'relative')}
+            >
+              <Baseline size={14} />
+              <span
+                className="absolute bottom-[3px] left-[7px] h-[3px] w-[14px] rounded-sm"
+                style={{ background: color ?? theme.textColor }}
+              />
+            </button>
+          </PopoverTrigger>
+        </TooltipTrigger>
+        <TooltipContent side="top">{t('color.label')}</TooltipContent>
+      </Tooltip>
+      <PopoverContent align="start" className="w-60 p-3">
+        <div className="space-y-2.5">
+          <div className="flex items-center gap-2">
+            {swatches.map((swatch) => (
+              <button
+                key={swatch.label}
+                type="button"
+                title={swatch.label}
+                aria-label={swatch.label}
+                onClick={() => {
+                  onChange(swatch.value)
+                  setOpen(false)
+                }}
+                className={cn(
+                  'h-7 w-7 rounded-full border-2 outline-none focus-visible:ring-2 focus-visible:ring-ring',
+                  color === swatch.value ? 'border-primary' : 'border-border'
+                )}
+                // The swatch is the theme's colour itself, not chrome.
+                style={{ background: swatch.value }}
+              />
+            ))}
+            <Button
+              type="button"
+              variant="outline"
+              size="xs"
+              onClick={() => {
+                onChange(null)
+                setOpen(false)
+              }}
+              className="ml-auto"
+            >
+              {t('color.default')}
+            </Button>
+          </div>
+          <form
+            onSubmit={(e) => {
+              e.preventDefault()
+              if (!hexValid) return
+              onChange(hex.toLowerCase())
+              setOpen(false)
+            }}
+            className="flex items-center gap-1.5"
+          >
+            <Input
+              value={hex}
+              onChange={(e) => setHex(e.target.value.trim())}
+              placeholder="#000000"
+              maxLength={7}
+              spellCheck={false}
+              aria-label={t('color.hex')}
+              aria-invalid={Boolean(hex) && !hexValid}
+              className="h-7 px-2 font-mono text-xs md:text-xs"
+            />
+            <Button type="submit" size="xs" disabled={!hexValid}>
+              {t('color.apply')}
+            </Button>
+          </form>
+        </div>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 130 - 0
src/features/email/Components/EmailThumbnail.tsx

@@ -0,0 +1,130 @@
+'use client'
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { cn } from '@/lib/utils'
+import { buildEmailSpec } from '../Lib/buildEmailSpec'
+import type { SummaryRow } from '../Lib/emailContext'
+import { kindSpec } from '../Lib/emailKinds'
+import { type EmailTemplate, emailLogoPublicPath } from '../Lib/emailTemplate'
+import { renderEmailHtml } from '../Render/renderEmailHtml'
+import type { TagValues } from '../Lib/tags'
+
+/** The stand-in data a preview is filled with, worked out once on the server. */
+export interface EmailSampleData {
+  values: TagValues
+  summary: SummaryRow[]
+}
+
+/** The width the mail is designed at; every client narrower than this wraps it. */
+export const EMAIL_DESIGN_WIDTH = 600
+
+/**
+ * The mail a template produces, as one HTML document for a frame.
+ *
+ * The attachment note is always drawn: the preview is for seeing every block
+ * the template has, and a note that only appears on a real send with a PDF
+ * would look like it had gone missing. The logo is the template's own,
+ * fetched from the public route the way a mail client would.
+ */
+export function previewHtml(template: EmailTemplate, sample: EmailSampleData) {
+  const spec = buildEmailSpec(template, {
+    values: sample.values,
+    summary: sample.summary,
+    logoUrl: emailLogoPublicPath(template.theme.logoUrl),
+    attached: kindSpec(template.kind).hasAttachment,
+  })
+  return renderEmailHtml(spec)
+}
+
+/**
+ * A rendered mail in a frame of its own.
+ *
+ * An iframe rather than dangerouslySetInnerHTML, because the mail is a whole
+ * document with its own body background and nothing from the app's
+ * stylesheet may leak into it: what the card shows has to be what the
+ * customer's client shows. The sandbox is empty, so nothing in it can run or
+ * navigate, and pointer events are off so a click lands on the card.
+ */
+export function EmailFrame({
+  html,
+  width,
+  className,
+  style,
+  title,
+}: {
+  html: string
+  width: number
+  className?: string
+  style?: React.CSSProperties
+  title: string
+}) {
+  return (
+    <iframe
+      title={title}
+      sandbox=""
+      srcDoc={html}
+      tabIndex={-1}
+      aria-hidden
+      className={className}
+      style={{ width, border: 0, pointerEvents: 'none', display: 'block', ...style }}
+    />
+  )
+}
+
+/**
+ * A template's card, scaled to whatever width the card gives it.
+ *
+ * The mail is rendered at its design width and shrunk with a transform, so
+ * the thumbnail is the real mail and not a mock-up of one. The height is
+ * fixed and the bottom is clipped; a card shows the top of the mail, which
+ * is where the difference between two templates is.
+ */
+export function EmailThumbnail({
+  template,
+  sample,
+  height = 170,
+  className,
+}: {
+  template: EmailTemplate
+  sample: EmailSampleData
+  height?: number
+  className?: string
+}) {
+  const box = useRef<HTMLDivElement>(null)
+  const [boxWidth, setBoxWidth] = useState(0)
+
+  useEffect(() => {
+    const el = box.current
+    if (!el) return
+    const measure = () => setBoxWidth(el.clientWidth)
+    measure()
+    const observer = new ResizeObserver(measure)
+    observer.observe(el)
+    return () => observer.disconnect()
+  }, [])
+
+  const html = useMemo(() => previewHtml(template, sample), [template, sample])
+  const scale = boxWidth > 0 ? boxWidth / EMAIL_DESIGN_WIDTH : 1
+
+  return (
+    <div
+      ref={box}
+      className={cn('relative overflow-hidden rounded', className)}
+      // The mail's own ground, so the top of the card matches the mail below it.
+      style={{ height, background: template.theme.backgroundColor }}
+    >
+      {boxWidth > 0 && (
+        <EmailFrame
+          title={template.name}
+          html={html}
+          width={EMAIL_DESIGN_WIDTH}
+          style={{
+            height: height / scale,
+            transform: `scale(${scale})`,
+            transformOrigin: 'top left',
+          }}
+        />
+      )}
+    </div>
+  )
+}

+ 223 - 0
src/features/email/Components/TagPicker.tsx

@@ -0,0 +1,223 @@
+'use client'
+
+import type { Editor } from '@tiptap/react'
+import { useTranslations } from 'next-intl'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { toast } from 'sonner'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import { cn } from '@/lib/utils'
+import { type EmailTag, TAG_GROUPS, TAGS, tagsInGroup } from '../Lib/tags'
+
+type TagInput = HTMLInputElement | HTMLTextAreaElement
+
+/**
+ * Somewhere a tag can be written: a plain input or textarea the picker
+ * splices the token into, or a Tiptap editor that inserts it at its own
+ * cursor.
+ */
+export type TagTarget =
+  | { kind: 'field'; element: TagInput; setValue: (value: string) => void }
+  | { kind: 'editor'; editor: Editor }
+
+/**
+ * Where a picked tag goes.
+ *
+ * Every text field in the inspector registers itself and says when it is
+ * focused; the picker writes into the one focused last, at its cursor, and
+ * into the first field when none has been touched yet. One picker for the
+ * whole panel rather than one per field, because a template with a subject
+ * and four paragraphs would otherwise show the same forty chips five times.
+ */
+export function useTagTargets() {
+  const t = useTranslations('settings.emailTemplates')
+  const targets = useRef(new Map<string, TagTarget>())
+  const [focused, setFocused] = useState<string | null>(null)
+
+  const register = useCallback((id: string, target: TagTarget | null) => {
+    if (target) targets.current.set(id, target)
+    else targets.current.delete(id)
+  }, [])
+
+  const insert = useCallback(
+    (tag: EmailTag) => {
+      const id =
+        focused && targets.current.has(focused) ? focused : targets.current.keys().next().value
+      const target = id ? targets.current.get(id) : undefined
+      if (!target) return
+      const token = `{${tag}}`
+      if (target.kind === 'editor') {
+        target.editor.chain().focus().insertContent({ type: 'text', text: token }).run()
+        return
+      }
+      const el = target.element
+      const start = el.selectionStart ?? el.value.length
+      const end = el.selectionEnd ?? start
+      const next = el.value.slice(0, start) + token + el.value.slice(end)
+      // The field's limit is the server's limit. A tag cut short is not a
+      // tag, so one that does not fit is refused whole rather than trimmed.
+      if (el.maxLength > 0 && next.length > el.maxLength) {
+        toast.error(t('tagDoesNotFit'))
+        return
+      }
+      target.setValue(next)
+      // React writes the new value on the next render; the cursor goes after
+      // the tag once it has.
+      requestAnimationFrame(() => {
+        el.focus()
+        const at = start + token.length
+        el.setSelectionRange(at, at)
+      })
+    },
+    [focused, t]
+  )
+
+  return { register, setFocused, insert }
+}
+
+export type TagTargets = ReturnType<typeof useTagTargets>
+
+/**
+ * A text field the picker can write into. Multiline for prose, a single
+ * line for a subject or a button label.
+ */
+export function TagField({
+  id,
+  label,
+  value,
+  onChange,
+  targets,
+  multiline = false,
+  rows = 5,
+  maxLength,
+  placeholder,
+  mono = false,
+}: {
+  id: string
+  label: string
+  value: string
+  onChange: (value: string) => void
+  targets: TagTargets
+  multiline?: boolean
+  rows?: number
+  maxLength?: number
+  placeholder?: string
+  mono?: boolean
+}) {
+  const element = useRef<TagInput>(null)
+  const latest = useRef(onChange)
+  latest.current = onChange
+
+  useEffect(() => {
+    const el = element.current
+    if (!el) return
+    targets.register(id, { kind: 'field', element: el, setValue: (next) => latest.current(next) })
+    return () => targets.register(id, null)
+  }, [id, targets])
+
+  const className = cn('leading-relaxed', mono && 'font-mono text-xs md:text-xs')
+
+  return (
+    <div>
+      <label
+        htmlFor={`tag-field-${id}`}
+        className="mb-1 block text-[13px] font-medium text-foreground"
+      >
+        {label}
+      </label>
+      {multiline ? (
+        <Textarea
+          id={`tag-field-${id}`}
+          ref={(el) => {
+            element.current = el
+          }}
+          value={value}
+          rows={rows}
+          maxLength={maxLength}
+          placeholder={placeholder}
+          onChange={(e) => onChange(e.target.value)}
+          onFocus={() => targets.setFocused(id)}
+          className={cn(className, 'resize-y')}
+        />
+      ) : (
+        <Input
+          id={`tag-field-${id}`}
+          ref={(el) => {
+            element.current = el
+          }}
+          value={value}
+          maxLength={maxLength}
+          placeholder={placeholder}
+          onChange={(e) => onChange(e.target.value)}
+          onFocus={() => targets.setFocused(id)}
+          className={cn(className, 'h-8')}
+        />
+      )}
+    </div>
+  )
+}
+
+/**
+ * The tags this kind of mail can say, as chips grouped the way the registry
+ * groups them. A chip's tooltip says what it fills with; a dotted chip is a
+ * tag not every send has a value for, so the sentence around it has to
+ * survive it being blank.
+ */
+export function TagPicker({
+  tags,
+  onInsert,
+}: {
+  tags: readonly EmailTag[]
+  onInsert: (tag: EmailTag) => void
+}) {
+  const t = useTranslations('settings.emailTemplates')
+  const offered = new Set(tags)
+
+  return (
+    <TooltipProvider delayDuration={300}>
+      <div className="space-y-2.5">
+        {TAG_GROUPS.map((group) => {
+          const inGroup = tagsInGroup(group).filter((tag) => offered.has(tag))
+          if (inGroup.length === 0) return null
+          return (
+            <div key={group}>
+              <div className="mb-1 text-[11px] font-medium text-muted-foreground">
+                {t(`tagGroups.${group}`)}
+              </div>
+              <div className="flex flex-wrap gap-1">
+                {inGroup.map((tag) => {
+                  const optional = 'optional' in TAGS[tag] && TAGS[tag].optional === true
+                  return (
+                    <Tooltip key={tag}>
+                      <TooltipTrigger asChild>
+                        <button
+                          type="button"
+                          // Mouse down would move focus off the field the
+                          // tag is meant for; the click still fires.
+                          onMouseDown={(e) => e.preventDefault()}
+                          onClick={() => onInsert(tag)}
+                          className={cn(
+                            'rounded-md border bg-background px-1.5 py-0.5 font-mono text-[11px] text-foreground outline-none transition-colors hover:border-primary hover:text-primary focus-visible:ring-2 focus-visible:ring-ring',
+                            optional ? 'border-dashed border-muted-foreground/40' : 'border-border'
+                          )}
+                        >
+                          {`{${tag}}`}
+                        </button>
+                      </TooltipTrigger>
+                      <TooltipContent side="top" className="max-w-[240px]">
+                        <div className="font-medium">{t(`tags.${tag}.label`)}</div>
+                        <div className="opacity-80">{t(`tags.${tag}.description`)}</div>
+                        {optional && <div className="mt-1 opacity-80">{t('optionalTag')}</div>}
+                      </TooltipContent>
+                    </Tooltip>
+                  )
+                })}
+              </div>
+            </div>
+          )
+        })}
+      </div>
+    </TooltipProvider>
+  )
+}

+ 211 - 0
src/features/email/Lib/buildEmailSpec.ts

@@ -0,0 +1,211 @@
+import type { SummaryRow } from './emailContext'
+import { kindSpec } from './emailKinds'
+import {
+  type BlockAlign,
+  EMAIL_IMAGE_MAX_WIDTH,
+  type EmailBlock,
+  type EmailTemplate,
+  type EmailTheme,
+  emailAssetPublicPath,
+} from './emailTemplate'
+import { safeHref } from './links'
+import { mapRichText, type RichDoc, richToPlain } from './richText'
+import { fillTags, type TagValues } from './tags'
+
+/**
+ * A template plus the job it is about, resolved into what the mail says.
+ *
+ * The one tree both renderers read. The HTML one draws it as tables for mail
+ * clients, the text one writes it out as plain text; neither decides anything
+ * about content, so the two can never say different things. This is the
+ * invoice designer's arrangement with a renderer that respects what email is.
+ */
+
+/** Words with their formatting, and the same words flat for the text part. */
+export interface SpecText {
+  text: string
+  rich?: RichDoc
+}
+
+type SpecBlockBody =
+  | { type: 'header'; workshopName: string; logoUrl?: string; logoWidth: number; align: BlockAlign }
+  | ({ type: 'heading' } & SpecText)
+  | ({ type: 'paragraph' } & SpecText)
+  | ({ type: 'callout' } & SpecText)
+  | { type: 'button'; label: string; href: string; align: BlockAlign }
+  | { type: 'document_summary'; rows: SummaryRow[] }
+  | ({ type: 'attachment_note' } & SpecText)
+  | { type: 'image'; src: string; alt: string; width: number; align: BlockAlign; href?: string }
+  | { type: 'divider' }
+  | { type: 'spacer'; height: number }
+  | { type: 'contact_footer'; lines: string[] }
+
+/** A resolved block, still carrying the id of the block it came from, so a preview can point back at it. */
+export type SpecBlock = SpecBlockBody & { id: string }
+
+export interface EmailSpec {
+  subject: string
+  theme: EmailTheme
+  blocks: SpecBlock[]
+}
+
+export interface EmailSpecInput {
+  values: TagValues
+  /** Absolute, because a mail cannot resolve a relative path. */
+  logoUrl?: string
+  /** Rows for the summary block, labels already worded for the reader. */
+  summary?: SummaryRow[]
+  /** Whether a PDF rides along, which is what the attachment note is for. */
+  attached?: boolean
+  /**
+   * Where each stored upload can be fetched from by the reader. The designer
+   * leaves this out and gets the relative public path; a sender passes
+   * absolute addresses, and nothing at all for a file that is gone, which
+   * drops the block rather than sending a broken image.
+   */
+  assets?: Record<string, string | undefined>
+}
+
+/** A block with no words left after its tags are filled prints nothing. */
+function resolveBlock(
+  block: EmailBlock,
+  template: EmailTemplate,
+  input: EmailSpecInput
+): SpecBlockBody | null {
+  if (block.visible === false) return null
+  const known = kindSpec(template.kind).tags
+  const fill = (text: string | undefined) =>
+    text ? fillTags(text, input.values, known).trim() : ''
+
+  // A text block's words, filled: the rich tree when the designer wrote one,
+  // else the plain string. Nothing left after filling means no block.
+  const words = (): SpecText | null => {
+    if (block.content) {
+      const rich = mapRichText(block.content, (text) => fillTags(text, input.values, known))
+      const text = richToPlain(rich)
+      return text ? { text, rich } : null
+    }
+    const text = fill(block.text)
+    return text ? { text } : null
+  }
+
+  switch (block.type) {
+    case 'header':
+      return {
+        type: 'header',
+        workshopName: input.values.workshop_name ?? '',
+        logoUrl: template.theme.showLogo ? input.logoUrl : undefined,
+        logoWidth: template.theme.logoWidth,
+        align: block.align ?? 'left',
+      }
+
+    case 'heading': {
+      const w = words()
+      return w ? { type: 'heading', ...w } : null
+    }
+
+    case 'paragraph': {
+      const w = words()
+      return w ? { type: 'paragraph', ...w } : null
+    }
+
+    case 'callout': {
+      const w = words()
+      return w ? { type: 'callout', ...w } : null
+    }
+
+    case 'button': {
+      const href = fill(block.href)
+      const label = fill(block.label)
+      // A link tag with no value fills to nothing, and a stored row from a
+      // future release might carry a tag this code cannot fill at all; a
+      // button pointing at "{share_link}" is worse than no button.
+      if (!href || !label || href.includes('{')) return null
+      const safe = safeHref(href)
+      if (!safe) return null
+      return { type: 'button', label, href: safe, align: block.align ?? 'left' }
+    }
+
+    case 'document_summary': {
+      const wanted = block.rows ? new Set(block.rows) : null
+      const rows = (input.summary ?? []).filter(
+        (row) => row.value && (!wanted || wanted.has(row.key))
+      )
+      return rows.length ? { type: 'document_summary', rows } : null
+    }
+
+    case 'attachment_note': {
+      if (!input.attached) return null
+      const w = words()
+      return w ? { type: 'attachment_note', ...w } : null
+    }
+
+    case 'image': {
+      if (!block.src) return null
+      const src = input.assets ? input.assets[block.src] : emailAssetPublicPath(block.src)
+      if (!src) return null
+      const href = block.href ? fill(block.href) : ''
+      return {
+        type: 'image',
+        src,
+        alt: fill(block.alt) || '',
+        width: Math.min(EMAIL_IMAGE_MAX_WIDTH, block.width ?? EMAIL_IMAGE_MAX_WIDTH),
+        align: block.align ?? 'left',
+        href: href && !href.includes('{') && safeHref(href) ? href : undefined,
+      }
+    }
+
+    case 'divider':
+      return { type: 'divider' }
+
+    case 'spacer':
+      return { type: 'spacer', height: block.height ?? 16 }
+
+    case 'contact_footer': {
+      const lines = [
+        input.values.workshop_name,
+        input.values.workshop_address,
+        [input.values.workshop_phone, input.values.workshop_email].filter(Boolean).join(' · '),
+      ]
+        .map((line) => line?.trim())
+        .filter((line): line is string => !!line)
+      return lines.length ? { type: 'contact_footer', lines } : null
+    }
+
+    default:
+      return null
+  }
+}
+
+export function buildEmailSpec(template: EmailTemplate, input: EmailSpecInput): EmailSpec {
+  const blocks = template.blocks
+    .map((block) => {
+      const body = resolveBlock(block, template, input)
+      return body ? ({ ...body, id: block.id } as SpecBlock) : null
+    })
+    .filter((block): block is SpecBlock => block !== null)
+
+  return {
+    subject: fillTags(template.subject, input.values, kindSpec(template.kind).tags).trim(),
+    theme: template.theme,
+    blocks: collapseSpacing(blocks),
+  }
+}
+
+/**
+ * Room and rules only mean anything between two things. A divider that opens
+ * the mail, or two spacers left behind by a hidden block, are noise the
+ * workshop did not ask for.
+ */
+function collapseSpacing(blocks: SpecBlock[]): SpecBlock[] {
+  const isSpacing = (block: SpecBlock | undefined) =>
+    block?.type === 'divider' || block?.type === 'spacer'
+
+  const out: SpecBlock[] = []
+  for (const block of blocks) {
+    if (isSpacing(block) && (out.length === 0 || isSpacing(out[out.length - 1]))) continue
+    out.push(block)
+  }
+  while (out.length && isSpacing(out[out.length - 1])) out.pop()
+  return out
+}

+ 6 - 66
src/features/email/Lib/documentEmail.ts

@@ -3,12 +3,14 @@
  *
  * Two ways to send an invoice, a quote or an inspection report. With the PDF
  * attached, which is what a customer who prints things wants. Or as a link to
- * the shared copy, which is what a workshop that chases unpaid invoices wants:
- * the link is the only thing that can be seen to have been opened, so the
- * viewed counter on the document finally means something. A mail carrying the
- * PDF is read in the mail client and the link never gets clicked.
+ * the shared copy, which is what a workshop that chases unpaid invoices
+ * wants: the link is the only thing that can be seen to have been opened, so
+ * the viewed counter on the document finally means something. A mail carrying
+ * the PDF is read in the mail client and the link never gets clicked.
  *
  * The workshop picks a default in settings and either send can override it.
+ * The words of the mail itself come from the email template for the kind;
+ * see sendTemplatedMail.
  */
 
 /** Attaching is the old behaviour, so an unset setting keeps it. */
@@ -23,65 +25,3 @@ export function resolveAttachPdf(
 ): boolean {
   return override ?? attachPdfDefault(settings)
 }
-
-export interface DocumentEmailBody {
-  /** Heading line, e.g. "Invoice INV-1042". */
-  heading: string
-  /** What the document is about, e.g. "your Volvo V70". Optional. */
-  subject?: string
-  /** The public link, when the document has one. */
-  link?: string | null
-  /** Wording of the link, e.g. "View Invoice Online". */
-  linkLabel: string
-  /** Whether the PDF rides along, which decides the opening line. */
-  attached: boolean
-  /** The sender's own note. */
-  message?: string
-  /** Workshop name, and phone when it has one. */
-  fromName: string
-  phone?: string
-}
-
-const html = (value: string) =>
-  value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
-
-/**
- * One body for all three documents, so a link-only mail cannot end up saying
- * "please find it attached" in one place and not another.
- *
- * A mail with neither an attachment nor a link would be an empty envelope, so
- * the callers make sure a link exists before they turn the attachment off;
- * should one slip through anyway, the opening line still reads sensibly.
- */
-export function buildDocumentEmailHtml(body: DocumentEmailBody): string {
-  const { heading, subject, link, linkLabel, attached, message, fromName, phone } = body
-
-  const intro = attached
-    ? subject
-      ? `Please find the ${subject} attached.`
-      : 'Please find it attached.'
-    : link
-      ? subject
-        ? `The ${subject} is ready. Use the link below to view it.`
-        : 'It is ready. Use the link below to view it.'
-      : subject
-        ? `The ${subject} is ready.`
-        : 'It is ready.'
-
-  const linkBlock = link
-    ? `<p><a href="${html(link)}" style="color: #2563eb;">${html(linkLabel)}</a></p>`
-    : ''
-
-  return `
-        <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
-          <h2>${html(heading)}</h2>
-          <p>${html(intro)}</p>
-          ${message ? `<p>${html(message)}</p>` : ''}
-          ${linkBlock}
-          <hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;" />
-          <p style="color: #666; font-size: 14px;">
-            ${html(fromName)}${phone ? ` · ${html(phone)}` : ''}
-          </p>
-        </div>
-      `
-}

+ 144 - 0
src/features/email/Lib/emailAssets.server.ts

@@ -0,0 +1,144 @@
+import 'server-only'
+
+import { readdir, stat, unlink } from 'fs/promises'
+import path from 'path'
+import { getAppBaseUrl } from '@/lib/app-url'
+import { db } from '@/lib/db'
+import { resolveUploadPath } from '@/lib/resolve-upload-path'
+import {
+  EMAIL_ASSET_CATEGORIES,
+  type EmailTemplate,
+  emailAssetPublicPath,
+  parseEmailAssetUrl,
+  templateAssets,
+} from './emailTemplate'
+
+/**
+ * The files behind a template's logo and pictures.
+ *
+ * Uploads are written the moment they are chosen, before the template is
+ * saved, because the preview has to show them. That leaves files behind
+ * whenever a picture is replaced, a block is removed, a draft is discarded
+ * or a template deleted. Rather than trusting every one of those paths to
+ * remember, the sweep runs after each save and delete and removes any file
+ * in the organisation's email folders that no saved template refers to. A
+ * file younger than a day is spared, since it may belong to a draft that
+ * is still open in another tab.
+ */
+
+const GRACE_MS = 24 * 60 * 60 * 1000
+
+/**
+ * The absolute address a sent mail fetches a stored upload from, or nothing.
+ *
+ * Checked against the disk first, so a template that still names a file
+ * after the file has gone sends without the picture rather than with a
+ * broken one. The organisation in the URL has to be the one sending, so one
+ * workshop's template can never point at another's upload.
+ */
+export async function emailAssetUrl(
+  organizationId: string,
+  storedUrl: string | null | undefined
+): Promise<string | undefined> {
+  const parts = parseEmailAssetUrl(storedUrl)
+  if (!parts || parts.organizationId !== organizationId || !storedUrl) return undefined
+  try {
+    await stat(resolveUploadPath(storedUrl))
+  } catch {
+    return undefined
+  }
+  return `${getAppBaseUrl()}${emailAssetPublicPath(storedUrl)}`
+}
+
+/** The uploads a template refers to that are not on disk, for refusing a save. */
+export async function missingAssets(
+  organizationId: string,
+  template: Pick<EmailTemplate, 'blocks' | 'theme'>
+): Promise<string[]> {
+  const urls = await assetUrlsFor(organizationId, template)
+  return Object.entries(urls)
+    .filter(([, url]) => !url)
+    .map(([stored]) => stored)
+}
+
+/** Every upload a template refers to, resolved to what a mail can fetch. */
+export async function assetUrlsFor(
+  organizationId: string,
+  template: Pick<EmailTemplate, 'blocks' | 'theme'>
+): Promise<Record<string, string | undefined>> {
+  const out: Record<string, string | undefined> = {}
+  await Promise.all(
+    templateAssets(template).map(async (stored) => {
+      out[stored] = await emailAssetUrl(organizationId, stored)
+    })
+  )
+  return out
+}
+
+/**
+ * Every upload any saved template of the organisation still refers to.
+ *
+ * Read off the raw JSON rather than through the lenient reader: a block a
+ * future release cannot parse is dropped by the reader, and its picture must
+ * not be swept for that. Any string anywhere in the row that looks like one
+ * of our upload URLs counts.
+ */
+async function referencedAssets(organizationId: string): Promise<Set<string>> {
+  const rows = await db.emailTemplate.findMany({
+    where: { organizationId },
+    select: { blocks: true, theme: true },
+  })
+  const referenced = new Set<string>()
+  const walk = (value: unknown) => {
+    if (typeof value === 'string') {
+      if (parseEmailAssetUrl(value)) referenced.add(value)
+    } else if (Array.isArray(value)) {
+      value.forEach(walk)
+    } else if (value && typeof value === 'object') {
+      Object.values(value).forEach(walk)
+    }
+  }
+  for (const row of rows) {
+    walk(row.blocks)
+    walk(row.theme)
+  }
+  return referenced
+}
+
+/**
+ * Removes the organisation's email uploads that nothing refers to any more.
+ * Never throws: a sweep that fails leaves a file behind, which the next
+ * sweep will find, and must not turn a successful save into an error.
+ */
+export async function sweepEmailAssets(organizationId: string): Promise<{ removed: number }> {
+  let removed = 0
+  try {
+    const referenced = await referencedAssets(organizationId)
+    const now = Date.now()
+    for (const category of EMAIL_ASSET_CATEGORIES) {
+      const dir = path.join(process.cwd(), 'data', 'uploads', organizationId, category)
+      let files: string[]
+      try {
+        files = await readdir(dir)
+      } catch {
+        continue
+      }
+      for (const file of files) {
+        const stored = `/api/protected/files/${organizationId}/${category}/${file}`
+        if (referenced.has(stored)) continue
+        const full = path.join(dir, file)
+        try {
+          const info = await stat(full)
+          if (!info.isFile() || now - info.mtimeMs < GRACE_MS) continue
+          await unlink(full)
+          removed += 1
+        } catch {
+          // Gone already, or not ours to remove; either way, leave it.
+        }
+      }
+    }
+  } catch (error) {
+    console.error('[email assets] sweep failed', error)
+  }
+  return { removed }
+}

+ 259 - 0
src/features/email/Lib/emailContext.ts

@@ -0,0 +1,259 @@
+import { formatCurrency, type CurrencyFormat } from '@/lib/format'
+import type { EmailKind } from './emailKinds'
+import { kindSpec } from './emailKinds'
+import type { SummaryRowKey } from './emailTemplate'
+import { type EmailTag, sampleTagValues, TAGS, type TagValues } from './tags'
+
+/**
+ * What a send knows, turned into what a template can say.
+ *
+ * Every send site passes a context of the shape its kind expects. This file
+ * turns that into tag values and summary rows, once, so an invoice mail and
+ * a quote mail format a total the same way and the preview in the designer
+ * is filled from the same code as a real send.
+ */
+
+export interface WorkshopContext {
+  name?: string | null
+  phone?: string | null
+  email?: string | null
+  address?: string | null
+}
+
+export interface VehicleContext {
+  year?: number | null
+  make?: string | null
+  model?: string | null
+  licensePlate?: string | null
+  mileage?: number | null
+}
+
+export interface DocumentContext {
+  number?: string | null
+  title?: string | null
+  total?: number | null
+  /** Already paid; the balance is worked out from it. */
+  paid?: number | null
+  dueDate?: Date | string | null
+  currencyCode?: string
+  currencyFormat?: CurrencyFormat
+}
+
+/** What every kind can carry. The workshop is filled in by the sender. */
+interface BaseContext {
+  customerName?: string | null
+  vehicle?: VehicleContext | null
+  /** Whoever pressed send, when a template wants to sign off with a name. */
+  currentUser?: string | null
+  /** The sender's own words for this send: a note on a document, or the whole message. */
+  message?: string | null
+}
+
+export interface DocumentEmailContext extends BaseContext {
+  document?: DocumentContext | null
+  shareLink?: string | null
+}
+
+export interface MessageEmailContext extends BaseContext {
+  message: string
+  portalLink?: string | null
+}
+
+export interface PortalSigninEmailContext extends BaseContext {
+  signinLink: string
+}
+
+export type EmailContextByKind = {
+  invoice_sent: DocumentEmailContext
+  quote_sent: DocumentEmailContext
+  inspection_sent: DocumentEmailContext
+  message: MessageEmailContext
+  portal_signin: PortalSigninEmailContext
+}
+
+export type EmailContext<K extends EmailKind = EmailKind> = EmailContextByKind[K]
+
+/** How a date is written for the reader. */
+export type DateFormatter = (date: Date) => string
+
+const isoDate: DateFormatter = (date) => date.toISOString().slice(0, 10)
+
+/** Blank rather than "null": a tag with no value is left out. */
+const text = (value: string | null | undefined): string | undefined =>
+  value?.trim() ? value.trim() : undefined
+
+function vehicleName(vehicle: VehicleContext | null | undefined): string | undefined {
+  if (!vehicle) return undefined
+  return text([vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' '))
+}
+
+function asDate(value: Date | string | null | undefined): Date | null {
+  if (!value) return null
+  const date = value instanceof Date ? value : new Date(value)
+  return Number.isNaN(date.getTime()) ? null : date
+}
+
+function money(doc: DocumentContext): (amount: number) => string {
+  const currency = doc.currencyCode || 'USD'
+  return (amount) => formatCurrency(amount, currency, doc.currencyFormat)
+}
+
+function balanceOf(doc: DocumentContext): number | null {
+  if (doc.total === null || doc.total === undefined) return null
+  return Math.max(0, doc.total - (doc.paid ?? 0))
+}
+
+export interface ContextOptions {
+  workshop: WorkshopContext
+  formatDate?: DateFormatter
+}
+
+/**
+ * The tag values for one send. Only the tags the kind offers are filled;
+ * anything the send has no value for is left out, and fillTags renders it
+ * as nothing.
+ */
+export function tagValuesFor<K extends EmailKind>(
+  kind: K,
+  context: EmailContext<K>,
+  options: ContextOptions
+): TagValues {
+  const { workshop } = options
+  const formatDate = options.formatDate ?? isoDate
+  const base = context as BaseContext
+  const doc = (context as DocumentEmailContext).document ?? undefined
+  const fmt = doc ? money(doc) : undefined
+  const balance = doc ? balanceOf(doc) : null
+  const dueDate = asDate(doc?.dueDate)
+
+  const all: TagValues = {
+    workshop_name: text(workshop.name),
+    workshop_phone: text(workshop.phone),
+    workshop_email: text(workshop.email),
+    workshop_address: text(workshop.address),
+    current_user: text(base.currentUser),
+    customer_name: text(base.customerName),
+    vehicle: vehicleName(base.vehicle),
+    plate: text(base.vehicle?.licensePlate),
+    mileage: base.vehicle?.mileage ? String(base.vehicle.mileage) : undefined,
+    document_number: text(doc?.number),
+    document_title: text(doc?.title),
+    total: doc?.total !== null && doc?.total !== undefined && fmt ? fmt(doc.total) : undefined,
+    balance_due: balance !== null && fmt ? fmt(balance) : undefined,
+    due_date: dueDate ? formatDate(dueDate) : undefined,
+    share_link: text((context as DocumentEmailContext).shareLink),
+    message: text(base.message),
+    signin_link: text((context as PortalSigninEmailContext).signinLink),
+    portal_link: text((context as MessageEmailContext).portalLink),
+  }
+
+  const values: TagValues = {}
+  for (const tag of kindSpec(kind).tags) {
+    const value = all[tag]
+    if (value !== undefined) values[tag] = value
+  }
+  return values
+}
+
+/** A row of the summary panel: which row it is, its label, and its value. */
+export interface SummaryRow {
+  key: SummaryRowKey
+  label: string
+  value: string
+}
+
+export type SummaryLabels = Record<SummaryRowKey, string>
+
+/**
+ * The rows the summary panel can show for one send. Labels come from the
+ * caller, worded in the reader's language. The balance row only appears
+ * when it differs from the total; otherwise it would repeat it.
+ */
+export function summaryRowsFor<K extends EmailKind>(
+  kind: K,
+  context: EmailContext<K>,
+  labels: SummaryLabels,
+  formatDate: DateFormatter = isoDate
+): SummaryRow[] {
+  if (!kindSpec(kind).hasSummary) return []
+  const doc = (context as DocumentEmailContext).document
+  const vehicle = vehicleName((context as BaseContext).vehicle)
+  const rows: SummaryRow[] = []
+  if (doc?.number) rows.push({ key: 'reference', label: labels.reference, value: doc.number })
+  if (vehicle) rows.push({ key: 'vehicle', label: labels.vehicle, value: vehicle })
+  if (doc && doc.total !== null && doc.total !== undefined) {
+    const fmt = money(doc)
+    rows.push({ key: 'total', label: labels.total, value: fmt(doc.total) })
+    const balance = balanceOf(doc)
+    if (balance !== null && balance !== doc.total) {
+      rows.push({ key: 'balance', label: labels.balance, value: fmt(balance) })
+    }
+  }
+  const dueDate = asDate(doc?.dueDate)
+  if (dueDate) rows.push({ key: 'due', label: labels.due, value: formatDate(dueDate) })
+  return rows
+}
+
+/**
+ * Stand-in values for the designer's preview: the workshop's own details,
+ * because seeing your own name in the footer is what tells you it is right,
+ * and an invented customer and job for everything else.
+ */
+export function sampleValuesFor(kind: EmailKind, workshop: WorkshopContext): TagValues {
+  const samples = sampleTagValues()
+  // The workshop's real details replace the samples wholesale: a missing
+  // phone shows as no phone, because that is what the customer would see.
+  samples.workshop_name = text(workshop.name) ?? samples.workshop_name
+  samples.workshop_phone = text(workshop.phone)
+  samples.workshop_email = text(workshop.email)
+  samples.workshop_address = text(workshop.address)
+  const values: TagValues = {}
+  for (const tag of kindSpec(kind).tags as readonly EmailTag[]) {
+    const value = samples[tag]
+    if (value !== undefined) values[tag] = value
+  }
+  return values
+}
+
+export function sampleSummaryRows(kind: EmailKind, labels: SummaryLabels): SummaryRow[] {
+  if (!kindSpec(kind).hasSummary) return []
+  return [
+    { key: 'reference', label: labels.reference, value: 'INV-1042' },
+    { key: 'vehicle', label: labels.vehicle, value: '2019 Volvo V70' },
+    { key: 'total', label: labels.total, value: '1 250,00' },
+    { key: 'balance', label: labels.balance, value: '250,00' },
+    { key: 'due', label: labels.due, value: '30 Sep 2026' },
+  ]
+}
+
+/**
+ * A context with the same stand-in data the preview shows, for a test send
+ * through the real pipeline.
+ */
+export function sampleContextFor(kind: EmailKind, currentUser?: string | null): EmailContext {
+  const base: BaseContext = {
+    customerName: TAGS.customer_name.sample,
+    vehicle: { year: 2019, make: 'Volvo', model: 'V70', licensePlate: 'AB 12345', mileage: 92000 },
+    currentUser: currentUser ?? TAGS.current_user.sample,
+    message: TAGS.message.sample,
+  }
+  switch (kind) {
+    case 'portal_signin':
+      return { ...base, signinLink: TAGS.signin_link.sample }
+    case 'message':
+      return { ...base, message: TAGS.message.sample, portalLink: TAGS.portal_link.sample }
+    default:
+      return {
+        ...base,
+        document: {
+          number: TAGS.document_number.sample,
+          title: TAGS.document_title.sample,
+          total: 1250,
+          paid: 1000,
+          dueDate: new Date('2026-09-30T12:00:00Z'),
+          currencyCode: 'EUR',
+        },
+        shareLink: TAGS.share_link.sample,
+      }
+  }
+}

+ 108 - 0
src/features/email/Lib/emailKinds.ts

@@ -0,0 +1,108 @@
+import type { EmailTag } from './tags'
+
+/**
+ * Every kind of mail a workshop can design, in one place.
+ *
+ * A kind is an operation the app performs, not a send: every invoice goes out
+ * through `invoice_sent`, so a change to that template lands everywhere an
+ * invoice is mailed from. Adding a kind is one entry here, one preset in
+ * messages/<locale>/email.json, and a context in emailContext.ts; the gallery,
+ * the designer, the tag picker and the resolver read this table.
+ */
+export const EMAIL_KINDS = [
+  'invoice_sent',
+  'quote_sent',
+  'inspection_sent',
+  'message',
+  'portal_signin',
+] as const
+
+export type EmailKind = (typeof EMAIL_KINDS)[number]
+
+export type EmailKindGroup = 'documents' | 'messages' | 'portal'
+
+export const EMAIL_KIND_GROUPS: readonly EmailKindGroup[] = ['documents', 'messages', 'portal']
+
+export interface EmailKindSpec {
+  group: EmailKindGroup
+  /** Tags this kind can fill. Everything else is refused on save. */
+  tags: readonly EmailTag[]
+  /**
+   * Tags the kind cannot do without. A sign-in mail with no sign-in link is
+   * undeliverable in the only sense that matters, so the editor refuses to
+   * save one. Checked across subject and every block.
+   */
+  required: readonly EmailTag[]
+  /** Whether the summary panel has anything to show for this kind. */
+  hasSummary: boolean
+  /** Whether a PDF may ride along, which is what the attachment note is for. */
+  hasAttachment: boolean
+}
+
+const WORKSHOP: readonly EmailTag[] = [
+  'workshop_name',
+  'workshop_phone',
+  'workshop_email',
+  'workshop_address',
+  'current_user',
+]
+const CUSTOMER: readonly EmailTag[] = ['customer_name']
+const VEHICLE: readonly EmailTag[] = ['vehicle', 'plate', 'mileage']
+const DOCUMENT: readonly EmailTag[] = [
+  'document_number',
+  'document_title',
+  'total',
+  'balance_due',
+  'due_date',
+  'share_link',
+]
+
+export const EMAIL_KIND_SPECS: Record<EmailKind, EmailKindSpec> = {
+  invoice_sent: {
+    group: 'documents',
+    tags: [...WORKSHOP, ...CUSTOMER, ...VEHICLE, ...DOCUMENT, 'message'],
+    required: [],
+    hasSummary: true,
+    hasAttachment: true,
+  },
+  quote_sent: {
+    group: 'documents',
+    tags: [...WORKSHOP, ...CUSTOMER, ...VEHICLE, ...DOCUMENT, 'message'],
+    required: [],
+    hasSummary: true,
+    hasAttachment: true,
+  },
+  inspection_sent: {
+    group: 'documents',
+    tags: [...WORKSHOP, ...CUSTOMER, ...VEHICLE, 'share_link', 'message'],
+    required: [],
+    hasSummary: false,
+    hasAttachment: true,
+  },
+  message: {
+    group: 'messages',
+    tags: [...WORKSHOP, ...CUSTOMER, ...VEHICLE, 'message', 'portal_link'],
+    required: ['message'],
+    hasSummary: false,
+    hasAttachment: false,
+  },
+  portal_signin: {
+    group: 'portal',
+    tags: [...WORKSHOP, ...CUSTOMER, 'signin_link'],
+    required: ['signin_link'],
+    hasSummary: false,
+    hasAttachment: false,
+  },
+}
+
+export function isEmailKind(value: string): value is EmailKind {
+  return (EMAIL_KINDS as readonly string[]).includes(value)
+}
+
+export function kindSpec(kind: EmailKind): EmailKindSpec {
+  return EMAIL_KIND_SPECS[kind]
+}
+
+export function kindsInGroup(group: EmailKindGroup): EmailKind[] {
+  return EMAIL_KINDS.filter((kind) => EMAIL_KIND_SPECS[kind].group === group)
+}

+ 25 - 0
src/features/email/Lib/emailMessages.server.ts

@@ -0,0 +1,25 @@
+import 'server-only'
+
+import { defaultLocale, type Locale, locales } from '@/i18n/config'
+import type { EmailMessages } from './emailPresets'
+
+/**
+ * The preset words and labels for one language, read straight from the
+ * message file.
+ *
+ * Not next-intl's getTranslations, for two reasons. The presets quote tags
+ * in braces, which ICU would take for arguments and demand values for. And
+ * mail goes out from cron jobs and public routes with no request to hang a
+ * locale off; a direct import works anywhere.
+ */
+export async function loadEmailMessages(locale: string): Promise<EmailMessages> {
+  const chosen: Locale = (locales as readonly string[]).includes(locale)
+    ? (locale as Locale)
+    : defaultLocale
+  try {
+    return (await import(`../../../../messages/${chosen}/email.json`)).default as EmailMessages
+  } catch {
+    return (await import(`../../../../messages/${defaultLocale}/email.json`))
+      .default as EmailMessages
+  }
+}

+ 85 - 0
src/features/email/Lib/emailPresets.ts

@@ -0,0 +1,85 @@
+import { type EmailKind, kindSpec } from './emailKinds'
+import { DEFAULT_EMAIL_THEME, type EmailBlock, type EmailTemplate } from './emailTemplate'
+
+/**
+ * What each kind of mail says before a workshop has changed anything.
+ *
+ * These are the starting points the gallery offers and the fallback when a
+ * workshop has saved no template of its own, the way layoutPresets.ts works
+ * for the sheet. The words come from messages/<locale>/email.json so a
+ * workshop starts from its own language, and a customer whose workshop never
+ * opened the editor gets the preset in theirs.
+ *
+ * Written with tags rather than fixed words so the same preset serves a mail
+ * with a link and one with the PDF attached, and phrased so an optional tag
+ * left blank still leaves a sentence: the vehicle lives in the summary panel,
+ * not in the prose, because a parts sale has none.
+ */
+
+export interface PresetWords {
+  name: string
+  subject: string
+  heading?: string
+  intro?: string
+  button?: string
+  linkFallback?: string
+  outro?: string
+}
+
+/** The shape of messages/<locale>/email.json. */
+export interface EmailMessages {
+  presets: Record<EmailKind, PresetWords>
+  attachmentNote: string
+  summary: { reference: string; vehicle: string; total: string; balance: string; due: string }
+}
+
+const block = (b: EmailBlock): EmailBlock => b
+
+/** The blocks a kind starts with, in the order they appear. */
+function presetBlocks(kind: EmailKind, words: PresetWords, attachmentNote: string): EmailBlock[] {
+  const spec = kindSpec(kind)
+  const blocks: EmailBlock[] = [block({ id: 'header', type: 'header' })]
+
+  if (words.heading) blocks.push(block({ id: 'heading', type: 'heading', text: words.heading }))
+  if (words.intro) blocks.push(block({ id: 'intro', type: 'paragraph', text: words.intro }))
+
+  // The sender's own words for this send. On a document mail they are a
+  // note beside the document; on a message they are the whole point.
+  if (kind === 'message') {
+    blocks.push(block({ id: 'message', type: 'callout', text: '{message}' }))
+  } else if (spec.tags.includes('message')) {
+    blocks.push(block({ id: 'message', type: 'paragraph', text: '{message}' }))
+  }
+
+  if (spec.hasSummary) blocks.push(block({ id: 'summary', type: 'document_summary' }))
+
+  if (words.button) {
+    const href = kind === 'portal_signin' ? '{signin_link}' : '{share_link}'
+    blocks.push(block({ id: 'cta', type: 'button', label: words.button, href }))
+  }
+  if (words.linkFallback) {
+    blocks.push(block({ id: 'link', type: 'paragraph', text: words.linkFallback }))
+  }
+  if (spec.hasAttachment) {
+    blocks.push(block({ id: 'attachment', type: 'attachment_note', text: attachmentNote }))
+  }
+  if (words.outro) blocks.push(block({ id: 'outro', type: 'paragraph', text: words.outro }))
+
+  blocks.push(
+    block({ id: 'divider', type: 'divider' }),
+    block({ id: 'footer', type: 'contact_footer' })
+  )
+  return blocks
+}
+
+/** The built-in template for a kind, in the language of the given messages. */
+export function presetTemplate(kind: EmailKind, messages: EmailMessages): EmailTemplate {
+  const words = messages.presets[kind]
+  return {
+    kind,
+    name: words.name,
+    subject: words.subject,
+    theme: { ...DEFAULT_EMAIL_THEME },
+    blocks: presetBlocks(kind, words, messages.attachmentNote),
+  }
+}

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

@@ -0,0 +1,229 @@
+import type { EmailKind } from './emailKinds'
+import { type RichDoc, richHrefs, richToPlain } from './richText'
+
+/**
+ * What an email template is made of.
+ *
+ * A list of blocks and a theme, not a canvas. The invoice designer places
+ * blocks by anchor on a sheet of a known size; email has no fixed page, no
+ * dependable flexbox or grid, and no position at all in Outlook. A layout
+ * dragged anywhere could not be rendered faithfully, so the blocks are the
+ * whole vocabulary and every one of them is something a workshop mail
+ * actually needs.
+ */
+export const EMAIL_BLOCK_TYPES = [
+  'header',
+  'heading',
+  'paragraph',
+  'callout',
+  'button',
+  'document_summary',
+  'attachment_note',
+  'image',
+  'divider',
+  'spacer',
+  'contact_footer',
+] as const
+
+export type EmailBlockType = (typeof EMAIL_BLOCK_TYPES)[number]
+
+/** The rows the summary panel can show, in the order it shows them. */
+export const SUMMARY_ROWS = ['reference', 'vehicle', 'total', 'balance', 'due'] as const
+export type SummaryRowKey = (typeof SUMMARY_ROWS)[number]
+
+export const BLOCK_ALIGNS = ['left', 'center', 'right'] as const
+export type BlockAlign = (typeof BLOCK_ALIGNS)[number]
+
+export interface EmailBlock {
+  /** Stable across edits, so the editor can key rows and reorder them. */
+  id: string
+  type: EmailBlockType
+  /** Absent counts as visible, the way a section's fields do. */
+  visible?: boolean
+  /**
+   * heading, paragraph, callout, attachment_note: the words, tags and all.
+   * Plain text, kept for presets and older rows; `content` wins when set.
+   */
+  text?: string
+  /** The same words with formatting, as the designer's editor writes them. */
+  content?: RichDoc
+  /** button: what it says. */
+  label?: string
+  /** button: where it goes, usually a link tag such as {share_link}. */
+  href?: string
+  /** header, button, image: where it sits across the mail. Absent is left. */
+  align?: BlockAlign
+  /** image: the upload, as the protected file URL it was stored under. */
+  src?: string
+  /** image: what a reader who cannot see it is told. */
+  alt?: string
+  /** image: rendered width in pixels, up to the mail's 600. Absent is full width. */
+  width?: number
+  /** spacer: how much room, in pixels. */
+  height?: number
+  /** document_summary: which rows to show. Absent shows every row there is. */
+  rows?: SummaryRowKey[]
+}
+
+/**
+ * Typefaces a mail client already has. Web fonts are not fetched by most
+ * clients, so the choice is a stack, named by an id the theme stores.
+ */
+export const EMAIL_FONTS = {
+  system: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif",
+  arial: 'Arial, Helvetica, sans-serif',
+  georgia: "Georgia, 'Times New Roman', serif",
+  verdana: 'Verdana, Geneva, sans-serif',
+  trebuchet: "'Trebuchet MS', Helvetica, sans-serif",
+  courier: "'Courier New', Courier, monospace",
+} as const
+
+export type EmailFontId = keyof typeof EMAIL_FONTS
+export const EMAIL_FONT_IDS = Object.keys(EMAIL_FONTS) as EmailFontId[]
+
+export interface EmailTheme {
+  primaryColor: string
+  textColor: string
+  mutedColor: string
+  backgroundColor: string
+  /** Behind the summary and callout panels. */
+  panelColor: string
+  fontFamily: EmailFontId
+  showLogo: boolean
+  /**
+   * The logo uploaded for email, as the protected upload URL it was stored
+   * under. Its own upload rather than the company logo, so it can be sized
+   * and trimmed for a mail header without touching the sheet's letterhead.
+   * Empty means none: the header prints the workshop name instead.
+   */
+  logoUrl: string
+  /** Rendered width of the logo, in pixels. */
+  logoWidth: number
+  /** Rounded corners on the button; Outlook squares them off regardless. */
+  buttonRadius: number
+}
+
+export const DEFAULT_EMAIL_THEME: EmailTheme = {
+  primaryColor: '#d97706',
+  textColor: '#111827',
+  mutedColor: '#6b7280',
+  backgroundColor: '#f6f7f9',
+  panelColor: '#ffffff',
+  fontFamily: 'system',
+  showLogo: true,
+  logoUrl: '',
+  logoWidth: 140,
+  buttonRadius: 6,
+}
+
+export const EMAIL_LOGO_MIN_WIDTH = 60
+export const EMAIL_LOGO_MAX_WIDTH = 320
+
+/** Where email uploads are stored under, as protected file URLs. */
+export const EMAIL_LOGO_CATEGORY = 'email-logos'
+export const EMAIL_IMAGE_CATEGORY = 'email-images'
+export const EMAIL_ASSET_CATEGORIES = [EMAIL_LOGO_CATEGORY, EMAIL_IMAGE_CATEGORY] as const
+
+export const EMAIL_IMAGE_MIN_WIDTH = 100
+export const EMAIL_IMAGE_MAX_WIDTH = 600
+
+const EMAIL_ASSET_URL =
+  /^\/api\/protected\/files\/([A-Za-z0-9_-]+)\/(email-logos|email-images)\/([A-Za-z0-9_-]+\.(?:png|jpg|jpeg|webp))$/
+
+/** The parts of a stored email upload URL, or null for anything else. */
+export function parseEmailAssetUrl(
+  value: string | undefined | null
+): { organizationId: string; category: string; file: string } | null {
+  const match = value ? EMAIL_ASSET_URL.exec(value) : null
+  return match ? { organizationId: match[1], category: match[2], file: match[3] } : null
+}
+
+export function isEmailLogoUrl(value: string): boolean {
+  return parseEmailAssetUrl(value)?.category === EMAIL_LOGO_CATEGORY
+}
+
+export function isEmailImageUrl(value: string): boolean {
+  return parseEmailAssetUrl(value)?.category === EMAIL_IMAGE_CATEGORY
+}
+
+/**
+ * The public address a mail client can fetch a stored email upload from, or
+ * nothing for an empty or foreign value. Relative; a sender prepends the
+ * app's base URL.
+ */
+export function emailAssetPublicPath(storedUrl: string | undefined | null): string | undefined {
+  const parts = parseEmailAssetUrl(storedUrl)
+  if (!parts) return undefined
+  const route = parts.category === EMAIL_LOGO_CATEGORY ? 'email-logo' : 'email-image'
+  return `/api/public/${route}/${parts.organizationId}/${parts.file}`
+}
+
+export const emailLogoPublicPath = emailAssetPublicPath
+export const emailImagePublicPath = emailAssetPublicPath
+
+/** Every upload a template refers to: its logo and its image blocks. */
+export function templateAssets(template: Pick<EmailTemplate, 'blocks' | 'theme'>): string[] {
+  const out = new Set<string>()
+  if (template.theme.logoUrl && parseEmailAssetUrl(template.theme.logoUrl)) {
+    out.add(template.theme.logoUrl)
+  }
+  for (const block of template.blocks) {
+    if (block.type === 'image' && block.src && parseEmailAssetUrl(block.src)) out.add(block.src)
+  }
+  return [...out]
+}
+
+export interface EmailTemplate {
+  kind: EmailKind
+  name: string
+  subject: string
+  blocks: EmailBlock[]
+  theme: EmailTheme
+}
+
+/** A template the workshop saved, as the gallery and the designer see it. */
+export interface SavedEmailTemplate extends EmailTemplate {
+  id: string
+  updatedAt: string
+}
+
+/** Every piece of writing in a template, for checking tags across all of it. */
+export function templateText(template: Pick<EmailTemplate, 'subject' | 'blocks'>): string {
+  return [
+    template.subject,
+    ...template.blocks.flatMap((block) => [
+      blockTaggable(block),
+      block.label,
+      block.href,
+      block.alt,
+    ]),
+  ]
+    .filter(Boolean)
+    .join('\n')
+}
+
+/** What a text block says, formatting aside. */
+export function blockWords(block: EmailBlock): string {
+  return block.content ? richToPlain(block.content) : (block.text ?? '')
+}
+
+/** Everything typed into a text block that may carry a tag: words and link addresses. */
+function blockTaggable(block: EmailBlock): string {
+  if (!block.content) return block.text ?? ''
+  return [richToPlain(block.content), ...richHrefs(block.content)].join('\n')
+}
+
+/** The setting that says which template a kind sends with. */
+export function activeTemplateSettingKey(kind: EmailKind): string {
+  return `email.template.${kind}`
+}
+
+/**
+ * What an active-template setting points at. "design:<id>" is a saved row;
+ * anything else, including no setting at all, is the built-in preset.
+ */
+export function activeTemplateId(value: string | undefined | null): string | null {
+  if (!value?.startsWith('design:')) return null
+  const id = value.slice('design:'.length)
+  return id || null
+}

+ 43 - 0
src/features/email/Lib/emailUploadAccess.server.ts

@@ -0,0 +1,43 @@
+import 'server-only'
+
+import { NextResponse } from 'next/server'
+import { getCachedMembership } from '@/lib/cached-session'
+import { getAuthContext } from '@/lib/get-auth-context'
+import type { AuthContext } from '@/lib/with-auth'
+import { hasPermission, PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { rateLimit } from '@/lib/rate-limit'
+
+/**
+ * Who may upload for the email designer, decided the way withAuth decides
+ * it for the actions: owners, admins and custom-admin roles always; anyone
+ * else only with the settings permission, which a member with no role does
+ * not have. One answer for the logo route, the image route and the actions,
+ * so a person can never upload what they could not save, or the reverse.
+ */
+export async function canManageEmailTemplates(ctx: AuthContext): Promise<boolean> {
+  if (ctx.isAdmin) return true
+  const membership = await getCachedMembership(ctx.userId)
+  const permissions = membership?.customRole?.permissions ?? []
+  return hasPermission(permissions, {
+    action: PermissionAction.UPDATE,
+    subject: PermissionSubject.SETTINGS,
+  })
+}
+
+/**
+ * The checks every email upload route runs before touching the file: a
+ * session, the permission, and a ceiling on how often one person can make
+ * the server decode an image.
+ */
+export async function guardEmailUpload(
+  request: Request
+): Promise<{ ctx: AuthContext } | { response: NextResponse }> {
+  const limited = rateLimit(request, { limit: 30, windowMs: 60_000 })
+  if (limited) return { response: limited }
+  const ctx = await getAuthContext()
+  if (!ctx) return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
+  if (!(await canManageEmailTemplates(ctx))) {
+    return { response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
+  }
+  return { ctx }
+}

+ 27 - 0
src/features/email/Lib/links.ts

@@ -0,0 +1,27 @@
+/**
+ * What a typed link address becomes before it is checked or sent.
+ *
+ * People type "example.com/book" and mean "https://example.com/book". A mail
+ * client will not guess, and the renderer refuses anything that is not
+ * http(s) or mailto, so the guess is made here, once, for the editor and the
+ * renderers alike. A tag such as {share_link} is left for filling; an
+ * address that already names a scheme is left alone.
+ */
+
+const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i
+const LOOKS_LIKE_HOST = /^(?:www\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)+(?:[/?#].*)?$/i
+const LOOKS_LIKE_EMAIL = /^[^\s@/]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+$/i
+
+export function normalizeHref(value: string): string {
+  const trimmed = value.trim()
+  if (!trimmed || trimmed.includes('{') || HAS_SCHEME.test(trimmed)) return trimmed
+  if (LOOKS_LIKE_EMAIL.test(trimmed)) return `mailto:${trimmed}`
+  if (LOOKS_LIKE_HOST.test(trimmed)) return `https://${trimmed}`
+  return trimmed
+}
+
+/** A link a mail client will follow, or null. Only http(s) and mailto pass. */
+export function safeHref(value: string): string | null {
+  const href = normalizeHref(value)
+  return /^(https?:|mailto:)/i.test(href) ? href : null
+}

+ 45 - 0
src/features/email/Lib/resolveEmailTemplate.ts

@@ -0,0 +1,45 @@
+import 'server-only'
+
+import { db } from '@/lib/db'
+import { readStoredTemplate } from '../Schema/emailTemplateSchema'
+import type { EmailKind } from './emailKinds'
+import { loadEmailMessages } from './emailMessages.server'
+import { presetTemplate } from './emailPresets'
+import { activeTemplateId, activeTemplateSettingKey, type EmailTemplate } from './emailTemplate'
+
+/**
+ * The template a kind of mail sends with: the row the workshop's setting
+ * points at, else the built-in preset in the reader's language.
+ *
+ * Read on the send rather than threaded down from a page, because most of
+ * these mails go out from a cron job or a public route with no request to
+ * hang settings off. A workshop that has never opened the editor has no
+ * setting and no rows, so the preset answers and there is nothing to migrate.
+ *
+ * A row that cannot be read falls back to the preset too. A mail that goes
+ * out looking like the default beats one that does not go out.
+ */
+export async function resolveEmailTemplate(
+  organizationId: string,
+  kind: EmailKind,
+  locale: string
+): Promise<EmailTemplate> {
+  try {
+    const setting = await db.appSetting.findUnique({
+      where: { organizationId_key: { organizationId, key: activeTemplateSettingKey(kind) } },
+      select: { value: true },
+    })
+    const id = activeTemplateId(setting?.value)
+    if (id) {
+      const row = await db.emailTemplate.findFirst({
+        where: { id, organizationId, kind },
+        select: { kind: true, name: true, subject: true, blocks: true, theme: true },
+      })
+      const stored = row ? readStoredTemplate(row) : null
+      if (stored) return stored
+    }
+  } catch {
+    // A database that cannot answer is not a reason to drop the invoice.
+  }
+  return presetTemplate(kind, await loadEmailMessages(locale))
+}

+ 223 - 0
src/features/email/Lib/richText.ts

@@ -0,0 +1,223 @@
+import { z } from 'zod'
+
+/**
+ * Rich text inside a block, as a small document tree rather than HTML.
+ *
+ * The designer edits with Tiptap, which speaks this shape natively, and the
+ * renderers walk the tree and write the markup themselves. Nothing a person
+ * typed is ever parsed as HTML, so there is nothing to sanitise, and the
+ * plain-text half of the mail is the same tree read without its marks. Only
+ * what a mail client can honour is in the vocabulary: paragraphs, lists,
+ * line breaks, and the inline marks below.
+ */
+
+export type RichMark =
+  | { type: 'bold' }
+  | { type: 'italic' }
+  | { type: 'underline' }
+  | { type: 'strike' }
+  | { type: 'link'; attrs: { href: string } }
+  | { type: 'textStyle'; attrs: { color?: string | null; fontSize?: string | null } }
+
+export type RichAlign = 'left' | 'center' | 'right'
+
+export type RichNode =
+  | { type: 'text'; text: string; marks?: RichMark[] }
+  | { type: 'hardBreak' }
+  | { type: 'paragraph'; attrs?: { textAlign?: RichAlign | null }; content?: RichNode[] }
+  | { type: 'bulletList'; content?: RichNode[] }
+  | { type: 'orderedList'; content?: RichNode[] }
+  | { type: 'listItem'; content?: RichNode[] }
+
+export interface RichDoc {
+  type: 'doc'
+  content?: RichNode[]
+}
+
+const hexColor = z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/)
+
+/** The sizes the toolbar offers, as the CSS the text carries. */
+export const RICH_FONT_SIZES = ['13px', '15px', '18px', '22px'] as const
+
+export const richMarkSchema: z.ZodType<RichMark> = z.discriminatedUnion('type', [
+  z.object({ type: z.literal('bold') }),
+  z.object({ type: z.literal('italic') }),
+  z.object({ type: z.literal('underline') }),
+  z.object({ type: z.literal('strike') }),
+  z.object({ type: z.literal('link'), attrs: z.object({ href: z.string().max(500) }) }),
+  z.object({
+    type: z.literal('textStyle'),
+    attrs: z.object({
+      color: hexColor.nullish(),
+      fontSize: z.enum(RICH_FONT_SIZES).nullish(),
+    }),
+  }),
+])
+
+export const richNodeSchema: z.ZodType<RichNode> = z.lazy(() =>
+  z.discriminatedUnion('type', [
+    z.object({
+      type: z.literal('text'),
+      text: z.string().max(4000),
+      marks: z.array(richMarkSchema).max(8).optional(),
+    }),
+    z.object({ type: z.literal('hardBreak') }),
+    z.object({
+      type: z.literal('paragraph'),
+      attrs: z.object({ textAlign: z.enum(['left', 'center', 'right']).nullish() }).optional(),
+      content: z.array(richNodeSchema).max(200).optional(),
+    }),
+    z.object({
+      type: z.literal('bulletList'),
+      content: z.array(richNodeSchema).max(50).optional(),
+    }),
+    z.object({
+      type: z.literal('orderedList'),
+      content: z.array(richNodeSchema).max(50).optional(),
+    }),
+    z.object({ type: z.literal('listItem'), content: z.array(richNodeSchema).max(20).optional() }),
+  ])
+)
+
+export const richDocSchema: z.ZodType<RichDoc> = z.object({
+  type: z.literal('doc'),
+  content: z.array(richNodeSchema).max(100).optional(),
+})
+
+/** A plain string as a document: one paragraph per line break pair, breaks within. */
+export function plainToRich(text: string): RichDoc {
+  const paragraphs = text.replace(/\r\n/g, '\n').split(/\n{2,}/)
+  return {
+    type: 'doc',
+    content: paragraphs.map((paragraph) => {
+      const lines = paragraph.split('\n')
+      const content: RichNode[] = []
+      lines.forEach((line, index) => {
+        if (index > 0) content.push({ type: 'hardBreak' })
+        if (line) content.push({ type: 'text', text: line })
+      })
+      return { type: 'paragraph', content }
+    }),
+  }
+}
+
+/** The words alone, the way the text part of the mail reads them. */
+export function richToPlain(doc: RichDoc): string {
+  const inline = (nodes: RichNode[] | undefined): string =>
+    (nodes ?? [])
+      .map((node) => {
+        if (node.type === 'text') return node.text
+        if (node.type === 'hardBreak') return '\n'
+        return block(node)
+      })
+      .join('')
+
+  const block = (node: RichNode): string => {
+    switch (node.type) {
+      case 'paragraph':
+        return inline(node.content)
+      case 'bulletList':
+        return (node.content ?? []).map((item) => `- ${block(item)}`).join('\n')
+      case 'orderedList':
+        return (node.content ?? []).map((item, i) => `${i + 1}. ${block(item)}`).join('\n')
+      case 'listItem':
+        return (node.content ?? []).map(block).join('\n')
+      default:
+        return inline([node])
+    }
+  }
+
+  return (doc.content ?? [])
+    .map(block)
+    .join('\n\n')
+    .replace(/[ \t]+\n/g, '\n')
+    .trim()
+}
+
+/**
+ * The same tree with every text node, and every link address, passed
+ * through `fill`. A link whose address still holds a tag afterwards had no
+ * value for it; the words stay and the link goes, the same rule as a button.
+ */
+/**
+ * A tag that was typed across two formattings, "{customer" in bold and
+ * "_name}" plain, is two text nodes to the editor and one tag to the
+ * person. Before filling, each such tag is pulled whole into the node it
+ * starts in, so it fills like any other and the validator and the mail
+ * agree on what the block says.
+ */
+export function joinSplitTags(doc: RichDoc): RichDoc {
+  const joinRun = (nodes: RichNode[]): RichNode[] => {
+    const out = nodes.map((node) => ({ ...node }))
+    const texts: { index: number; start: number }[] = []
+    let all = ''
+    out.forEach((node, index) => {
+      if (node.type === 'text') {
+        texts.push({ index, start: all.length })
+        all += node.text
+      } else {
+        // Anything that is not text breaks the run: a tag cannot span it.
+        all += '\u0000'
+      }
+    })
+    for (const match of all.matchAll(/\{(\w+)\}/g)) {
+      const from = match.index ?? 0
+      const to = from + match[0].length
+      const first = texts.findLast((t) => t.start <= from)
+      const last = texts.findLast((t) => t.start < to)
+      if (!first || !last || first.index === last.index) continue
+      // Move the whole tag into the first node and cut it out of the rest.
+      const firstNode = out[first.index] as Extract<RichNode, { type: 'text' }>
+      firstNode.text = firstNode.text.slice(0, from - first.start) + match[0]
+      for (const t of texts) {
+        if (t.index <= first.index || t.index > last.index) continue
+        const node = out[t.index] as Extract<RichNode, { type: 'text' }>
+        const cut = Math.min(node.text.length, Math.max(0, to - t.start))
+        node.text = node.text.slice(cut)
+      }
+    }
+    return out.filter((node) => node.type !== 'text' || node.text.length > 0)
+  }
+  const walk = (node: RichNode): RichNode => {
+    if (!('content' in node) || !node.content) return node
+    const content = node.content.map(walk)
+    return { ...node, content: node.type === 'paragraph' ? joinRun(content) : content }
+  }
+  return { type: 'doc', content: doc.content?.map(walk) }
+}
+
+export function mapRichText(doc: RichDoc, fill: (text: string) => string): RichDoc {
+  const marks = (list: RichMark[] | undefined): RichMark[] | undefined => {
+    if (!list) return list
+    const out: RichMark[] = []
+    for (const mark of list) {
+      if (mark.type !== 'link') {
+        out.push(mark)
+        continue
+      }
+      const href = fill(mark.attrs.href).trim()
+      if (href && !href.includes('{')) out.push({ type: 'link', attrs: { href } })
+    }
+    return out.length ? out : undefined
+  }
+  const walk = (node: RichNode): RichNode => {
+    if (node.type === 'text') return { ...node, text: fill(node.text), marks: marks(node.marks) }
+    if ('content' in node && node.content) return { ...node, content: node.content.map(walk) }
+    return node
+  }
+  return { type: 'doc', content: joinSplitTags(doc).content?.map(walk) }
+}
+
+/** Every link address in the tree, for tag checks alongside the words. */
+export function richHrefs(doc: RichDoc): string[] {
+  const out: string[] = []
+  const walk = (node: RichNode) => {
+    if (node.type === 'text') {
+      for (const mark of node.marks ?? []) if (mark.type === 'link') out.push(mark.attrs.href)
+    } else if ('content' in node && node.content) {
+      node.content.forEach(walk)
+    }
+  }
+  doc.content?.forEach(walk)
+  return out
+}

+ 145 - 0
src/features/email/Lib/sendTemplatedMail.ts

@@ -0,0 +1,145 @@
+import 'server-only'
+
+import { resolveCustomerLocale } from '@/i18n/locale-from-request'
+import { db } from '@/lib/db'
+import { getOrgFromAddress, type SendMailOptions, sendOrgMail } from '@/lib/email'
+import { formatDate } from '@/lib/format'
+import { resolveWorkshopTimeZone } from '@/lib/workshop-timezone'
+import { renderEmailHtml } from '../Render/renderEmailHtml'
+import { renderEmailText } from '../Render/renderEmailText'
+import { buildEmailSpec } from './buildEmailSpec'
+import {
+  type EmailContext,
+  type SummaryLabels,
+  summaryRowsFor,
+  tagValuesFor,
+  type WorkshopContext,
+} from './emailContext'
+import type { EmailKind } from './emailKinds'
+import { loadEmailMessages } from './emailMessages.server'
+import { resolveEmailTemplate } from './resolveEmailTemplate'
+import { assetUrlsFor } from './emailAssets.server'
+
+/**
+ * One door every customer-facing mail leaves through.
+ *
+ * Resolves the workshop's template for the kind, fills it from the context
+ * the caller knows, renders both halves and sends through the workshop's
+ * provider. Callers gather what they are sending about; nothing about how a
+ * mail looks lives anywhere else.
+ */
+
+export interface TemplatedMailOptions<K extends EmailKind> {
+  kind: K
+  to: string
+  context: EmailContext<K>
+  /** The reader's language. Resolved from the workshop's settings when absent. */
+  locale?: string
+  /** Whether a PDF rides along; the attachment note appears only then. */
+  attached?: boolean
+  attachments?: SendMailOptions['attachments']
+  replyTo?: string
+  /**
+   * A subject the caller insists on, in place of the template's. For a
+   * status report the subject names the vehicle and is worded by the app.
+   */
+  subject?: string
+  /** For the designer's test send: this template, not the one that is active. */
+  template?: Awaited<ReturnType<typeof resolveEmailTemplate>>
+}
+
+const WORKSHOP_KEYS = [
+  'workshop.phone',
+  'workshop.email',
+  'workshop.address',
+  'workshop.dateFormat',
+  'workshop.timezone',
+  'workshop.timezoneDetected',
+] as const
+
+interface WorkshopMailSettings {
+  workshop: WorkshopContext
+  dateFormat?: string
+  timezone?: string
+}
+
+async function loadWorkshop(organizationId: string): Promise<WorkshopMailSettings> {
+  const [rows, org] = await Promise.all([
+    db.appSetting.findMany({
+      where: { organizationId, key: { in: [...WORKSHOP_KEYS] } },
+      select: { key: true, value: true },
+    }),
+    db.organization.findUnique({ where: { id: organizationId }, select: { name: true } }),
+  ])
+  const settings: Record<string, string> = {}
+  for (const row of rows) settings[row.key] = row.value
+
+  return {
+    workshop: {
+      name: org?.name,
+      phone: settings['workshop.phone'],
+      email: settings['workshop.email'],
+      address: settings['workshop.address'],
+    },
+    dateFormat: settings['workshop.dateFormat'],
+    timezone: resolveWorkshopTimeZone(
+      settings['workshop.timezone'],
+      settings['workshop.timezoneDetected']
+    ),
+  }
+}
+
+/** The mail as it would go out: subject, HTML and its plain-text half. */
+export async function buildTemplatedMail<K extends EmailKind>(
+  organizationId: string,
+  options: TemplatedMailOptions<K>
+) {
+  const [locale, settings] = await Promise.all([
+    options.locale ?? resolveCustomerLocale(organizationId, null),
+    loadWorkshop(organizationId),
+  ])
+  const [template, messages] = await Promise.all([
+    options.template ?? resolveEmailTemplate(organizationId, options.kind, locale),
+    loadEmailMessages(locale),
+  ])
+
+  const dateOf = (date: Date) => formatDate(date, settings.dateFormat, settings.timezone)
+  const labels: SummaryLabels = messages.summary
+
+  // Every upload the template refers to, checked against the disk, as an
+  // address a mail client can fetch. A file that is gone drops its block.
+  const assets = await assetUrlsFor(organizationId, template)
+
+  const spec = buildEmailSpec(template, {
+    values: tagValuesFor(options.kind, options.context, {
+      workshop: settings.workshop,
+      formatDate: dateOf,
+    }),
+    summary: summaryRowsFor(options.kind, options.context, labels, dateOf),
+    // A URL rather than the file inlined: Gmail and Outlook.com drop data
+    // URIs, and the public route serves the upload by org and file.
+    logoUrl: template.theme.logoUrl ? assets[template.theme.logoUrl] : undefined,
+    assets,
+    attached: options.attached ?? false,
+  })
+
+  const subject = options.subject?.trim() || spec.subject
+  return { subject, html: renderEmailHtml(spec), text: renderEmailText(spec) }
+}
+
+export async function sendTemplatedMail<K extends EmailKind>(
+  organizationId: string,
+  options: TemplatedMailOptions<K>
+): Promise<{ subject: string }> {
+  const mail = await buildTemplatedMail(organizationId, options)
+  await sendOrgMail(organizationId, {
+    from: await getOrgFromAddress(organizationId),
+    to: options.to,
+    replyTo: options.replyTo,
+    subject: mail.subject,
+    html: mail.html,
+    text: mail.text,
+    attachments: options.attachments,
+  })
+  return { subject: mail.subject }
+}

+ 159 - 0
src/features/email/Lib/tags.ts

@@ -0,0 +1,159 @@
+/**
+ * The tags a workshop may type into an email template, and how they are
+ * filled.
+ *
+ * One registry for every mail. Each tag knows which group it belongs to (for
+ * the picker), a stand-in value for the preview, and whether it is always
+ * there or only sometimes: a parts sale has no vehicle, an invoice with no
+ * customer has no name. The editor can warn about the sometimes ones, and
+ * the presets are written so the sentence still reads when they are blank.
+ *
+ * Two rules decide what happens to a tag at send time:
+ *
+ * - A tag the kind offers but the send has no value for renders as nothing,
+ *   and a block that is left with no words disappears. Nobody gets a mail
+ *   saying "Hi {customer_name}".
+ * - A tag the kind does not offer is refused when the template is saved, so
+ *   it cannot reach a mail. A stored row from a future release that mentions
+ *   a tag this code does not know is left as typed, which is the visible
+ *   failure a stranger's row deserves.
+ */
+
+export type TagGroup = 'workshop' | 'customer' | 'vehicle' | 'document' | 'message' | 'portal'
+
+export interface TagSpec {
+  group: TagGroup
+  /** What the preview shows in its place. */
+  sample: string
+  /** Not every send has one: the editor says so next to the tag. */
+  optional?: boolean
+}
+
+export const TAGS = {
+  workshop_name: { group: 'workshop', sample: 'Bergen Auto' },
+  workshop_phone: { group: 'workshop', sample: '+47 55 00 00 00', optional: true },
+  workshop_email: { group: 'workshop', sample: 'post@bergenauto.example', optional: true },
+  workshop_address: { group: 'workshop', sample: 'Havnegata 3\n5003 Bergen', optional: true },
+  current_user: { group: 'workshop', sample: 'Kari Nordmann', optional: true },
+
+  customer_name: { group: 'customer', sample: 'Alex Carter', optional: true },
+
+  vehicle: { group: 'vehicle', sample: '2019 Volvo V70', optional: true },
+  plate: { group: 'vehicle', sample: 'AB 12345', optional: true },
+  mileage: { group: 'vehicle', sample: '92 000', optional: true },
+
+  document_number: { group: 'document', sample: 'INV-1042' },
+  document_title: { group: 'document', sample: 'Brake service', optional: true },
+  total: { group: 'document', sample: '1 250,00', optional: true },
+  balance_due: { group: 'document', sample: '250,00', optional: true },
+  due_date: { group: 'document', sample: '30 Sep 2026', optional: true },
+  share_link: { group: 'document', sample: 'https://example.com/share/preview', optional: true },
+
+  message: {
+    group: 'message',
+    sample: 'Your car is ready for pickup. We close at 17:00 today.',
+    optional: true,
+  },
+
+  signin_link: { group: 'portal', sample: 'https://example.com/portal/sign-in/preview' },
+  portal_link: { group: 'portal', sample: 'https://example.com/portal/preview', optional: true },
+} as const satisfies Record<string, TagSpec>
+
+export type EmailTag = keyof typeof TAGS
+
+export const TAG_GROUPS: readonly TagGroup[] = [
+  'workshop',
+  'customer',
+  'vehicle',
+  'document',
+  'message',
+  'portal',
+]
+
+/**
+ * Older spellings that still resolve, so wording copied from an SMS template
+ * keeps working. Never offered by the picker.
+ */
+export const TAG_ALIASES: Record<string, EmailTag> = {
+  company_name: 'workshop_name',
+  invoice_number: 'document_number',
+  amount: 'total',
+}
+
+export type TagValues = Partial<Record<EmailTag, string>>
+
+const TAG_PATTERN = /\{(\w+)\}/g
+
+export function isEmailTag(key: string): key is EmailTag {
+  return Object.hasOwn(TAGS, key)
+}
+
+/** The tag a typed key means, aliases included; null when it is nothing. */
+export function canonicalTag(key: string): EmailTag | null {
+  if (isEmailTag(key)) return key
+  return TAG_ALIASES[key] ?? null
+}
+
+export function tagsInGroup(group: TagGroup): EmailTag[] {
+  return (Object.keys(TAGS) as EmailTag[]).filter((tag) => TAGS[tag].group === group)
+}
+
+/**
+ * Fill the tags a template uses.
+ *
+ * `known` is the set of tags this kind offers. A known tag with no value
+ * becomes nothing; a tag outside the set is left as typed. Tidies the
+ * punctuation a blank leaves behind, so "Hi {customer_name}," with no name
+ * reads "Hi," rather than "Hi ,".
+ */
+export function fillTags(
+  template: string,
+  values: TagValues,
+  known: readonly EmailTag[] = Object.keys(TAGS) as EmailTag[]
+): string {
+  const knownSet = new Set<string>(known)
+  const filled = template.replace(TAG_PATTERN, (match, key: string) => {
+    const tag = canonicalTag(key)
+    if (!tag || !knownSet.has(tag)) return match
+    return values[tag] ?? ''
+  })
+  return tidy(filled)
+}
+
+/** Whitespace and punctuation left behind by a blank tag. */
+function tidy(text: string): string {
+  return text
+    .replace(/[ \t]+([,.;:!?])/g, '$1')
+    .replace(/[ \t]{2,}/g, ' ')
+    .replace(/[ \t]+\n/g, '\n')
+    .replace(/\n[ \t]+/g, '\n')
+}
+
+/** Every tag a template mentions, in the order it first mentions them. */
+export function tagsUsed(template: string): string[] {
+  const seen = new Set<string>()
+  for (const match of template.matchAll(TAG_PATTERN)) seen.add(match[1])
+  return [...seen]
+}
+
+/** Tags a template mentions that the given set does not offer. */
+export function unknownTags(template: string, allowed: readonly EmailTag[]): string[] {
+  const known = new Set<string>(allowed)
+  return tagsUsed(template).filter((key) => {
+    const tag = canonicalTag(key)
+    return !tag || !known.has(tag)
+  })
+}
+
+/** Tags a template is required to carry but does not. */
+export function missingTags(template: string, required: readonly EmailTag[]): string[] {
+  const used = new Set(tagsUsed(template).map((key) => canonicalTag(key) ?? key))
+  return required.filter((tag) => !used.has(tag))
+}
+
+/** Stand-in values for every tag, for a preview. */
+export function sampleTagValues(overrides: TagValues = {}): TagValues {
+  const values: TagValues = {}
+  for (const tag of Object.keys(TAGS) as EmailTag[]) values[tag] = TAGS[tag].sample
+  return { ...values, ...overrides }
+}

+ 8 - 0
src/features/email/Render/escape.ts

@@ -0,0 +1,8 @@
+/** Text as it may appear inside markup or a double-quoted attribute. */
+export function escapeHtml(value: string): string {
+  return value
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+}

+ 231 - 0
src/features/email/Render/renderEmailHtml.ts

@@ -0,0 +1,231 @@
+import type { EmailSpec, SpecBlock, SpecText } from '../Lib/buildEmailSpec'
+import {
+  DEFAULT_EMAIL_THEME,
+  EMAIL_FONTS,
+  EMAIL_LOGO_MAX_WIDTH,
+  EMAIL_LOGO_MIN_WIDTH,
+  type EmailTheme,
+} from '../Lib/emailTemplate'
+import { safeHref } from '../Lib/links'
+import { escapeHtml } from './escape'
+import { richToHtml } from './richTextHtml'
+
+export { escapeHtml }
+
+/**
+ * A spec drawn as HTML a mail client will actually render.
+ *
+ * Email is not the web. Outlook renders through Word, Gmail strips <style>
+ * from some clients, and nothing can be relied on beyond tables and inline
+ * styles. So: a table for the outer frame, a table for the content, every
+ * rule written on the element it applies to, a 600px column, no external
+ * stylesheet, no web font, no flexbox, no grid, no position.
+ *
+ * The workshop never types HTML, so nothing here has to survive a paste from
+ * Word. Every string that came from a person is escaped on the way in, and
+ * the theme is checked again here even though the schema already did: this
+ * file is the last thing between a stored row and somebody's inbox.
+ */
+
+const WIDTH = 600
+
+const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
+
+/** A colour that is a colour, or the default; never anything that could close an attribute. */
+function color(value: string | undefined, fallback: string): string {
+  return value && HEX_COLOR.test(value) ? value : fallback
+}
+
+/** The theme with every value known to be safe inside an attribute. */
+function safeTheme(theme: EmailTheme) {
+  const d = DEFAULT_EMAIL_THEME
+  const radius = Number.isFinite(theme.buttonRadius)
+    ? Math.min(32, Math.max(0, Math.round(theme.buttonRadius)))
+    : d.buttonRadius
+  return {
+    primary: color(theme.primaryColor, d.primaryColor),
+    text: color(theme.textColor, d.textColor),
+    muted: color(theme.mutedColor, d.mutedColor),
+    background: color(theme.backgroundColor, d.backgroundColor),
+    panel: color(theme.panelColor, d.panelColor),
+    font: EMAIL_FONTS[theme.fontFamily] ?? EMAIL_FONTS[d.fontFamily],
+    radius,
+  }
+}
+
+type SafeTheme = ReturnType<typeof safeTheme>
+
+/** Line breaks the workshop typed, as breaks rather than as spaces. */
+function paragraphHtml(text: string): string {
+  return escapeHtml(text).replace(/\r?\n/g, '<br />')
+}
+
+/**
+ * A text block's words as markup: the rich tree when there is one, else the
+ * plain string with its line breaks. The style is what the block would have
+ * looked like anyway, so plain and rich text sit on the same baseline.
+ */
+function wordsHtml(
+  words: SpecText,
+  t: SafeTheme,
+  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 })
+}
+
+function blockHtml(block: SpecBlock, t: SafeTheme, marked: boolean): string {
+  const base = `font-family:${t.font};`
+  const row = (cell: string) => tableRow(cell, marked ? block.id : undefined)
+
+  switch (block.type) {
+    case 'header': {
+      const width = Math.min(
+        EMAIL_LOGO_MAX_WIDTH,
+        Math.max(EMAIL_LOGO_MIN_WIDTH, Math.round(block.logoWidth || 140))
+      )
+      const logo = block.logoUrl
+        ? `<img src="${escapeHtml(block.logoUrl)}" alt="${escapeHtml(block.workshopName)}" width="${width}" style="display:inline-block;border:0;max-width:${width}px;height:auto;vertical-align:middle;" />`
+        : block.workshopName
+          ? `<span style="${base}font-size:20px;font-weight:700;color:${t.text};">${escapeHtml(block.workshopName)}</span>`
+          : ''
+      if (!logo) return ''
+      // The td's align attribute is what Outlook reads; the style is for
+      // everyone else. The image is inline so the alignment applies to it.
+      return row(
+        `<td align="${block.align}" style="padding:0 0 20px 0;text-align:${block.align};">${logo}</td>`
+      )
+    }
+
+    case 'heading':
+      return row(
+        `<td style="${base}font-size:20px;font-weight:700;color:${t.text};padding:0 0 12px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 20, lineHeight: 1.3 })}</td>`
+      )
+
+    case 'paragraph':
+      return row(
+        `<td style="${base}font-size:15px;line-height:1.5;color:${t.text};padding:0 0 14px 0;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.5 })}</td>`
+      )
+
+    case 'callout':
+      return row(
+        `<td style="padding:0 0 18px 0;">` +
+          `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${t.panel};border-left:4px solid ${t.primary};border-radius:4px;">` +
+          `<tr><td style="${base}font-size:15px;line-height:1.5;color:${t.text};padding:14px 16px;">${wordsHtml(block, t, { color: t.text, fontSize: 15, lineHeight: 1.5 })}</td></tr>` +
+          `</table></td>`
+      )
+
+    case 'button': {
+      const href = safeHref(block.href)
+      if (!href) return ''
+      // A table around the anchor, because Outlook ignores padding on one.
+      const side = block.align === 'center' ? 'center' : block.align === 'right' ? 'right' : 'left'
+      return row(
+        `<td align="${side}" style="padding:6px 0 20px 0;text-align:${side};">` +
+          `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="${side}" style="display:inline-table;"><tr>` +
+          `<td align="center" bgcolor="${t.primary}" style="border-radius:${t.radius}px;">` +
+          `<a href="${escapeHtml(href)}" style="${base}display:inline-block;padding:12px 22px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:${t.radius}px;">${escapeHtml(block.label)}</a>` +
+          `</td></tr></table></td>`
+      )
+    }
+
+    case 'document_summary': {
+      const rows = block.rows
+        .map(
+          (line) =>
+            `<tr>` +
+            `<td style="${base}font-size:13px;color:${t.muted};padding:4px 16px 4px 0;white-space:nowrap;">${escapeHtml(line.label)}</td>` +
+            `<td style="${base}font-size:14px;font-weight:600;color:${t.text};padding:4px 0;">${escapeHtml(line.value)}</td>` +
+            `</tr>`
+        )
+        .join('')
+      return row(
+        `<td style="padding:0 0 18px 0;">` +
+          `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${t.panel};border:1px solid #e5e7eb;border-radius:6px;">` +
+          `<tr><td style="padding:14px 16px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0">${rows}</table></td></tr>` +
+          `</table></td>`
+      )
+    }
+
+    case 'attachment_note':
+      return row(
+        `<td style="${base}font-size:13px;line-height:1.5;color:${t.muted};padding:0 0 14px 0;">${wordsHtml(block, t, { color: t.muted, fontSize: 13, lineHeight: 1.5 })}</td>`
+      )
+
+    case 'image': {
+      const img = `<img src="${escapeHtml(block.src)}" alt="${escapeHtml(block.alt)}" width="${block.width}" style="display:inline-block;border:0;max-width:100%;width:${block.width}px;height:auto;vertical-align:middle;" />`
+      const href = block.href ? safeHref(block.href) : null
+      const inner = href
+        ? `<a href="${escapeHtml(href)}" style="text-decoration:none;">${img}</a>`
+        : img
+      return row(
+        `<td align="${block.align}" style="padding:0 0 18px 0;text-align:${block.align};">${inner}</td>`
+      )
+    }
+
+    case 'divider':
+      return row(
+        `<td style="padding:4px 0 18px 0;"><div style="height:1px;line-height:1px;font-size:0;background:#e5e7eb;">&nbsp;</div></td>`
+      )
+
+    case 'spacer':
+      return row(
+        `<td style="height:${block.height}px;line-height:${block.height}px;font-size:0;">&nbsp;</td>`
+      )
+
+    case 'contact_footer':
+      return row(
+        `<td style="${base}font-size:13px;line-height:1.5;color:${t.muted};padding:8px 0 0 0;">` +
+          block.lines.map((line) => paragraphHtml(line)).join('<br />') +
+          `</td>`
+      )
+
+    default:
+      return ''
+  }
+}
+
+/** A row of the mail; in a preview it also says which block it is. */
+function tableRow(cell: string, blockId?: string): string {
+  const mark = blockId ? ` data-block="${escapeHtml(blockId)}"` : ''
+  return `<tr${mark}>${cell}</tr>`
+}
+
+/**
+ * What the designer's preview adds to the mail: a hand cursor on every block
+ * and an outline on the one that is selected or under the pointer. Never
+ * part of a sent mail, which has no stylesheet at all.
+ */
+const PREVIEW_STYLE =
+  '<style>' +
+  '[data-block]{cursor:pointer}' +
+  '[data-block]>td{outline:2px solid transparent;outline-offset:3px;border-radius:4px;transition:outline-color .12s}' +
+  '[data-block]:hover>td{outline-color:rgba(37,99,235,.35)}' +
+  '[data-block].is-selected>td{outline-color:#2563eb}' +
+  '</style>'
+
+export interface RenderHtmlOptions {
+  /**
+   * Mark every row with the id of the block it came from, for the designer's
+   * preview to highlight and to answer clicks. Off for anything that is sent.
+   */
+  marked?: boolean
+}
+
+export function renderEmailHtml(spec: EmailSpec, options: RenderHtmlOptions = {}): string {
+  const marked = options.marked === true
+  const t = safeTheme(spec.theme)
+  const body = spec.blocks.map((block) => blockHtml(block, t, marked)).join('')
+
+  return (
+    `<!doctype html><html><head><meta charset="utf-8" />` +
+    `<meta name="viewport" content="width=device-width,initial-scale=1" />` +
+    `<title>${escapeHtml(spec.subject)}</title>${marked ? PREVIEW_STYLE : ''}</head>` +
+    `<body style="margin:0;padding:0;background:${t.background};">` +
+    `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background:${t.background};">` +
+    `<tr><td align="center" style="padding:24px 12px;">` +
+    `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="${WIDTH}" style="width:100%;max-width:${WIDTH}px;">` +
+    body +
+    `</table></td></tr></table></body></html>`
+  )
+}

+ 63 - 0
src/features/email/Render/renderEmailText.ts

@@ -0,0 +1,63 @@
+import type { EmailSpec, SpecBlock } from '../Lib/buildEmailSpec'
+
+/**
+ * The same spec written out as plain text.
+ *
+ * Not an afterthought: a mail with no text part scores worse with spam
+ * filters, and some people read mail as text on purpose. Generated from the
+ * spec rather than written a second time, so the text half cannot say
+ * something the HTML half does not.
+ *
+ * A button becomes its words followed by the address, because a reader of the
+ * text part has nothing to click.
+ */
+function blockText(block: SpecBlock): string | null {
+  switch (block.type) {
+    case 'header':
+      return block.workshopName || null
+
+    case 'heading':
+      return block.text
+
+    case 'paragraph':
+      return block.text
+
+    case 'callout':
+      return block.text
+
+    case 'attachment_note':
+      return block.text
+
+    case 'button':
+      return `${block.label}: ${block.href}`
+
+    case 'document_summary':
+      return block.rows.map((row) => `${row.label}: ${row.value}`).join('\n')
+
+    case 'image':
+      // A picture has no text half; its caption and its link are what a
+      // text reader can still use.
+      return block.href ? `${block.alt || block.href}: ${block.href}` : block.alt || null
+
+    case 'divider':
+      return '---'
+
+    case 'spacer':
+      return null
+
+    case 'contact_footer':
+      return block.lines.join('\n')
+
+    default:
+      return null
+  }
+}
+
+export function renderEmailText(spec: EmailSpec): string {
+  return spec.blocks
+    .map(blockText)
+    .filter((part): part is string => !!part)
+    .join('\n\n')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim()
+}

+ 113 - 0
src/features/email/Render/richTextHtml.ts

@@ -0,0 +1,113 @@
+import type { RichDoc, RichMark, RichNode } from '../Lib/richText'
+import { safeHref } from '../Lib/links'
+import { escapeHtml } from './escape'
+
+/**
+ * A rich text tree as the inline-styled markup a mail client will honour.
+ *
+ * Every rule is written on the element: no classes, no stylesheet, and only
+ * the properties Outlook's renderer understands. Paragraph spacing is done
+ * with margins on the <p>, which every client keeps; lists are real lists,
+ * which every client draws. The last block carries no bottom margin, since
+ * the row it sits in has padding of its own.
+ */
+
+export interface RichTextStyle {
+  /** The font stack, colour and size the block would have had as plain text. */
+  font: string
+  color: string
+  fontSize: number
+  lineHeight: number
+  /** Links take the theme's primary colour. */
+  linkColor: string
+}
+
+const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
+
+function markText(text: string, marks: RichMark[] | undefined, style: RichTextStyle): string {
+  let out = escapeHtml(text)
+  const inline: string[] = []
+  let href: string | null = null
+
+  for (const mark of marks ?? []) {
+    switch (mark.type) {
+      case 'bold':
+        inline.push('font-weight:700')
+        break
+      case 'italic':
+        inline.push('font-style:italic')
+        break
+      case 'underline':
+        inline.push('text-decoration:underline')
+        break
+      case 'strike':
+        inline.push('text-decoration:line-through')
+        break
+      case 'link':
+        href = safeHref(mark.attrs.href)
+        break
+      case 'textStyle':
+        if (mark.attrs.color && HEX_COLOR.test(mark.attrs.color)) {
+          inline.push(`color:${mark.attrs.color}`)
+        }
+        if (mark.attrs.fontSize && /^\d{1,2}px$/.test(mark.attrs.fontSize)) {
+          inline.push(`font-size:${mark.attrs.fontSize}`)
+        }
+        break
+    }
+  }
+
+  if (inline.length) out = `<span style="${inline.join(';')};">${out}</span>`
+  if (href) {
+    out = `<a href="${escapeHtml(href)}" style="color:${style.linkColor};text-decoration:underline;">${out}</a>`
+  }
+  return out
+}
+
+function inlineHtml(nodes: RichNode[] | undefined, style: RichTextStyle): string {
+  return (nodes ?? [])
+    .map((node) => {
+      if (node.type === 'text') return markText(node.text, node.marks, style)
+      if (node.type === 'hardBreak') return '<br />'
+      return blockHtml(node, style, false)
+    })
+    .join('')
+}
+
+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;'
+  switch (node.type) {
+    case 'paragraph': {
+      const align = node.attrs?.textAlign
+      const alignCss = align && align !== 'left' ? `text-align:${align};` : ''
+      const inner = inlineHtml(node.content, style)
+      // An empty paragraph is a blank line the writer meant.
+      return `<p style="${base}${alignCss}${margin}">${inner || '&nbsp;'}</p>`
+    }
+    case 'bulletList':
+    case 'orderedList': {
+      const tag = node.type === 'bulletList' ? 'ul' : 'ol'
+      const items = (node.content ?? []).map((item) => blockHtml(item, style, false)).join('')
+      return `<${tag} style="${base}${margin}padding:0 0 0 22px;">${items}</${tag}>`
+    }
+    case 'listItem': {
+      // A list item's paragraphs lose their own margin; the list has one.
+      const inner = (node.content ?? [])
+        .map((child) =>
+          child.type === 'paragraph'
+            ? inlineHtml(child.content, style)
+            : blockHtml(child, style, true)
+        )
+        .join('<br />')
+      return `<li style="${base}margin:0 0 4px 0;">${inner}</li>`
+    }
+    default:
+      return inlineHtml([node], style)
+  }
+}
+
+export function richToHtml(doc: RichDoc, style: RichTextStyle): string {
+  const nodes = doc.content ?? []
+  return nodes.map((node, index) => blockHtml(node, style, index === nodes.length - 1)).join('')
+}

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

@@ -0,0 +1,141 @@
+import { z } from 'zod'
+import { EMAIL_KINDS } from '../Lib/emailKinds'
+import { richDocSchema } from '../Lib/richText'
+import {
+  BLOCK_ALIGNS,
+  DEFAULT_EMAIL_THEME,
+  EMAIL_BLOCK_TYPES,
+  EMAIL_IMAGE_MAX_WIDTH,
+  EMAIL_IMAGE_MIN_WIDTH,
+  EMAIL_LOGO_MAX_WIDTH,
+  EMAIL_LOGO_MIN_WIDTH,
+  isEmailImageUrl,
+  isEmailLogoUrl,
+  EMAIL_FONT_IDS,
+  type EmailFontId,
+  type EmailTemplate,
+  type SavedEmailTemplate,
+  SUMMARY_ROWS,
+} from '../Lib/emailTemplate'
+
+/**
+ * What a stored template has to look like.
+ *
+ * Rows are read leniently, the way the issued-invoice snapshot is: a template
+ * saved today must still render on the code of several releases from now,
+ * which will have block types this one never heard of. An unknown block is
+ * dropped rather than throwing, so one bad row cannot stop a mail going out.
+ *
+ * Colours are hex and fonts are ids, because both end up inside a style
+ * attribute in the rendered mail and nothing else may.
+ */
+
+const hexColor = z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Not a colour')
+
+export const emailBlockSchema = z.object({
+  id: z.string().min(1).max(64),
+  type: z.enum(EMAIL_BLOCK_TYPES),
+  visible: z.boolean().optional(),
+  text: z.string().max(4000).optional(),
+  content: richDocSchema.optional(),
+  label: z.string().max(120).optional(),
+  href: z.string().max(500).optional(),
+  align: z.enum(BLOCK_ALIGNS).optional(),
+  src: z
+    .string()
+    .max(300)
+    .refine((value) => value === '' || isEmailImageUrl(value), 'Not an email image')
+    .optional(),
+  alt: z.string().max(200).optional(),
+  width: z.number().int().min(EMAIL_IMAGE_MIN_WIDTH).max(EMAIL_IMAGE_MAX_WIDTH).optional(),
+  height: z.number().int().min(0).max(120).optional(),
+  rows: z.array(z.enum(SUMMARY_ROWS)).max(SUMMARY_ROWS.length).optional(),
+})
+
+export const emailThemeSchema = z.object({
+  primaryColor: hexColor,
+  textColor: hexColor,
+  mutedColor: hexColor,
+  backgroundColor: hexColor,
+  panelColor: hexColor,
+  fontFamily: z.enum(EMAIL_FONT_IDS as [EmailFontId, ...EmailFontId[]]),
+  showLogo: z.boolean(),
+  logoUrl: z
+    .string()
+    .max(300)
+    .refine((value) => value === '' || isEmailLogoUrl(value), 'Not an email logo'),
+  logoWidth: z.number().int().min(EMAIL_LOGO_MIN_WIDTH).max(EMAIL_LOGO_MAX_WIDTH),
+  buttonRadius: z.number().int().min(0).max(32),
+})
+
+export const emailKindSchema = z.enum(EMAIL_KINDS)
+
+export const emailTemplateSchema = z.object({
+  kind: emailKindSchema,
+  name: z.string().trim().min(1).max(60),
+  subject: z.string().trim().min(1).max(200),
+  blocks: z.array(emailBlockSchema).min(1).max(40),
+  theme: emailThemeSchema,
+})
+
+export const saveEmailTemplateSchema = emailTemplateSchema.extend({
+  id: z.string().optional(),
+})
+
+export type SaveEmailTemplateInput = z.infer<typeof saveEmailTemplateSchema>
+
+/**
+ * A stored row read back as a template, filling in whatever it lacks. Returns
+ * null only when there is nothing usable at all, which is the caller's cue to
+ * fall back to the preset.
+ */
+export function readStoredTemplate(row: {
+  kind: string
+  name: string
+  subject: string
+  blocks: unknown
+  theme: unknown
+}): EmailTemplate | null {
+  const kind = emailKindSchema.safeParse(row.kind)
+  if (!kind.success) return null
+
+  const blocks = Array.isArray(row.blocks)
+    ? row.blocks.flatMap((block) => {
+        const parsed = emailBlockSchema.safeParse(block)
+        return parsed.success ? [parsed.data] : []
+      })
+    : []
+  if (blocks.length === 0) return null
+
+  // Theme fields one at a time, so a bad colour costs that colour and not
+  // the whole look.
+  const theme: Record<string, unknown> = { ...DEFAULT_EMAIL_THEME }
+  if (row.theme && typeof row.theme === 'object') {
+    const stored = row.theme as Record<string, unknown>
+    for (const [key, schema] of Object.entries(emailThemeSchema.shape)) {
+      const parsed = schema.safeParse(stored[key])
+      if (parsed.success) theme[key] = parsed.data
+    }
+  }
+
+  return {
+    kind: kind.data,
+    name: row.name,
+    subject: row.subject,
+    blocks,
+    theme: theme as unknown as EmailTemplate['theme'],
+  }
+}
+
+export function savedTemplateFromRow(row: {
+  id: string
+  kind: string
+  name: string
+  subject: string
+  blocks: unknown
+  theme: unknown
+  updatedAt: Date
+}): SavedEmailTemplate | null {
+  const template = readStoredTemplate(row)
+  return template ? { id: row.id, updatedAt: row.updatedAt.toISOString(), ...template } : null
+}

+ 8 - 0
src/features/integrations/Actions/videoCallActions.ts

@@ -107,6 +107,14 @@ export async function sendServiceVideoCall(input: unknown) {
             recipientEmail: customer.email as string,
             subject: t('emailSubject', { vehicle }),
             body,
+            customerName: customer.name,
+            vehicle: record.vehicle
+              ? {
+                  year: record.vehicle.year,
+                  make: record.vehicle.make,
+                  model: record.vehicle.model,
+                }
+              : null,
           })
         )
       }

+ 6 - 12
src/features/scheduled-messages/Lib/dispatchScheduledMessage.ts

@@ -1,6 +1,6 @@
+import { sendTemplatedMail } from '@/features/email/Lib/sendTemplatedMail'
 import { db } from '@/lib/db'
 import { notify } from '@/lib/notify'
-import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
 import { sendOrgSms, getOrgSmsPhoneNumber, normalizeOrgPhone } from '@/lib/sms'
 import { sendTelegramMessage } from '@/lib/telegram'
 import { sendOrgWhatsapp } from '@/lib/whatsapp'
@@ -19,12 +19,6 @@ export type DispatchableMessage = {
   vehicleId: string | null
 }
 
-/** Plain text to the minimal HTML the mail senders expect. */
-function toHtml(body: string): string {
-  const escaped = body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-  return `<div style="font-family:system-ui,sans-serif;white-space:pre-wrap;line-height:1.5;">${escaped}</div>`
-}
-
 /**
  * Send one scheduled message through its channel.
  *
@@ -47,12 +41,12 @@ export async function dispatchScheduledMessage(message: DispatchableMessage): Pr
     case 'email': {
       const to = message.recipient?.trim() || customer?.email
       if (!to) throw new Error('No email address for this message')
-      const from = await getOrgFromAddress(organizationId)
-      await sendOrgMail(organizationId, {
-        from,
+      // A blank subject falls through to the template's own
+      await sendTemplatedMail(organizationId, {
+        kind: 'message',
         to,
-        subject: message.subject?.trim() || '',
-        html: toHtml(message.body),
+        subject: message.subject?.trim() || undefined,
+        context: { message: message.body, customerName: customer?.name },
       })
       return
     }

+ 10 - 0
src/features/status-reports/Actions/sendStatusReport.ts

@@ -102,6 +102,16 @@ export async function sendStatusReport(input: unknown) {
             recipientEmail: customer.email as string,
             subject: t('emailSubject', { vehicle: vehicleName }),
             body: messageBody,
+            customerName: customer.name,
+            vehicle: vehicle
+              ? {
+                  year: vehicle.year,
+                  make: vehicle.make,
+                  model: vehicle.model,
+                  licensePlate: vehicle.licensePlate,
+                  mileage: vehicle.mileage,
+                }
+              : null,
           })
         )
       }

+ 2 - 0
src/features/vehicles/Components/service-page/ServicePageClient.tsx

@@ -622,6 +622,7 @@ export function ServicePageClient({
             open={actions.showPaymentNotifyDialog}
             onOpenChange={actions.setShowPaymentNotifyDialog}
             customer={customer}
+            vehicle={record.vehicle}
             defaultMessage={actions.paymentNotifyMessage}
             emailSubject={t('invoice.emailSubject')}
             smsEnabled={smsEnabled}
@@ -633,6 +634,7 @@ export function ServicePageClient({
             open={showNotifyDialog}
             onOpenChange={setShowNotifyDialog}
             customer={customer}
+            vehicle={record.vehicle}
             defaultMessage={notifyMessage}
             emailSubject={t('invoice.statusEmailSubject')}
             smsEnabled={smsEnabled}

+ 2 - 0
src/i18n/request.ts

@@ -77,6 +77,7 @@ export default getRequestConfig(async () => {
   const dataImport = (await import(`../../messages/${locale}/dataImport.json`)).default
   const integrations = (await import(`../../messages/${locale}/integrations.json`)).default
   const timeTracking = (await import(`../../messages/${locale}/timeTracking.json`)).default
+  const email = (await import(`../../messages/${locale}/email.json`)).default
 
   return {
     locale,
@@ -123,6 +124,7 @@ export default getRequestConfig(async () => {
       dataImport,
       integrations,
       timeTracking,
+      email,
     },
   }
 })

+ 11 - 0
src/lib/backup/manifest.ts

@@ -61,6 +61,15 @@ export const BACKUP_ENTITIES: readonly BackupEntity[] = [
     restore: 'replace',
     clearOrder: 88,
   },
+  // Email templates likewise: the setting that names the one in use is in
+  // settings, so the rows travel with it.
+  {
+    model: 'EmailTemplate',
+    key: 'emailTemplates',
+    option: 'settings',
+    restore: 'replace',
+    clearOrder: 88,
+  },
   // What issued invoices were issued with. Service records point at these
   // with Restrict, so they clear only after every service record has.
   {
@@ -351,6 +360,8 @@ export const EXCLUDED_MODELS: Readonly<Record<string, string>> = {
  */
 export const UPLOAD_CATEGORIES: readonly string[] = [
   'logos',
+  'email-logos',
+  'email-images',
   'vehicles',
   'inventory',
   'services',

+ 14 - 0
src/lib/email.ts

@@ -29,6 +29,13 @@ export interface SendMailOptions {
   replyTo?: string
   subject: string
   html: string
+  /**
+   * The plain-text half of the mail. A message with no text part scores worse
+   * with spam filters, and some people read mail as text on purpose. Every
+   * provider below takes one; the document mails generate theirs from the
+   * same spec the HTML comes from, so the two cannot disagree.
+   */
+  text?: string
   attachments?: {
     filename: string
     content: Buffer
@@ -204,6 +211,7 @@ async function sendViaSmtpWithSettings(
     replyTo: options.replyTo,
     subject: options.subject,
     html: options.html,
+    text: options.text,
     attachments: options.attachments?.map((a) => ({
       filename: a.filename,
       content: a.content,
@@ -257,6 +265,7 @@ async function sendViaResendWithSettings(
     replyTo: options.replyTo,
     subject: options.subject,
     html: options.html,
+    text: options.text,
     attachments: options.attachments?.map((a) => ({
       filename: a.filename,
       content: a.content,
@@ -290,6 +299,7 @@ async function sendViaPostmarkWithSettings(
       ReplyTo: options.replyTo,
       Subject: options.subject,
       HtmlBody: options.html,
+      TextBody: options.text,
       Attachments: options.attachments.map((a) => ({
         Name: a.filename,
         Content: a.content.toString('base64'),
@@ -304,6 +314,7 @@ async function sendViaPostmarkWithSettings(
       ReplyTo: options.replyTo,
       Subject: options.subject,
       HtmlBody: options.html,
+      TextBody: options.text,
     })
   }
 }
@@ -341,6 +352,7 @@ async function sendViaMailgunWithSettings(
     ...(options.replyTo && { 'h:Reply-To': options.replyTo }),
     subject: options.subject,
     html: options.html,
+    ...(options.text && { text: options.text }),
     ...(options.attachments?.length && {
       attachment: options.attachments.map((a) => ({
         filename: a.filename,
@@ -384,6 +396,7 @@ async function sendViaSendGridWithSettings(
     replyTo: options.replyTo,
     subject: options.subject,
     html: options.html,
+    ...(options.text && { text: options.text }),
   }
 
   if (options.attachments?.length) {
@@ -425,6 +438,7 @@ async function sendViaSesWithSettings(
     replyTo: options.replyTo,
     subject: options.subject,
     html: options.html,
+    text: options.text,
     attachments: options.attachments?.map((a) => ({
       filename: a.filename,
       content: a.content,