Skip to content

v0.4.0 — link / small / attach / inline_image / info_row + RFC 8058 unsubscribe + Minitest 6 + plaintext fixes#3

Merged
rameerez merged 7 commits into
mainfrom
feature/dsl-links-small-attachments-and-rfc8058-unsubscribe
May 21, 2026
Merged

v0.4.0 — link / small / attach / inline_image / info_row + RFC 8058 unsubscribe + Minitest 6 + plaintext fixes#3
rameerez merged 7 commits into
mainfrom
feature/dsl-links-small-attachments-and-rfc8058-unsubscribe

Conversation

@rameerez
Copy link
Copy Markdown
Owner

@rameerez rameerez commented May 7, 2026

Summary

This PR is the v0.4.0 Goodmail DX, correctness, deliverability, and Rails integration release.

The end-state is deliberately small for users: in a Rails mailer, keep your normal Action Mailer headers and write one Goodmail block:

goodmail_mail(headers_for(:confirmation_instructions, opts), locale: record.locale) do
  text "Confirm your account below."
  button "Confirm my account", confirmation_url(record, confirmation_token: token)
  sign
end

Goodmail now owns the mechanical work users should not have to do: render config scoping, mailer context, plaintext generation, CSS inlining, attachment fan-out, inline CID pinning, Action Mailer header filtering, multipart assembly, and RFC 8058 unsubscribe headers.

The final cleanup also removes duplicated internal work: Goodmail.compose now calls Goodmail.render once, then passes already inlined HTML, cleaned plaintext, and attachment descriptors into Goodmail's internal Action Mailer action. The internal mailer no longer runs its own second Premailer/plaintext pipeline.

Why this exists

The downstream trigger was production-style email work in real Rails applications and app templates: plaintext artifacts, inline image/CID issues, unsubscribe header correctness, attachment behavior, and repeated app-level glue in Pay, Devise, and custom mailers.

The conclusion from the audit is that this belongs in Goodmail. Host apps should write product-specific copy and pass their native Action Mailer headers; they should not manually bridge Goodmail.render into mail, copy ivars, pin CIDs, strip render-only keys, or rebuild multipart trees.

Major changes

Action Mailer-native integration

  • Adds private helpers auto-installed on ActionMailer::Base:
    • goodmail_mail(...) { ... }
    • goodmail_render_parts(...) { ... }
    • goodmail_mail_parts(parts, headers, unsubscribe_url:)
  • Helpers stay private so Rails does not expose them as deliverable mailer actions.
  • DSL blocks keep normal mailer context: mailer ivars and private helper methods remain available.
  • locale: wraps the DSL render in I18n.with_locale.
  • Attachments are applied before mail, then Action Mailer's documented block form builds text/html responses.
  • Goodmail forwards Action Mailer's normal header surface (date:, return_path:, delivery options, custom "X-..." headers, etc.) and strips only Goodmail render-only keys (preheader:, unsubscribe_url:, locale:, context:, config:, configuration:, layout_path:).
  • layout_path: works through Goodmail.compose, Goodmail.render, and the Action Mailer helpers without leaking as a mail header.

Single render pipeline

  • Goodmail.render is now the single source of truth for DSL evaluation, layout rendering, Premailer CSS inlining, plaintext cleanup, and attachment descriptor collection.
  • Goodmail.compose renders once, then passes rendered strings and attachment descriptors to Goodmail::Mailer.compose_message.
  • Goodmail::Mailer now only does the Action Mailer handoff: unsubscribe headers, attachment application, and block-form mail.
  • This removes the old duplicate Premailer/plaintext path and avoids drift between render and compose.

New DSL helpers

  • link(text, url)
  • small(text)
  • info_row(label, value)
  • attach(filename, content, mime_type:, inline:)
  • inline_image(filename, content, alt:, width:, height:, mime_type:)

