v0.4.0 — link / small / attach / inline_image / info_row + RFC 8058 unsubscribe + Minitest 6 + plaintext fixes#3
Merged
rameerez merged 7 commits intoMay 21, 2026
Conversation
ca66687 to
0aef056
Compare
0aef056 to
15f14d6
Compare
…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>
15f14d6 to
ddd1c40
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 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.composenow callsGoodmail.renderonce, 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.renderintomail, copy ivars, pin CIDs, strip render-only keys, or rebuild multipart trees.Major changes
Action Mailer-native integration
ActionMailer::Base:goodmail_mail(...) { ... }goodmail_render_parts(...) { ... }goodmail_mail_parts(parts, headers, unsubscribe_url:)locale:wraps the DSL render inI18n.with_locale.mail, then Action Mailer's documented block form builds text/html responses.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 throughGoodmail.compose,Goodmail.render, and the Action Mailer helpers without leaking as a mail header.Single render pipeline
Goodmail.renderis now the single source of truth for DSL evaluation, layout rendering, Premailer CSS inlining, plaintext cleanup, and attachment descriptor collection.Goodmail.composerenders once, then passes rendered strings and attachment descriptors toGoodmail::Mailer.compose_message.Goodmail::Mailernow only does the Action Mailer handoff: unsubscribe headers, attachment application, and block-formmail.renderandcompose.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
EmailPartscarriesattachmentsdescriptors.attachaccepts raw bytes or a filesystem path..invaliddomain, then pinned onto the actual Mail part.Goodmail::Errorearly because Action Mailer's attachment hash is keyed by filename in custom render fan-out paths.Deliverability
List-Unsubscribe: <https://...>List-Unsubscribe-Post: List-Unsubscribe=One-ClickList-Unsubscribewithout falsely advertising one-click support.Sources:
Plaintext, Encoding, and Rendering Fixes
info_rowrenders asLabel: Valuein plaintext while preserving table layout in HTML.Per-Render Config and Downstream DX
Goodmail.compose,Goodmail.render,goodmail_mail, andgoodmail_render_partsacceptconfig:/configuration:for tenant/product-specific branding.Goodmail.with_config; apps do not need to mutate globalGoodmail.configaround a delivery.goodmail_mail(...).goodmail_renderwrappers or manualGoodmail.render(..., context: self)glue.Action Mailer Source Audit
I cloned Rails to
/tmp/railsand audited the Action Mailer README plus every file underactionmailer/lib/**/*.rbat Rails commitdebbd18c562df17d01944c475e9291d927910b58.Key Rails contracts Goodmail now follows:
mail; templates/actions read that state: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/README.rdoc#L20-L37action_methods; Goodmail helpers stay private: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L614-L618mail; late writes raise: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L766-L781mailowns defaults, delivery behavior, response collection, MIME assembly, inline wrapping, content type, and sort order: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L875-L907mailusesActionMailer::Collectorresponse bodies: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/collector.rb#L25-L29ActionMailer::MessageDeliverylazily processes mailer actions: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/message_delivery.rb#L22-L35deliver_laterserializes only mailer action arguments: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/message_delivery.rb#L142-L155cid:URLs against attachment CIDs: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/inline_preview_interceptor.rb#L33-L57Inline implementation comments reference the exact Rails source locations where the contract matters.
Backward Compatibility
Goodmail.composecallers keep working.Goodmail.rendercallers keep working;EmailParts#attachmentsdefaults to[].goodmail_mail_partstolerates legacy parts objects withoutattachments.include Goodmail::ActionMailerIntegration; helpers are installed once onActionMailer::Base.Goodmail.composeevaluates the Ruby DSL block before returningActionMailer::MessageDelivery, because Ruby blocks are not Active Job-serializable. For fully native Action Mailer lazy action execution in host apps, usegoodmail_mail/goodmail_render_partsinside real mailer actions.Downstream Verification
Verified with the local gem path in three downstream Rails codebases:
Results:
893 runs, 5126 assertions, 0 failures15 files inspected, no offenses detected332-346.200 OK, validimage/png,1280 x 720,152972bytes.attachsmoke email deliveredgoodmail-smoke.txtasmultipart/mixed; Mailcatcher returned200 OK, correct filename/content-disposition, and exact attachment body.46 runs, 289 assertions, 0 failures7 files inspected, no offenses detected227 runs, 1064 assertions, 0 failures11 files inspected, no offenses detectedKnown unrelated downstream output:
config/routes.rb:2.List-Unsubscribewhen an unsubscribe URL is configured.Test Plan
bundle exec rake test-253 tests, 913 assertions, 0 failures, 0 errors, 0 skipsCOVERAGE=1 bundle exec rake test-100.0%line coverage,87.96%branch coveragebundle exec rubocop-15 files inspected, no offenses detectedbundle exec rake build-goodmail 0.4.0 built to pkg/goodmail-0.4.0.gemgit diff --check && git diff --cached --checkRelease 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.0and pushing the built gem.