goodmail 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97d19566a8e8eacb10fed28cddfbe7c9f254ae6f4df4b0c2a8ad5259d646631d
4
- data.tar.gz: a15578a0b463e3372ffeff6f9d0a2b0aaa3754c0cf604cc574a97fb0506545fe
3
+ metadata.gz: 49fc7b9412bf9c59d6f4369f8a5ba26f7faaeffd982e98684dd294728254f1e9
4
+ data.tar.gz: 858a30035cba78799ee8b133d3531e531e952658d9270f35a80ed1fd9cf982ea
5
5
  SHA512:
6
- metadata.gz: 8ec18e8bc6a78545259c5a359b1811fa7df18cccdca0df36761b94db25304ea9f894ae85cbddbeb54c674526ad7eb5c5a80e24767f7bd97d23baa692ab02aa5f
7
- data.tar.gz: d384ec22ed4107cb20c73deff523e15b3d5dcaf7c3d5a464030024336fde15e6346f5f02f90673e23d6790e085db70782b2a2e0df783dbd7db8ad23908b8e3ff
6
+ metadata.gz: 74d2b6dd4665aa63b5394d715d043193d03e64f0130913442f596db2663d546386bb06ea4fb6940966932877a09751b53972e95bbff619ec0d994fbcd29fc9af
7
+ data.tar.gz: 8469b9ea99e5de6b53f6c31c4edfb53c4eaee37eb5bf557351a9576456f5907b4fc032f3cb916961c595063f2449584d63ec038c5bc903ab21c2a8ab495fe791
data/.rubocop.yml ADDED
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ plugins:
4
+ - rubocop-minitest
5
+ - rubocop-performance
6
+
7
+ AllCops:
8
+ TargetRubyVersion: 3.1
9
+ NewCops: enable
10
+ SuggestExtensions: false
11
+ Exclude:
12
+ - 'bin/**/*'
13
+ - 'examples/**/*'
14
+ - 'coverage/**/*'
15
+ - 'pkg/**/*'
16
+ - 'test/**/*'
17
+ - 'vendor/**/*'
18
+ - 'test/dummy/**/*'
19
+ - 'db/migrate/**/*' # Generated migrations
20
+ - 'lib/generators/**/templates/**/*' # Generator templates
21
+
22
+ # Layout & Formatting
23
+ Layout/LineLength:
24
+ Max: 120
25
+ AllowedPatterns:
26
+ - '\s*#.*' # Allow long comments
27
+ - '^\s*raise\s' # Allow long raise statements
28
+
29
+ Layout/MultilineMethodCallIndentation:
30
+ EnforcedStyle: indented
31
+
32
+ Layout/ArgumentAlignment:
33
+ EnforcedStyle: with_first_argument
34
+
35
+ Layout/FirstArgumentIndentation:
36
+ EnforcedStyle: consistent
37
+
38
+ # Style
39
+ Style/Documentation:
40
+ Enabled: false # Don't require class documentation for now
41
+
42
+ Style/StringLiterals:
43
+ EnforcedStyle: double_quotes
44
+
45
+ Style/FrozenStringLiteralComment:
46
+ Enabled: true
47
+ EnforcedStyle: always
48
+
49
+ Style/ClassAndModuleChildren:
50
+ EnforcedStyle: nested
51
+
52
+ Style/GuardClause:
53
+ MinBodyLength: 3
54
+
55
+ # Metrics
56
+ Metrics/ClassLength:
57
+ Max: 220
58
+
59
+ Metrics/ModuleLength:
60
+ Max: 260
61
+
62
+ Metrics/MethodLength:
63
+ Max: 40
64
+ AllowedMethods:
65
+ - 'configure' # Configuration blocks can be longer
66
+
67
+ Metrics/BlockLength:
68
+ Max: 40
69
+ AllowedMethods:
70
+ - 'configure'
71
+ - 'describe'
72
+ - 'context'
73
+ - 'it'
74
+ - 'test'
75
+ Exclude:
76
+ - 'test/**/*' # Allow long test blocks
77
+ - 'goodmail.gemspec'
78
+
79
+ Metrics/AbcSize:
80
+ Max: 50
81
+ AllowedMethods:
82
+ - 'configure'
83
+
84
+ Metrics/CyclomaticComplexity:
85
+ Max: 10
86
+
87
+ Metrics/PerceivedComplexity:
88
+ Max: 10
89
+
90
+ Metrics/ParameterLists:
91
+ Max: 6
92
+
93
+ # Naming
94
+ Naming/PredicatePrefix:
95
+ ForbiddenPrefixes:
96
+ - 'is_'
97
+ AllowedMethods:
98
+ - 'is_a?'
99
+
100
+ Naming/MethodParameterName:
101
+ MinNameLength: 1
102
+
103
+ Naming/BlockForwarding:
104
+ Enabled: false
105
+
106
+ # Performance
107
+ Performance/StringReplacement:
108
+ Enabled: true
109
+
110
+ Performance/RedundantMerge:
111
+ Enabled: true
112
+
113
+ # Minitest
114
+ Minitest/MultipleAssertions:
115
+ Enabled: false # Allow multiple assertions in integration tests
116
+
117
+ Minitest/AssertTruthy:
118
+ Enabled: false # Allow assert instead of assert_equal true
119
+
120
+ # Custom overrides for this gem
121
+ Style/AccessorGrouping:
122
+ Enabled: false # Allow separate attr_reader/attr_writer
123
+
124
+ Style/MutableConstant:
125
+ Enabled: false # We have some intentionally mutable constants
126
+
127
+ Style/Alias:
128
+ Enabled: false
129
+
130
+ Style/ArgumentsForwarding:
131
+ Enabled: false
132
+
133
+ Style/ConditionalAssignment:
134
+ Enabled: false
135
+
136
+ Style/IfUnlessModifier:
137
+ Enabled: false
138
+
139
+ Style/ModuleFunction:
140
+ Enabled: false
141
+
142
+ Style/OpenStructUse:
143
+ Enabled: false
144
+
145
+ Style/PercentLiteralDelimiters:
146
+ Enabled: false
147
+
148
+ Style/RedundantRegexpArgument:
149
+ Enabled: false
150
+
151
+ Style/RegexpLiteral:
152
+ Enabled: false
153
+
154
+ Style/RescueStandardError:
155
+ Enabled: false
156
+
157
+ Style/StringLiteralsInInterpolation:
158
+ Enabled: false
159
+
160
+ Style/Next:
161
+ Enabled: false
162
+
163
+ # Allow class variables for registry pattern
164
+ Style/ClassVars:
165
+ Enabled: false
166
+
167
+ # Allow metaprogramming patterns common in Rails engines
168
+ Style/EvalWithLocation:
169
+ Enabled: false
170
+
171
+ Lint/MissingSuper:
172
+ Enabled: false # Allow classes that don't call super
173
+
174
+ # Disable some cops that don't work well with our DSL
175
+ Style/MethodCallWithoutArgsParentheses:
176
+ Enabled: false # Our DSL looks better without parens
177
+
178
+ Layout/EmptyLineAfterMagicComment:
179
+ Enabled: false
180
+
181
+ Layout/EmptyLinesAfterModuleInclusion:
182
+ Enabled: false
183
+
184
+ Layout/HashAlignment:
185
+ Enabled: false
186
+
187
+ Layout/CommentIndentation:
188
+ Enabled: false
189
+
190
+ Lint/UselessConstantScoping:
191
+ Enabled: false
192
+
193
+ # Thread safety
194
+ Style/GlobalVars:
195
+ AllowedVariables: ['$0'] # Only allow program name
196
+
197
+ # Database-related
198
+ Style/NumericLiterals:
199
+ Enabled: false # Allow raw numbers in database IDs/amounts
data/.simplecov ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SimpleCov configuration file, loaded by test/test_helper.rb when
4
+ # COVERAGE=1 is set. Normal test runs skip coverage instrumentation.
5
+
6
+ SimpleCov.start do
7
+ # Use SimpleFormatter for terminal-only output (no HTML generation)
8
+ formatter SimpleCov::Formatter::SimpleFormatter
9
+ use_merging false
10
+
11
+ # Track coverage for the lib directory (gem source code)
12
+ add_filter "/test/"
13
+
14
+ # `lib/goodmail/version.rb` is loaded by Bundler (via the gemspec
15
+ # `require_relative "lib/goodmail/version"`) BEFORE SimpleCov starts,
16
+ # so its lines never get instrumented even though they're executed.
17
+ # Excluding it keeps the report honest. Goodmail::VERSION's shape is
18
+ # asserted in `test/goodmail_module_test.rb` instead.
19
+ add_filter "/lib/goodmail/version.rb"
20
+
21
+ # Track Ruby files in lib directory
22
+ track_files "lib/**/*.rb"
23
+
24
+ # Enable branch coverage for more detailed metrics
25
+ enable_coverage :branch
26
+
27
+ # Set minimum coverage threshold to prevent coverage regression.
28
+ # Goodmail currently sits at 100% line coverage; the branch floor is set
29
+ # generously to allow non-trivial future additions without immediately
30
+ # tripping CI.
31
+ minimum_coverage line: 90, branch: 80
32
+
33
+ # Disambiguate parallel test runs
34
+ command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV["TEST_ENV_NUMBER"]
35
+ end
36
+
37
+ # Print coverage summary to terminal after tests complete
38
+ SimpleCov.at_exit do
39
+ SimpleCov.result.format!
40
+ if ENV["COVERAGE_DETAIL"]
41
+ SimpleCov.result.files.each do |file|
42
+ missed_lines = file.missed_lines.map(&:line_number)
43
+ next if missed_lines.empty?
44
+
45
+ puts "#{file.filename}:#{missed_lines.join(',')}"
46
+ end
47
+ end
48
+ puts "\n#{'=' * 60}"
49
+ puts "COVERAGE SUMMARY"
50
+ puts "=" * 60
51
+ puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
52
+ branch_coverage = SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || "N/A"
53
+ puts "Branch Coverage: #{branch_coverage}%"
54
+ puts "=" * 60
55
+ end
data/CHANGELOG.md CHANGED
@@ -1,3 +1,79 @@
1
+ ## [0.4.0] - 2026-05-08
2
+
3
+ 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.
4
+
5
+ ### Added
6
+
7
+ #### DSL helpers
8
+ - **`link(text, url)`** — inline styled text link rendered in the configured `brand_color`. Cleaner than hand-writing `<a>` tags inside `text` blocks when the whole paragraph is the link.
9
+ - **`small(text)`** — small grey paragraph for fine print, legal disclaimers, and "you are receiving this because…" footers. Sanitization mirrors `text` (allows `<a>` / `<strong>` / `<em>` / `<b>` / `<i>`).
10
+ - **`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.
11
+ - **`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.
12
+ - **`inline_image(filename, content, alt:, width:, height:, mime_type:)`** — convenience helper that registers an inline-disposition attachment AND emits the matching `<img src="cid:...">` 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.
13
+
14
+ #### Inline emphasis tags allowed in `text`
15
+ - `<strong>`, `<em>`, `<b>`, and `<i>` now pass through the `text(string)` sanitizer alongside `<a>`. Previously these were silently stripped, so copy like `text "<strong>Important:</strong> 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.
16
+
17
+ #### `Goodmail.render` parts struct
18
+ - `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.
19
+
20
+ #### Action Mailer integration helpers
21
+ - **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.
22
+ - **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.
23
+ - **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.
24
+
25
+ #### Test suite
26
+ - **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`.
27
+
28
+ ### Fixed
29
+
30
+ #### Deliverability + headers
31
+ - **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.
32
+
33
+ #### Plaintext quality
34
+ 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):
35
+
36
+ - **Preheader no longer leaks as a phantom first line.** The layout's hidden inbox-preview `<span style="display:none">` 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.
37
+ - **Button labels no longer appear twice.** `button` emits both a `<v:roundrect>` (Outlook VML, inside `<!--[if mso]>...<![endif]-->`) AND a regular `<a>`. Premailer ignores conditional comments and was extracting text from BOTH, so plaintext got the label twice (once bare from the VML's `<center>label</center>`, once with the URL from `<a href>label</a>`). The MSO conditional blocks are now stripped from the source HTML before plaintext extraction.
38
+ - **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.
39
+ - **`info_row` flattens to the conventional `Label: Value` shape in plaintext.** A two-cell `<table>` 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.
40
+
41
+ #### Encoding
42
+ - **HTML + plaintext: accented characters / Unicode no longer get double-encoded.** Premailer's libxml2 backend was defaulting to Latin-1 when no `<meta charset>` 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.
43
+
44
+ #### Inline images
45
+ - **`inline_image` now produces an `<img>` that actually renders.** Mail gem auto-generates a globally-unique Content-ID (`<longhash@host.tld.mail>`) for every attachment, but the DSL must write the `<img src="cid:...">` 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.
46
+ - **`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.
47
+ - **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).
48
+
49
+ #### Visual tweak
50
+ - **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).
51
+
52
+ ### Internal
53
+ - 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.
54
+ - 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.
55
+ - `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.
56
+ - `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.
57
+
58
+ ### Meta
59
+ - Standardized the testing scaffolding to match the convention shared with the surrounding Ruby gems:
60
+ - `.simplecov` config file (auto-loaded; SimpleFormatter, branch coverage enabled, minimum thresholds, custom at_exit summary).
61
+ - `Gemfile` with `:development` / `:development, :test` groups (minitest ~> 6.0, minitest-mock, minitest-reporters, simplecov, rubocop + rubocop-minitest + rubocop-performance).
62
+ - `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.
63
+ - `.rubocop.yml` shared cop set + thresholds (style/layout/metrics overrides matching the other gems).
64
+ - `.github/workflows/test.yml` matrix tests across Ruby 3.3, 3.4, and 4.0.
65
+ - `.github/workflows/claude.yml` and `.github/workflows/claude-code-review.yml` for Claude Code automation parity.
66
+ - `goodmail.gemspec` no longer carries development dependencies — they all live in the Gemfile groups, same as the sibling gems.
67
+
68
+ ## [0.3.1] - 2026-02-25
69
+ - Maintenance release. Documentation alignment (example mailers
70
+ follow the `Goodmailer` suffix convention) and a configuration
71
+ alias (`Goodmail.configuration` ≡ `Goodmail.config`). No public
72
+ API changes.
73
+
74
+ ## [0.3.0] - 2025-05-15
75
+ - Add Goodmail.render for custom mailers
76
+
1
77
  ## [0.2.0] - 2025-05-02
2
78
 
3
79
  ### Added
data/README.md CHANGED
@@ -9,7 +9,7 @@ Goodmail turns your ugly, default, text-only emails into SaaS-ready emails. It's
9
9
 
10
10
  You can easily add buttons, images, links, price lines, and text to your emails, and it'll look good everywhere, no styling needed.
11
11
 
12
- 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.
12
+ 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.
13
13
 
14
14
  (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!)
15
15
 
@@ -106,7 +106,7 @@ mail = Goodmail.compose(
106
106
  to: recipient.email,
107
107
  from: "'#{Goodmail.config.company_name} Support' <support@myapp.com>",
108
108
  subject: "Welcome to MyApp!",
109
- preheader: "Your adventure begins now!" # Optional override
109
+ preheader: "Your account is ready." # Optional override
110
110
  ) do
111
111
  h1 "Welcome aboard, #{recipient.name}!"
112
112
  text "We're thrilled to have you join the MyApp community."
@@ -134,6 +134,17 @@ mail.deliver_later
134
134
 
135
135
  *(Requires Active Job configured.)*
136
136
 
137
+ `Goodmail.compose` returns a normal `ActionMailer::MessageDelivery`. Pass the
138
+ same headers you would pass to Action Mailer's `mail()` (`reply_to:`,
139
+ `date:`, `return_path:`, custom `"X-..."` headers, delivery options, etc.);
140
+ Goodmail strips only its own render options before handing the message to
141
+ Action Mailer.
142
+
143
+ For real Rails mailer classes, prefer the auto-installed `goodmail_mail`
144
+ helper shown below. It keeps the work inside the mailer action, preserves
145
+ Action Mailer's lazy `MessageDelivery` / `deliver_later` model, and avoids
146
+ manual `Goodmail.render(..., context: self)` glue.
147
+
137
148
  ## Why does `goodmail` exist?
138
149
 
139
150
  Here's the problem: you can't just use standard HTML and CSS in mails.
@@ -162,77 +173,169 @@ Inside the `Goodmail.compose` block, you have access to these methods:
162
173
 
163
174
  * `h1(text)`, `h2(text)`, `h3(text)`: Styled heading tags.
164
175
  * `text(string)`: A paragraph of text. Allows simple inline `<a>` tags with `href` attributes; other HTML is stripped for safety. Handles `\n` for line breaks.
176
+ * `link(link_text, url)`: An inline styled link, rendered in the configured `brand_color`. Cleaner than hand-writing `<a>` tags inside `text` blocks when the whole paragraph is the link.
177
+ * `small(string)`: A small grey paragraph for fine print, legal disclaimers and "you are receiving this because…" footers.
165
178
  * `button(link_text, url)`: A prominent, styled call-to-action button (includes Outlook VML fallback).
166
179
  * `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.
