diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..a160d60 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,56 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..4c92159 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,49 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..53db73f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,43 @@ +name: Tests + +on: + pull_request: + paths-ignore: + - "*.md" + - "LICENSE.txt" + push: + branches: + - main + paths-ignore: + - "*.md" + - "LICENSE.txt" + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + ruby_version: ["3.3", "3.4", "4.0"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby_version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby_version }} + bundler-cache: true + + - name: Run tests + run: bundle exec rake test + + - name: Upload test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results-ruby-${{ matrix.ruby_version }} + path: test/reports/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index fe40c02..261be01 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,10 @@ /doc/ /pkg/ /spec/reports/ +/test/reports/ /tmp/ .cursor +.claude /dist/ \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..d1c7fbd --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +plugins: + - rubocop-minitest + - rubocop-performance + +AllCops: + TargetRubyVersion: 3.1 + NewCops: enable + SuggestExtensions: false + Exclude: + - 'bin/**/*' + - 'examples/**/*' + - 'coverage/**/*' + - 'pkg/**/*' + - 'test/**/*' + - 'vendor/**/*' + - 'test/dummy/**/*' + - 'db/migrate/**/*' # Generated migrations + - 'lib/generators/**/templates/**/*' # Generator templates + +# Layout & Formatting +Layout/LineLength: + Max: 120 + AllowedPatterns: + - '\s*#.*' # Allow long comments + - '^\s*raise\s' # Allow long raise statements + +Layout/MultilineMethodCallIndentation: + EnforcedStyle: indented + +Layout/ArgumentAlignment: + EnforcedStyle: with_first_argument + +Layout/FirstArgumentIndentation: + EnforcedStyle: consistent + +# Style +Style/Documentation: + Enabled: false # Don't require class documentation for now + +Style/StringLiterals: + EnforcedStyle: double_quotes + +Style/FrozenStringLiteralComment: + Enabled: true + EnforcedStyle: always + +Style/ClassAndModuleChildren: + EnforcedStyle: nested + +Style/GuardClause: + MinBodyLength: 3 + +# Metrics +Metrics/ClassLength: + Max: 220 + +Metrics/ModuleLength: + Max: 260 + +Metrics/MethodLength: + Max: 40 + AllowedMethods: + - 'configure' # Configuration blocks can be longer + +Metrics/BlockLength: + Max: 40 + AllowedMethods: + - 'configure' + - 'describe' + - 'context' + - 'it' + - 'test' + Exclude: + - 'test/**/*' # Allow long test blocks + - 'goodmail.gemspec' + +Metrics/AbcSize: + Max: 50 + AllowedMethods: + - 'configure' + +Metrics/CyclomaticComplexity: + Max: 10 + +Metrics/PerceivedComplexity: + Max: 10 + +Metrics/ParameterLists: + Max: 6 + +# Naming +Naming/PredicatePrefix: + ForbiddenPrefixes: + - 'is_' + AllowedMethods: + - 'is_a?' + +Naming/MethodParameterName: + MinNameLength: 1 + +Naming/BlockForwarding: + Enabled: false + +# Performance +Performance/StringReplacement: + Enabled: true + +Performance/RedundantMerge: + Enabled: true + +# Minitest +Minitest/MultipleAssertions: + Enabled: false # Allow multiple assertions in integration tests + +Minitest/AssertTruthy: + Enabled: false # Allow assert instead of assert_equal true + +# Custom overrides for this gem +Style/AccessorGrouping: + Enabled: false # Allow separate attr_reader/attr_writer + +Style/MutableConstant: + Enabled: false # We have some intentionally mutable constants + +Style/Alias: + Enabled: false + +Style/ArgumentsForwarding: + Enabled: false + +Style/ConditionalAssignment: + Enabled: false + +Style/IfUnlessModifier: + Enabled: false + +Style/ModuleFunction: + Enabled: false + +Style/OpenStructUse: + Enabled: false + +Style/PercentLiteralDelimiters: + Enabled: false + +Style/RedundantRegexpArgument: + Enabled: false + +Style/RegexpLiteral: + Enabled: false + +Style/RescueStandardError: + Enabled: false + +Style/StringLiteralsInInterpolation: + Enabled: false + +Style/Next: + Enabled: false + +# Allow class variables for registry pattern +Style/ClassVars: + Enabled: false + +# Allow metaprogramming patterns common in Rails engines +Style/EvalWithLocation: + Enabled: false + +Lint/MissingSuper: + Enabled: false # Allow classes that don't call super + +# Disable some cops that don't work well with our DSL +Style/MethodCallWithoutArgsParentheses: + Enabled: false # Our DSL looks better without parens + +Layout/EmptyLineAfterMagicComment: + Enabled: false + +Layout/EmptyLinesAfterModuleInclusion: + Enabled: false + +Layout/HashAlignment: + Enabled: false + +Layout/CommentIndentation: + Enabled: false + +Lint/UselessConstantScoping: + Enabled: false + +# Thread safety +Style/GlobalVars: + AllowedVariables: ['$0'] # Only allow program name + +# Database-related +Style/NumericLiterals: + Enabled: false # Allow raw numbers in database IDs/amounts diff --git a/.simplecov b/.simplecov new file mode 100644 index 0000000..510709c --- /dev/null +++ b/.simplecov @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# SimpleCov configuration file, loaded by test/test_helper.rb when +# COVERAGE=1 is set. Normal test runs skip coverage instrumentation. + +SimpleCov.start do + # Use SimpleFormatter for terminal-only output (no HTML generation) + formatter SimpleCov::Formatter::SimpleFormatter + use_merging false + + # Track coverage for the lib directory (gem source code) + add_filter "/test/" + + # `lib/goodmail/version.rb` is loaded by Bundler (via the gemspec + # `require_relative "lib/goodmail/version"`) BEFORE SimpleCov starts, + # so its lines never get instrumented even though they're executed. + # Excluding it keeps the report honest. Goodmail::VERSION's shape is + # asserted in `test/goodmail_module_test.rb` instead. + add_filter "/lib/goodmail/version.rb" + + # Track Ruby files in lib directory + track_files "lib/**/*.rb" + + # Enable branch coverage for more detailed metrics + enable_coverage :branch + + # Set minimum coverage threshold to prevent coverage regression. + # Goodmail currently sits at 100% line coverage; the branch floor is set + # generously to allow non-trivial future additions without immediately + # tripping CI. + minimum_coverage line: 90, branch: 80 + + # Disambiguate parallel test runs + command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV["TEST_ENV_NUMBER"] +end + +# Print coverage summary to terminal after tests complete +SimpleCov.at_exit do + SimpleCov.result.format! + if ENV["COVERAGE_DETAIL"] + SimpleCov.result.files.each do |file| + missed_lines = file.missed_lines.map(&:line_number) + next if missed_lines.empty? + + puts "#{file.filename}:#{missed_lines.join(',')}" + end + end + puts "\n#{'=' * 60}" + puts "COVERAGE SUMMARY" + puts "=" * 60 + puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%" + branch_coverage = SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || "N/A" + puts "Branch Coverage: #{branch_coverage}%" + puts "=" * 60 +end diff --git a/CHANGELOG.md b/CHANGELOG.md index 56e2723..e85e1ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,76 @@ +## [0.4.0] - 2026-05-08 + +A polish-and-correctness release. Adds five new DSL helpers, a comprehensive Minitest 6 test suite (100% line coverage), and fixes nine real-world bugs that surfaced after running every email shape through Mailcatcher and inspecting the rendered HTML, plaintext, headers, and attachments end-to-end. The bug fixes are the headline — most of them silently degraded plaintext quality, deliverability, or accessibility before; downstream apps inherit the fixes for free on upgrade. + +### Added + +#### DSL helpers +- **`link(text, url)`** — inline styled text link rendered in the configured `brand_color`. Cleaner than hand-writing `` tags inside `text` blocks when the whole paragraph is the link. +- **`small(text)`** — small grey paragraph for fine print, legal disclaimers, and "you are receiving this because…" footers. Sanitization mirrors `text` (allows `` / `` / `` / `` / ``). +- **`info_row(label, value)`** — label/value row using the email-safe two-column table pattern Stripe / Linear / Square / Resend converge on for transactional info cards: muted label on the left, dark right-aligned value on the right, 1px hairline at the bottom. Use this when the LABEL is supporting context and the VALUE is the primary content (`Plan - Pro`, `Status - Active`). The existing `price_row` (centered, both sides equal weight) stays the right tool for receipt-style line items. +- **`attach(filename, content, mime_type:, inline:)`** — adds a binary attachment to the outgoing message. `content` accepts raw bytes OR a filesystem path (when the string matches an existing file, it's read for you). Pass `inline: true` to send the part with `Content-Disposition: inline`; prefer `inline_image` when you also want Goodmail to emit the matching `cid:` image tag. +- **`inline_image(filename, content, alt:, width:, height:, mime_type:)`** — convenience helper that registers an inline-disposition attachment AND emits the matching `` tag at that point in the email body. Use when you need the image to travel with the email (no public hosting, offline reading, archival contexts); when you have a public URL prefer `image(src, alt)` — it's lighter on the wire. + +#### Inline emphasis tags allowed in `text` +- ``, ``, ``, and `` now pass through the `text(string)` sanitizer alongside ``. Previously these were silently stripped, so copy like `text "Important: read the receipt"` rendered as plain `Important: read the receipt`. All four tags are universally supported in every modern email client and add no layout risk to the table-based template. + +#### `Goodmail.render` parts struct +- `EmailParts` now carries an `attachments` field (in addition to `html` and `text`) populated with everything the DSL block registered via `attach` / `inline_image`. Inline descriptors include the generated `content_id` that matches the `cid:` URL emitted into the HTML. + +#### Action Mailer integration helpers +- **Zero-include Action Mailer helpers.** When Goodmail loads, it installs private `goodmail_mail(...) { ... }`, `goodmail_render_parts(...) { ... }`, and `goodmail_mail_parts(parts, headers, unsubscribe_url:)` helpers on `ActionMailer::Base`. Custom mailers, Devise mailers, Pay mailers, and app-specific wrappers can use them without per-class include boilerplate. The helpers keep mailer instance variables/private helpers available inside DSL blocks, support render-only `locale:`, fan attachments into Action Mailer, pin inline Content-IDs, strip Goodmail-only headers before `mail()`, and add RFC 8058-correct unsubscribe headers. +- **Per-render configuration overrides.** `Goodmail.compose`, `Goodmail.render`, `goodmail_mail`, and `goodmail_render_parts` accept `config: { ... }` for tenant / product whitelabel emails. Overrides are scoped to the current render via a thread-local config and do not mutate process-wide `Goodmail.config`, so concurrent mail deliveries cannot bleed one product's branding into another's email. +- **Action Mailer header passthrough.** `Goodmail.compose` and the auto-installed mailer helpers now forward Action Mailer's normal header surface (`date:`, `return_path:`, delivery options, custom `"X-..."` headers, etc.) after stripping only Goodmail render options. This follows Rails' own `mail` behavior instead of maintaining a narrow Goodmail whitelist. + +#### Test suite +- **253 tests, 913 assertions, 100% line coverage** across the Ruby source. The gem previously had no tests of its own; the README's `rake spec` instruction was aspirational. Run with `rake test`; gate SimpleCov instrumentation on `COVERAGE=1 rake test`. + +### Fixed + +#### Deliverability + headers +- **RFC 8058 one-click unsubscribe.** Goodmail now sets `List-Unsubscribe-Post: List-Unsubscribe=One-Click` alongside the existing `List-Unsubscribe` header when the unsubscribe URL is HTTPS, matching RFC 8058's HTTPS URI requirement. Non-HTTPS unsubscribe URLs still get the classic `List-Unsubscribe` header, but Goodmail no longer advertises one-click POST support for URLs that are not eligible. Gmail's and Yahoo's [Feb 2024 sender requirements](https://support.google.com/mail/answer/81126) treat missing one-click support as a spam signal for eligible bulk senders. Existing applications with HTTPS unsubscribe URLs inherit the fix on upgrade; make sure the final sender/provider DKIM-signs these headers, since Goodmail can only set them before delivery. + +#### Plaintext quality +The plaintext part of every multipart message had four classes of artifact that surfaced after running real emails through Mailcatcher. Each one would silently degrade quality for recipients on text-only clients (CLI mail, accessibility tooling, spam filters that judge from the plaintext part): + +- **Preheader no longer leaks as a phantom first line.** The layout's hidden inbox-preview `` was being 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 in the body. The 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 no longer appear twice.** `button` emits both a `` (Outlook VML, inside ``) AND a regular ``. Premailer ignores conditional comments and was extracting text from BOTH, so plaintext got the label twice (once bare from the VML's `
label
`, once with the URL from `
label`). The MSO conditional blocks are now stripped from the source HTML before plaintext extraction. +- **Stray `CompanyName` line from inline image alt is gone.** `image` / `inline_image` calls without an explicit alt fall back to `config.company_name` (so screen readers have something to read). Premailer extracted that alt verbatim into plaintext, leaving a bare-company-name line floating next to every embedded image. The cleanup pass now strips standalone lines that exactly match the company name; legitimate uses embedded in sentences are preserved untouched. +- **`info_row` flattens to the conventional `Label: Value` shape in plaintext.** A two-cell `` previously extracted as two separate lines (`Label\nValue`) — correct table extraction, but a worse plaintext UX than the colon-form every modern transactional sender uses. The HTML side keeps the visible two-cell table. + +#### Encoding +- **HTML + plaintext: accented characters / Unicode no longer get double-encoded.** Premailer's libxml2 backend was defaulting to Latin-1 when no `` tag was present in the source HTML, mangling every UTF-8 character (`Duración` → `Duración`, `€` → `â¬`). All Premailer calls now pin `input_encoding: "UTF-8"`. The shipped layout already declares the meta charset; this fix protects custom `layout_path:` callers that don't. + +#### Inline images +- **`inline_image` now produces an `` that actually renders.** Mail gem auto-generates a globally-unique Content-ID (``) for every attachment, but the DSL must write the `` before Action Mailer materializes the attachment. Goodmail now generates an RFC 2392-shaped Content-ID in the Builder, emits that exact `cid:` URL in the HTML, and pins the inline Mail part to the same ID 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: path name contains null byte` 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), so the documented `inline_image("logo.png", png_bytes)` shape works as advertised. +- **Duplicate inline filenames raise `Goodmail::Error` at registration time.** Inline descriptors are still keyed by filename when custom `Goodmail.render` callers fan them into Action Mailer's attachments hash, so duplicate inline filenames are ambiguous even though Goodmail generates distinct Content-IDs. We now fail loud at the DSL with an actionable error. Non-inline `attach` with duplicate filenames is still allowed (it's a UX wart but not a rendering bug — recipients see two files with the same name). + +#### 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 was opinionated and broke acronyms, all-lowercase casual copy, and i18n cases where capitalization rules differ from English (German nouns, Spanish proper nouns following articles). + +### Internal +- Replaced the `case heading_tag` style lookup inside `Builder`'s `define_method` heading definer with a frozen `HEADING_STYLES` constant. The previous shape carried an unreachable `else` clause that no test could cover by construction; the replacement is shorter, faster (one hash lookup per heading), and exhaustive by definition. +- 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 and prevents future drift between the two paths. +- `Goodmail.compose` now renders through `Goodmail.render` once, then passes already inlined HTML, cleaned plaintext, and attachment descriptors into the internal Action Mailer action. This removes the duplicate Premailer/plaintext path from `Goodmail::Mailer` while preserving Action Mailer's lazy `MessageDelivery` handoff. +- `ostruct` declared as an explicit runtime dependency. Goodmail requires it directly (configuration is backed by `OpenStruct`); 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. + +### Meta +- Standardized the testing scaffolding to match the convention shared with the surrounding Ruby gems: + - `.simplecov` config file (auto-loaded; SimpleFormatter, branch coverage enabled, minimum thresholds, custom at_exit summary). + - `Gemfile` with `:development` / `:development, :test` groups (minitest ~> 6.0, minitest-mock, minitest-reporters, simplecov, rubocop + rubocop-minitest + rubocop-performance). + - `Rakefile` using `bundler/gem_tasks` + `Rake::TestTask`; `rake test` runs the suite with the canonical Minitest::Reporters output and emits the coverage summary at the end. + - `.rubocop.yml` shared cop set + thresholds (style/layout/metrics overrides matching the other gems). + - `.github/workflows/test.yml` matrix tests across Ruby 3.3, 3.4, and 4.0. + - `.github/workflows/claude.yml` and `.github/workflows/claude-code-review.yml` for Claude Code automation parity. + - `goodmail.gemspec` no longer carries development dependencies — they all live in the Gemfile groups, same as the sibling gems. + +## [0.3.1] - 2026-02-25 +- Maintenance release. Documentation alignment (example mailers + follow the `Goodmailer` suffix convention) and a configuration + alias (`Goodmail.configuration` ≡ `Goodmail.config`). No public + API changes. + ## [0.3.0] - 2025-05-15 - Add Goodmail.render for custom mailers diff --git a/Gemfile b/Gemfile index 7682298..f11a04d 100644 --- a/Gemfile +++ b/Gemfile @@ -5,5 +5,24 @@ source "https://rubygems.org" # Specify your gem's dependencies in goodmail.gemspec gemspec -gem "irb" +# Build & release tools gem "rake", "~> 13.0" + +group :development do + gem "irb" + + # Code quality + gem "rubocop", "~> 1.0" + gem "rubocop-minitest", "~> 0.35" + gem "rubocop-performance", "~> 1.0" +end + +group :development, :test do + # Minitest 6 split `Minitest::Mock` into the standalone gem + # `minitest-mock`. Pin >= 6 so contributors get the same runtime + + # matching mock surface. + gem "minitest", "~> 6.0" + gem "minitest-mock" + gem "minitest-reporters" + gem "simplecov", require: false +end diff --git a/Gemfile.lock b/Gemfile.lock index e0d0ee8..2b494fa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,8 @@ PATH remote: . specs: - goodmail (0.1.0) + goodmail (0.4.0) + ostruct (>= 0.6) premailer-rails (>= 1.10) rails (>= 7.0) rails-html-sanitizer (>= 1.0) @@ -82,17 +83,19 @@ GEM uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) + ansi (1.6.0) + ast (2.4.3) base64 (0.2.0) benchmark (0.4.0) bigdecimal (3.1.9) builder (3.3.0) - coderay (1.1.3) concurrent-ruby (1.3.5) connection_pool (2.5.3) crass (1.0.6) css_parser (1.21.1) addressable date (3.4.1) + docile (1.4.1) drb (2.2.1) erubi (1.13.1) globalid (1.2.1) @@ -105,6 +108,9 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) + json (2.19.5) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) logger (1.7.0) loofah (2.24.0) crass (~> 1.0.2) @@ -115,10 +121,17 @@ GEM net-pop net-smtp marcel (1.0.4) - method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.8) - minitest (5.25.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + minitest-mock (5.27.0) + minitest-reporters (1.8.0) + ansi + builder + minitest (>= 5.0, < 7) + ruby-progressbar net-imap (0.5.8) date net-protocol @@ -134,6 +147,11 @@ GEM racc (~> 1.4) nokogiri (1.18.8-arm64-darwin) racc (~> 1.4) + ostruct (0.6.3) + parallel (2.1.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc pp (0.6.2) prettyprint premailer (1.27.0) @@ -145,9 +163,7 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - pry (0.15.2) - coderay (~> 1.1) - method_source (~> 1.0) + prism (1.9.0) psych (5.2.4) date stringio @@ -190,17 +206,51 @@ GEM rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) + rainbow (3.1.1) rake (13.2.1) rdoc (6.13.1) psych (>= 4.0.0) + regexp_parser (2.12.0) reline (0.6.1) io-console (~> 0.5) + rubocop (1.86.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-minitest (0.39.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (1.13.0) securerandom (0.4.1) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) stringio (3.1.7) thor (1.3.2) timeout (0.4.3) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) uri (1.0.3) useragent (0.16.11) websocket-driver (0.7.7) @@ -216,8 +266,14 @@ PLATFORMS DEPENDENCIES goodmail! irb - pry + minitest (~> 6.0) + minitest-mock + minitest-reporters rake (~> 13.0) + rubocop (~> 1.0) + rubocop-minitest (~> 0.35) + rubocop-performance (~> 1.0) + simplecov BUNDLED WITH 2.6.4 diff --git a/README.md b/README.md index 61d1bbd..3f6f6a5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Goodmail turns your ugly, default, text-only emails into SaaS-ready emails. It's You can easily add buttons, images, links, price lines, and text to your emails, and it'll look good everywhere, no styling needed. -Here's the catch: there's only one template. You can't change it. You're guaranteed you'll send good emails, but the cost is you don't have much flexibility. If you're okay with this, welcome to `goodmail`! You'll be shipping decent emails that look great everywhere in no time. +Here's the catch: Goodmail gives you one opinionated default template. You can override the layout for advanced cases, but the happy path is deliberately narrow: no templates, no partials, and no styling decisions for every transactional email. If you're okay with this, welcome to `goodmail`! You'll be shipping decent emails that look great everywhere in no time. (And you can still use Action Mailer for all other template-intensive emails – Goodmail doesn't replace Action Mailer, just builds on top of it!) @@ -106,7 +106,7 @@ mail = Goodmail.compose( to: recipient.email, from: "'#{Goodmail.config.company_name} Support' ", subject: "Welcome to MyApp!", - preheader: "Your adventure begins now!" # Optional override + preheader: "Your account is ready." # Optional override ) do h1 "Welcome aboard, #{recipient.name}!" text "We're thrilled to have you join the MyApp community." @@ -134,6 +134,17 @@ mail.deliver_later *(Requires Active Job configured.)* +`Goodmail.compose` returns a normal `ActionMailer::MessageDelivery`. Pass the +same headers you would pass to Action Mailer's `mail()` (`reply_to:`, +`date:`, `return_path:`, custom `"X-..."` headers, delivery options, etc.); +Goodmail strips only its own render options before handing the message to +Action Mailer. + +For real Rails mailer classes, prefer the auto-installed `goodmail_mail` +helper shown below. It keeps the work inside the mailer action, preserves +Action Mailer's lazy `MessageDelivery` / `deliver_later` model, and avoids +manual `Goodmail.render(..., context: self)` glue. + ## Why does `goodmail` exist? Here's the problem: you can't just use standard HTML and CSS in mails. @@ -162,83 +173,138 @@ Inside the `Goodmail.compose` block, you have access to these methods: * `h1(text)`, `h2(text)`, `h3(text)`: Styled heading tags. * `text(string)`: A paragraph of text. Allows simple inline `` tags with `href` attributes; other HTML is stripped for safety. Handles `\n` for line breaks. +* `link(link_text, url)`: An inline styled link, rendered in the configured `brand_color`. Cleaner than hand-writing `` tags inside `text` blocks when the whole paragraph is the link. +* `small(string)`: A small grey paragraph for fine print, legal disclaimers and "you are receiving this because…" footers. * `button(link_text, url)`: A prominent, styled call-to-action button (includes Outlook VML fallback). * `image(src, alt = "", width: nil, height: nil)`: Embeds an image, centered by default (includes Outlook MSO fallback). Uses `config.company_name` for alt text if none provided. +* `attach(filename, content, mime_type: nil, inline: false)`: Attaches a binary file (PDF, .ics, .csv, image…) to the outgoing email. `content` can be raw bytes or a filesystem path — when the string matches an existing file it is read for you. Pass `inline: true` to send the part with `Content-Disposition: inline`; prefer `inline_image` when you also want Goodmail to emit the matching `cid:` image tag. +* `inline_image(filename, content, alt: "", width: nil, height: nil, mime_type: nil)`: Convenience helper that registers an inline-disposition attachment AND emits the matching `` tag at that point in the email body. Use when you need the image to travel with the email (no public hosting available, offline reading, etc.); when you already have a public URL prefer `image(src, alt)` — it's lighter on the wire. * `space(pixels = 16)`: Adds vertical whitespace. * `line`: Adds a horizontal rule (`
`). * `center { ... }`: Centers the content generated within the block. * `code_box(text)`: Displays text centered and bold within a styled box (grey background, padding, italic). Text is HTML-escaped. -* `price_row(name, price)`: Adds a styled paragraph showing a name and price, separated by a top border (e.g., for simple receipt line items). Text is HTML-escaped. +* `price_row(name, price)`: Adds a styled paragraph showing a name and price, separated by a top border (e.g., for simple receipt line items). Both the name and the price render bold and centered — meant for cases where label and amount carry equal weight. Text is HTML-escaped. +* `info_row(label, value)`: Adds a label/value row using the email-safe two-column table pattern (muted label on the left, dark right-aligned value on the right, 1px hairline at the bottom). Use this when the LABEL is supporting context and the VALUE is the primary content ("Plan - Pro", "Status - Active"). Stack multiple consecutive `info_row` calls to build a clean info card. Text is HTML-escaped. * `sign(name = Goodmail.config.company_name)`: Adds a standard closing signature line. * `html(raw_html_string)`: **Use with extreme caution.** Allows embedding raw, *un-sanitized* HTML. +### Rails Mailers: Use `goodmail_mail` + +When Goodmail is loaded, Rails mailers get three private helpers automatically: +`goodmail_mail` for the common render-and-send path, and +`goodmail_render_parts` + `goodmail_mail_parts` when you need to render first. + +```ruby +# In your custom mailer, including framework overrides such as Devise or Pay + +# Define your headers (to, from, subject, etc.) +# You can pass :unsubscribe_url, :preheader, :locale, :config / +# :configuration, and :layout_path in the same hash. Goodmail uses them for +# rendering and strips them before calling Action Mailer's mail(). +class NotificationMailer < ApplicationMailer + def important_update(recipient) + details_url = view_details_url(recipient) + + goodmail_mail( + to: recipient.email, + from: "notifications@myapp.com", + subject: "Important Update for #{recipient.name}", + unsubscribe_url: custom_unsubscribe_url_for_user(recipient), # Optional + preheader: "A quick update you should see." # Optional + ) do + h1 "Hello, #{recipient.name}!" + text "This is an important update regarding your account." + button "View Details", details_url + sign "The MyApp Team" + end + end +end +``` + +`goodmail_mail` renders the DSL, strips Goodmail-only keys before calling +Action Mailer's `mail()`, applies `attach` / `inline_image` parts, pins inline +Content-IDs, and adds the correct `List-Unsubscribe` headers. +Any normal Action Mailer header you pass (`date:`, `return_path:`, +`delivery_method:`, `"X-Custom"`, etc.) is forwarded to `mail()`; only +Goodmail render options such as `preheader:`, `unsubscribe_url:`, `locale:`, +`context:`, `config:` / `configuration:`, and `layout_path:` are removed from +the wire headers. +The block keeps normal mailer context: instance variables and private mailer +helpers are available, and `locale:` wraps the DSL block in `I18n.with_locale`. + +Framework mailers can also pass their native header hash directly. For example, +a Devise override can keep Devise's own `headers_for(...)` output as the single +source of truth and let Goodmail handle only the body/render mechanics: + +```ruby +class DeviseGoodmailer < Devise::Mailer + def confirmation_instructions(record, token, opts = {}) + @token = token + initialize_from_record(record) + + 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 + end +end +``` + ### Advanced: Rendering Email Parts with `Goodmail.render` -For more advanced use cases, such as integrating Goodmail's content generation into existing mailer workflows (like Devise mailers) or when you need direct access to the generated HTML and plain text parts before sending, Goodmail provides the `Goodmail.render` method. +For advanced use cases where you need direct access to the generated HTML and +plain text parts before sending, Goodmail provides the `Goodmail.render` method. -This method processes your DSL block, applies the layout, runs Premailer for CSS inlining, and performs plain text cleanup, similar to `Goodmail.compose`. However, instead of returning a `Mail::Message` object ready for delivery, it returns a `Goodmail::EmailParts` struct. +This method processes your DSL block, applies the layout, runs Premailer for CSS inlining, and performs plain text cleanup, similar to `Goodmail.compose`. However, instead of returning an `ActionMailer::MessageDelivery` ready for delivery, it returns a `Goodmail::EmailParts` struct. -The `Goodmail::EmailParts` struct (defined in `goodmail/email.rb`) has two attributes: +The `Goodmail::EmailParts` struct (defined in `goodmail/email.rb`) has three attributes: * `html`: The final, inlined HTML content for your email. * `text`: The cleaned-up plain text version of your email. +* `attachments`: An array of `{ filename:, content:, mime_type:, inline:, content_id: }` hashes for every `attach` / `inline_image` call inside the DSL block. Empty array when the DSL didn't register any attachments. `content_id` is present for inline attachments and already matches any `cid:` URL emitted by `inline_image`. `goodmail_mail` / `goodmail_render_parts` / `goodmail_mail_parts` hand these descriptors to Action Mailer for you. -**How to use it:** - -You can then use these parts within any Action Mailer setup: +If you truly need to render first because another step must inspect or mutate +the generated parts before sending, use the lower-level helpers: ```ruby -# In your custom mailer (e.g., a Devise mailer override) - -# Define your headers (to, from, subject, etc.) -# The :subject is crucial for Goodmail.render. -# You can also pass :unsubscribe_url and :preheader to Goodmail.render -# to override global configurations for that specific email. -# Note: these Goodmail-specific keys will be used by Goodmail.render -# and should not be passed directly to ActionMailer's mail() method -# if they are not standard mail headers. -mail_rendering_headers = { - to: recipient.email, - from: "notifications@myapp.com", - subject: "Important Update for #{recipient.name}", - unsubscribe_url: custom_unsubscribe_url_for_user(recipient), # Optional - preheader: "A quick update you should see." # Optional -} - -# Render the email parts using Goodmail's DSL -# Goodmail.render will use :subject, :unsubscribe_url, :preheader internally. -parts = Goodmail.render(mail_rendering_headers) do - h1 "Hello, #{recipient.name}!" - text "This is an important update regarding your account." - button "View Details", view_details_url(recipient) - sign "The MyApp Team" -end - -# Prepare headers for ActionMailer's mail() method, -# ensuring only standard mail headers are passed. -action_mailer_headers = mail_rendering_headers.slice(:to, :from, :subject, :cc, :bcc, :reply_to) +class CustomMailer < ApplicationMailer + def custom_message(recipient) + render_options = { + subject: "Important Update", + unsubscribe_url: custom_unsubscribe_url_for_user(recipient), + preheader: "A quick update you should see." + } + + parts = goodmail_render_parts(render_options) do + text "Rendered separately." + inline_image "logo.png", logo_bytes, mime_type: "image/png" + end -# Now use these parts in ActionMailer's mail method -# You might also want to add the List-Unsubscribe header manually here if needed. -final_mail_object = mail(action_mailer_headers) do |format| - format.html { render html: parts.html.html_safe } - format.text { render plain: parts.text } + goodmail_mail_parts( + parts, + to: recipient.email, + from: "notifications@myapp.com", + subject: render_options[:subject], + unsubscribe_url: render_options[:unsubscribe_url] + ) + end end - -# The `final_mail_object` returned by ActionMailer can then be delivered: -# final_mail_object.deliver_now or final_mail_object.deliver_later ``` **Key Differences from `Goodmail.compose`:** -* **Return Value**: `Goodmail.render` returns an instance of `Goodmail::EmailParts` (e.g., `EmailParts.new(html: "...", text: "...")`). `Goodmail.compose` returns a `Mail::Message` object. -* **Purpose**: `Goodmail.render` is primarily for generating and retrieving processed email content parts. `Goodmail.compose` is for generating a complete, deliverable `Mail::Message` object. -* **List-Unsubscribe Header**: `Goodmail.render` itself does *not* add the `List-Unsubscribe` header to any mail object (as it doesn't create one). If you use `Goodmail.render`, you are responsible for adding this header to your `Mail::Message` object if an `unsubscribe_url` was effectively used during rendering (either passed to `Goodmail.render` or taken from global config) and you require this header. The internal `Goodmail::Mailer` (used by `Goodmail.compose`) handles adding this header automatically to the `Mail::Message` object it builds. +* **Return Value**: `Goodmail.render` returns an instance of `Goodmail::EmailParts` (e.g., `EmailParts.new(html: "...", text: "...")`). `Goodmail.compose` returns an `ActionMailer::MessageDelivery`. +* **Purpose**: `Goodmail.render` is primarily for generating and retrieving processed email content parts. `Goodmail.compose` is for generating a complete, deliverable Action Mailer message. +* **Lazy delivery model**: `Goodmail.compose` evaluates the Ruby DSL block before returning the `ActionMailer::MessageDelivery`, then passes already rendered HTML, plaintext, and attachment descriptors into Goodmail's internal mailer action. Ruby blocks are not Active Job-serializable, so this is the right one-shot API. For fully native Action Mailer action execution in app mailers, use `goodmail_mail` inside the mailer method. +* **List-Unsubscribe Headers**: `Goodmail.render` itself does *not* add the `List-Unsubscribe` or `List-Unsubscribe-Post` headers to any mail object (as it doesn't create one). The auto-installed Action Mailer helpers add them when you call `goodmail_mail` / `goodmail_mail_parts`; they set one-click `List-Unsubscribe-Post` only for HTTPS URLs. Gmail's and Yahoo's [bulk-sender requirements](https://support.google.com/mail/answer/81126) treat missing one-click unsubscribe support as a spam signal for eligible bulk senders. Your delivery stack must also DKIM-sign the unsubscribe headers; Goodmail can set the headers, but the final sender/provider controls the signature. +* **Attachments + inline images**: `Goodmail.render` collects every `attach` / `inline_image` call into `parts.attachments`. The auto-installed Action Mailer helpers fan those descriptors into Action Mailer's attachments hash and pins inline Content-IDs for you. `Goodmail.compose` handles both internally. +* **Per-message branding**: pass `config: { company_name:, logo_url:, brand_color:, footer_text: }` to `Goodmail.compose`, `Goodmail.render`, `goodmail_mail`, or `goodmail_render_parts` for tenant / product whitelabel emails. The override is scoped to that render in the current thread, so apps do not need to mutate global `Goodmail.config` around a delivery. ### Integrating with the Pay Gem Goodmail works seamlessly with the [Pay gem](https://github.com/pay-rails/pay) to send beautiful transactional emails for payment notifications (receipts, refunds, subscription updates, etc.). -Since Pay allows you to configure a custom mailer class, you can create a mailer that uses `Goodmail.render` to generate beautiful email content for all Pay notifications. +Since Pay allows you to configure a custom mailer class, you can create a mailer that uses Goodmail's auto-installed helpers to generate beautiful email content for all Pay notifications. In the examples below, app-defined mailers that use Goodmail follow the `*Goodmailer` suffix convention. This is just a naming convention for clarity, not a requirement imposed by Goodmail itself. @@ -267,7 +333,9 @@ The example implementation includes all Pay notification types: - `subscription_trial_ended` - Trial has ended - `payment_failed` - Failed payment alerts -Each method uses `Goodmail.render` to create beautiful, consistent emails that match your brand. +Each method uses `goodmail_mail(pay_mail_arguments, ...)` so Pay-specific +recipient/header setup stays in the app while Goodmail owns rendering, +attachments, multipart assembly, and unsubscribe headers. ### Adding Unsubscribe Functionality @@ -284,7 +352,7 @@ Goodmail helps you add the `List-Unsubscribe` header and an optional visible lin # ... other headers ... ) do # ... ``` - *If an `unsubscribe_url` is provided, Goodmail adds the `List-Unsubscribe` header.* + *If an `unsubscribe_url` is provided, Goodmail adds the `List-Unsubscribe` header. If that URL is HTTPS, Goodmail also adds the RFC 8058 `List-Unsubscribe-Post: List-Unsubscribe=One-Click` header. Gmail's and Yahoo's [bulk-sender requirements](https://support.google.com/mail/answer/81126) (Feb 2024+) treat missing one-click unsubscribe as a spam signal for eligible bulk senders. Your HTTPS endpoint should accept a POST body of `List-Unsubscribe=One-Click`, complete the unsubscribe without another confirmation step, and avoid redirects. Your sender/provider must DKIM-sign the unsubscribe headers for mailbox providers to trust one-click support.* 2. **Optionally Show Footer Link:** * Set `config.show_footer_unsubscribe_link = true`. @@ -303,7 +371,9 @@ Goodmail helps you add the `List-Unsubscribe` header and an optional visible lin ## Development -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the test suite (Minitest 6+, no Rails app required — every code path is exercised in isolation through `Goodmail.compose` / `Goodmail.render`). You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To check line coverage, run `COVERAGE=1 rake test`. The suite ships with 100% line coverage as a baseline; if you add a method, add a test for it. To install this gem onto your local machine, run `bundle exec rake install`. diff --git a/Rakefile b/Rakefile index cd510a0..4d6a672 100644 --- a/Rakefile +++ b/Rakefile @@ -1,4 +1,15 @@ # frozen_string_literal: true require "bundler/gem_tasks" -task default: %i[] +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/*_test.rb"] + # Silence noisy stdlib warnings (e.g. ostruct deprecation notice in + # Ruby 3.4) so the run output stays focused on real test failures. + t.warning = false +end + +task default: :test diff --git a/examples/pay_goodmailer.rb b/examples/pay_goodmailer.rb index 8c533d8..f017d61 100644 --- a/examples/pay_goodmailer.rb +++ b/examples/pay_goodmailer.rb @@ -12,7 +12,6 @@ # - Automatic List-Unsubscribe header handling # - Receipt PDF attachment support # - Extra billing info support -# - Customer name personalization # - URL helper integration # # SETUP INSTRUCTIONS: @@ -49,11 +48,8 @@ class PayGoodmailer < Pay.parent_mailer.constantize # Triggered by: charge.succeeded webhook # Params: params[:pay_customer], params[:pay_charge] def receipt - pay_customer = params[:pay_customer] pay_charge = params[:pay_charge] - # Get recipient details - recipient_name = customer_display_name(pay_customer) formatted_date = localize_date(pay_charge.created_at) # Capture URLs before the block @@ -61,7 +57,7 @@ def receipt send_pay_goodmail(:receipt) do # Add a friendly GIF (customize the URL to your own!) - image("https://assets.rameerez.com/mailers/ok.gif", "Payment confirmed!", width: 250) + image("https://example.com/mailers/ok.gif", "Payment confirmed!", width: 250) h1 t('pay.mailer.receipt.title', default: 'Payment Received!') @@ -105,15 +101,13 @@ def receipt # Triggered by: charge.refunded webhook # Params: params[:pay_customer], params[:pay_charge] def refund - pay_customer = params[:pay_customer] pay_charge = params[:pay_charge] - recipient_name = customer_display_name(pay_customer) formatted_date = localize_date(pay_charge.created_at) send_pay_goodmail(:refund) do # Add a friendly GIF (customize the URL to your own!) - image("https://assets.rameerez.com/mailers/ok.gif", "Refund confirmed!", width: 250) + image("https://example.com/mailers/ok.gif", "Refund confirmed!", width: 250) h1 t('pay.mailer.refund.title', default: 'Refund Processed') @@ -151,10 +145,8 @@ def refund # Params: params[:pay_customer], params[:pay_subscription], params[:date] def subscription_renewing pay_subscription = params[:pay_subscription] - pay_customer = params[:pay_customer] || pay_subscription.customer renewal_date = params[:date] - recipient_name = customer_display_name(pay_customer) formatted_renewal_date = renewal_date ? localize_date(renewal_date) : nil days_until_renewal = renewal_date ? (renewal_date.to_date - Date.current).to_i : nil @@ -199,13 +191,9 @@ def subscription_renewing # (e.g., 3D Secure authentication, expired card) # # Triggered by: invoice.payment_action_required webhook - # Params: params[:pay_customer], params[:pay_subscription], params[:payment_intent_id] + # Params: params[:pay_customer], params[:pay_subscription] def payment_action_required pay_subscription = params[:pay_subscription] - pay_customer = params[:pay_customer] || pay_subscription.customer - payment_intent_id = params[:payment_intent_id] - - recipient_name = customer_display_name(pay_customer) # Capture URLs before the block billing_link = billing_url @@ -242,9 +230,7 @@ def payment_action_required # Params: params[:pay_customer], params[:pay_subscription] def subscription_trial_will_end pay_subscription = params[:pay_subscription] - pay_customer = params[:pay_customer] || pay_subscription.customer - recipient_name = customer_display_name(pay_customer) formatted_trial_end = pay_subscription.trial_ends_at ? localize_date(pay_subscription.trial_ends_at) : nil days_remaining = pay_subscription.trial_ends_at ? (pay_subscription.trial_ends_at.to_date - Date.current).to_i : nil @@ -290,9 +276,6 @@ def subscription_trial_will_end # Params: params[:pay_customer], params[:pay_subscription] def subscription_trial_ended pay_subscription = params[:pay_subscription] - pay_customer = params[:pay_customer] || pay_subscription.customer - - recipient_name = customer_display_name(pay_customer) # Capture URLs before the block billing_link = billing_url @@ -313,7 +296,7 @@ def subscription_trial_ended space - if pay_subscription.active? + if subscription_continuing_after_trial?(pay_subscription) text t('pay.mailer.subscription_trial_ended.continue_message', default: 'Thanks for sticking with us! Your subscription is now active and billing normally.' ) @@ -343,16 +326,13 @@ def subscription_trial_ended # Params: params[:pay_customer], params[:pay_subscription] def payment_failed pay_subscription = params[:pay_subscription] - pay_customer = params[:pay_customer] || pay_subscription.customer - - recipient_name = customer_display_name(pay_customer) # Capture URLs before the block billing_link = billing_url send_pay_goodmail(:payment_failed) do # Add a friendly "uh-oh" GIF (customize the URL to your own!) - image("https://assets.rameerez.com/mailers/uh-oh.gif", "Uh-oh!", width: 150) + image("https://example.com/mailers/uh-oh.gif", "Uh-oh!", width: 150) h1 t('pay.mailer.payment_failed.title', default: 'Uh-oh! Payment Issue') @@ -396,10 +376,10 @@ def payment_failed # This method: # - Gets mail arguments from Pay's configuration # - Sets up i18n subject and preheader - # - Renders email content using Goodmail - # - Adds List-Unsubscribe header if configured + # - Lets Goodmail render the DSL and call Action Mailer's `mail` + # - Adds List-Unsubscribe / RFC 8058 one-click headers if configured # - Attaches receipts for receipt emails - # - Sends the email via ActionMailer + # - Sends the email via Action Mailer def send_pay_goodmail(action_sym, &dsl_block) # Ensure pay_customer is set in params (get from subscription if needed) # This is necessary because Pay.mail_arguments expects params[:pay_customer] to exist @@ -420,48 +400,49 @@ def send_pay_goodmail(action_sym, &dsl_block) # Update subject in mail arguments pay_mail_arguments[:subject] = custom_subject - # Prepare Goodmail render options - goodmail_options = { - subject: custom_subject, - preheader: t( - "pay.mailer.#{action_sym}.preheader", - application_name: app_name, - default: custom_subject - ) - } - - # Add unsubscribe URL if configured - if Goodmail.config.unsubscribe_url.present? - goodmail_options[:unsubscribe_url] = Goodmail.config.unsubscribe_url - end - - # Render email using Goodmail - parts = Goodmail.render(goodmail_options, &dsl_block) - - # Add List-Unsubscribe header if unsubscribe URL is present - if goodmail_options[:unsubscribe_url].present? - pay_mail_arguments["List-Unsubscribe"] = "<#{goodmail_options[:unsubscribe_url]}>" - end + preheader = t( + "pay.mailer.#{action_sym}.preheader", + application_name: app_name, + default: custom_subject + ) - # Attach receipt PDF if this is a receipt email and one exists + # Pay's optional receipt helper exposes `receipt` plus + # `receipt_filename` / `filename`; attach only when that helper is + # actually mixed into the charge object. + # Source: https://github.com/pay-rails/pay/blob/v11.4.3/lib/pay/receipts.rb#L3-L8 if action_sym == :receipt && params[:pay_charge]&.respond_to?(:receipt) - attachments[params[:pay_charge].filename] = params[:pay_charge].receipt + filename = + if params[:pay_charge].respond_to?(:receipt_filename) + params[:pay_charge].receipt_filename + else + params[:pay_charge].filename + end + + attachments[filename] = params[:pay_charge].receipt end - # Send the email - mail(pay_mail_arguments) do |format| - format.text { render plain: parts.text } - format.html { render html: parts.html.html_safe } - end + # Preserve Pay.mail_arguments as the envelope/header source of truth, just + # as Pay::UserMailer does, and let Goodmail own the Goodmail-specific + # render keys, multipart body, attachments, and unsubscribe headers. + # + # Sources: + # - Pay::UserMailer calls `mail mail_arguments`: + # https://github.com/pay-rails/pay/blob/v11.4.3/app/mailers/pay/user_mailer.rb#L2-L38 + # - Pay.mail_arguments default: + # https://github.com/pay-rails/pay/blob/v11.4.3/lib/pay.rb#L93-L101 + goodmail_mail(pay_mail_arguments, preheader: preheader, &dsl_block) end - # Get customer display name with fallback to email - def customer_display_name(pay_customer) - if pay_customer.respond_to?(:customer_name) && pay_customer.customer_name.present? - pay_customer.customer_name - else - pay_customer.owner.email - end + # Avoid depending on Pay's instance predicate here; older Pay versions have + # had status predicate differences across loaded model code. The email only + # needs to choose active-vs-inactive copy, so persisted status fields are the + # stable source of truth. + # Source: https://github.com/pay-rails/pay/blob/v11.4.3/app/models/pay/subscription.rb#L97-L102 + def subscription_continuing_after_trial?(pay_subscription) + return false unless %w[active trialing].include?(pay_subscription.status.to_s) + return true unless pay_subscription.respond_to?(:ends_at) && pay_subscription.ends_at.present? + + pay_subscription.ends_at.future? end # Localize date using i18n diff --git a/goodmail.gemspec b/goodmail.gemspec index ea26943..39fc02a 100644 --- a/goodmail.gemspec +++ b/goodmail.gemspec @@ -9,7 +9,9 @@ Gem::Specification.new do |spec| spec.email = ["rubygems@rameerez.com"] spec.summary = "Make your transactional emails look beautiful" - spec.description = "Send beautiful, simple transactional emails with zero HTML hell. Goodmail is a minimal, opinionated, expressive Ruby DSL for sending production-grade transactional emails in Rails apps that look good in any email client out of the box." + spec.description = "Send beautiful, simple transactional emails with zero HTML hell. Goodmail is a minimal, " \ + "opinionated, expressive Ruby DSL for sending production-grade transactional emails in " \ + "Rails apps that look good in any email client out of the box." spec.homepage = "https://github.com/rameerez/goodmail" spec.license = "MIT" @@ -35,12 +37,17 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] # Uncomment to register a new dependency of your gem + spec.add_dependency "premailer-rails", ">= 1.10" spec.add_dependency "rails", ">= 7.0" spec.add_dependency "rails-html-sanitizer", ">= 1.0" - spec.add_dependency "premailer-rails", ">= 1.10" - - # For more information and examples about making a new gem, check out our - # guide at: https://bundler.io/guides/creating_gem.html - spec.add_development_dependency "pry" - spec.add_development_dependency "rake", "~> 13.0" + # Ruby warns that `ostruct` leaves the default gems set in Ruby 3.5. + # Goodmail requires it directly in `lib/goodmail.rb`, so declare the + # runtime dependency now instead of relying on the interpreter bundle. + # Source: https://bugs.ruby-lang.org/journals/107033/diff?detail_id=66531 + spec.add_dependency "ostruct", ">= 0.6" + + # Development dependencies live in the `Gemfile`'s `:development` / + # `:test` groups, matching the convention shared with the sibling + # gems. The gemspec stays + # focused on what downstream gems install at runtime. end diff --git a/lib/goodmail.rb b/lib/goodmail.rb index 75b1da8..c5e1fba 100644 --- a/lib/goodmail.rb +++ b/lib/goodmail.rb @@ -10,8 +10,11 @@ require_relative "goodmail/error" # Load Error class explicitly if needed elsewhere require_relative "goodmail/builder" require_relative "goodmail/layout" +require_relative "goodmail/plaintext" # Shared plaintext generator require_relative "goodmail/email" -require_relative "goodmail/mailer" # Require the internal Mailer +require_relative "goodmail/action_mailer_integration" +Goodmail.install_action_mailer_integration! +require_relative "goodmail/mailer" # Require the internal Mailer require_relative "goodmail/dispatcher" # The main namespace for the Goodmail gem. @@ -20,16 +23,17 @@ module Goodmail # Extend self with Configuration module methods (config, configure, reset_config!) extend Configuration - # Composes a Mail::Message object using the Goodmail DSL and layout. + # Composes an ActionMailer::MessageDelivery using the Goodmail DSL and layout. # # This is the primary entry point for creating emails with Goodmail. - # The returned Mail::Message object can then have `.deliver_now` or + # The returned MessageDelivery can then have `.deliver_now` or # `.deliver_later` called on it. # # @param headers [Hash] Mail headers (:to, :from, :subject, etc.) # Also accepts :unsubscribe (true or String URL). # @param block [Proc] Block containing Goodmail DSL calls (text, button, etc.) - # @return [Mail::Message] The generated Mail object, ready for delivery. + # @return [ActionMailer::MessageDelivery] The generated message delivery, + # ready for delivery. # # @example # mail = Goodmail.compose(to: 'user@example.com', subject: 'Hello!') do diff --git a/lib/goodmail/action_mailer_integration.rb b/lib/goodmail/action_mailer_integration.rb new file mode 100644 index 0000000..0185c3b --- /dev/null +++ b/lib/goodmail/action_mailer_integration.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require "uri" + +module Goodmail + LIST_UNSUBSCRIBE_HEADER = "List-Unsubscribe" + LIST_UNSUBSCRIBE_POST_HEADER = "List-Unsubscribe-Post" + LIST_UNSUBSCRIBE_ONE_CLICK_VALUE = "List-Unsubscribe=One-Click" + GOODMAIL_RENDER_HEADER_KEYS = %i[ + preheader + unsubscribe_url + locale + context + config + configuration + layout_path + ].freeze + + # Returns the deliverability headers for a configured unsubscribe URL. + # + # RFC 8058 one-click unsubscribe is only eligible for HTTPS + # List-Unsubscribe URLs and uses the exact `List-Unsubscribe=One-Click` + # POST body. Keep the classic List-Unsubscribe header for other non-blank + # values, but don't advertise one-click POST support unless the URL qualifies. + # + # Sources: + # - RFC 8058 §3.1: + # https://www.rfc-editor.org/rfc/rfc8058#section-3.1 + # - Gmail sender guidelines: + # https://support.google.com/mail/answer/81126 + # - Yahoo sender best practices: + # https://senders.yahooinc.com/best-practices/ + def self.list_unsubscribe_headers(unsubscribe_url) + return {} unless unsubscribe_url.is_a?(String) + + stripped_url = unsubscribe_url.strip + return {} if stripped_url.empty? + + headers = { LIST_UNSUBSCRIBE_HEADER => "<#{stripped_url}>" } + if one_click_unsubscribe_url?(stripped_url) + headers[LIST_UNSUBSCRIBE_POST_HEADER] = LIST_UNSUBSCRIBE_ONE_CLICK_VALUE + end + headers + end + + def self.one_click_unsubscribe_url?(url) + uri = URI.parse(url) + uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty? + rescue URI::InvalidURIError + false + end + + # Returns the header hash Goodmail should hand to Action Mailer's `mail`. + # Rails intentionally accepts arbitrary message headers and filters only + # framework-only render keys internally; Goodmail should follow that shape + # instead of maintaining a narrow whitelist of envelope fields. + # Source: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L972-L976 + def self.action_mailer_headers(headers) + headers.each_with_object({}) do |(key, value), result| + next if render_header_key?(key) + + result[key] = value + end + end + + def self.render_header_key?(key) + GOODMAIL_RENDER_HEADER_KEYS.include?(key) || + (key.is_a?(String) && GOODMAIL_RENDER_HEADER_KEYS.include?(key.to_sym)) + end + + # Installs Goodmail's mailer helpers once on ActionMailer::Base so app + # mailers, Devise mailers, Pay mailers, and other custom Action Mailer + # subclasses can call `goodmail_mail` without per-class include glue. + # + # The helper methods stay private because Action Mailer dispatches mailer + # actions through `action_methods`; keeping the API private prevents Rails + # from treating helpers as deliverable actions. + # Source: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L614-L618 + def self.install_action_mailer_integration!(base = ActionMailer::Base) + return if base < ActionMailerIntegration + + base.include(ActionMailerIntegration) + end + + # Private helpers for Action Mailer classes that use `Goodmail.render` and + # then call `mail()` themselves. Goodmail installs this module into + # ActionMailer::Base at load time; apps should not need to include it. + module ActionMailerIntegration + DEFAULT_UNSUBSCRIBE_URL = Object.new.freeze + + private + + # High-level wrapper for the common custom-mailer path: + # + # goodmail_mail(to: user.email, subject: "Hello", preheader: "...") do + # text "Body" + # end + # + # Goodmail-specific keys (`:preheader`, `:unsubscribe_url`) are passed to + # `Goodmail.render` and stripped before calling Action Mailer's `mail()`. + # Attachments and inline CIDs are applied before the final `mail()` call + # because Rails rejects attachment writes after `mail` has materialized the + # message. + # Sources: + # - `mail` creates parts and finalizes content type: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L875-L907 + # - late attachments raise: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L766-L781 + def goodmail_mail( + mail_headers = {}, + render_options: nil, + unsubscribe_url: DEFAULT_UNSUBSCRIBE_URL, + **headers, + &block + ) + raise ArgumentError, "goodmail_mail requires a block" unless block_given? + + mail_headers = mail_headers.merge(headers) + render_headers = goodmail_render_options(mail_headers, render_options) + render_config = goodmail_render_config(render_headers) + + Goodmail.with_config(render_config) do + resolved_unsubscribe_url = goodmail_resolve_unsubscribe_url( + mail_headers, + render_headers, + unsubscribe_url + ) + render_headers[:unsubscribe_url] = resolved_unsubscribe_url if goodmail_present?(resolved_unsubscribe_url) + # Preserve the Action Mailer instance as Goodmail's render context so + # DSL blocks can still read mailer ivars and helper methods even though + # Goodmail evaluates them on its Builder receiver. + # Source: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/README.rdoc#L20-L37 + render_headers[:context] = self unless goodmail_header_key?(render_headers, :context) + + parts = goodmail_render_parts(render_headers, &block) + goodmail_mail_parts(parts, mail_headers, unsubscribe_url: resolved_unsubscribe_url) + end + end + + # Context-aware render helper for mailers that truly need to render first + # and inspect or mutate generated parts before calling `mail()` later. + # It injects the current mailer as the Goodmail render context so app code + # does not need to repeat `Goodmail.render(..., context: self)` everywhere. + # Source: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/README.rdoc#L20-L37 + def goodmail_render_parts(render_options = {}, **headers, &block) + raise ArgumentError, "goodmail_render_parts requires a block" unless block_given? + + render_headers = render_options.merge(headers) + render_headers[:context] = self unless goodmail_header_key?(render_headers, :context) + + Goodmail.render(render_headers, &block) + end + + # Lower-level wrapper for apps that must call `Goodmail.render` separately + # but still want Goodmail to own the mechanical Action Mailer handoff. This + # stays inside the mailer method, so Action Mailer's lazy `MessageDelivery` + # and `deliver_later` serialization model remain intact. + # Source: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/message_delivery.rb#L142-L155 + def goodmail_mail_parts(parts, mail_headers = {}, unsubscribe_url: DEFAULT_UNSUBSCRIBE_URL, **headers) + goodmail_apply_parts!(parts) + + mail_headers = mail_headers.merge(headers) + final_headers = goodmail_mail_headers(mail_headers) + resolved_unsubscribe_url = goodmail_resolve_unsubscribe_url( + mail_headers, + {}, + unsubscribe_url + ) + goodmail_add_list_unsubscribe_headers!(final_headers, resolved_unsubscribe_url) + + # Rails' documented block form builds explicit text/html responses via + # ActionMailer::Collector and then lets `mail` assemble the MIME tree. + # Sources: + # - block-form `mail`: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L851-L873 + # - collector response body: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/collector.rb#L25-L29 + mail(final_headers) do |format| + format.text { render plain: parts.text.to_s } + format.html { render html: parts.html.to_s.html_safe } + end + end + + # Applies `Goodmail.render(...).attachments` to Action Mailer's attachment + # collection. Old Goodmail versions returned only html/text, so a missing + # `attachments` method is a no-op for compatibility with older apps. + def goodmail_apply_parts!(parts) + return parts unless parts.respond_to?(:attachments) + + goodmail_apply_attachments!(parts.attachments) + parts + end + + def goodmail_apply_attachments!(attachment_descriptors) + Array(attachment_descriptors).each do |attachment| + # Use Action Mailer's public attachment APIs. Rails chooses the final + # MIME container afterwards (`multipart/related` for inline-only, + # `multipart/mixed` plus nested related parts for mixed attachments). + # Source: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L1024-L1042 + target = attachment[:inline] ? attachments.inline : attachments + payload = + if attachment[:mime_type].to_s.strip.empty? + attachment[:content] + else + { mime_type: attachment[:mime_type], content: attachment[:content] } + end + target[attachment[:filename]] = payload + + next unless attachment[:inline] + + # `inline_image` emits `` before Action + # Mailer materializes the part. Pin the Mail::Part to that generated + # Content-ID so RFC 2392 `cid:` resolution lands on this exact part. + # Rails' preview interceptor also resolves `cid:` URLs by matching + # against attachment CIDs, so this keeps previews and deliveries aligned. + # Sources: + # - RFC 2392: https://www.rfc-editor.org/rfc/rfc2392 + # - Rails CID preview lookup: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/inline_preview_interceptor.rb#L33-L57 + content_id = attachment[:content_id].to_s.strip + content_id = attachment[:filename].to_s if content_id.empty? + attachments[attachment[:filename]].content_id = "<#{content_id}>" + end + end + + def goodmail_add_list_unsubscribe_headers!(headers, unsubscribe_url) + headers.merge!(Goodmail.list_unsubscribe_headers(unsubscribe_url)) + end + + def goodmail_list_unsubscribe_headers(unsubscribe_url) + Goodmail.list_unsubscribe_headers(unsubscribe_url) + end + + def goodmail_render_options(mail_headers, render_options) + render_headers = {} + if goodmail_header_key?(mail_headers, :subject) + render_headers[:subject] = goodmail_header_value(mail_headers, :subject) + end + render_headers.merge!(render_options || {}) + + GOODMAIL_RENDER_HEADER_KEYS.each do |key| + next if render_headers.key?(key) || !goodmail_header_key?(mail_headers, key) + + render_headers[key] = goodmail_header_value(mail_headers, key) + end + + render_headers + end + + def goodmail_mail_headers(mail_headers) + Goodmail.action_mailer_headers(mail_headers) + end + + def goodmail_resolve_unsubscribe_url(mail_headers, render_headers, unsubscribe_url) + return unsubscribe_url unless unsubscribe_url.equal?(DEFAULT_UNSUBSCRIBE_URL) + return render_headers[:unsubscribe_url] if render_headers.key?(:unsubscribe_url) + if goodmail_header_key?(mail_headers, :unsubscribe_url) + return goodmail_header_value(mail_headers, :unsubscribe_url) + end + + Goodmail.config.unsubscribe_url + end + + def goodmail_header_key?(headers, key) + headers.key?(key) || headers.key?(key.to_s) + end + + def goodmail_header_value(headers, key) + headers.key?(key) ? headers[key] : headers[key.to_s] + end + + def goodmail_render_config(headers) + return goodmail_header_value(headers, :config) if goodmail_header_key?(headers, :config) + + goodmail_header_value(headers, :configuration) if goodmail_header_key?(headers, :configuration) + end + + def goodmail_present?(value) + !value.nil? && (!value.respond_to?(:empty?) || !value.empty?) + end + end +end diff --git a/lib/goodmail/builder.rb b/lib/goodmail/builder.rb index 2eec9b3..3574937 100644 --- a/lib/goodmail/builder.rb +++ b/lib/goodmail/builder.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "erb" require "rails-html-sanitizer" # Require the sanitizer +require "securerandom" module Goodmail # Builds the HTML content string based on DSL method calls. @@ -9,16 +10,51 @@ class Builder # The h helper, included from ERB::Util, stands for html_escape. # It converts special characters (&, <, >, ", ') into their HTML entity equivalents (&, <, >, ", '). This prevents Cross-Site Scripting (XSS) by ensuring dynamic content is displayed as literal text rather than being interpreted as HTML. - - # Initialize a basic sanitizer allowing only
tags with href + # Initialize a basic sanitizer allowing inline emphasis (, , + # , , ) — the formatting tags every email client renders + # consistently and that don't add layout risk to the table-based template. + # + # Why these specific tags: + # - `a[href]`: clickable links, basic. + # - `strong` / `em`: semantic emphasis. Both are universally supported + # in email clients (including Outlook 2007+ which famously drops + # more exotic tags). Source: https://www.caniemail.com/features/html-strong/ + # - `b` / `i`: legacy non-semantic equivalents that some translation + # workflows still emit. Allowed for symmetry — they render identically + # to `strong` / `em` in every modern client. + # + # Anything more (h1/h2 inside text, ul/li, span/div) belongs in dedicated + # DSL helpers (`h1`, `h2`, `h3`, `code_box`, …) that compose proper + # styled blocks with table-safe markup, not in inline text. HTML_SANITIZER = Rails::Html::SafeListSanitizer.new - ALLOWED_TAGS = %w(a).freeze + ALLOWED_TAGS = %w(a strong em b i).freeze ALLOWED_ATTRIBUTES = %w(href).freeze + # RFC 2606 / RFC 6761 reserve `.invalid` for names that should not collide + # with real DNS. Goodmail only needs a stable addr-spec domain for generated + # Content-IDs; it should never imply a routable host. + # Sources: + # - https://www.rfc-editor.org/rfc/rfc2606#section-2 + # - https://www.rfc-editor.org/rfc/rfc6761#section-6.4 + INLINE_CONTENT_ID_DOMAIN = "inline.goodmail.invalid" + + attr_reader :parts, :attachments - attr_reader :parts + INTERNAL_INSTANCE_VARIABLES = %i[@parts @attachments @goodmail_context].freeze - def initialize + def initialize(context: nil) + copy_context_instance_variables(context) + @goodmail_context = context @parts = [] + # Email-level attachments collected via the `attach` DSL method. Stored as + # `[{ filename:, content:, mime_type: }, ...]` and consumed by the + # internal `Goodmail::Mailer` (via `Goodmail::Dispatcher`) before the + # `mail()` call so they are forwarded on the outgoing message. We collect + # here (rather than calling `attachments[]=` directly on a mailer + # instance) because the DSL block is `instance_eval`'d on the Builder + # — it has no Mailer context and can't reach into ActionMailer's + # attachments hash. See `Mailer#compose_message` for how these are + # applied. + @attachments = [] end # DSL Methods @@ -84,11 +120,73 @@ def image(src, alt = "", width: nil, height: nil) end # Adds a simple price row as a styled paragraph. - # NOTE: This does not create a table structure. + # NOTE: This does not create a table structure. The visual is bold, + # centered, and separator-bordered — designed for receipt-style line + # items where the LABEL and the AMOUNT carry equal weight ("Premium + # plan - $49.00", "Tax - $4.90"). For label/value rows where the + # label is supporting context and the value is the primary content + # ("Plan - Pro", "Status - Active"), prefer `info_row` below. def price_row(name, price) parts << %(