Attachments and inline images

  • EmailParts carries attachments descriptors.
  • attach accepts raw bytes or a filesystem path.
  • Binary strings containing NUL bytes no longer crash path detection.
  • Inline image Content-IDs are generated as RFC 2392-shaped IDs using a reserved .invalid domain, then pinned onto the actual Mail part.
  • Duplicate inline filenames raise Goodmail::Error early because Action Mailer's attachment hash is keyed by filename in custom render fan-out paths.
  • Non-inline duplicate filenames remain allowed.

Deliverability

  • Adds RFC 8058-compatible one-click unsubscribe support:
    • List-Unsubscribe: <https://...>
    • List-Unsubscribe-Post: List-Unsubscribe=One-Click
  • One-click POST is emitted only for HTTPS URLs.
  • Non-HTTPS values still get classic List-Unsubscribe without falsely advertising one-click support.
  • README documents that the final sender/provider must DKIM-sign these headers.

Sources:

Plaintext, Encoding, and Rendering Fixes

  • Hidden preheaders no longer leak into plaintext.
  • Outlook MSO/VML button labels no longer duplicate in plaintext.
  • Standalone company-name image alt lines no longer leak into plaintext.
  • info_row renders as Label: Value in plaintext while preserving table layout in HTML.
  • UTF-8 / accented characters / emoji survive HTML and plaintext generation by pinning Premailer input encoding to UTF-8.
  • Button labels preserve caller casing instead of forcing capitalization.

Per-Render Config and Downstream DX

  • Goodmail.compose, Goodmail.render, goodmail_mail, and goodmail_render_parts accept config: / configuration: for tenant/product-specific branding.
  • Overrides are scoped via Goodmail.with_config; apps do not need to mutate global Goodmail.config around a delivery.
  • Devise and Pay-style mailers can pass their native header hashes directly to goodmail_mail(...).
  • Downstream apps no longer need app-level goodmail_render wrappers or manual Goodmail.render(..., context: self) glue.

Action Mailer Source Audit

I cloned Rails to /tmp/rails and audited the Action Mailer README plus every file under actionmailer/lib/**/*.rb at Rails commit debbd18c562df17d01944c475e9291d927910b58.

Key Rails contracts Goodmail now follows:

Inline implementation comments reference the exact Rails source locations where the contract matters.

Backward Compatibility

  • Existing Goodmail.compose callers keep working.
  • Existing Goodmail.render callers keep working; EmailParts#attachments defaults to [].
  • goodmail_mail_parts tolerates legacy parts objects without attachments.
  • Apps do not need per-class include Goodmail::ActionMailerIntegration; helpers are installed once on ActionMailer::Base.
  • Important caveat: Goodmail.compose evaluates the Ruby DSL block before returning ActionMailer::MessageDelivery, because Ruby blocks are not Active Job-serializable. For fully native Action Mailer lazy action execution in host apps, use goodmail_mail / goodmail_render_parts inside real mailer actions.

Downstream Verification

Verified with the local gem path in three downstream Rails codebases:

  • a production-style Rails application with a broad transactional mailer surface
  • a Rails application template
  • a tenant/product-branded Rails application

Results:

  • Production-style app full suite: 893 runs, 5126 assertions, 0 failures
  • Production-style app Goodmail/mail preview lint: 15 files inspected, no offenses detected
  • Production-style app Mailcatcher delivery readback:
    • Preview runner delivered 15 fresh Goodmail messages, IDs 332-346.
    • All had nonblank plaintext and HTML.
    • No Goodmail render-only header leaks.
    • Inline image emails delivered valid PNG CID attachments.
    • Latest inline image attachment endpoints returned 200 OK, valid image/png, 1280 x 720, 152972 bytes.
    • A separate regular attach smoke email delivered goodmail-smoke.txt as multipart/mixed; Mailcatcher returned 200 OK, correct filename/content-disposition, and exact attachment body.
    • No private external asset/API URLs leaked in message sources.
  • Rails app template full suite: 46 runs, 289 assertions, 0 failures
  • Rails app template Goodmail mailer lint: 7 files inspected, no offenses detected
  • Tenant/product-branded app full suite: 227 runs, 1064 assertions, 0 failures
  • Tenant/product-branded app Goodmail mailer lint: 11 files inspected, no offenses detected