180
+ * `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.
181
+ * `inline_image(filename, content, alt: "", width: nil, height: nil, mime_type: nil)`: Convenience helper that registers an inline-disposition attachment AND emits the matching `<img src="cid:...">` 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.
167
182
  * `space(pixels = 16)`: Adds vertical whitespace.
168
183
  * `line`: Adds a horizontal rule (`<hr>`).
169
184
  * `center { ... }`: Centers the content generated within the block.
170
185
  * `code_box(text)`: Displays text centered and bold within a styled box (grey background, padding, italic). Text is HTML-escaped.
171
- * `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.
186
+ * `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.
187
+ * `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.
172
188
  * `sign(name = Goodmail.config.company_name)`: Adds a standard closing signature line.
173
189
  * `html(raw_html_string)`: **Use with extreme caution.** Allows embedding raw, *un-sanitized* HTML.
174
190
 
191
+ ### Rails Mailers: Use `goodmail_mail`
192
+
193
+ When Goodmail is loaded, Rails mailers get three private helpers automatically:
194
+ `goodmail_mail` for the common render-and-send path, and
195
+ `goodmail_render_parts` + `goodmail_mail_parts` when you need to render first.
196
+
197
+ ```ruby
198
+ # In your custom mailer, including framework overrides such as Devise or Pay
199
+
200
+ # Define your headers (to, from, subject, etc.)
201
+ # You can pass :unsubscribe_url, :preheader, :locale, :config /
202
+ # :configuration, and :layout_path in the same hash. Goodmail uses them for
203
+ # rendering and strips them before calling Action Mailer's mail().
204
+ class NotificationMailer < ApplicationMailer
205
+ def important_update(recipient)
206
+ details_url = view_details_url(recipient)
207
+
208
+ goodmail_mail(
209
+ to: recipient.email,
210
+ from: "notifications@myapp.com",
211
+ subject: "Important Update for #{recipient.name}",
212
+ unsubscribe_url: custom_unsubscribe_url_for_user(recipient), # Optional
213
+ preheader: "A quick update you should see." # Optional
214
+ ) do
215
+ h1 "Hello, #{recipient.name}!"
216
+ text "This is an important update regarding your account."
217
+ button "View Details", details_url
218
+ sign "The MyApp Team"
219
+ end
220
+ end
221
+ end
222
+ ```
223
+
224
+ `goodmail_mail` renders the DSL, strips Goodmail-only keys before calling
225
+ Action Mailer's `mail()`, applies `attach` / `inline_image` parts, pins inline
226
+ Content-IDs, and adds the correct `List-Unsubscribe` headers.
227
+ Any normal Action Mailer header you pass (`date:`, `return_path:`,
228
+ `delivery_method:`, `"X-Custom"`, etc.) is forwarded to `mail()`; only
229
+ Goodmail render options such as `preheader:`, `unsubscribe_url:`, `locale:`,
230
+ `context:`, `config:` / `configuration:`, and `layout_path:` are removed from
231
+ the wire headers.
232
+ The block keeps normal mailer context: instance variables and private mailer
233
+ helpers are available, and `locale:` wraps the DSL block in `I18n.with_locale`.
234
+
235
+ Framework mailers can also pass their native header hash directly. For example,
236
+ a Devise override can keep Devise's own `headers_for(...)` output as the single
237
+ source of truth and let Goodmail handle only the body/render mechanics:
238
+
239
+ ```ruby
240
+ class DeviseGoodmailer < Devise::Mailer
241
+ def confirmation_instructions(record, token, opts = {})
242
+ @token = token
243
+ initialize_from_record(record)
244
+
245
+ goodmail_mail(headers_for(:confirmation_instructions, opts), locale: record.locale) do
246
+ text "Confirm your account below."
247
+ button "Confirm my account", confirmation_url(record, confirmation_token: token)
248
+ sign
249
+ end
250
+ end
251
+ end
252
+ ```
253
+
175
254
  ### Advanced: Rendering Email Parts with `Goodmail.render`