#{h name}   –   #{h price}

) end + # Adds a label/value row using the two-column table pattern that + # Stripe / Linear / Square / Resend all converge on for transactional + # info cards: muted label on the left, dark right-aligned value on + # the right, 1px hairline at the bottom for visual separation. + # + # Why a TWO-CELL TABLE (and not a flexbox/grid div): + # - Outlook on Windows uses Word's HTML rendering engine (no + # `display: flex` / `grid`, no `gap`). Tables are the only + # layout primitive that renders consistently across every modern + # and legacy client. Source: https://www.caniemail.com/features/css-display-flex/ + # - `cellpadding=0 cellspacing=0 border=0` + `border-collapse: + # collapse` neutralizes the historical browser defaults and + # gives us pixel control via the inline `padding`. + # - `role="presentation"` tells screen readers to skip the table + # semantics — this is layout, not data. Source: WAI-ARIA 1.2 + # `presentation` / `none` role: + # https://www.w3.org/TR/wai-aria-1.2/#presentation + # + # Why two SEPARATE tables per call (vs. one table with many rows): + # - The block-level DSL emits each call as a self-contained unit, + # same as `price_row` / `text` / `button`. Mixing rows from + # different DSL calls into one shared table would require a + # `Builder` flush phase that mutates earlier output — complex + # and surprising. Adjacent two-cell tables visually collapse + # into one continuous list when their bottom border meets the + # next row's top edge, so the user sees a single list anyway. + # + # Sources on email-safe table-row patterns: + # - https://www.cerberusemail.com/templates (responsive table patterns) + # - https://www.litmus.com/blog/the-ultimate-guide-to-css/ + # - https://htmlemail.io/blog/responsive-html-emails-creating-a-simple-responsive-email/ + def info_row(label, value) + label_html = h(label.to_s) + value_html = h(value.to_s) + # The `class="goodmail-info-row"` hook is the marker + # `Goodmail::Plaintext` looks for to flatten this two-cell table + # into a single `Label: Value` line in the plaintext part. HTML + # email clients render the visible table; text-only clients see + # the readable colon-form. Without the marker, Premailer would + # emit the cells on two separate lines: + # + # Label + # Value + # + # which is correct table-extraction behavior but a worse + # plaintext UX than the conventional `Label: Value` shape every + # other transactional sender uses. + parts << <<~HTML.strip +
+ + + + + + HTML + end + # Adds a simple code box with background styling. def code_box(text) # Re-added background/padding; content is simple, should survive Premailer plain text. @@ -105,16 +203,122 @@ def sign(name = Goodmail.config.company_name) parts << %(