Known unrelated downstream output:

  • Two downstream Rails apps emit Rails 8.2 route deprecation warnings from config/routes.rb:2.
  • One development environment only includes List-Unsubscribe when an unsubscribe URL is configured.

Test Plan

  • bundle exec rake test - 253 tests, 913 assertions, 0 failures, 0 errors, 0 skips
  • COVERAGE=1 bundle exec rake test - 100.0% line coverage, 87.96% branch coverage
  • bundle exec rubocop - 15 files inspected, no offenses detected
  • bundle exec rake build - goodmail 0.4.0 built to pkg/goodmail-0.4.0.gem
  • git diff --check && git diff --cached --check
  • Production-style downstream app full suite
  • Production-style downstream app Mailcatcher plaintext/html/header/inline-attachment/readback verification
  • Rails app template full suite
  • Tenant/product-branded downstream app full suite

Release State

Current pushed head: 19da4a76aa2d18d467e83a7720a62a0f970a138e (Use generic OSS references).

This PR is production-release-ready from the gem audit, Rails Action Mailer source audit, downstream app suites, and Mailcatcher delivery verification. After merge, remaining maintainer steps are tagging v0.4.0 and pushing the built gem.

@rameerez rameerez force-pushed the feature/dsl-links-small-attachments-and-rfc8058-unsubscribe branch from ca66687 to 0aef056 Compare May 8, 2026 17:14
@rameerez rameerez changed the title Add link/small/attach DSL helpers + RFC 8058 one-click unsubscribe v0.5.0 — link / small / attach / inline_image / info_row + RFC 8058 unsubscribe + Minitest 6 + plaintext fixes May 8, 2026
@rameerez rameerez force-pushed the feature/dsl-links-small-attachments-and-rfc8058-unsubscribe branch from 0aef056 to 15f14d6 Compare May 8, 2026 17:21
@rameerez rameerez changed the title v0.5.0 — link / small / attach / inline_image / info_row + RFC 8058 unsubscribe + Minitest 6 + plaintext fixes v0.4.0 — link / small / attach / inline_image / info_row + RFC 8058 unsubscribe + Minitest 6 + plaintext fixes May 8, 2026
…ubscribe, Minitest 6 suite, plaintext fixes

A polish-and-correctness release.

ADDS five new DSL helpers (`link`, `small`, `attach`, `inline_image`,
`info_row`), exposes `parts.attachments` on the `Goodmail.render` path,
expands the `text` sanitizer's allow-list (`<strong>`, `<em>`, `<b>`,
`<i>` survive instead of being silently stripped), and ships a
comprehensive Minitest 6 test suite — 212 tests, 740 assertions,
100% line coverage across all 9 lib files.

FIXES nine real-world bugs that surfaced after running every
documented email shape through Mailcatcher and inspecting the
rendered HTML, plaintext, headers, and attachments end-to-end.
The bug fixes are the headline of this release; downstream
applications inherit them on upgrade with no config change.

DELIVERABILITY
- RFC 8058 one-click unsubscribe: Goodmail now sets the
  `List-Unsubscribe-Post: List-Unsubscribe=One-Click` header
  alongside `List-Unsubscribe`. Gmail's and Yahoo's Feb 2024
  sender requirements treat the missing pair as a spam signal
  for senders averaging 5k+ messages/day.