176
255
 
177
- 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.
256
+ For advanced use cases where you need direct access to the generated HTML and
257
+ plain text parts before sending, Goodmail provides the `Goodmail.render` method.
178
258
 
179
- 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.
259
+ 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.
180
260
 
181
- The `Goodmail::EmailParts` struct (defined in `goodmail/email.rb`) has two attributes:
261
+ The `Goodmail::EmailParts` struct (defined in `goodmail/email.rb`) has three attributes:
182
262
  * `html`: The final, inlined HTML content for your email.
183
263
  * `text`: The cleaned-up plain text version of your email.
264
+ * `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.
184
265
 
185
- **How to use it:**
186
-
187
- You can then use these parts within any Action Mailer setup:
266
+ If you truly need to render first because another step must inspect or mutate
267
+ the generated parts before sending, use the lower-level helpers:
188
268
 
189
269
  ```ruby
190
- # In your custom mailer (e.g., a Devise mailer override)
270
+ class CustomMailer < ApplicationMailer
271
+ def custom_message(recipient)
272
+ render_options = {
273
+ subject: "Important Update",
274
+ unsubscribe_url: custom_unsubscribe_url_for_user(recipient),
275
+ preheader: "A quick update you should see."
276
+ }
277
+
278
+ parts = goodmail_render_parts(render_options) do
279
+ text "Rendered separately."
280
+ inline_image "logo.png", logo_bytes, mime_type: "image/png"
281
+ end
191
282
 
192
- # Define your headers (to, from, subject, etc.)
193
- # The :subject is crucial for Goodmail.render.
194
- # You can also pass :unsubscribe_url and :preheader to Goodmail.render
195
- # to override global configurations for that specific email.
196
- # Note: these Goodmail-specific keys will be used by Goodmail.render
197
- # and should not be passed directly to ActionMailer's mail() method
198
- # if they are not standard mail headers.
199
- mail_rendering_headers = {
200
- to: recipient.email,
201
- from: "notifications@myapp.com",
202
- subject: "Important Update for #{recipient.name}",
203
- unsubscribe_url: custom_unsubscribe_url_for_user(recipient), # Optional
204
- preheader: "A quick update you should see." # Optional
205
- }
206
-
207
- # Render the email parts using Goodmail's DSL
208
- # Goodmail.render will use :subject, :unsubscribe_url, :preheader internally.
209
- parts = Goodmail.render(mail_rendering_headers) do
210
- h1 "Hello, #{recipient.name}!"
211
- text "This is an important update regarding your account."
212
- button "View Details", view_details_url(recipient)
213
- sign "The MyApp Team"
283
+ goodmail_mail_parts(
284
+ parts,
285
+ to: recipient.email,
286
+ from: "notifications@myapp.com",
287
+ subject: render_options[:subject],
288
+ unsubscribe_url: render_options[:unsubscribe_url]
289
+ )
290
+ end
214
291
  end
292
+ ```
215
293
 
216
- # Prepare headers for ActionMailer's mail() method,
217
- # ensuring only standard mail headers are passed.
218
- action_mailer_headers = mail_rendering_headers.slice(:to, :from, :subject, :cc, :bcc, :reply_to)
294
+ **Key Differences from `Goodmail.compose`:**
219
295
 
220
- # Now use these parts in ActionMailer's mail method
221
- # You might also want to add the List-Unsubscribe header manually here if needed.
222
- final_mail_object = mail(action_mailer_headers) do |format|
223
- format.html { render html: parts.html.html_safe }
224
- format.text { render plain: parts.text }
225
- end
296
+ * **Return Value**: `Goodmail.render` returns an instance of `Goodmail::EmailParts` (e.g., `EmailParts.new(html: "...", text: "...")`). `Goodmail.compose` returns an `ActionMailer::MessageDelivery`.
297
+ * **Purpose**: `Goodmail.render` is primarily for generating and retrieving processed email content parts. `Goodmail.compose` is for generating a complete, deliverable Action Mailer message.
298
+ * **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.
299
+ * **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.
300
+ * **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.
301
+ * **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.
302
+
303
+ ### Integrating with the Pay Gem
304
+
305
+ 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.).
306
+
307
+ 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.
226
308
 
227
- # The `final_mail_object` returned by ActionMailer can then be delivered:
228
- # final_mail_object.deliver_now or final_mail_object.deliver_later
309
+ 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.
310
+
311
+ **Quick Setup:**
312
+
313
+ 1. Copy the example mailer from [`examples/pay_goodmailer.rb`](examples/pay_goodmailer.rb) to your Rails app at `app/mailers/pay_goodmailer.rb`
314
+
315
+ 2. Configure Pay to use the custom mailer in `config/initializers/pay.rb`:
316
+
317
+ ```ruby
318
+ Pay.setup do |config|
319
+ config.parent_mailer = "ApplicationMailer"
320
+ config.mailer = "PayGoodmailer"
321
+ # ... other Pay configuration
322
+ end
229
323
  ```
230
324
 
231
- **Key Differences from `Goodmail.compose`:**
325
+ 3. Customize the email content and URLs in the mailer to match your app
232
326
 
233
- * **Return Value**: `Goodmail.render` returns an instance of `Goodmail::EmailParts` (e.g., `EmailParts.new(html: "...", text: "...")`). `Goodmail.compose` returns a `Mail::Message` object.
234
- * **Purpose**: `Goodmail.render` is primarily for generating and retrieving processed email content parts. `Goodmail.compose` is for generating a complete, deliverable `Mail::Message` object.
235
- * **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.
327
+ The example implementation includes all Pay notification types:
328
+ - `receipt` - Payment receipts
329
+ - `refund` - Refund confirmations
330
+ - `subscription_renewing` - Renewal reminders
331
+ - `payment_action_required` - Payment action needed
332
+ - `subscription_trial_will_end` - Trial ending soon
333
+ - `subscription_trial_ended` - Trial has ended
334
+ - `payment_failed` - Failed payment alerts
335
+
336
+ Each method uses `goodmail_mail(pay_mail_arguments, ...)` so Pay-specific
337
+ recipient/header setup stays in the app while Goodmail owns rendering,
338
+ attachments, multipart assembly, and unsubscribe headers.
236
339
 
237
340
  ### Adding Unsubscribe Functionality
238
341
 
@@ -249,7 +352,7 @@ Goodmail helps you add the `List-Unsubscribe` header and an optional visible lin
249
352
  # ... other headers ...
250
353
  ) do # ...
251
354
  ```
252
- *If an `unsubscribe_url` is provided, Goodmail adds the `List-Unsubscribe` header.*
355
+ *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.*
253
356
 
254
357
  2. **Optionally Show Footer Link:**
255
358
  * Set `config.show_footer_unsubscribe_link = true`.
@@ -268,7 +371,9 @@ Goodmail helps you add the `List-Unsubscribe` header and an optional visible lin
268
371
 
269
372
  ## Development
270
373
 
271
- 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.
374
+ 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.
375
+
376
+ 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.
272
377
 
273
378
  To install this gem onto your local machine, run `bundle exec rake install`.
274
379
 
data/Rakefile CHANGED
@@ -1,4 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
- task default: %i[]
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ # Silence noisy stdlib warnings (e.g. ostruct deprecation notice in
11
+ # Ruby 3.4) so the run output stays focused on real test failures.
12
+ t.warning = false
13
+ end
14
+
15
+ task default: :test
data/context7.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/goodmail",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }