Skip to content

feat(templates): rich text editor + inline images in email templates#462

Open
shukiv wants to merge 2 commits into
bulwarkmail:mainfrom
shukiv:feat/template-rich-editor
Open

feat(templates): rich text editor + inline images in email templates#462
shukiv wants to merge 2 commits into
bulwarkmail:mainfrom
shukiv:feat/template-rich-editor

Conversation

@shukiv

@shukiv shukiv commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

What

Adds a rich text editor and inline images to email templates. The template
editor's Body was a plain <textarea>; this swaps it for the existing TipTap
RichTextEditor
(the same component the composer uses), so templates get
formatting, links, tables, and inline images — drag-drop or toolbar, already
supported via the @tiptap/extension-image dependency.

Why

Templates could only hold plain text, so any branded/HTML email (signatures,
logos, formatted layouts) had to be rebuilt by hand each time. Reusing the
composer's editor keeps the UX consistent and adds almost no new surface.

How

  • lib/template-types — new optional bodyIsHtml flag. Absent/false =
    legacy plain text; true = HTML from the rich editor. Fully backward compatible.
  • template-form<textarea>RichTextEditor. Legacy plain-text bodies
    are converted with plainTextToSafeHtml on load so they render correctly;
    saved bodies are run through sanitizeEmailHtml. Placeholder insertion now
    lands at the cursor via the editor instead of appending to the end.
  • Inline images are stored as base64 data URIs in the template (templates
    have no send context, so no cid is minted — the compose/send path handles
    inlining when the template is applied). Each image is capped at 1 MB to
    protect the localStorage-backed template store.
  • email-composer — applying a template branches on bodyIsHtml: HTML
    templates are sanitized and inserted as-is (plain-text compose mode flattens
    them via htmlToPlainText); legacy plain templates keep the previous
    escape-and-wrap behaviour, so existing templates are unaffected.

Backward compatibility

Existing templates have no bodyIsHtml → treated as plain text exactly as before.
They're upgraded to HTML only when re-saved through the new editor.

Notes / follow-ups

  • settings.templates.image_too_large was added to all locales with the English
    string
    ; non-English entries need translation.
  • Inline images live in localStorage (the template store). The 1 MB/image cap
    keeps a few logos well within budget; this PR does not add server-side image
    hosting for templates.

Test plan

  • npm run typecheck, npm run lint, npm run build — all green
  • npm run test:translations — 38/38 (locale keys consistent)
  • New template: type formatted text, drag in an image → save → re-open shows it
  • Apply an HTML template in the composer (rich mode) → formatting + image preserved
  • Apply the same template in plain-text compose mode → flattened to text
  • Open a pre-existing plain-text template → still renders/edits correctly
  • Image > 1 MB → rejected with a toast

Replace the plain-text Body textarea in the template editor with the existing
TipTap RichTextEditor (the same one the composer uses), giving templates
formatting and inline images (drag-drop / toolbar, already supported via
@tiptap/extension-image).

- lib/template-types: add optional `bodyIsHtml` flag. Absent/false = legacy
  plain text; true = HTML from the rich editor. Backward compatible.
- template-form: swap textarea for RichTextEditor. Legacy plain-text bodies are
  converted with plainTextToSafeHtml on load; saved bodies are sanitized HTML.
  Placeholder insertion now lands at the cursor via the editor. Inline images
  are stored as base64 data URIs (templates have no send context, so no cid),
  capped at 1 MB each to protect the localStorage-backed template store.
- email-composer: applying a template branches on `bodyIsHtml` — HTML templates
  are sanitized and inserted as-is (plain-text compose mode flattens them);
  legacy plain templates keep the previous escape-and-wrap behaviour.
- i18n: add settings.templates.image_too_large to all locales (English value;
  non-English need translation).
@rathlinus

Copy link
Copy Markdown
Member

It says the send path inlines template images but it doesn't. Template images are stored as plain data: URIs and never get a cid or registered for sending, so at send time they go out as raw data: URIs. Gmail and most clients strip these, so recipients see no image.

Per review (bulwarkmail#462): template images were stored as data: URIs and, when a template
was applied to the composer, went out at send time as raw data: URIs — which
Gmail/Outlook strip, so recipients saw no image.

On template apply, decode each inline data: image to a File and run it through
the composer's existing handleImageUpload (uploads the blob, registers it in
inlineImagesRef with a cid), then tag the img with data-cid. The existing
send-time rewrite then converts those to cid: refs with real inline attachments,
exactly like dropped/pasted images. Moved handleImageUpload above
handleTemplateSelect to satisfy the new dependency.
@shukiv

shukiv commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Yeah you're right, my bad. Fixed it now: when a template is applied I decode its data: images, push them through the same upload path as dropped images so each gets a real blob + cid, and tag the img with data-cid. The send path then rewrites them to cid: like normal inline images. So they actually reach the recipient now instead of going out as data: URIs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants