Skip to content

Send a plain-text version with every email (#443) - #504

Draft
pierre-b wants to merge 10 commits into
devfrom
feat/plain-text-alternative
Draft

pierre-b wants to merge 10 commits into
devfrom
feat/plain-text-alternative

Conversation

@pierre-b

@pierre-b pierre-b commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Closes #443.

What it does

Every email now goes out with a text/plain alternative next to its HTML, generated at send time from the final HTML (after link tracking), so the text links are tracked or direct exactly as decided for the recipient. No schema change, no new environment variable, nothing stored in the queue payload.

  • Converter pkg/plaintext (FromHTML, on golang.org/x/net/html, no new dependency): paragraphs, headings, lists (including the <li><p> markup of the visual editor and <ol start>), blockquotes, pre, hr; links as label (url), mailto:/tel: targets, linked images by their alt; head, scripts, styles, form controls, hidden elements and the inbox preheader are skipped; invisible padding characters removed; a space always separates a URL from the next word, and only ASCII punctuation stays attached after a URL. Returns "" when the HTML holds nothing to read, and the email is then sent as HTML only.
  • Send path: SendEmailProviderRequest.TextContent, filled in EmailService.SendEmail when the caller gives none. Providers: SMTP (multipart/alternative, text first), Amazon SES simple (Body.Text) and raw MIME (mixed > alternative { text, related { html, inline } } + attachments), SendGrid (text/plain first, as required), Mailgun form and multipart (text), Postmark (TextBody), Mailjet (TextPart), SparkPost (content.text). Each field is absent when there is no text.
  • Preview: templates.compile returns text (only when called by the preview endpoint: IncludeText is json:"-", so send-time compiles do not convert twice). Console: a Text version mode next to Edit and Preview in the visual editor, a Text version tab in the MJML editor and in the template preview drawer. The editor toolbar labels are now translated; new strings translated in the 8 catalogs.
  • Version 43.2 (dev released 43.1 meanwhile and is merged in), changelog entry, SDK bundle rebuilt, OpenAPI updated, plan in plans/feat-plain-text-alternative-plan.md. Docs: Document the plain-text version of emails docs#27.

Tests

  • Unit: converter table tests, invalid UTF-8, fixture, fuzz (FuzzFromHTML, script never leaks, at most one blank line, valid UTF-8), benchmark; MIME tree of every SMTP and SES shape; exact body of each HTTP provider with and without text; SendEmail generation; compile (IncludeText, web channel, errors, URL-set equality with the HTML hrefs with tracking on and off, golden template); handler.
  • Integration (tests/integration/plain_text_alternative_e2e_test.go, Mailpit raw MIME): transactional and broadcast emails are multipart/alternative with text first, the text links to exactly the URLs of the HTML, tracked for a consenting contact and direct for one who refused click tracking, List-Unsubscribe kept, templates.compile returns the text.
  • Locally green: go test -race ./internal/... ./pkg/..., full make test-integration (on 11faeff), then after the dev merge the plain-text, List-Unsubscribe and inline-CID e2e tests, cd console && npx vitest run (173 files), tsc -b, eslint on touched files.

Review status — in progress

Plan reviewed before implementation; code reviewed three times (whole PR, then each correction). The last blocking finding (typographic closing quotes ”, ’, » read by autolinkers as part of a URL) is fixed in 4133bc2 with a test for a bare URL followed by each closer. The fix and the dev merge (3baffee) are under review.

Known, not handled

  • With click tracking on, a labelled social element is written on two lines (the icon's tracked URL, then Facebook (…)): the two links get different tokens, and nothing in the HTML says they lead to the same place. An earlier attempt to drop the icon lost destinations in other layouts and was removed.
  • Nested anchors whose outer label starts with a URL keep only the outer href; a bare URL line can be dropped if the next line ends with the same URL in brackets inside another URL (contrived).
  • A list item starting with <pre> or a nested list misplaces its marker; start="3px" numbers from 1; URLs inside <pre> are not separated from the following text.
  • mailto: with only a query (mailto:?subject=…) gives an empty target.
  • pkg/mailer system emails put their text part last; the API field email.text of a template is still unused (custom text is a later PR).
  • The MJML editor's text tab shows "Compiling..." with an empty source and keeps the last text when the source is cleared.
  • Provider-side link tracking (SendGrid, Mailgun, Postmark) rewrites text links too, as for the HTML.

EmailService.SendEmail derives a text/plain alternative from the final HTML
when the caller gives none, and every provider sends it: SMTP and Amazon SES
as a multipart/alternative part, SendGrid, Mailgun, Postmark, Mailjet and
SparkPost through their text field. An email whose HTML holds nothing to read
is still sent as HTML only.
The preview endpoint now returns the text/plain version an email would be sent
with, derived from the tracked HTML exactly as a send does. Send-time compiles
do not compute it, so a broadcast still converts each email once. A link
followed by punctuation keeps it attached in the text.
The visual editor gets a Text version mode next to Edit and Preview, the MJML
editor a Text version tab, and the template preview drawer a Text version tab,
all showing the text the email is sent with. The editor toolbar labels are now
translated.
Transactional and broadcast emails are read back raw from Mailpit: each is
multipart/alternative with the text first, the text links to exactly the URLs
the HTML links to, tracked or direct as the recipient consented, and
templates.compile returns the same text.
Bump the version, rebuild the web analytics SDK bundle that embeds it, add the
changelog entry and the implementation plan.
List items stored as paragraphs, as the visual editor stores them, keep their
marker on the same line, honour an ordered list's start and indent their
following lines. A social element's icon is left out when its row then links
with a label, including with click tracking. Two line breaks leave an empty
line, and punctuation after a URL is separated from a word that follows it.
The code editor's text tab waits for the first compile.
A social element's icon and its labelled link are both written when click
tracking gives them different URLs: nothing in the HTML says they lead to the
same place, and leaving out an unlabelled link next to a labelled one could
drop a link written nowhere else. Punctuation followed by a closing quote
stays attached to the link before it.
…rsion

Autolinkers take a closing quote right after a URL into the link, which broke
it, a tracked link answering 400. Only ASCII punctuation stays attached.
@codecov

codecov Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.33333% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.77%. Comparing base (f7699b8) to head (3baffee).

Files with missing lines Patch % Lines
internal/service/ses_service.go 65.00% 7 Missing and 7 partials ⚠️
pkg/plaintext/plaintext.go 96.98% 4 Missing and 3 partials ⚠️
internal/service/mailgun_service.go 60.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #504      +/-   ##
==========================================
+ Coverage   88.73%   88.77%   +0.03%     
==========================================
  Files         365      366       +1     
  Lines       71231    71512     +281     
==========================================
+ Hits        63210    63484     +274     
+ Misses       5397     5396       -1     
- Partials     2624     2632       +8     
Flag Coverage Δ
unit 88.77% <92.33%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

43.1 is released, so the plain-text version moves to 43.2: version bumped, SDK
bundle rebuilt, catalogs re-extracted from dev with the new strings translated.

This branch has not been deployed

No deployments
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.

1 participant