PLAINTEXT QUALITY (four classes of artifact, every email affected)
- The hidden inbox-preview span was extracted to plaintext by
  Premailer (which doesn't honor `display:none`), opening every
  email with a duplicate intro the recipient was never supposed
  to see. The hidden-preheader span is now stripped from the
  source HTML before plaintext extraction, matched by its
  specific `display:none + font-size:1px` signature so legitimate
  hidden spans elsewhere are preserved.
- `button` labels appeared twice in plaintext: Premailer ignores
  the `<!--[if mso]>...<![endif]-->` conditional comment that
  guards Outlook's VML button, so it extracted text from BOTH
  the VML's inner `<center>label</center>` AND the regular
  `<a href>label</a>`. The MSO conditional blocks are now
  stripped from the source HTML before plaintext extraction.
- A stray bare-company-name line floated next to every embedded
  image: `image` / `inline_image` calls without an explicit alt
  fall back to `config.company_name` (so screen readers have
  something to read). The cleanup pass now strips standalone
  lines that exactly match the company name; legitimate uses
  embedded in sentences are preserved untouched.
- `info_row` now flattens to the conventional `Label: Value`
  shape in plaintext (a two-cell `<table>` previously extracted
  as two separate lines). HTML keeps the visible two-cell table.

ENCODING
- All Premailer call sites now pin `input_encoding: "UTF-8"`.
  Premailer's libxml2 backend was defaulting to Latin-1 when no
  `<meta charset>` was present in the source, mangling every
  UTF-8 character ("Duración" → "Duración", "€" → "â¬"). The
  shipped layout already declares the meta charset, but custom
  `layout_path:` callers were silently broken before this fix.

INLINE IMAGES
- `inline_image` now produces an `<img>` that actually renders.
  Mail gem auto-generates a globally-unique Content-ID
  (`<longhash@host.tld.mail>`) for every attachment; the DSL
  emits `<img src="cid:FILENAME">` in the body, so before this
  fix the body's `cid:` reference would dangle and the image
  would render as a broken icon in every email client. Goodmail
  now pins the inline part's Content-ID to its filename so the
  body's reference resolves.
- `attach` / `inline_image` no longer crash on binary content.
  The path-or-bytes resolver was calling `File.file?` on the
  string unconditionally, and `File.file?` raises ArgumentError
  on any String containing `\0` — exactly what binary file
  content (PNG / PDF / .ics) routinely contains. The resolver
  now short-circuits when the string contains a NUL byte or
  exceeds typical PATH_MAX (4096 bytes).
- Duplicate inline filenames raise `Goodmail::Error` at
  registration time. Two `inline_image` calls with the same
  filename produced a broken second image: Mail gem's
  `cid:FILENAME` resolution returns the FIRST matching part,
  the second never gets a Content-ID pinned, and the second
  `<img>` renders as a broken icon. We now fail loud at the
  DSL with an actionable error pointing at the conflicting
  `cid:` reference. Non-inline `attach` with duplicate
  filenames is still allowed (it's a UX wart but not a
  rendering bug).

VISUAL TWEAK
- Buttons no longer force `text-transform: capitalize`. The
  default styling now preserves the EXACT casing the caller
  wrote — a button labeled `view receipt` renders as `view
  receipt`, not `View Receipt`; `OPEN` stays `OPEN`. The
  previous default broke acronyms, all-lowercase casual copy,
  and i18n cases where capitalization rules differ from
  English.

INTERNAL
- Extracted plaintext generation into `Goodmail::Plaintext`.
  Both `Goodmail::Email.render` and `Goodmail::Mailer#compose_message`
  previously had their own copies of the cleanup pipeline;
  consolidating into one module makes plaintext quality
  testable in one place.
- Replaced the `case heading_tag` style lookup inside `Builder`'s
  heading definer with a frozen `HEADING_STYLES` constant. The
  previous shape carried an unreachable `else` clause; the
  replacement is shorter, faster, and exhaustive by definition.
- `ostruct` declared as an explicit runtime dependency. Goodmail
  requires it directly; Ruby 3.4 prints a deprecation warning
  when ostruct is loaded from the standard library, and Ruby
  3.5 removes it from the default gems set entirely.
- `bundler/gem_tasks` + `Rake::TestTask` so `rake test` does
  the right thing. Optional SimpleCov via `COVERAGE=1 rake test`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rameerez rameerez force-pushed the feature/dsl-links-small-attachments-and-rfc8058-unsubscribe branch from 15f14d6 to ddd1c40 Compare May 8, 2026 17:43
@rameerez rameerez merged commit dc36de7 into main May 21, 2026
4 checks passed
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