– #{h name}

) end - %i[h1 h2 h3].each do |heading_tag| + # Inline styled link as a paragraph. Wraps `button` for cases where a full + # call-to-action button is too heavy — e.g. "View receipt", "Open the + # account", "Read the full policy". The link is rendered in the + # configured brand color and underlined, matching the layout's `a {}` rule + # so the visual stays consistent in clients that strip inline styles. + # + # Both `text` and `url` are HTML-escaped to prevent any accidental injection + # from interpolated user content (e.g. a customer name in a label, a + # URL with arbitrary query strings). + def link(text, url) + parts << %(

#{h text}

) + end + + # Small/disclaimer text. Designed for legal language, fine print, "you + # received this because…", and similar secondary content. Uses the same + # neutral grey as the footer (`#777`) and a slightly smaller font size. + # Newlines become
, mirroring `text` so callers don't have to think + # about which helper handles which. + def small(str) + sanitized_content = HTML_SANITIZER.sanitize( + str.to_s, + tags: ALLOWED_TAGS, + attributes: ALLOWED_ATTRIBUTES + ) + parts << %(

#{sanitized_content.gsub(/\n/, "
")}

) + end + + # Adds an email attachment (file) to the outgoing message. Use for PDFs, + # .ics calendar invites, .csv exports, summary images you want stored on + # the recipient's machine, etc. + # + # Sources: + # - ActionMailer attachments docs: + # https://guides.rubyonrails.org/action_mailer_basics.html#sending-emails-with-attachments + # - RFC 2392 (Content-ID URLs for inline images): + # https://www.rfc-editor.org/rfc/rfc2392 + # + # Parameters: + # filename — the name the recipient sees (e.g. "receipt.pdf"). + # content — either the raw bytes (String, IO) or a filesystem path + # (String). Strings that point at an existing file path are + # read from disk; otherwise the String is used as-is. + # mime_type — optional Content-Type override. When omitted, Action Mailer + # infers it from the filename via Mime::Type.lookup_by_extension. + # inline — when true, the attachment is marked as `inline` so the + # email body can reference it via `cid:`. Useful for + # embedding logos / maps when you can't (or don't want to) + # host them publicly. Prefer `inline_image` below when you + # also want Goodmail to emit the matching tag. + def attach(filename, content, mime_type: nil, inline: false) + filename = filename.to_s + + # Inline attachments are referenced from the email body via `cid:`. + # Duplicate filenames are ambiguous in custom `Goodmail.render` + # fan-out code because Action Mailer's attachment hash is keyed by + # filename, so keep the documented "one inline filename per message" + # contract even though Goodmail generates distinct Content-IDs. + # Source: https://guides.rubyonrails.org/action_mailer_basics.html#sending-emails-with-attachments + # + # Non-inline attachments don't have the same problem — they're + # downloaded by the recipient by filename, so a duplicate + # produces two files with the same name (annoying UX but not a + # rendering bug). We allow those. + if inline && attachments.any? { |a| a[:inline] && a[:filename] == filename } + raise Goodmail::Error, "duplicate inline filename #{filename.inspect}. Use a distinct filename per inline_image call." + end + + descriptor = { + filename: filename, + content: resolve_attachment_content(content), + mime_type: mime_type, + inline: inline, + content_id: (generate_inline_content_id(filename) if inline) + } + attachments << descriptor + descriptor + end + + # Embeds an inline image and emits the matching tag at this point in + # the email body, referencing the attachment via `cid:`. Goodmail assigns + # a globally unique RFC 2392-shaped Content-ID and pins the Mail part to + # that same ID when the message is materialized. + # Sources: + # - RFC 2392 `cid:` URL / Content-ID mapping: + # https://www.rfc-editor.org/rfc/rfc2392 + # - Rails inline attachment pattern: + # https://guides.rubyonrails.org/action_mailer_basics.html#making-inline-attachments + # + # `inline_image` is the right tool when: + # - the image must travel WITH the email so it renders in offline / + # end-of-cache scenarios (e.g. an Outlook user reading three months + # later when the public URL has expired), + # - or when you don't have a public URL to point at (private S3 + # bucket, dev environment with localhost URLs, etc). + # + # When the asset already has a public URL you control, prefer the regular + # `image(src, alt)` helper — it's lighter on the wire and avoids attaching + # binary parts to every send. + def inline_image(filename, content, alt: "", width: nil, height: nil, mime_type: nil) + attachment = attach(filename, content, mime_type: mime_type, inline: true) + image("cid:#{attachment[:content_id]}", alt, width: width, height: height) + end + + # The `case` only ever sees the three keys we iterate over below, so + # the inline lookup is exhaustive by construction — no defensive + # `else` clause needed. + HEADING_STYLES = { + h1: "margin: 40px 0 10px; font-size: 32px; font-weight: 500; line-height: 1.2em;", + h2: "margin: 40px 0 10px; font-size: 24px; font-weight: 400; line-height: 1.2em;", + h3: "margin: 40px 0 10px; font-size: 18px; font-weight: 400; line-height: 1.2em;" + }.freeze + + HEADING_STYLES.each do |heading_tag, style| define_method(heading_tag) do |str| - # Added basic heading styles, consistent with layout.erb - style = case heading_tag - when :h1 then "margin: 40px 0 10px; font-size: 32px; font-weight: 500; line-height: 1.2em;" - when :h2 then "margin: 40px 0 10px; font-size: 24px; font-weight: 400; line-height: 1.2em;" - when :h3 then "margin: 40px 0 10px; font-size: 18px; font-weight: 400; line-height: 1.2em;" - else "margin: 16px 0; line-height: 1.6;" - end - # Headings should still have their content escaped + # Headings still escape their content — only the surrounding tag + # markup is trusted. parts << tag(heading_tag, h(str), style: style) end end @@ -140,6 +344,52 @@ def html_output private + # Loads file contents when `content` is a path to an existing file, + # otherwise returns it unchanged so callers can pass raw bytes / IO + # streams transparently. Paths win over byte-strings that happen to + # match a filename: this is intentional — the README's documented + # contract is "pass a path, we'll read it for you". + # + # Defensive checks before reaching `File.file?`: + # + # 1. NUL bytes — `File.file?` raises `ArgumentError: path name + # contains null byte` on any String containing `\0`, and that's + # exactly what binary file content (PNG / PDF / .ics) looks + # like. Treat NUL-containing Strings as "definitely not a + # path" so callers can pass `inline_image("logo.png", png_bytes)` + # without us blowing up trying to look up `png_bytes` as a path. + # 2. PATH_MAX — most filesystems cap paths at 4096 bytes (Linux + # `PATH_MAX`); macOS HFS+ at 1024. A String longer than that is + # structurally not a path and almost certainly file contents. + # We pick 4096 as the cutoff to be the most generous to legit + # paths while still cheaply screening out anything bigger. + # + # Source on `File.file?` and the NUL byte error: + # https://docs.ruby-lang.org/en/3.4/File.html#method-c-file-3F + def resolve_attachment_content(content) + return content unless content.is_a?(String) + return content if content.include?("\0") + return content if content.bytesize > 4096 + return content unless File.file?(content) + + File.binread(content) + end + + # RFC 2392 maps `cid:` URLs to Content-ID headers using an addr-spec and + # says Content-IDs should be globally unique. Use a random local part plus + # a reserved `.invalid` domain rather than the filename itself; filenames + # can contain spaces/non-URL characters and are often reused across emails. + # Sources: + # - https://www.rfc-editor.org/rfc/rfc2392 + # - https://www.rfc-editor.org/rfc/rfc6761#section-6.4 + def generate_inline_content_id(filename) + safe_filename = filename.gsub(/[^A-Za-z0-9._+-]/, "-") + safe_filename = "attachment" if safe_filename.empty? + safe_filename = safe_filename[0, 64] + + "#{SecureRandom.hex(12)}.#{safe_filename}@#{INLINE_CONTENT_ID_DOMAIN}" + end + # Helper for creating simple HTML tags with optional style # Assumes content is already appropriately escaped or marked safe. def tag(name, content, style: nil) @@ -162,5 +412,44 @@ def wrap(tag_name, style, &block) # Prevent external modification of the parts array directly attr_writer :parts + + def copy_context_instance_variables(context) + return unless context + + # Goodmail evaluates DSL blocks with `instance_eval` so calls like + # `text "..."` remain terse. That changes `self` from the mailer to the + # builder, which would normally hide mailer ivars such as `@user`. + # Snapshot public mailer state onto the transient builder so Action + # Mailer users can write the same instance-variable style Rails + # documents for mailer views/actions while Goodmail still owns the DSL + # receiver. + # Sources: + # - Action Mailer actions assign instance variables for templates: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/README.rdoc#L20-L37 + # - Ruby `instance_eval` changes the block receiver: + # https://docs.ruby-lang.org/en/3.4/BasicObject.html#method-i-instance_eval + context.instance_variables.each do |ivar| + next if internal_instance_variable?(ivar) + + instance_variable_set(ivar, context.instance_variable_get(ivar)) + end + end + + def internal_instance_variable?(ivar) + INTERNAL_INSTANCE_VARIABLES.include?(ivar) || ivar.to_s.start_with?("@_") + end + + def method_missing(method_name, *args, **kwargs, &block) + context = @goodmail_context + if context.respond_to?(method_name, true) + return context.__send__(method_name, *args, **kwargs, &block) + end + + super + end + + def respond_to_missing?(method_name, include_private = false) + @goodmail_context.respond_to?(method_name, true) || super + end end end diff --git a/lib/goodmail/configuration.rb b/lib/goodmail/configuration.rb index b615329..5179e7e 100644 --- a/lib/goodmail/configuration.rb +++ b/lib/goodmail/configuration.rb @@ -4,6 +4,8 @@ module Goodmail # Handles configuration settings for the Goodmail gem. module Configuration + THREAD_CONFIG_KEY = :goodmail_current_config + # Default configuration values DEFAULT_CONFIG = OpenStruct.new( brand_color: "#348eda", @@ -29,22 +31,57 @@ module Configuration # Provides the configuration block helper. # Ensures validation runs after the block is executed. def configure - yield config # Ensures config is initialized via accessor - validate_config!(config) + yield global_config # Ensures global config is initialized via accessor + validate_config!(global_config) end # Returns the current configuration object. # Initializes with a copy of the defaults if not already configured. def config + Thread.current[THREAD_CONFIG_KEY] || global_config + end + alias_method :configuration, :config + + # Runs a block with a per-render configuration override in the current + # thread. This keeps whitelabel / tenant-specific emails from mutating the + # process-wide config while preserving the existing `Goodmail.config` read + # path used by Builder, Layout, and Plaintext. + # + # Source: Ruby thread-local variables: + # https://docs.ruby-lang.org/en/3.4/Thread.html#method-i-5B-5D + def with_config(overrides) + return yield if overrides.nil? + + previous_config = Thread.current[THREAD_CONFIG_KEY] + Thread.current[THREAD_CONFIG_KEY] = config_with(overrides) + yield + ensure + Thread.current[THREAD_CONFIG_KEY] = previous_config if defined?(previous_config) + end + + def config_with(overrides) + override_config = config.dup + if overrides.respond_to?(:to_h) + overrides.to_h.each { |key, value| override_config[key] = value } + else + overrides.each_pair { |key, value| override_config[key] = value } + end + validate_config!(override_config) + override_config + end + + # Returns the process-wide configuration object, ignoring any temporary + # per-render override installed by `with_config`. + def global_config @config = DEFAULT_CONFIG.dup unless defined?(@config) && @config @config end - alias_method :configuration, :config # Resets the configuration back to the default values. # Primarily useful for testing environments. def reset_config! @config = nil + Thread.current[THREAD_CONFIG_KEY] = nil end private diff --git a/lib/goodmail/dispatcher.rb b/lib/goodmail/dispatcher.rb index cc9c5ba..1fbeb91 100644 --- a/lib/goodmail/dispatcher.rb +++ b/lib/goodmail/dispatcher.rb @@ -1,60 +1,59 @@ # frozen_string_literal: true require "action_mailer" -require "cgi" # For unescaping HTML in plaintext generation -require_relative "mailer" # Require the internal mailer +require_relative "mailer" module Goodmail - # Responsible for orchestrating the building of the Mail::Message object. + # Responsible for orchestrating the building of the Action Mailer delivery. module Dispatcher extend self - # Builds the Mail::Message object with HTML and Text parts, wrapped in - # an ActionMailer::MessageDelivery object. + # Builds an ActionMailer::MessageDelivery with HTML and text parts. # @api private def build_message(headers, &block) - # 1. Initialize the Builder - builder = Goodmail::Builder.new - - # 2. Execute the DSL block within the Builder instance - builder.instance_eval(&block) if block_given? - - # 3. Determine the final unsubscribe URL (user-provided) - unsubscribe_url = headers[:unsubscribe_url] || Goodmail.config.unsubscribe_url - - # 4. Determine preheader text (priority: header > config > subject) - preheader = headers[:preheader] || Goodmail.config.default_preheader || headers[:subject] - - # 5. Render the raw HTML body using the Layout - raw_html_body = Goodmail::Layout.render( - builder.html_output, - headers[:subject], - unsubscribe_url: unsubscribe_url, - preheader: preheader # Pass preheader to layout - ) - - # 6. Slice standard headers for the mailer action - mailer_headers = slice_mail_headers(headers) - - # 7. Build the mail object via the internal Mailer class action. - delivery_object = Goodmail::Mailer.compose_message( - mailer_headers, - raw_html_body, - nil, # Pass nil for raw_text_body - Premailer generates it - unsubscribe_url - ) - - # 8. Return the ActionMailer::MessageDelivery object - delivery_object + headers = headers.dup + render_config_overrides = render_config(headers) + + Goodmail.with_config(render_config_overrides) do + parts = Goodmail.render(headers, &block) + + # ActionMailer::MessageDelivery processes this mailer action lazily and + # `deliver_later` serializes only action arguments, so pass already + # rendered strings and plain attachment descriptor hashes into the action. + # Sources: + # - MessageDelivery laziness: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/message_delivery.rb#L22-L35 + # - deliver_later serializes mailer action arguments: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/message_delivery.rb#L142-L155 + Goodmail::Mailer.compose_message( + slice_mail_headers(headers), + parts.html, + parts.text, + resolved_unsubscribe_url(headers), + parts.attachments + ) + end end private - # Whitelist standard headers to pass to ActionMailer's mail() method - # Excludes custom headers like :unsubscribe_url, :preheader + # Pass Action Mailer's normal header surface through, excluding only + # Goodmail render-only options such as :unsubscribe_url and :preheader. def slice_mail_headers(h) - h.slice(:to, :from, :cc, :bcc, :reply_to, :subject) + Goodmail.action_mailer_headers(h) end - # Removed generate_plaintext - now handled by Premailer in Mailer#compose_message + def render_config(headers) + render_option(headers, :config) || render_option(headers, :configuration) + end + + def resolved_unsubscribe_url(headers) + render_option(headers, :unsubscribe_url) || Goodmail.config.unsubscribe_url + end + + def render_option(headers, key) + return headers[key] if headers.key?(key) + + headers[key.to_s] if headers.key?(key.to_s) + end end end diff --git a/lib/goodmail/email.rb b/lib/goodmail/email.rb index 5a83909..6c60ff6 100644 --- a/lib/goodmail/email.rb +++ b/lib/goodmail/email.rb @@ -3,69 +3,105 @@ require "cgi" # For unescaping HTML in plaintext generation (though Premailer might handle most) module Goodmail - # Simple struct to hold the rendered HTML and text parts of an email. - EmailParts = Struct.new(:html, :text, keyword_init: true) + # Simple struct to hold the rendered HTML and text parts of an email, plus + # any attachments collected via the `attach` / `inline_image` DSL helpers. + # Custom Action Mailer classes can call Goodmail's auto-installed + # `goodmail_mail_parts(parts, headers)` helper to apply attachments, pin + # inline Content-IDs, add unsubscribe headers, and send the multipart body. + # + # `attachments` defaults to `[]` for backwards compatibility — callers + # written against 0.3.x keep working unchanged. + EmailParts = Struct.new(:html, :text, :attachments, keyword_init: true) do + def initialize(html: nil, text: nil, attachments: []) + super(html: html, text: text, attachments: attachments || []) + end + end # Renders the email content using the Goodmail DSL and returns HTML and text parts. # This method does not send the email but prepares its content for sending. # # @param headers [Hash] Mail headers. Expected to contain :subject. - # Can also contain :unsubscribe_url and :preheader to override defaults. + # Can also contain :unsubscribe_url, :preheader, :locale, + # :context, :config, and :layout_path to override + # render-only behavior. # @param dsl_block [Proc] Block containing Goodmail DSL calls (text, button, etc.) # @return [Goodmail::EmailParts] An object containing the :html and :text email parts. def self.render(headers = {}, &dsl_block) # 1. Initialize the Builder and execute the DSL block - builder = Goodmail::Builder.new - builder.instance_eval(&dsl_block) if block_given? - core_html_content = builder.html_output - - # 2. Determine unsubscribe_url and preheader - # These are removed from headers as they are Goodmail-specific, not standard mail headers. current_headers = headers.dup # Avoid modifying the original headers hash directly - unsubscribe_url = current_headers.delete(:unsubscribe_url) || Goodmail.config.unsubscribe_url - preheader = current_headers.delete(:preheader) || Goodmail.config.default_preheader || current_headers[:subject] + context = render_header_value!(current_headers, :context) + locale = render_header_value!(current_headers, :locale) + render_config = render_header_value!(current_headers, :config) + render_config = render_header_value!(current_headers, :configuration) if render_config.nil? + layout_path = render_header_value!(current_headers, :layout_path) + subject = render_header_value!(current_headers, :subject) - # 3. Render the raw HTML body using the Layout - # The subject is passed for the tag and potentially other uses in layout. - # Unsubscribe URL and preheader are passed for inclusion in the layout. - raw_html_body = Goodmail::Layout.render( - core_html_content, - current_headers[:subject], # Use subject from (potentially modified) current_headers - unsubscribe_url: unsubscribe_url, - preheader: preheader - ) + Goodmail.with_config(render_config) do + builder = Goodmail::Builder.new(context: context) + evaluate_builder_dsl(builder, locale, &dsl_block) + core_html_content = builder.html_output - # 4. Use Premailer to inline CSS and generate plaintext - premailer = Premailer.new( - raw_html_body, - with_html_string: true, - adapter: :nokogiri, - preserve_styles: false, # Force inlining and remove <style> block - remove_ids: true, # Remove IDs - remove_comments: false # Keep MSO conditional comments - ) + # 2. Determine unsubscribe_url and preheader + # These are removed from headers as they are Goodmail-specific, not standard mail headers. + unsubscribe_url = render_header_value!(current_headers, :unsubscribe_url) || Goodmail.config.unsubscribe_url + preheader = render_header_value!(current_headers, :preheader) || Goodmail.config.default_preheader || subject - final_inlined_html = premailer.to_inline_css - generated_plain_text = premailer.to_plain_text + # 3. Render the raw HTML body using the Layout + # The subject is passed for the <title> tag and potentially other uses in layout. + # Unsubscribe URL and preheader are passed for inclusion in the layout. + raw_html_body = Goodmail::Layout.render( + core_html_content, + subject, + layout_path: layout_path, + unsubscribe_url: unsubscribe_url, + preheader: preheader + ) - # 5. Perform refined plaintext cleanup (ported from Goodmail::Mailer) - # 5.1. Remove logo alt text line (if logo exists and has associated URL) - if Goodmail.config.logo_url.present? && Goodmail.config.company_url.present? && Goodmail.config.company_name.present? - company_name_escaped = Regexp.escape(Goodmail.config.company_name) - company_url_escaped = Regexp.escape(Goodmail.config.company_url) - # Regex to match the typical alt text pattern for a linked logo image - logo_alt_pattern = /^\s*#{company_name_escaped}\s+Logo\s*\(.*?#{company_url_escaped}.*?\).*\n?/i - generated_plain_text.gsub!(logo_alt_pattern, "") + # 4. Run Premailer for CSS inlining (HTML part). Plaintext goes + # through `Goodmail::Plaintext` which pre-processes the source + # HTML to neutralize MSO-only markup and the hidden preheader + # span — both of which Premailer's plaintext extractor would + # otherwise leak into the text body. + premailer = Premailer.new( + raw_html_body, + with_html_string: true, + adapter: :nokogiri, + preserve_styles: false, # Force inlining and remove <style> block + remove_ids: true, # Remove IDs + remove_comments: false, # Keep MSO conditional comments in HTML + input_encoding: "UTF-8" # See Goodmail::Plaintext for the full + # rationale — short version: Premailer + # double-encodes accented characters when + # the source has no <meta charset>. + ) + final_inlined_html = premailer.to_inline_css + final_plain_text = Goodmail::Plaintext.generate(raw_html_body, preheader: preheader) + + # 5. Return the structured parts + EmailParts.new( + html: final_inlined_html, + text: final_plain_text, + attachments: builder.attachments + ) end + end - # 5.2. Remove any remaining standalone URL lines (often from logo links or similar artifacts) - # This targets lines that consist *only* of a URL. - generated_plain_text.gsub!(/^\s*https?:\/\/\S+\s*$\n?/i, "") + def self.render_header_value!(headers, key) + value = headers.delete(key) + value = headers.delete(key.to_s) if value.nil? && headers.key?(key.to_s) + value + end + private_class_method :render_header_value! - # 5.3. Compact excess blank lines (more than two consecutive newlines) - generated_plain_text.gsub!(/\n{3,}/, "\n\n") + def self.evaluate_builder_dsl(builder, locale, &dsl_block) + return unless block_given? - # 6. Return the structured parts - EmailParts.new(html: final_inlined_html, text: generated_plain_text.strip) + render_block = proc { builder.instance_eval(&dsl_block) } + if !locale.nil? && !locale.to_s.empty? && defined?(I18n) + I18n.with_locale(locale, &render_block) + else + render_block.call + end end + private_class_method :evaluate_builder_dsl end diff --git a/lib/goodmail/layout.erb b/lib/goodmail/layout.erb index be1869a..43176ad 100644 --- a/lib/goodmail/layout.erb +++ b/lib/goodmail/layout.erb @@ -107,7 +107,6 @@ cursor: pointer; display: inline-block; border-radius: 5px; - text-transform: capitalize; background-color: <%= config.brand_color %>; margin: 0; border-color: <%= config.brand_color %>; diff --git a/lib/goodmail/layout.rb b/lib/goodmail/layout.rb index 013e558..0146d44 100644 --- a/lib/goodmail/layout.rb +++ b/lib/goodmail/layout.rb @@ -32,7 +32,7 @@ def render(body_html, subject, layout_path: nil, unsubscribe_url: nil, preheader body_html: body_html, subject: subject || "", config: Goodmail.config, # Make config available - unsubscribe_url: unsubscribe_url, # Pass unsubscribe URL to template + unsubscribe_url: unsubscribe_url, # Pass unsubscribe URL to template preheader: preheader # Pass preheader to template ) rescue => e diff --git a/lib/goodmail/mailer.rb b/lib/goodmail/mailer.rb index 318bc50..100ea58 100644 --- a/lib/goodmail/mailer.rb +++ b/lib/goodmail/mailer.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true require "action_mailer" -require "premailer" # Require premailer library module Goodmail # Internal Mailer class. @@ -16,53 +15,29 @@ class Mailer < ActionMailer::Base # This instance method acts as the mailer action. # It's called via Goodmail::Mailer.compose_message(...) # Action Mailer wraps the result in a MessageDelivery object. - # It uses Premailer to inline CSS and generate plaintext. # @api internal - def compose_message(headers, raw_html_body, _raw_text_body, unsubscribe_url) - # Initialize Premailer with the raw HTML body from the layout - premailer = Premailer.new( - raw_html_body, - with_html_string: true, - # Common options: - adapter: :nokogiri, - preserve_styles: false, # Set to false to force inlining *and* remove <style> block - remove_ids: true, # Can usually remove IDs - remove_comments: false, # KEEP conditional comments so MSO conditionals in the template work! - # Note: `plain_text_images: false` might exist but is not standard; - # relying on gsub cleanup below. - ) + def compose_message( + headers, + html_body, + text_body, + unsubscribe_url, + dsl_attachments = [] + ) + headers = headers.dup + goodmail_add_list_unsubscribe_headers!(headers, unsubscribe_url) + goodmail_apply_attachments!(dsl_attachments) - # Get processed content - inlined_html = premailer.to_inline_css - # Generate plain text, skipping image conversion - generated_plain_text = premailer.to_plain_text - - # Clean up plaintext: - # 1. Remove logo alt text line (if logo exists and has associated URL) - if Goodmail.config.logo_url.present? && Goodmail.config.company_url.present? && Goodmail.config.company_name.present? - company_name_escaped = Regexp.escape(Goodmail.config.company_name) - company_url_escaped = Regexp.escape(Goodmail.config.company_url) - logo_alt_pattern = /^\s*#{company_name_escaped}\s+Logo\s+\(.*?#{company_url_escaped}.*?\).*\n?/i - generated_plain_text.gsub!(logo_alt_pattern, '') - end - # 2. Remove any remaining standalone URL lines (often from logo links) - generated_plain_text.gsub!(/^\s*https?:\/\/\S+\s*$\n?/i, '') - # 3. Compact excess blank lines created by gsubbing - generated_plain_text.gsub!(/\n{3,}/, "\n\n") - - # Add List-Unsubscribe header to the headers hash *before* calling mail() - if unsubscribe_url.is_a?(String) && !unsubscribe_url.strip.empty? - headers["List-Unsubscribe"] = "<#{unsubscribe_url.strip}>" - end - - # Call the instance-level `mail` method + # Attachments must be registered before `mail` materializes the message; + # the block form below lets Rails build the multipart/alternative tree. + # Sources: + # - late attachments raise: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L766-L781 + # - block-form `mail` with direct plain/html rendering: + # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L851-L873 mail(headers) do |format| - # Use the premailer-generated plaintext - format.text { render plain: generated_plain_text.strip } - # Use the CSS-inlined HTML - format.html { render html: inlined_html.html_safe } + format.text { render plain: text_body.to_s } + format.html { render html: html_body.to_s.html_safe } end - # Action Mailer automatically returns the MessageDelivery object end end end diff --git a/lib/goodmail/plaintext.rb b/lib/goodmail/plaintext.rb new file mode 100644 index 0000000..e1d9ff5 --- /dev/null +++ b/lib/goodmail/plaintext.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true +require "premailer" +require "nokogiri" + +module Goodmail + # Plain-text generator for the `text/plain` part of every Goodmail + # multipart message. Single source of truth shared by + # `Goodmail::Email.render` and `Goodmail::Mailer#compose_message` — + # before this consolidation, both code paths had a copy of the + # same gsub cleanup pipeline and the same Premailer call, which + # made it easy for plaintext quality to drift between them. + # + # Why we DO NOT just hand the raw HTML to Premailer: + # ──────────────────────────────────────────────────────────────── + # The Goodmail layout is built for HTML email clients that + # respect display:none, conditional comments, and inline alt + # attributes — none of which Premailer's `to_plain_text` honors. + # If we feed it the layout HTML directly, the plaintext part has + # three artifacts that look like rendering bugs to a recipient + # using a text-only client: + # + # 1. Preheader leak — the layout's hidden inbox-preview + # `<span style="display:none">` is rendered as a phantom + # first line of the message body. + # 2. Button text duplication — the `button` DSL helper emits + # both a `<v:roundrect>` (Outlook VML, wrapped in + # `<!--[if mso]>...<![endif]-->`) AND a regular `<a>`. + # Premailer ignores the conditional comment and extracts + # text from BOTH, so the button label appears twice in + # plaintext. + # 3. Image alt-text leak — `image` / `inline_image` calls + # with no explicit alt fall back to `config.company_name`. + # That alt is fine in HTML (screen readers read it) but + # shows up as a stray "CompanyName" line in plaintext + # since Premailer extracts alt attributes verbatim. + # + # We pre-process the HTML to neutralize each of these BEFORE + # plaintext extraction, then apply a small post-extraction + # cleanup pass for the residual artifacts (logo alt line, + # blank-line compaction). + # + # Sources: + # - Premailer to_plain_text: + # https://github.com/premailer/premailer/blob/master/lib/premailer/premailer.rb + # - MSO conditional comments syntax: + # https://www.litmus.com/blog/a-guide-to-rendering-differences-in-microsoft-outlook-clients + module Plaintext + extend self + + # Matches the `<!--[if mso]>...<![endif]-->` blocks Outlook reads + # exclusively. The `m` flag lets `.*?` span newlines (these blocks + # are usually multi-line). The non-greedy quantifier ensures we + # don't eat past the first matching `<![endif]-->`. + MSO_CONDITIONAL_BLOCK = /<!--\[if mso\]>.*?<!\[endif\]-->/m + + # Generates the plaintext part for a multipart message. + # + # @param raw_html [String] The full layout-rendered HTML body. + # @param preheader [String, nil] The preheader text (the value + # we wrote into the hidden inbox-preview span). Passed in so + # we can strip it specifically from plaintext rather than + # guessing at a generic heuristic. + # @return [String] The cleaned plaintext, ready for the text/plain + # part of the outgoing message. + def generate(raw_html, preheader: nil) + premailer_html = strip_mso_only_markup(raw_html) + premailer = Premailer.new( + premailer_html, + with_html_string: true, + adapter: :nokogiri, + preserve_styles: false, + remove_ids: true, + remove_comments: false, + # Goodmail outputs UTF-8 end-to-end. Without this, Premailer's + # libxml2 backend defaults to Latin-1 when no `<meta charset>` + # tag is present in the source, double-encoding every accented + # character ("Duración" → "Duración", "€" → "â¬"). The shipped + # layout DOES include the meta tag, but custom `layout_path:` + # callers might not — pinning here makes us robust to either. + input_encoding: "UTF-8" + ) + text = premailer.to_plain_text + + text = strip_preheader_line(text, preheader) + text = strip_logo_alt_line(text) + text = strip_company_name_alt_line(text) + text = compact_blank_lines(text) + text.strip + end + + private + + # Removes everything Outlook-only from the source HTML before it's + # handed to Premailer's plaintext extractor: + # + # - The `<!--[if mso]>...<![endif]-->` blocks themselves (Premailer + # doesn't honor conditional comments and would otherwise extract + # text from the VML button INSIDE the block — duplicating every + # button label in plaintext). + # - The hidden preheader span (display:none in HTML, but Premailer + # ignores CSS visibility and would otherwise emit the preheader + # as a phantom first line). + def strip_mso_only_markup(html) + cleaned = html.gsub(MSO_CONDITIONAL_BLOCK, "") + cleaned = strip_hidden_preheader(cleaned) + flatten_info_rows(cleaned) + end + + # Replaces every `<table class="goodmail-info-row">` (the markup + # `Builder#info_row` emits) with a single-line `Label: Value` + # paragraph. Two-cell tables otherwise extract as two separate + # lines (Premailer renders each `<td>` on its own line) — the + # colon-form is the conventional plaintext shape every modern + # transactional sender uses for label/value pairs. + # + # Why we Nokogiri-parse rather than regex-match: tables can be + # nested inside other layout chrome (the layout's `.main` table, + # the content-wrap cell, the data-row table itself). Trying to + # match nested tables with a regex is the canonical case study + # for "don't parse HTML with regex". + # + # We parse as a FULL DOCUMENT, not a fragment. `Nokogiri::HTML.fragment` + # would strip the `<head>` wrapper and expose the `<title>` text as + # body content — Premailer's plaintext extractor would then leak the + # subject as a phantom first line. + # + # We pin the encoding to UTF-8 explicitly. Without that, Nokogiri's + # libxml2 backend falls back to Latin-1 when no `<meta charset>` tag + # is present, which mangles every accented character in the layout + # ("Duración" → "Duración", "€" → "€"). The shipped layout + # DOES declare `<meta http-equiv="Content-Type" content=".../UTF-8">`, + # but downstream apps may use a custom `layout_path:` that doesn't, + # and the API contract is "Goodmail outputs UTF-8 end-to-end" — so + # we don't depend on the meta tag for correctness. + # Source: https://nokogiri.org/rdoc/Nokogiri/HTML4.html#method-c-parse + def flatten_info_rows(html) + doc = Nokogiri::HTML.parse(html, nil, "UTF-8") + doc.css("table.goodmail-info-row").each do |table| + cells = table.css("td") + next if cells.length < 2 + + label = cells[0].text.strip + value = cells[1].text.strip + replacement = Nokogiri::XML::Node.new("p", doc) + replacement.content = "#{label}: #{value}" + table.replace(replacement) + end + doc.to_html + rescue StandardError => e + # If Nokogiri ever chokes (truncated HTML, malformed input from a + # custom layout), preserve the original behavior — the table + # cells still get emitted as two lines, which is ugly but not + # broken. We only log so the failure is visible without crashing + # the whole email pipeline. + warn "[Goodmail::Plaintext] info-row flatten failed: #{e.class}: #{e.message}" + html + end + + # The layout emits the preheader inside a `<span>` with a strong + # signature: `display:none !important; font-size:1px; color:#ffffff; + # line-height:1px; ...`. We use a regex anchored on `display:none` + # AND `font-size:1px` to avoid stripping any legit hidden span a + # downstream caller might emit via the raw `html` DSL helper. + # + # The non-greedy `.*?` between the opening tag and `</span>`, plus + # the `m` flag, lets the match span the whitespace and the + # interpolated preheader text inside the tag. + HIDDEN_PREHEADER_SPAN = / + <span\s[^>]* + style="[^"]* + display:\s*none[^"]* + font-size:\s*1px[^"]* + "[^>]*> + .*? + <\/span> + /xm + + def strip_hidden_preheader(html) + html.gsub(HIDDEN_PREHEADER_SPAN, "") + end + + # Belt-and-suspenders for the preheader: if the caller passed an + # explicit preheader and it happens to land at the top of the + # plaintext anyway (e.g. a custom layout that doesn't use the + # hidden-span pattern, or a preheader that ALSO appears as visible + # body content), strip the leading occurrence so the plaintext + # doesn't open with a duplicate. + def strip_preheader_line(text, preheader) + return text if preheader.to_s.strip.empty? + + escaped = Regexp.escape(preheader.to_s.strip) + text.sub(/\A\s*#{escaped}\s*\n+/, "") + end + + # Removes the historical "CompanyName Logo (https://company.url/...)" + # line generated by the layout's clickable header logo. The opening + # `<a href=...><img alt="CompanyName Logo">...</a>` extracts as + # `CompanyName Logo ( https://... )` in plaintext. + def strip_logo_alt_line(text) + return text unless Goodmail.config.logo_url.present? && + Goodmail.config.company_url.present? && + Goodmail.config.company_name.present? + + company_name = Regexp.escape(Goodmail.config.company_name) + company_url = Regexp.escape(Goodmail.config.company_url) + pattern = /^\s*#{company_name}\s+Logo\s*\(.*?#{company_url}.*?\).*\n?/i + text.gsub(pattern, "") + end + + # Builder's `image` / `inline_image` DSL helpers fall back to + # `config.company_name` for the alt attribute when the caller + # doesn't pass one. That's reasonable in HTML (screen readers + # need SOMETHING). In plaintext, Premailer extracts the alt + # verbatim — leaving a stray "CompanyName" line on its own + # next to wherever the image landed. + # + # We strip standalone lines that EXACTLY match the company name. + # This is conservative: a message with the company name embedded + # in a sentence ("Welcome to ExampleApp, thanks for joining") + # is preserved verbatim — only lines that are nothing but the + # bare company name are removed. + def strip_company_name_alt_line(text) + return text unless Goodmail.config.company_name.present? + + company_name = Regexp.escape(Goodmail.config.company_name) + text.gsub(/^\s*#{company_name}\s*$\n?/, "") + end + + # Compacts runs of 3+ newlines down to exactly 2 (one blank line + # between paragraphs is the canonical readable shape; more is + # visual noise from cumulative gsubs above). + def compact_blank_lines(text) + text.gsub(/\n{3,}/, "\n\n") + end + end +end diff --git a/lib/goodmail/version.rb b/lib/goodmail/version.rb index 9dbeef2..90dd1d2 100644 --- a/lib/goodmail/version.rb +++ b/lib/goodmail/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Goodmail - VERSION = "0.3.1" + VERSION = "0.4.0" end diff --git a/test/action_mailer_integration_test.rb b/test/action_mailer_integration_test.rb new file mode 100644 index 0000000..cf1111a --- /dev/null +++ b/test/action_mailer_integration_test.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require "test_helper" +require "tempfile" + +class GoodmailIntegrationMailer < ActionMailer::Base + default from: "sender@example.com" + + def wrapped(headers = {}) + goodmail_mail(headers) do + text "Wrapped body" + end + end + + def wrapped_inline(headers = {}) + goodmail_mail(headers) do + inline_image "logo.png", "PNG_BYTES", alt: "Logo", mime_type: "image/png" + text "Logo body" + end + end + + def pre_rendered(headers = {}, render_options = {}) + parts = goodmail_render_parts(render_options) do + inline_image "hero.png", "PNG_BYTES", alt: "Hero", mime_type: "image/png" + text "Pre-rendered body" + end + + goodmail_mail_parts(parts, headers, unsubscribe_url: render_options[:unsubscribe_url]) + end + + def pre_rendered_with_header_unsubscribe(headers = {}) + parts = goodmail_render_parts(subject: headers[:subject]) do + text "Header unsubscribe body" + end + + goodmail_mail_parts(parts, headers) + end + + def pre_rendered_context(headers = {}) + @recipient_name = "Avery" + + parts = goodmail_render_parts(subject: headers[:subject]) do + text "Hello #{@recipient_name}" + link "Open dashboard", dashboard_url(account_id: 42) + end + + goodmail_mail_parts(parts, headers, unsubscribe_url: nil) + end + + def legacy_parts(headers = {}) + legacy = Struct.new(:html, :text).new("<p>Legacy body</p>", "Legacy body") + goodmail_mail_parts(legacy, headers, unsubscribe_url: nil) + end + + def context_wrapped(headers = {}) + @recipient_name = "Avery" + + goodmail_mail(headers) do + text "Hello #{@recipient_name}" + link "Open dashboard", dashboard_url(account_id: 42) + end + end + + def wrapped_with_config(headers = {}) + goodmail_mail(headers) do + button "Open", "https://example.test/open" + sign + end + end + + private + + def dashboard_url(account_id:) + "https://example.test/accounts/#{account_id}" + end +end + +class ActionMailerIntegrationTest < Minitest::Test + def test_list_unsubscribe_headers_returns_classic_and_one_click_headers_for_https + headers = Goodmail.list_unsubscribe_headers(" https://example.com/u ") + + assert_equal "<https://example.com/u>", headers["List-Unsubscribe"] + assert_equal "List-Unsubscribe=One-Click", headers["List-Unsubscribe-Post"] + end + + def test_list_unsubscribe_headers_keeps_classic_header_only_for_non_https + headers = Goodmail.list_unsubscribe_headers("http://example.com/u") + + assert_equal "<http://example.com/u>", headers["List-Unsubscribe"] + assert_nil headers["List-Unsubscribe-Post"] + end + + def test_list_unsubscribe_headers_returns_empty_hash_for_blank_or_non_string_values + assert_empty Goodmail.list_unsubscribe_headers(nil) + assert_empty Goodmail.list_unsubscribe_headers("") + assert_empty Goodmail.list_unsubscribe_headers(true) + end + + def test_one_click_unsubscribe_url_rejects_invalid_urls + refute Goodmail.one_click_unsubscribe_url?("https://example .com/unsubscribe") + end + + def test_integration_methods_are_not_action_mailer_actions + actions = GoodmailIntegrationMailer.action_methods + + assert_includes ActionMailer::Base.private_instance_methods, :goodmail_mail + assert_includes GoodmailIntegrationMailer.private_instance_methods, :goodmail_mail_parts + refute_includes actions, "goodmail_mail" + refute_includes actions, "goodmail_render_parts" + refute_includes actions, "goodmail_mail_parts" + refute_includes actions, "goodmail_apply_parts!" + end + + def test_action_mailer_integration_install_is_idempotent + before = ActionMailer::Base.ancestors.count(Goodmail::ActionMailerIntegration) + + Goodmail.install_action_mailer_integration! + + assert_equal before, ActionMailer::Base.ancestors.count(Goodmail::ActionMailerIntegration) + end + + def test_instance_header_helper_delegates_to_goodmail_headers + headers = GoodmailIntegrationMailer.new.send( + :goodmail_list_unsubscribe_headers, + "https://example.com/u" + ) + + assert_equal "<https://example.com/u>", headers["List-Unsubscribe"] + assert_equal "List-Unsubscribe=One-Click", headers["List-Unsubscribe-Post"] + end + + def test_goodmail_mail_renders_and_sends_multipart_message + msg = GoodmailIntegrationMailer.wrapped( + to: "user@example.com", + subject: "Wrapped", + preheader: "Preview text", + unsubscribe_url: "https://example.com/u" + ).message + + assert_equal ["user@example.com"], msg.to + assert_equal "Wrapped", msg.subject + assert_includes msg.text_part.body.decoded, "Wrapped body" + assert_includes msg.html_part.body.decoded, "Wrapped body" + assert_nil msg["preheader"] + assert_nil msg["unsubscribe_url"] + assert_equal "<https://example.com/u>", msg["List-Unsubscribe"].value + assert_equal "List-Unsubscribe=One-Click", msg["List-Unsubscribe-Post"].value + end + + def test_goodmail_mail_evaluates_blocks_with_the_mailer_context + msg = GoodmailIntegrationMailer.context_wrapped( + to: "user@example.com", + subject: "Context" + ).message + + assert_includes msg.text_part.body.decoded, "Hello Avery" + assert_includes msg.html_part.body.decoded, "Hello Avery" + assert_includes msg.html_part.body.decoded, "https://example.test/accounts/42" + assert_nil msg["context"] + end + + def test_goodmail_mail_supports_per_message_config_overrides + GoodmailTestConfig.configure(company_name: "Global Co.", brand_color: "#111827") + + msg = GoodmailIntegrationMailer.wrapped_with_config( + to: "user@example.com", + subject: "Whitelabel", + config: { company_name: "Tenant Co.", brand_color: "#ff5500", unsubscribe_url: "https://tenant.example/u" } + ).message + + assert_includes msg.html_part.body.decoded, "Tenant Co." + assert_includes msg.html_part.body.decoded, "#ff5500" + assert_equal "<https://tenant.example/u>", msg["List-Unsubscribe"].value + assert_nil msg["config"] + assert_equal "Global Co.", Goodmail.config.company_name + end + + def test_goodmail_mail_passes_custom_ActionMailer_headers_through + sent_at = Time.utc(2026, 5, 21, 12, 30, 0) + msg = GoodmailIntegrationMailer.wrapped( + to: "user@example.com", + subject: "Custom headers", + date: sent_at, + "X-Correlation-ID" => "wrapped-123" + ).message + + assert_equal sent_at.to_datetime, msg.date + assert_equal "wrapped-123", msg["X-Correlation-ID"].value + end + + def test_goodmail_mail_uses_custom_layout_path_without_leaking_the_header + Tempfile.create(["goodmail-action-mailer-layout", ".erb"]) do |file| + file.write("<html><body>MAILER-LAYOUT <%= body_html %></body></html>") + file.flush + + msg = GoodmailIntegrationMailer.wrapped( + to: "user@example.com", + subject: "Custom layout", + layout_path: file.path + ).message + + assert_includes msg.html_part.body.decoded, "MAILER-LAYOUT" + assert_includes msg.html_part.body.decoded, "Wrapped body" + assert_nil msg["layout_path"] + end + end + + def test_goodmail_mail_applies_inline_attachments_and_pins_content_id + msg = GoodmailIntegrationMailer.wrapped_inline( + to: "user@example.com", + subject: "Inline" + ).message + + logo = msg.attachments.find { |attachment| attachment.filename == "logo.png" } + + refute_nil logo + assert_predicate logo, :inline? + assert_match(/\A<[0-9a-f]{24}\.logo\.png@inline\.goodmail\.invalid>\z/, logo.content_id) + + content_id = logo.content_id.delete_prefix("<").delete_suffix(">") + + assert_match(/<img[^>]+src="cid:#{Regexp.escape(content_id)}"/, msg.html_part.body.decoded) + end + + def test_goodmail_mail_parts_supports_pre_rendered_custom_mailer_flows + msg = GoodmailIntegrationMailer.pre_rendered( + { to: "user@example.com", subject: "Rendered" }, + { subject: "Rendered", unsubscribe_url: "https://example.com/u" } + ).message + + hero = msg.attachments.find { |attachment| attachment.filename == "hero.png" } + + refute_nil hero + assert_predicate hero, :inline? + assert_equal "<https://example.com/u>", msg["List-Unsubscribe"].value + assert_equal "List-Unsubscribe=One-Click", msg["List-Unsubscribe-Post"].value + end + + def test_goodmail_mail_parts_can_resolve_unsubscribe_url_from_headers + msg = GoodmailIntegrationMailer.pre_rendered_with_header_unsubscribe( + to: "user@example.com", + subject: "Header unsubscribe", + unsubscribe_url: "https://example.com/header-u" + ).message + + assert_nil msg["unsubscribe_url"] + assert_equal "<https://example.com/header-u>", msg["List-Unsubscribe"].value + assert_equal "List-Unsubscribe=One-Click", msg["List-Unsubscribe-Post"].value + end + + def test_goodmail_render_parts_evaluates_blocks_with_the_mailer_context + msg = GoodmailIntegrationMailer.pre_rendered_context( + to: "user@example.com", + subject: "Pre-rendered context" + ).message + + assert_includes msg.text_part.body.decoded, "Hello Avery" + assert_includes msg.html_part.body.decoded, "https://example.test/accounts/42" + end + + def test_goodmail_mail_parts_noops_for_legacy_parts_without_attachments + msg = GoodmailIntegrationMailer.legacy_parts( + to: "user@example.com", + subject: "Legacy" + ).message + + assert_empty msg.attachments + assert_includes msg.text_part.body.decoded, "Legacy body" + assert_includes msg.html_part.body.decoded, "Legacy body" + end +end diff --git a/test/builder_test.rb b/test/builder_test.rb new file mode 100644 index 0000000..d2789ca --- /dev/null +++ b/test/builder_test.rb @@ -0,0 +1,717 @@ +# frozen_string_literal: true + +require "test_helper" + +class BuilderContextProbe + def initialize + @recipient_name = "Avery" + @_internal_lookup_context = "Action Mailer internals should not leak" + @parts = ["context internals should not leak"] + end + + def dashboard_url(account_id:) + "https://example.test/accounts/#{account_id}" + end + + private + + def private_greeting + "private hello" + end +end + +# Tests for `Goodmail::Builder` — the DSL evaluator that backs every +# `Goodmail.compose { ... }` and `Goodmail.render { ... }` block. +# +# We exercise the Builder directly (`Builder.new.instance_eval { ... }`) +# so each DSL helper is checked in isolation, with its raw HTML output +# inspected before Premailer has a chance to mutate it. End-to-end tests +# (compose / render) live in their own files. +class BuilderTest < Minitest::Test + def setup + super # GoodmailTestConfig.configure — gives us a Test Co. + #111827 + @builder = Goodmail::Builder.new + end + + # ── parts / attachments / html_output ──────────────────────────────── + + def test_a_fresh_builder_starts_empty + assert_equal [], @builder.parts + assert_equal [], @builder.attachments + assert_equal "", @builder.html_output + end + + def test_context_instance_variables_are_visible_inside_the_dsl_block + builder = Goodmail::Builder.new(context: BuilderContextProbe.new) + + builder.instance_eval { text "Hello #{@recipient_name}" } + + assert_includes builder.html_output, "Hello Avery" + assert_equal 1, builder.parts.length + end + + def test_context_internal_instance_variables_are_not_copied_to_the_builder + builder = Goodmail::Builder.new(context: BuilderContextProbe.new) + + refute builder.instance_variable_defined?(:@_internal_lookup_context) + end + + def test_context_public_methods_are_delegated_from_the_dsl_block + builder = Goodmail::Builder.new(context: BuilderContextProbe.new) + + builder.instance_eval { button "Open dashboard", dashboard_url(account_id: 42) } + + assert_includes builder.html_output, 'href="https://example.test/accounts/42"' + end + + def test_context_private_methods_are_delegated_like_unqualified_mailer_helpers + builder = Goodmail::Builder.new(context: BuilderContextProbe.new) + + builder.instance_eval { text private_greeting } + + assert_includes builder.html_output, "private hello" + end + + def test_unknown_context_methods_still_raise_name_error + builder = Goodmail::Builder.new(context: BuilderContextProbe.new) + + assert_raises(NameError) { builder.instance_eval { missing_goodmail_helper } } + end + + def test_html_output_joins_parts_with_newlines + @builder.instance_eval do + text "first" + text "second" + end + assert_equal 2, @builder.parts.length + assert_match(/<p[^>]*>first<\/p>\n<p[^>]*>second<\/p>/, @builder.html_output) + end + + def test_parts_attr_writer_is_private_to_block_external_mutation + refute_respond_to @builder, :parts=, + "external code shouldn't be able to swap out the collected parts wholesale" + end + + # ── text ───────────────────────────────────────────────────────────── + + def test_text_wraps_content_in_a_paragraph_tag + @builder.instance_eval { text "hello" } + assert_equal 1, @builder.parts.length + assert_match(/\A<p [^>]*>hello<\/p>\z/, @builder.parts.first) + end + + def test_text_applies_inline_paragraph_style + @builder.instance_eval { text "hi" } + assert_match(/style="margin:16px 0; line-height: 1\.6;"/, @builder.parts.first) + end + + def test_text_preserves_safe_inline_anchor_tags + @builder.instance_eval { text 'visit <a href="https://example.com">our site</a>' } + assert_match(/<a href="https:\/\/example\.com">our site<\/a>/, @builder.parts.first) + end + + def test_text_preserves_strong_em_b_i_inline_emphasis + # 0.4.1 expanded the allowed-tags list. This locks the contract so a + # future contributor doesn't silently shrink it again. + @builder.instance_eval { text "<strong>bold</strong> <em>italic</em> <b>also</b> <i>also</i>" } + output = @builder.parts.first + assert_includes output, "<strong>bold</strong>" + assert_includes output, "<em>italic</em>" + assert_includes output, "<b>also</b>" + assert_includes output, "<i>also</i>" + end + + def test_text_strips_unsafe_tags_silently + # `Rails::Html::SafeListSanitizer` strips disallowed TAGS but keeps + # their text content as-is. That's the canonical sanitizer contract: + # an attacker can't break the document structure via injected tags, + # but the text body of a stripped tag survives as a literal string + # (no script execution, since there's no `<script>` element left). + @builder.instance_eval do + text '<script>alert("xss")</script>safe content' + end + output = @builder.parts.first + refute_includes output, "<script>" + refute_includes output, "</script>" + assert_includes output, "safe content" + end + + def test_text_strips_disallowed_attributes + # `class` and `onclick` are not in `ALLOWED_ATTRIBUTES`. The sanitizer + # drops them but keeps the tag. + @builder.instance_eval do + text '<a href="https://x.co" class="evil" onclick="bad()">click</a>' + end + output = @builder.parts.first + assert_includes output, '<a href="https://x.co">click</a>' + refute_includes output, "class=" + refute_includes output, "onclick" + end + + def test_text_converts_newlines_to_br_tags + @builder.instance_eval { text "line one\nline two\nline three" } + output = @builder.parts.first + assert_includes output, "line one<br>line two<br>line three" + end + + def test_text_coerces_non_strings_via_to_s + @builder.instance_eval { text 12_345 } + assert_match(/<p[^>]*>12345<\/p>/, @builder.parts.first) + end + + def test_text_is_safe_against_amp_and_quote_injection_when_no_html_present + # Plain & gets passed through as-is by SafeListSanitizer when there's no + # HTML to escape. What matters is that injected HTML structure cannot + # break out — covered by the script-strip test above. + @builder.instance_eval { text "Q&A: who?" } + assert_includes @builder.parts.first, "Q&A: who?" + end + + # ── h1 / h2 / h3 ───────────────────────────────────────────────────── + + def test_h1_h2_h3_render_with_distinct_inline_styles + @builder.instance_eval do + h1 "Big" + h2 "Mid" + h3 "Small" + end + assert_match(/<h1[^>]*font-size: 32px[^>]*>Big<\/h1>/, @builder.parts[0]) + assert_match(/<h2[^>]*font-size: 24px[^>]*>Mid<\/h2>/, @builder.parts[1]) + assert_match(/<h3[^>]*font-size: 18px[^>]*>Small<\/h3>/, @builder.parts[2]) + end + + def test_h1_html_escapes_content_so_user_input_cannot_inject + @builder.instance_eval { h1 "<script>alert(1)</script>" } + output = @builder.parts.first + assert_includes output, "<script>alert(1)</script>" + refute_includes output, "<script>" + end + + def test_h2_and_h3_also_escape_content + @builder.instance_eval do + h2 "<b>two</b>" + h3 "<b>three</b>" + end + assert_includes @builder.parts[0], "<b>two</b>" + assert_includes @builder.parts[1], "<b>three</b>" + end + + # ── button ─────────────────────────────────────────────────────────── + + def test_button_emits_anchor_with_url_and_label + @builder.instance_eval { button "Open it", "https://example.com/open" } + output = @builder.parts.first + assert_includes output, 'href="https://example.com/open"' + assert_includes output, "Open it" + end + + def test_button_includes_outlook_vml_fallback + @builder.instance_eval { button "CTA", "https://x.co" } + output = @builder.parts.first + assert_includes output, "<v:roundrect" + assert_includes output, "<![endif]-->" + assert_includes output, "<!--[if !mso]><!-->" + end + + def test_button_uses_brand_color_in_VML_fillcolor + GoodmailTestConfig.configure(brand_color: "#abcdef") + @builder.instance_eval { button "CTA", "https://x.co" } + assert_includes @builder.parts.first, "fillcolor=\"#abcdef\"" + end + + def test_button_html_escapes_label_to_block_xss + @builder.instance_eval { button "<script>x</script>", "https://x.co" } + output = @builder.parts.first + refute_includes output, "<script>x</script>" + assert_includes output, "<script>x</script>" + end + + def test_button_html_escapes_url_so_query_string_cant_inject + @builder.instance_eval { button "View", 'https://x.co/?q="><script>x</script>' } + output = @builder.parts.first + refute_match(/<script>/, output) + assert_includes output, ""><script>" + end + + def test_button_styling_does_not_capitalize_label_casing + # Default styling preserves the EXACT casing the caller wrote — no + # `text-transform: capitalize`. A button labeled "view receipt" + # renders as "view receipt", not "View Receipt"; "OPEN" stays + # "OPEN". The previous default was opinionated and broke acronyms, + # all-lowercase casual copy, and i18n cases where capitalization + # rules differ from English (German nouns, Spanish "el iPhone"). + @builder.instance_eval { button "view receipt", "https://x.co/r/1" } + full_html = Goodmail::Layout.render(@builder.html_output, "S") + refute_match(/text-transform:\s*capitalize/, full_html, + "default button styling must not force any text-transform") + end + + # ── image ──────────────────────────────────────────────────────────── + + def test_image_emits_img_tag_with_src_and_alt + @builder.instance_eval { image "https://cdn.example.com/x.png", "A photo" } + output = @builder.parts.first + assert_includes output, 'src="https://cdn.example.com/x.png"' + assert_includes output, 'alt="A photo"' + end + + def test_image_falls_back_to_company_name_for_alt_when_blank + GoodmailTestConfig.configure(company_name: "Acme") + @builder.instance_eval { image "https://cdn.example.com/x.png" } + assert_includes @builder.parts.first, 'alt="Acme"' + end + + def test_image_inlines_width_when_provided + @builder.instance_eval { image "https://cdn.example.com/x.png", "alt", width: 600 } + assert_includes @builder.parts.first, "width:600px" + end + + def test_image_inlines_height_when_provided + @builder.instance_eval { image "https://cdn.example.com/x.png", "alt", height: 400 } + assert_includes @builder.parts.first, "height:400px" + end + + def test_image_includes_mso_outlook_table_wrapper + @builder.instance_eval { image "https://cdn.example.com/x.png" } + output = @builder.parts.first + assert_includes output, "<!--[if mso]>" + assert_match(/role="presentation"/, output) + end + + def test_image_html_escapes_src_attribute + @builder.instance_eval { image '"><script>x</script>', "alt" } + output = @builder.parts.first + refute_match(/<script>/, output) + assert_includes output, ""><script>" + end + + # ── price_row ──────────────────────────────────────────────────────── + + def test_price_row_emits_centered_bold_paragraph_with_separator + @builder.instance_eval { price_row "Premium plan", "$49.00" } + output = @builder.parts.first + assert_includes output, "Premium plan" + assert_includes output, "$49.00" + assert_includes output, "–" + assert_includes output, "text-align:center" + assert_includes output, "font-weight:bold" + end + + def test_price_row_html_escapes_both_sides + @builder.instance_eval { price_row "<b>Premium</b>", "<script>x</script>" } + output = @builder.parts.first + refute_match(/<b>Premium<\/b>/, output) + refute_match(/<script>/, output) + assert_includes output, "<b>Premium</b>" + assert_includes output, "<script>x</script>" + end + + # ── info_row ───────────────────────────────────────────────────────── + + def test_info_row_emits_a_two_cell_presentation_table + @builder.instance_eval { info_row "Status", "Active" } + output = @builder.parts.first + assert_match(/<table[^>]*role="presentation"/, output) + assert_match(/<td[^>]*>Status<\/td>/, output) + assert_match(/<td[^>]*>Active<\/td>/, output) + end + + def test_info_row_label_uses_muted_grey + @builder.instance_eval { info_row "Status", "Active" } + output = @builder.parts.first + # First <td> is the label cell. + label_cell = output[/<td[^>]*?>Status<\/td>/] + assert_includes label_cell, "color:#6b7280" + assert_includes label_cell, "font-weight:400" + end + + def test_info_row_value_uses_dark_text_and_email_safe_align_attribute + @builder.instance_eval { info_row "Status", "Active" } + output = @builder.parts.first + # Email clients vary on whether `text-align: right` (CSS) survives + # Premailer + their own renderer; the `align="right"` HTML attribute + # is the email-safe fallback. We intentionally emit both. + value_cell = output[/<td[^>]*?>Active<\/td>/] + assert_includes value_cell, 'align="right"' + assert_includes value_cell, "font-weight:600" + assert_includes value_cell, "color:#111827" + end + + def test_info_row_html_escapes_both_label_and_value + @builder.instance_eval { info_row "<b>Status</b>", "<script>x</script>" } + output = @builder.parts.first + refute_match(/<b>Status<\/b>/, output) + refute_match(/<script>/, output) + assert_includes output, "<b>Status</b>" + assert_includes output, "<script>x</script>" + end + + def test_info_row_coerces_non_string_label_and_value + @builder.instance_eval { info_row :seats, 3 } + output = @builder.parts.first + assert_match(/<td[^>]*>seats<\/td>/, output) + assert_match(/<td[^>]*>3<\/td>/, output) + end + + def test_info_row_includes_bottom_hairline_for_visual_separation + @builder.instance_eval { info_row "Foo", "Bar" } + output = @builder.parts.first + assert_includes output, "border-bottom:1px solid #eaeaea" + end + + # ── code_box ───────────────────────────────────────────────────────── + + def test_code_box_emits_a_styled_paragraph_with_centered_bold_content + @builder.instance_eval { code_box "ABC-123" } + output = @builder.parts.first + assert_includes output, "<strong>ABC-123</strong>" + assert_includes output, "background:#F8F8F8" + assert_includes output, "text-align:center" + end + + def test_code_box_html_escapes_content + @builder.instance_eval { code_box "<script>x</script>" } + output = @builder.parts.first + refute_match(/<script>x<\/script>/, output) + assert_includes output, "<script>x</script>" + end + + # ── space ──────────────────────────────────────────────────────────── + + def test_space_emits_a_div_with_default_height_of_16px + @builder.instance_eval { space } + output = @builder.parts.first + assert_includes output, "height:16px" + assert_includes output, "line-height: 16px" + end + + def test_space_accepts_a_custom_height + @builder.instance_eval { space 32 } + assert_includes @builder.parts.first, "height:32px" + end + + def test_space_coerces_string_integers_via_Integer + @builder.instance_eval { space "24" } + assert_includes @builder.parts.first, "height:24px" + end + + def test_space_raises_ArgumentError_on_non_integer_input_so_typos_surface + assert_raises(ArgumentError) { @builder.instance_eval { space "twenty" } } + end + + # ── line ───────────────────────────────────────────────────────────── + + def test_line_emits_a_styled_horizontal_rule + @builder.instance_eval { line } + assert_equal '<hr class="goodmail-hr">', @builder.parts.first + end + + # ── center ─────────────────────────────────────────────────────────── + + def test_center_wraps_block_output_in_a_centered_div + @builder.instance_eval do + center { text "in the middle" } + end + assert_equal 1, @builder.parts.length + output = @builder.parts.first + assert_match(/\A<div style="text-align:center;">/, output) + assert_includes output, "in the middle" + assert output.end_with?("</div>") + end + + def test_center_block_can_emit_multiple_parts_that_get_joined + @builder.instance_eval do + center do + text "first line" + text "second line" + end + end + output = @builder.parts.first + # Two paragraphs survive INSIDE the center wrapper, joined by \n. + assert_equal 2, output.scan("<p ").length + end + + def test_center_restores_outer_parts_when_block_raises + assert_raises(RuntimeError) do + @builder.instance_eval do + text "before" + center do + text "inside" + raise "boom" + end + end + end + # The `before` part should still be there, untouched. The `inside` part + # should NOT — we never finished the center wrap, so we don't bake + # the half-built block into the final output. + assert_equal 1, @builder.parts.length + assert_includes @builder.parts.first, "before" + end + + # ── sign ───────────────────────────────────────────────────────────── + + def test_sign_defaults_to_company_name + GoodmailTestConfig.configure(company_name: "Hola Co.") + @builder.instance_eval { sign } + assert_includes @builder.parts.first, "Hola Co." + end + + def test_sign_accepts_an_explicit_name_override + @builder.instance_eval { sign "The Support Team" } + assert_includes @builder.parts.first, "The Support Team" + end + + def test_sign_html_escapes_the_name + @builder.instance_eval { sign "<b>Team</b>" } + refute_match(/<b>Team<\/b>/, @builder.parts.first) + assert_includes @builder.parts.first, "<b>Team</b>" + end + + # ── link ───────────────────────────────────────────────────────────── + + def test_link_emits_a_paragraph_anchor_styled_with_brand_color + GoodmailTestConfig.configure(brand_color: "#deadbeef") + @builder.instance_eval { link "Open the receipt", "https://example.com/r/1" } + output = @builder.parts.first + assert_match(/<p[^>]*><a href="https:\/\/example\.com\/r\/1"[^>]*>Open the receipt<\/a><\/p>/, output) + assert_includes output, "color:#deadbeef" + assert_includes output, "text-decoration:underline" + end + + def test_link_html_escapes_label_and_url + @builder.instance_eval do + link '<script>x</script>', 'javascript:alert(1)' + end + output = @builder.parts.first + # Label content is escaped. + refute_includes output, "<script>x</script>" + assert_includes output, "<script>x</script>" + # URL is HTML-escaped (Goodmail does NOT enforce a scheme allow-list — the + # gem is for trusted-content transactional emails, not arbitrary input. + # If callers need scheme-validation, that's a host-app concern). What we + # DO check is that the URL can't break out of the `href=""` attribute. + refute_match(/<script/, output[%r{href="[^"]*"}]) + end + + # ── small ──────────────────────────────────────────────────────────── + + def test_small_emits_grey_low_emphasis_paragraph + @builder.instance_eval { small "Fine print." } + output = @builder.parts.first + assert_match(/<p[^>]*>Fine print\.<\/p>/, output) + assert_includes output, "color: #777" + assert_includes output, "font-size: 12px" + end + + def test_small_runs_through_the_same_sanitizer_as_text + @builder.instance_eval { small "<strong>read</strong> <script>x</script>" } + output = @builder.parts.first + assert_includes output, "<strong>read</strong>" + refute_match(/<script>/, output) + end + + def test_small_handles_newlines_via_br + @builder.instance_eval { small "first\nsecond" } + assert_includes @builder.parts.first, "first<br>second" + end + + # ── attach ─────────────────────────────────────────────────────────── + + def test_attach_records_a_non_inline_attachment_descriptor + @builder.instance_eval { attach "receipt.pdf", "PDF_BYTES", mime_type: "application/pdf" } + assert_equal 1, @builder.attachments.length + a = @builder.attachments.first + assert_equal "receipt.pdf", a[:filename] + assert_equal "PDF_BYTES", a[:content] + assert_equal "application/pdf", a[:mime_type] + assert_equal false, a[:inline] + assert_nil a[:content_id] + end + + def test_attach_accepts_a_filesystem_path_and_reads_the_file + Tempfile.create(["attach_test", ".txt"]) do |f| + f.write("hello from disk") + f.flush + @builder.instance_eval { attach "data.txt", f.path, mime_type: "text/plain" } + end + a = @builder.attachments.first + assert_equal "hello from disk", a[:content] + end + + def test_attach_treats_NUL_byte_strings_as_binary_content_not_paths + # Regression guard for the binary-content fix: `File.file?` raises ArgumentError + # on Strings containing `\0`, and PNG / PDF / .ics bytes routinely + # contain NUL bytes (PNG IHDR chunk length, PDF cross-reference + # offsets, .ics produced through binary-safe transports, etc). + # The resolver short-circuits before `File.file?` so these don't + # crash the gem. + binary = "\x89PNG\r\n\x1A\n\x00\x00\x00\rIHDR".b + assert_includes binary, "\x00", "test setup precondition: binary should contain a NUL byte" + @builder.instance_eval { attach "logo.png", binary } + assert_equal binary, @builder.attachments.first[:content] + end + + def test_attach_treats_strings_longer_than_PATH_MAX_as_content_not_paths + # Many filesystems cap PATH_MAX at 4096; a 5KB String is structurally + # not a path. The resolver short-circuits to avoid an unnecessary + # disk syscall + a confusing fallthrough that would otherwise return + # the bytes anyway. + long_content = "x" * 5_000 + @builder.instance_eval { attach "big.txt", long_content } + assert_equal long_content, @builder.attachments.first[:content] + end + + def test_attach_returns_non_string_content_unchanged + io = StringIO.new("from io") + @builder.instance_eval { attach "x.txt", io } + assert_same io, @builder.attachments.first[:content] + end + + def test_attach_mime_type_defaults_to_nil_when_omitted + @builder.instance_eval { attach "x.txt", "bytes" } + assert_nil @builder.attachments.first[:mime_type] + end + + def test_attach_inline_flag_defaults_to_false + @builder.instance_eval { attach "x.txt", "bytes" } + assert_equal false, @builder.attachments.first[:inline] + end + + def test_attach_records_inline_when_requested + @builder.instance_eval { attach "logo.png", "bytes", inline: true } + assert_equal true, @builder.attachments.first[:inline] + end + + def test_attach_coerces_filename_to_string + @builder.instance_eval { attach :receipt, "bytes" } + assert_equal "receipt", @builder.attachments.first[:filename] + end + + # ── inline_image ───────────────────────────────────────────────────── + + def test_inline_image_records_an_inline_attachment + @builder.instance_eval { inline_image "logo.png", "PNG_BYTES" } + assert_equal 1, @builder.attachments.length + a = @builder.attachments.first + assert_equal "logo.png", a[:filename] + assert_equal "PNG_BYTES", a[:content] + assert_equal true, a[:inline] + assert_match(/\A[0-9a-f]{24}\.logo\.png@inline\.goodmail\.invalid\z/, a[:content_id]) + end + + def test_inline_image_emits_an_img_tag_with_a_cid_reference + @builder.instance_eval { inline_image "logo.png", "PNG_BYTES" } + content_id = @builder.attachments.first[:content_id] + output = @builder.parts.first + assert_includes output, %(src="cid:#{content_id}") + end + + def test_inline_image_content_id_is_url_safe_when_filename_has_spaces + @builder.instance_eval { inline_image "hero image ü.png", "PNG_BYTES" } + content_id = @builder.attachments.first[:content_id] + + assert_match(/\A[0-9a-f]{24}\.hero-image--\.png@inline\.goodmail\.invalid\z/, content_id) + assert_includes @builder.parts.first, %(src="cid:#{content_id}") + end + + def test_inline_image_content_id_has_a_fallback_basename_when_filename_is_empty + @builder.instance_eval { inline_image "", "PNG_BYTES" } + content_id = @builder.attachments.first[:content_id] + + assert_match(/\A[0-9a-f]{24}\.attachment@inline\.goodmail\.invalid\z/, content_id) + end + + def test_inline_image_uses_company_name_alt_when_no_alt_passed + GoodmailTestConfig.configure(company_name: "Acme") + @builder.instance_eval { inline_image "logo.png", "PNG_BYTES" } + assert_includes @builder.parts.first, 'alt="Acme"' + end + + def test_inline_image_passes_explicit_alt_through + @builder.instance_eval { inline_image "logo.png", "PNG_BYTES", alt: "Hello" } + assert_includes @builder.parts.first, 'alt="Hello"' + end + + def test_inline_image_passes_width_and_height_to_the_img_tag + @builder.instance_eval { inline_image "logo.png", "PNG_BYTES", width: 600, height: 200 } + output = @builder.parts.first + assert_includes output, "width:600px" + assert_includes output, "height:200px" + end + + def test_inline_image_propagates_mime_type_to_the_attachment_descriptor + @builder.instance_eval { inline_image "logo.png", "PNG_BYTES", mime_type: "image/png" } + assert_equal "image/png", @builder.attachments.first[:mime_type] + end + + def test_inline_image_raises_on_duplicate_filename + # Two `inline_image` calls with the same filename produce broken + # output in custom Goodmail.render fan-out code because the + # attachments hash is keyed by filename. Better to fail loud at + # registration time than silently ship ambiguous inline parts. + error = assert_raises(Goodmail::Error) do + @builder.instance_eval do + inline_image "logo.png", "FIRST" + inline_image "logo.png", "SECOND" + end + end + assert_match(/duplicate inline filename/, error.message) + assert_match(/logo\.png/, error.message) + assert_match(/distinct filename/, error.message) + end + + def test_attach_allows_duplicate_filenames_for_non_inline_attachments + # Non-inline attachments aren't referenced from the body via cid: + # so a duplicate filename is just a UX wart (recipient sees two + # files with the same name) rather than a rendering bug. We allow + # it — users may have legit reasons (two CSV exports, two PDFs). + @builder.instance_eval do + attach "data.csv", "alpha,bytes" + attach "data.csv", "beta,bytes" + end + assert_equal 2, @builder.attachments.length + assert_equal ["data.csv", "data.csv"], @builder.attachments.map { |a| a[:filename] } + end + + def test_attach_with_inline_then_non_inline_same_filename_is_allowed + # Pathological corner — inline + non-inline with the same name. + # The inline one gets the cid: reference, the non-inline one is + # an attachment download. Different surfaces, no conflict. + @builder.instance_eval do + inline_image "logo.png", "INLINE_BYTES" + attach "logo.png", "DOWNLOAD_BYTES" + end + assert_equal 2, @builder.attachments.length + end + + # ── html (raw passthrough) ─────────────────────────────────────────── + + def test_html_passes_raw_string_through_without_escaping + @builder.instance_eval { html "<custom-thing data-x=\"1\">hi</custom-thing>" } + assert_equal '<custom-thing data-x="1">hi</custom-thing>', @builder.parts.first + end + + def test_html_coerces_non_string_via_to_s + @builder.instance_eval { html 42 } + assert_equal "42", @builder.parts.first + end + + # ── DSL composition (smoke that everything plays together) ─────────── + + def test_complex_block_composes_into_correct_part_count_and_order + @builder.instance_eval do + h1 "Hello" + text "Body 1" + space 24 + info_row "Status", "Active" + info_row "Plan", "Pro" + button "CTA", "https://x.co" + line + sign + end + assert_equal 8, @builder.parts.length + # First part must be the h1, last must be the sign. + assert_match(/<h1[^>]*>Hello<\/h1>/, @builder.parts.first) + assert_match(/Test Co\./, @builder.parts.last) + end +end diff --git a/test/compose_test.rb b/test/compose_test.rb new file mode 100644 index 0000000..19898cd --- /dev/null +++ b/test/compose_test.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require "test_helper" +require "tempfile" + +# End-to-end tests for `Goodmail.compose` — the public, top-level API +# documented in the README. These tests verify the gem from the user's +# perspective: build a Mail::Message, deliver it, then introspect the +# encoded message exactly as a recipient's MTA would see it. +# +# Action Mailer's `:test` delivery method (set in test_helper) captures +# `deliver_now` calls into `ActionMailer::Base.deliveries` so we can +# assert on the on-the-wire message without sending real mail. +class ComposeTest < Minitest::Test + def test_compose_returns_a_MessageDelivery_that_responds_to_deliver_now + delivery = Goodmail.compose(to: "u@x.co", from: "n@x.co", subject: "Hi") { text "hi" } + assert_kind_of ActionMailer::MessageDelivery, delivery + assert_respond_to delivery, :deliver_now + assert_respond_to delivery, :deliver_later + end + + def test_compose_delivers_to_ActionMailer_test_inbox + Goodmail.compose( + to: "alice@example.com", from: "bot@example.com", subject: "Test" + ) { text "hello" }.deliver_now + + assert_equal 1, ActionMailer::Base.deliveries.length + msg = ActionMailer::Base.deliveries.last + assert_equal ["alice@example.com"], msg.to + assert_equal ["bot@example.com"], msg.from + assert_equal "Test", msg.subject + end + + def test_compose_renders_a_full_multipart_message_with_html_and_text_parts + Goodmail.compose( + to: "u@x.co", from: "n@x.co", subject: "Multipart" + ) { text "hello world" }.deliver_now + + msg = ActionMailer::Base.deliveries.last + assert_match(%r{multipart/alternative}, msg.content_type) + refute_nil msg.html_part + refute_nil msg.text_part + assert_includes msg.html_part.body.decoded, "<!DOCTYPE html>" + assert_includes msg.text_part.body.decoded, "hello world" + end + + def test_compose_passes_ActionMailer_headers_through_after_stripping_Goodmail_options + sent_at = Time.utc(2026, 5, 21, 12, 30, 0) + msg = Goodmail.compose( + to: "u@x.co", + from: "n@x.co", + subject: "Headers", + date: sent_at, + "X-Correlation-ID" => "abc-123", + preheader: "Preview", + unsubscribe_url: "https://example.com/u" + ) { text "hello" }.message + + assert_equal sent_at.to_datetime, msg.date + assert_equal "abc-123", msg["X-Correlation-ID"].value + assert_nil msg["preheader"] + assert_nil msg["unsubscribe_url"] + end + + def test_compose_uses_custom_layout_path_without_leaking_it_as_a_mail_header + Tempfile.create(["goodmail-compose-layout", ".erb"]) do |file| + file.write("<html><body>CUSTOM-LAYOUT <%= body_html %></body></html>") + file.flush + + msg = Goodmail.compose( + to: "u@x.co", from: "n@x.co", subject: "Custom layout", + layout_path: file.path + ) { text "custom body" }.message + + assert_includes msg.html_part.body.decoded, "CUSTOM-LAYOUT" + assert_includes msg.html_part.body.decoded, "custom body" + assert_nil msg["layout_path"] + end + end + + def test_compose_can_be_called_with_only_required_headers_no_block + # Edge case from the dispatcher: no block. The compose path should + # still produce a deliverable Mail::Message. + delivery = Goodmail.compose(to: "u@x.co", from: "n@x.co", subject: "Empty") + delivery.deliver_now + assert_equal 1, ActionMailer::Base.deliveries.length + end + + def test_compose_emits_RFC_8058_one_click_unsubscribe_header_pair + # End-to-end check that the gem's headline deliverability fix is in + # the encoded message a real MTA would forward. RFC 8058 requires + # the one-click URL to be HTTPS. + msg = Goodmail.compose( + to: "u@x.co", from: "n@x.co", subject: "Unsub", + unsubscribe_url: "https://example.com/u/42" + ) { text "hi" }.message + + encoded = msg.encoded + assert_includes encoded, "List-Unsubscribe: <https://example.com/u/42>" + assert_includes encoded, "List-Unsubscribe-Post: List-Unsubscribe=One-Click" + end + + def test_compose_does_not_emit_one_click_post_for_http_unsubscribe_url + msg = Goodmail.compose( + to: "u@x.co", from: "n@x.co", subject: "Unsub", + unsubscribe_url: "http://example.com/u/42" + ) { text "hi" }.message + + assert_equal "<http://example.com/u/42>", msg["List-Unsubscribe"].value + assert_nil msg["List-Unsubscribe-Post"] + end + + def test_compose_inline_image_round_trips_through_a_real_delivery + Goodmail.compose( + to: "u@x.co", from: "n@x.co", subject: "Hero" + ) do + inline_image "hero.png", "FAKE\x89PNG_BYTES_WITH_\x00_NUL".b + text "see the hero above" + end.deliver_now + + msg = ActionMailer::Base.deliveries.last + hero = msg.attachments.find { |a| a.filename == "hero.png" } + refute_nil hero + assert hero.inline? + assert_match(/\A<[0-9a-f]{24}\.hero\.png@inline\.goodmail\.invalid>\z/, hero.content_id) + + # The body's generated `cid:` reference resolves to the part above. + content_id = hero.content_id.delete_prefix("<").delete_suffix(">") + assert_match(/<img[^>]+src="cid:#{Regexp.escape(content_id)}"/, msg.html_part.body.decoded) + end + + def test_compose_uses_global_config_brand_color_in_the_button + GoodmailTestConfig.configure(brand_color: "#abcdef") + msg = Goodmail.compose( + to: "u@x.co", from: "n@x.co", subject: "S" + ) { button "Click me", "https://example.com" }.message + + html = msg.html_part.body.decoded + # Premailer inlines the brand color onto the anchor's `background-color`. + assert_includes html, "#abcdef" + end + + def test_compose_uses_global_config_company_name_in_the_signature + GoodmailTestConfig.configure(company_name: "Acme Inc.") + msg = Goodmail.compose( + to: "u@x.co", from: "n@x.co", subject: "S" + ) { sign }.message + + assert_includes msg.html_part.body.decoded, "Acme Inc." + end + + def test_compose_accepts_a_per_message_config_override + GoodmailTestConfig.configure(company_name: "Global Co.", brand_color: "#111827") + + msg = Goodmail.compose( + to: "u@x.co", from: "n@x.co", subject: "S", + config: { company_name: "Tenant Co.", brand_color: "#ff5500", unsubscribe_url: "https://tenant.example/u" } + ) do + button "Click me", "https://example.com" + sign + end.message + + assert_includes msg.html_part.body.decoded, "Tenant Co." + assert_includes msg.html_part.body.decoded, "#ff5500" + assert_equal "<https://tenant.example/u>", msg["List-Unsubscribe"].value + assert_nil msg["config"] + assert_equal "Global Co.", Goodmail.config.company_name + end + + def test_compose_snapshots_effective_config_for_lazy_message_materialization + GoodmailTestConfig.configure(company_name: "OriginalCo", brand_color: "#111827") + delivery = Goodmail.compose( + to: "u@x.co", from: "n@x.co", subject: "Lazy config" + ) do + image "https://cdn.example.com/banner.png" + text "Body after image" + end + + refute delivery.processed? + + GoodmailTestConfig.configure(company_name: "ChangedCo", brand_color: "#000000") + msg = delivery.message + + assert_includes msg.html_part.body.decoded, "OriginalCo" + refute_includes msg.html_part.body.decoded, "ChangedCo" + assert_includes msg.text_part.body.decoded, "Body after image" + refute_match(/^OriginalCo$/, msg.text_part.body.decoded) + end + + def test_compose_full_kitchen_sink_block_round_trips + # One block exercising every visible DSL helper plus attachments. + # If any helper later regresses (errors, garbled output, missing + # styles), this test surfaces it without requiring the contributor + # to read the per-helper unit tests. + delivery = Goodmail.compose( + to: "kitchen@example.com", from: "sink@example.com", + subject: "Kitchen sink", + unsubscribe_url: "https://example.com/u", + preheader: "Kitchen sink preview", + cc: "cc@example.com", + reply_to: "support@example.com" + ) do + h1 "Big Title" + h2 "Mid title" + h3 "Small title" + text "<strong>Important:</strong> this email exercises every DSL surface." + space 24 + info_row "Status", "Active" + info_row "Plan", "Pro" + price_row "Premium plan", "$49.00" + code_box "ABC-123" + center { text "centered text" } + line + link "Read the policy", "https://example.com/policy" + small "Fine print and disclaimers." + button "Open the receipt", "https://example.com/r/1" + image "https://cdn.example.com/banner.png", "Banner", width: 600 + attach "receipt.pdf", "PDF_BYTES", mime_type: "application/pdf" + inline_image "logo.png", "PNG_BYTES", alt: "Logo" + sign + end + + delivery.deliver_now + msg = ActionMailer::Base.deliveries.last + + # Headers + assert_equal ["kitchen@example.com"], msg.to + assert_equal ["cc@example.com"], msg.cc + assert_equal ["support@example.com"], msg.reply_to + assert_equal "Kitchen sink", msg.subject + assert_equal "<https://example.com/u>", msg["List-Unsubscribe"].value + assert_equal "List-Unsubscribe=One-Click", msg["List-Unsubscribe-Post"].value + + # Attachments + filenames = msg.attachments.map(&:filename).sort + assert_equal ["logo.png", "receipt.pdf"], filenames + assert msg.attachments.find { |a| a.filename == "logo.png" }.inline? + refute msg.attachments.find { |a| a.filename == "receipt.pdf" }.inline? + + # Body content + html = msg.html_part.body.decoded + [ + "Big Title", "Mid title", "Small title", + "<strong>Important:</strong>", + "Status", "Active", "Plan", "Pro", + "Premium plan", "$49.00", + "ABC-123", + "centered text", + "Read the policy", "https://example.com/policy", + "Fine print and disclaimers.", + "Open the receipt", "https://example.com/r/1", + "https://cdn.example.com/banner.png", + "inline.goodmail.invalid", + "Test Co.", # signature + "Kitchen sink preview" # preheader + ].each do |needle| + assert_includes html, needle, "kitchen-sink HTML missing: #{needle.inspect}" + end + + # Plain-text body (Premailer-derived) + text = msg.text_part.body.decoded + assert_includes text, "Big Title" + assert_includes text, "this email exercises every DSL surface" + assert_includes text, "Open the receipt" + end + + def test_compose_does_NOT_mutate_the_caller_provided_headers_hash + headers = { to: "u@x.co", from: "n@x.co", subject: "S", + unsubscribe_url: "https://example.com/u", + preheader: "Hello" } + snapshot = headers.dup + Goodmail.compose(headers) { text "hi" }.message + assert_equal snapshot, headers, "compose must not mutate the caller's headers hash" + end +end diff --git a/test/configuration_test.rb b/test/configuration_test.rb new file mode 100644 index 0000000..2342ab0 --- /dev/null +++ b/test/configuration_test.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require "test_helper" + +# Exhaustive tests for `Goodmail::Configuration`. The module is mixed into +# the top-level `Goodmail` namespace via `extend Configuration` in +# `lib/goodmail.rb`, so the public surface is reached as `Goodmail.config`, +# `Goodmail.configure { ... }`, `Goodmail.reset_config!`, and the +# `:configuration` alias. +class ConfigurationTest < Minitest::Test + def setup + # Skip the parent setup that pre-configures with defaults — every test + # in this file wants to start from a fully fresh, unconfigured state. + Goodmail.reset_config! + ActionMailer::Base.deliveries.clear + end + + def test_config_returns_a_dup_of_DEFAULT_CONFIG_when_unconfigured + cfg = Goodmail.config + assert_kind_of OpenStruct, cfg + assert_equal "Example Inc.", cfg.company_name + assert_equal "#348eda", cfg.brand_color + assert_nil cfg.logo_url + assert_nil cfg.company_url + assert_nil cfg.unsubscribe_url + assert_nil cfg.default_preheader + assert_nil cfg.footer_text + assert_equal false, cfg.show_footer_unsubscribe_link + assert_equal "Unsubscribe", cfg.footer_unsubscribe_link_text + end + + def test_DEFAULT_CONFIG_constant_is_frozen_and_cannot_be_mutated + # Defending against accidental mutation: if a future contributor pulls + # the constant directly (instead of calling `Goodmail.config`), they + # cannot poison defaults for every other consumer in the same process. + assert_predicate Goodmail::Configuration::DEFAULT_CONFIG, :frozen? + assert_raises(FrozenError) do + Goodmail::Configuration::DEFAULT_CONFIG.company_name = "Mutant Co." + end + end + + def test_config_is_memoized_after_first_access + a = Goodmail.config + b = Goodmail.config + assert_same a, b, "config should return the same instance on repeat reads" + end + + def test_each_call_to_config_after_reset_returns_a_fresh_dup + a = Goodmail.config + a.brand_color = "#ff0000" + Goodmail.reset_config! + b = Goodmail.config + refute_same a, b + assert_equal "#348eda", b.brand_color, "fresh dup should not see the previous instance's mutations" + end + + def test_configuration_is_an_alias_of_config + assert_same Goodmail.config, Goodmail.configuration + end + + def test_with_config_applies_a_temporary_thread_local_override + Goodmail.configure { |c| c.company_name = "Global Co." } + + Goodmail.with_config(company_name: "Tenant Co.", brand_color: "#123456") do + assert_equal "Tenant Co.", Goodmail.config.company_name + assert_equal "#123456", Goodmail.config.brand_color + end + + assert_equal "Global Co.", Goodmail.config.company_name + assert_equal "#348eda", Goodmail.config.brand_color + end + + def test_with_config_validates_required_keys_without_mutating_global_config + Goodmail.configure { |c| c.company_name = "Global Co." } + + error = assert_raises(Goodmail::Error) do + Goodmail.with_config(company_name: " ") { flunk "invalid config should not yield" } + end + + assert_match(/company_name/, error.message) + assert_equal "Global Co.", Goodmail.config.company_name + end + + def test_with_config_accepts_each_pair_configuration_objects + each_pair_config_class = Class.new do + def initialize(pairs) + @pairs = pairs + end + + def each_pair(&block) + @pairs.each(&block) + end + end + each_pair_config = each_pair_config_class.new([[:company_name, "Each Pair Co."]]) + + Goodmail.with_config(each_pair_config) do + assert_equal "Each Pair Co.", Goodmail.config.company_name + end + end + + def test_configure_mutates_global_config_even_inside_a_temporary_override + Goodmail.configure { |c| c.company_name = "Global Co." } + + Goodmail.with_config(company_name: "Tenant Co.") do + Goodmail.configure { |c| c.company_name = "Updated Global Co." } + assert_equal "Tenant Co.", Goodmail.config.company_name + end + + assert_equal "Updated Global Co.", Goodmail.config.company_name + end + + def test_configure_yields_the_config_object + yielded = nil + Goodmail.configure do |c| + yielded = c + c.company_name = "Yield Co." + end + assert_same Goodmail.config, yielded + end + + def test_configure_persists_overrides + Goodmail.configure do |c| + c.company_name = "Override Co." + c.brand_color = "#000000" + c.logo_url = "https://cdn.example.com/logo.png" + c.company_url = "https://example.com" + c.unsubscribe_url = "https://example.com/unsubscribe" + c.default_preheader = "Hi from Override Co." + c.footer_text = "Why you got this email" + c.show_footer_unsubscribe_link = true + c.footer_unsubscribe_link_text = "Unsuscríbete" + end + + cfg = Goodmail.config + assert_equal "Override Co.", cfg.company_name + assert_equal "#000000", cfg.brand_color + assert_equal "https://cdn.example.com/logo.png", cfg.logo_url + assert_equal "https://example.com", cfg.company_url + assert_equal "https://example.com/unsubscribe", cfg.unsubscribe_url + assert_equal "Hi from Override Co.", cfg.default_preheader + assert_equal "Why you got this email", cfg.footer_text + assert_equal true, cfg.show_footer_unsubscribe_link + assert_equal "Unsuscríbete", cfg.footer_unsubscribe_link_text + end + + def test_configure_validates_after_yield_so_user_error_surfaces_immediately + error = assert_raises(Goodmail::Error) do + Goodmail.configure do |c| + c.company_name = nil + end + end + assert_match(/Missing required Goodmail configuration keys.*company_name/, error.message) + end + + def test_configure_treats_blank_string_company_name_as_missing + error = assert_raises(Goodmail::Error) do + Goodmail.configure do |c| + c.company_name = " " + end + end + assert_match(/company_name/, error.message) + end + + def test_configure_treats_empty_string_company_name_as_missing + error = assert_raises(Goodmail::Error) do + Goodmail.configure do |c| + c.company_name = "" + end + end + assert_match(/company_name/, error.message) + end + + def test_validation_error_message_directs_user_to_initializer + error = assert_raises(Goodmail::Error) do + Goodmail.configure do |c| + c.company_name = nil + end + end + assert_match(%r{config/initializers/goodmail\.rb}, error.message) + end + + def test_required_keys_constant_lists_company_name_only_for_now + # If a contributor adds another required key they must update both this + # test and the validation error string. Locking the surface keeps the + # gem's contract explicit. + assert_equal %i[company_name], Goodmail::Configuration::REQUIRED_CONFIG_KEYS + end + + def test_reset_config_back_to_unconfigured_state + Goodmail.configure do |c| + c.company_name = "Will Be Reset" + c.brand_color = "#ff0000" + end + Goodmail.reset_config! + + # Default brand color is restored AND a re-read uses a fresh dup of the + # frozen default, not the previous instance. + assert_equal "#348eda", Goodmail.config.brand_color + assert_equal "Example Inc.", Goodmail.config.company_name + end + + def test_validate_config_raises_a_Goodmail_Error_subclass_of_StandardError + error = assert_raises(Goodmail::Error) do + Goodmail.configure do |c| + c.company_name = nil + end + end + assert_kind_of StandardError, error + end +end diff --git a/test/dispatcher_test.rb b/test/dispatcher_test.rb new file mode 100644 index 0000000..2afb2b8 --- /dev/null +++ b/test/dispatcher_test.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require "test_helper" + +# Tests for `Goodmail::Dispatcher` — the orchestration layer that wires +# `Goodmail.render` to the internal Action Mailer handoff for the +# `Goodmail.compose(...)` path. +# +# We exercise the dispatcher directly here (`Goodmail::Dispatcher.build_message`) +# because `Goodmail.compose` is just a one-line delegate and the dispatcher +# is where the interesting branches live: header slicing, unsubscribe-URL +# fallback, preheader fallback chain, attachment plumbing. +class DispatcherTest < Minitest::Test + def test_build_message_returns_an_ActionMailer_MessageDelivery + delivery = Goodmail::Dispatcher.build_message( + to: "user@example.com", from: "noreply@example.com", subject: "Subj" + ) { text "hello" } + assert_kind_of ActionMailer::MessageDelivery, delivery + end + + def test_build_message_supports_calling_without_a_block + # Edge case: caller may want a layout-only email (e.g. delivery-status + # placeholder). The dispatcher must not crash on a missing block. + delivery = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "Empty" + ) + msg = delivery.message + assert_kind_of Mail::Message, msg + assert_equal ["u@x.co"], msg.to + end + + def test_build_message_passes_to_from_subject_through_to_the_mail_object + msg = Goodmail::Dispatcher.build_message( + to: "alice@example.com", from: "bot@example.com", subject: "Hi Alice" + ) { text "hello" }.message + + assert_equal ["alice@example.com"], msg.to + assert_equal ["bot@example.com"], msg.from + assert_equal "Hi Alice", msg.subject + end + + def test_build_message_passes_cc_bcc_reply_to_through + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "S", + cc: "cc@x.co", bcc: "bcc@x.co", reply_to: "support@x.co" + ) { text "hi" }.message + + assert_equal ["cc@x.co"], msg.cc + assert_equal ["bcc@x.co"], msg.bcc + assert_equal ["support@x.co"], msg.reply_to + end + + def test_build_message_does_NOT_leak_unsubscribe_url_or_preheader_as_mail_headers + # `:unsubscribe_url` and `:preheader` are Goodmail-specific options; + # they must not be passed to ActionMailer's `mail()` method (which + # treats unknown keys as headers and would emit them on the wire). + # The dispatcher's `slice_mail_headers` filters them out — this test + # locks that contract. + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "S", + unsubscribe_url: "https://example.com/u", + preheader: "Inbox preview" + ) { text "hi" }.message + + refute msg.header["unsubscribe_url"], "raw `unsubscribe_url` should not appear as a header" + refute msg.header["preheader"], "raw `preheader` should not appear as a header" + # But List-Unsubscribe (the actual standard header) IS set. + assert_equal "<https://example.com/u>", msg["List-Unsubscribe"].value + end + + def test_build_message_uses_explicit_unsubscribe_url_over_global_config + GoodmailTestConfig.configure(unsubscribe_url: "https://example.com/global-u") + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "S", + unsubscribe_url: "https://example.com/explicit-u" + ) { text "hi" }.message + + assert_equal "<https://example.com/explicit-u>", msg["List-Unsubscribe"].value + end + + def test_build_message_falls_back_to_global_unsubscribe_url + GoodmailTestConfig.configure(unsubscribe_url: "https://example.com/global-u") + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "S" + ) { text "hi" }.message + + assert_equal "<https://example.com/global-u>", msg["List-Unsubscribe"].value + end + + def test_build_message_keeps_classic_unsubscribe_but_skips_one_click_for_http_url + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "S", + unsubscribe_url: "http://example.com/u" + ) { text "hi" }.message + + assert_equal "<http://example.com/u>", msg["List-Unsubscribe"].value + assert_nil msg["List-Unsubscribe-Post"] + end + + def test_build_message_skips_unsubscribe_headers_when_no_url_anywhere + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "S" + ) { text "hi" }.message + + assert_nil msg["List-Unsubscribe"] + assert_nil msg["List-Unsubscribe-Post"] + end + + def test_build_message_uses_explicit_preheader_in_the_body + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "S", + preheader: "Custom preview" + ) { text "hi" }.message + + html = msg.html_part.body.decoded + assert_includes html, "Custom preview" + end + + def test_build_message_falls_back_to_config_default_preheader + GoodmailTestConfig.configure(default_preheader: "Default preview") + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "Subj" + ) { text "hi" }.message + + assert_includes msg.html_part.body.decoded, "Default preview" + end + + def test_build_message_falls_back_to_subject_when_no_preheader_anywhere + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "Last-Resort Preview" + ) { text "hi" }.message + + # The subject lands in <title>, in <meta itemprop="name">, AND in the + # hidden preheader span. + assert_operator msg.html_part.body.decoded.scan("Last-Resort Preview").length, :>=, 2 + end + + def test_build_message_passes_DSL_attachments_through_to_the_mailer + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "S" + ) do + text "see attached" + attach "receipt.pdf", "PDF_BYTES", mime_type: "application/pdf" + end.message + + pdf = msg.attachments.find { |a| a.filename == "receipt.pdf" } + refute_nil pdf + refute pdf.inline? + end + + def test_build_message_handles_inline_image_attachments_and_pins_their_CIDs + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "S" + ) do + inline_image "logo.png", "PNG_BYTES", alt: "Logo" + end.message + + logo = msg.attachments.find { |a| a.filename == "logo.png" } + refute_nil logo + assert logo.inline? + assert_match(/\A<[0-9a-f]{24}\.logo\.png@inline\.goodmail\.invalid>\z/, logo.content_id) + end + + def test_build_message_calls_the_block_in_Builder_context + # The block uses `instance_eval` on the Builder, so DSL methods like + # `text` / `button` are reachable as bare identifiers. This test + # confirms that side-effects (raising an error) propagate cleanly. + error = assert_raises(RuntimeError) do + Goodmail::Dispatcher.build_message(to: "u@x.co", from: "n@x.co", subject: "S") do + text "before" + raise "boom from inside the block" + end + end + assert_match(/boom from inside the block/, error.message) + end + + def test_build_message_renders_the_body_as_a_complete_HTML_document + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "Doc" + ) { text "hello" }.message + + html = msg.html_part.body.decoded + assert html.include?("<!DOCTYPE html>"), "body should be a full HTML document" + assert_includes html, "<title>Doc" + end + + def test_build_message_emits_a_text_part_alongside_the_html_part + msg = Goodmail::Dispatcher.build_message( + to: "u@x.co", from: "n@x.co", subject: "S" + ) { text "hello world" }.message + + refute_nil msg.text_part + refute_nil msg.html_part + assert_includes msg.text_part.body.decoded, "hello world" + end + + # ── header slicing (the only private surface the dispatcher exposes + # via `private` — we use `send` because covering the slicing in + # isolation makes regressions immediately localizable). ───────────── + + def test_slice_mail_headers_keeps_ActionMailer_headers_and_strips_only_Goodmail_render_keys + # Rails' `mail` API accepts the common envelope fields AND arbitrary + # message headers. Goodmail should only remove its own render-only + # options, then let Action Mailer do the normal header assignment. + sliced = Goodmail::Dispatcher.send(:slice_mail_headers, { + to: "a", from: "b", cc: "c", bcc: "d", reply_to: "e", subject: "S", + date: Time.utc(2026, 5, 21), "X-Custom" => "kept", + unsubscribe_url: "u", preheader: "p", layout_path: "/dev/null", random: 1 + }) + assert_equal "a", sliced[:to] + assert_equal "kept", sliced["X-Custom"] + assert_equal 1, sliced[:random] + assert_equal Time.utc(2026, 5, 21), sliced[:date] + refute sliced.key?(:unsubscribe_url) + refute sliced.key?(:preheader) + refute sliced.key?(:layout_path) + end + + def test_slice_mail_headers_returns_an_empty_hash_when_nothing_matches + sliced = Goodmail::Dispatcher.send(:slice_mail_headers, { unsubscribe_url: "u" }) + assert_equal({}, sliced) + end +end diff --git a/test/email_test.rb b/test/email_test.rb new file mode 100644 index 0000000..75d4a9b --- /dev/null +++ b/test/email_test.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require "test_helper" +require "tempfile" + +# Tests for `Goodmail::EmailParts` (the data struct) and `Goodmail.render` +# (the entry point that composes Builder + Layout + Premailer and returns +# the parts struct, ready for a custom mailer to call `mail()` itself). +# +# `Goodmail.render` is the recommended path for apps that want to integrate +# with Devise / Pay / org-invite mailers without giving up control over +# the mail object — `Goodmail.compose` is for one-shot use cases. +class EmailTest < Minitest::Test + # ── EmailParts struct ─────────────────────────────────────────────── + + def test_EmailParts_accepts_keyword_init + parts = Goodmail::EmailParts.new(html: "

h

", text: "t", attachments: [{ filename: "x" }]) + assert_equal "

h

", parts.html + assert_equal "t", parts.text + assert_equal [{ filename: "x" }], parts.attachments + end + + def test_EmailParts_attachments_defaults_to_empty_array_when_omitted + # Backwards-compat hook: 0.3.x callers that pre-date the attachments + # field still get a workable struct without having to know about it. + parts = Goodmail::EmailParts.new(html: "

h

", text: "t") + assert_equal [], parts.attachments + end + + def test_EmailParts_coerces_explicit_nil_attachments_to_empty_array + parts = Goodmail::EmailParts.new(html: "

h

", text: "t", attachments: nil) + assert_equal [], parts.attachments + end + + def test_EmailParts_html_and_text_default_to_nil + parts = Goodmail::EmailParts.new + assert_nil parts.html + assert_nil parts.text + assert_equal [], parts.attachments + end + + # ── Goodmail.render — happy path ──────────────────────────────────── + + def test_render_returns_an_EmailParts_instance + parts = Goodmail.render(subject: "Subj") { text "hello" } + assert_kind_of Goodmail::EmailParts, parts + assert_kind_of String, parts.html + assert_kind_of String, parts.text + assert_kind_of Array, parts.attachments + end + + def test_render_inlines_styles_via_premailer + parts = Goodmail.render(subject: "Subj") { text "hello" } + # Premailer pushes the layout's