goodmail 0.3.1 → 0.4.1

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.
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+ require "premailer"
3
+ require "nokogiri"
4
+
5
+ module Goodmail
6
+ # Plain-text generator for the `text/plain` part of every Goodmail
7
+ # multipart message. Single source of truth shared by
8
+ # `Goodmail::Email.render` and `Goodmail::Mailer#compose_message` —
9
+ # before this consolidation, both code paths had a copy of the
10
+ # same gsub cleanup pipeline and the same Premailer call, which
11
+ # made it easy for plaintext quality to drift between them.
12
+ #
13
+ # Why we DO NOT just hand the raw HTML to Premailer:
14
+ # ────────────────────────────────────────────────────────────────
15
+ # The Goodmail layout is built for HTML email clients that
16
+ # respect display:none, conditional comments, and inline alt
17
+ # attributes — none of which Premailer's `to_plain_text` honors.
18
+ # If we feed it the layout HTML directly, the plaintext part has
19
+ # three artifacts that look like rendering bugs to a recipient
20
+ # using a text-only client:
21
+ #
22
+ # 1. Preheader leak — the layout's hidden inbox-preview
23
+ # `<span style="display:none">` is rendered as a phantom
24
+ # first line of the message body.
25
+ # 2. Button text duplication — the `button` DSL helper emits
26
+ # both a `<v:roundrect>` (Outlook VML, wrapped in
27
+ # `<!--[if mso]>...<![endif]-->`) AND a regular `<a>`.
28
+ # Premailer ignores the conditional comment and extracts
29
+ # text from BOTH, so the button label appears twice in
30
+ # plaintext.
31
+ # 3. Image alt-text leak — `image` / `inline_image` calls
32
+ # with no explicit alt fall back to `config.company_name`.
33
+ # That alt is fine in HTML (screen readers read it) but
34
+ # shows up as a stray "CompanyName" line in plaintext
35
+ # since Premailer extracts alt attributes verbatim.
36
+ #
37
+ # We pre-process the HTML to neutralize each of these BEFORE
38
+ # plaintext extraction, then apply a small post-extraction
39
+ # cleanup pass for the residual artifacts (logo alt line,
40
+ # blank-line compaction).
41
+ #
42
+ # Sources:
43
+ # - Premailer to_plain_text:
44
+ # https://github.com/premailer/premailer/blob/master/lib/premailer/premailer.rb
45
+ # - MSO conditional comments syntax:
46
+ # https://www.litmus.com/blog/a-guide-to-rendering-differences-in-microsoft-outlook-clients
47
+ module Plaintext
48
+ extend self
49
+
50
+ # Matches the `<!--[if mso]>...<![endif]-->` blocks Outlook reads
51
+ # exclusively. The `m` flag lets `.*?` span newlines (these blocks
52
+ # are usually multi-line). The non-greedy quantifier ensures we
53
+ # don't eat past the first matching `<![endif]-->`.
54
+ MSO_CONDITIONAL_BLOCK = /<!--\[if mso\]>.*?<!\[endif\]-->/m
55
+
56
+ # Generates the plaintext part for a multipart message.
57
+ #
58
+ # @param raw_html [String] The full layout-rendered HTML body.
59
+ # @param preheader [String, nil] The preheader text (the value
60
+ # we wrote into the hidden inbox-preview span). Passed in so
61
+ # we can strip it specifically from plaintext rather than
62
+ # guessing at a generic heuristic.
63
+ # @return [String] The cleaned plaintext, ready for the text/plain
64
+ # part of the outgoing message.
65
+ def generate(raw_html, preheader: nil)
66
+ premailer_html = strip_mso_only_markup(raw_html)
67
+ premailer = Premailer.new(
68
+ premailer_html,
69
+ with_html_string: true,
70
+ adapter: :nokogiri,
71
+ preserve_styles: false,
72
+ remove_ids: true,
73
+ remove_comments: false,
74
+ # Goodmail outputs UTF-8 end-to-end. Without this, Premailer's
75
+ # libxml2 backend defaults to Latin-1 when no `<meta charset>`
76
+ # tag is present in the source, double-encoding every accented
77
+ # character ("Duración" → "Duración", "€" → "â¬"). The shipped
78
+ # layout DOES include the meta tag, but custom `layout_path:`
79
+ # callers might not — pinning here makes us robust to either.
80
+ input_encoding: "UTF-8"
81
+ )
82
+ text = premailer.to_plain_text
83
+
84
+ text = strip_preheader_line(text, preheader)
85
+ text = strip_logo_alt_line(text)
86
+ text = strip_company_name_alt_line(text)
87
+ text = compact_blank_lines(text)
88
+ text.strip
89
+ end
90
+
91
+ private
92
+
93
+ # Removes everything Outlook-only from the source HTML before it's
94
+ # handed to Premailer's plaintext extractor:
95
+ #
96
+ # - The `<!--[if mso]>...<![endif]-->` blocks themselves (Premailer
97
+ # doesn't honor conditional comments and would otherwise extract
98
+ # text from the VML button INSIDE the block — duplicating every
99
+ # button label in plaintext).
100
+ # - The hidden preheader span (display:none in HTML, but Premailer
101
+ # ignores CSS visibility and would otherwise emit the preheader
102
+ # as a phantom first line).
103
+ def strip_mso_only_markup(html)
104
+ cleaned = html.gsub(MSO_CONDITIONAL_BLOCK, "")
105
+ cleaned = strip_hidden_preheader(cleaned)
106
+ flatten_info_rows(cleaned)
107
+ end
108
+
109
+ # Replaces every `<table class="goodmail-info-row">` (the markup
110
+ # `Builder#info_row` emits) with a single-line `Label: Value`
111
+ # paragraph. Two-cell tables otherwise extract as two separate
112
+ # lines (Premailer renders each `<td>` on its own line) — the
113
+ # colon-form is the conventional plaintext shape every modern
114
+ # transactional sender uses for label/value pairs.
115
+ #
116
+ # Why we Nokogiri-parse rather than regex-match: tables can be
117
+ # nested inside other layout chrome (the layout's `.main` table,
118
+ # the content-wrap cell, the data-row table itself). Trying to
119
+ # match nested tables with a regex is the canonical case study
120
+ # for "don't parse HTML with regex".
121
+ #
122
+ # We parse as a FULL DOCUMENT, not a fragment. `Nokogiri::HTML.fragment`
123
+ # would strip the `<head>` wrapper and expose the `<title>` text as
124
+ # body content — Premailer's plaintext extractor would then leak the
125
+ # subject as a phantom first line.
126
+ #
127
+ # We pin the encoding to UTF-8 explicitly. Without that, Nokogiri's
128
+ # libxml2 backend falls back to Latin-1 when no `<meta charset>` tag
129
+ # is present, which mangles every accented character in the layout
130
+ # ("Duración" → "Duración", "€" → "€"). The shipped layout
131
+ # DOES declare `<meta http-equiv="Content-Type" content=".../UTF-8">`,
132
+ # but downstream apps may use a custom `layout_path:` that doesn't,
133
+ # and the API contract is "Goodmail outputs UTF-8 end-to-end" — so
134
+ # we don't depend on the meta tag for correctness.
135
+ # Source: https://nokogiri.org/rdoc/Nokogiri/HTML4.html#method-c-parse
136
+ def flatten_info_rows(html)
137
+ doc = Nokogiri::HTML.parse(html, nil, "UTF-8")
138
+ doc.css("table.goodmail-info-row").each do |table|
139
+ cells = table.css("td")
140
+ next if cells.length < 2
141
+
142
+ label = cells[0].text.strip
143
+ value = cells[1].text.strip
144
+ replacement = Nokogiri::XML::Node.new("p", doc)
145
+ replacement.content = "#{label}: #{value}"
146
+ table.replace(replacement)
147
+ end
148
+ doc.to_html
149
+ rescue StandardError => e
150
+ # If Nokogiri ever chokes (truncated HTML, malformed input from a
151
+ # custom layout), preserve the original behavior — the table
152
+ # cells still get emitted as two lines, which is ugly but not
153
+ # broken. We only log so the failure is visible without crashing
154
+ # the whole email pipeline.
155
+ warn "[Goodmail::Plaintext] info-row flatten failed: #{e.class}: #{e.message}"
156
+ html
157
+ end
158
+
159
+ # The layout emits the preheader inside a `<span>` with a strong
160
+ # signature: `display:none !important; font-size:1px; color:#ffffff;
161
+ # line-height:1px; ...`. We use a regex anchored on `display:none`
162
+ # AND `font-size:1px` to avoid stripping any legit hidden span a
163
+ # downstream caller might emit via the raw `html` DSL helper.
164
+ #
165
+ # The non-greedy `.*?` between the opening tag and `</span>`, plus
166
+ # the `m` flag, lets the match span the whitespace and the
167
+ # interpolated preheader text inside the tag.
168
+ HIDDEN_PREHEADER_SPAN = /
169
+ <span\s[^>]*
170
+ style="[^"]*
171
+ display:\s*none[^"]*
172
+ font-size:\s*1px[^"]*
173
+ "[^>]*>
174
+ .*?
175
+ <\/span>
176
+ /xm
177
+
178
+ def strip_hidden_preheader(html)
179
+ html.gsub(HIDDEN_PREHEADER_SPAN, "")
180
+ end
181
+
182
+ # Belt-and-suspenders for the preheader: if the caller passed an
183
+ # explicit preheader and it happens to land at the top of the
184
+ # plaintext anyway (e.g. a custom layout that doesn't use the
185
+ # hidden-span pattern, or a preheader that ALSO appears as visible
186
+ # body content), strip the leading occurrence so the plaintext
187
+ # doesn't open with a duplicate.
188
+ def strip_preheader_line(text, preheader)
189
+ return text if preheader.to_s.strip.empty?
190
+
191
+ escaped = Regexp.escape(preheader.to_s.strip)
192
+ text.sub(/\A\s*#{escaped}\s*\n+/, "")
193
+ end
194
+
195
+ # Removes the historical "CompanyName Logo (https://company.url/...)"
196
+ # line generated by the layout's clickable header logo. The opening
197
+ # `<a href=...><img alt="CompanyName Logo">...</a>` extracts as
198
+ # `CompanyName Logo ( https://... )` in plaintext.
199
+ def strip_logo_alt_line(text)
200
+ return text unless Goodmail.config.logo_url.present? &&
201
+ Goodmail.config.company_url.present? &&
202
+ Goodmail.config.company_name.present?
203
+
204
+ company_name = Regexp.escape(Goodmail.config.company_name)
205
+ company_url = Regexp.escape(Goodmail.config.company_url)
206
+ pattern = /^\s*#{company_name}\s+Logo\s*\(.*?#{company_url}.*?\).*\n?/i
207
+ text.gsub(pattern, "")
208
+ end
209
+
210
+ # Builder's `image` / `inline_image` DSL helpers fall back to
211
+ # `config.company_name` for the alt attribute when the caller
212
+ # doesn't pass one. That's reasonable in HTML (screen readers
213
+ # need SOMETHING). In plaintext, Premailer extracts the alt
214
+ # verbatim — leaving a stray "CompanyName" line on its own
215
+ # next to wherever the image landed.
216
+ #
217
+ # We strip standalone lines that EXACTLY match the company name.
218
+ # This is conservative: a message with the company name embedded
219
+ # in a sentence ("Welcome to ExampleApp, thanks for joining")
220
+ # is preserved verbatim — only lines that are nothing but the
221
+ # bare company name are removed.
222
+ def strip_company_name_alt_line(text)
223
+ return text unless Goodmail.config.company_name.present?
224
+
225
+ company_name = Regexp.escape(Goodmail.config.company_name)
226
+ text.gsub(/^\s*#{company_name}\s*$\n?/, "")
227
+ end
228
+
229
+ # Compacts runs of 3+ newlines down to exactly 2 (one blank line
230
+ # between paragraphs is the canonical readable shape; more is
231
+ # visual noise from cumulative gsubs above).
232
+ def compact_blank_lines(text)
233
+ text.gsub(/\n{3,}/, "\n\n")
234
+ end
235
+ end
236
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Goodmail
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/goodmail.rb CHANGED
@@ -10,8 +10,11 @@ require_relative "goodmail/configuration"
10
10
  require_relative "goodmail/error" # Load Error class explicitly if needed elsewhere
11
11
  require_relative "goodmail/builder"
12
12
  require_relative "goodmail/layout"
13
+ require_relative "goodmail/plaintext" # Shared plaintext generator
13
14
  require_relative "goodmail/email"
14
- require_relative "goodmail/mailer" # Require the internal Mailer
15
+ require_relative "goodmail/action_mailer_integration"
16
+ Goodmail.install_action_mailer_integration!
17
+ require_relative "goodmail/mailer" # Require the internal Mailer
15
18
  require_relative "goodmail/dispatcher"
16
19
 
17
20
  # The main namespace for the Goodmail gem.
@@ -20,16 +23,17 @@ module Goodmail
20
23
  # Extend self with Configuration module methods (config, configure, reset_config!)
21
24
  extend Configuration
22
25
 
23
- # Composes a Mail::Message object using the Goodmail DSL and layout.
26
+ # Composes an ActionMailer::MessageDelivery using the Goodmail DSL and layout.
24
27
  #
25
28
  # This is the primary entry point for creating emails with Goodmail.
26
- # The returned Mail::Message object can then have `.deliver_now` or
29
+ # The returned MessageDelivery can then have `.deliver_now` or
27
30
  # `.deliver_later` called on it.
28
31
  #
29
32
  # @param headers [Hash] Mail headers (:to, :from, :subject, etc.)
30
33
  # Also accepts :unsubscribe (true or String URL).
31
34
  # @param block [Proc] Block containing Goodmail DSL calls (text, button, etc.)
32
- # @return [Mail::Message] The generated Mail object, ready for delivery.
35
+ # @return [ActionMailer::MessageDelivery] The generated message delivery,
36
+ # ready for delivery.
33
37
  #
34
38
  # @example
35
39
  # mail = Goodmail.compose(to: 'user@example.com', subject: 'Hello!') do
metadata CHANGED
@@ -1,84 +1,70 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: goodmail
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-02-25 00:00:00.000000000 Z
10
+ date: 2026-10-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
- name: rails
13
+ name: premailer-rails
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '7.0'
18
+ version: '1.10'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '7.0'
25
+ version: '1.10'
26
26
  - !ruby/object:Gem::Dependency
27
- name: rails-html-sanitizer
27
+ name: rails
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '1.0'
32
+ version: '7.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '1.0'
39
+ version: '7.0'
40
40
  - !ruby/object:Gem::Dependency
41
- name: premailer-rails
41
+ name: rails-html-sanitizer
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '1.10'
46
+ version: '1.0'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: '1.10'
53
+ version: '1.0'
54
54
  - !ruby/object:Gem::Dependency
55
- name: pry
55
+ name: ostruct
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: '0'
61
- type: :development
60
+ version: '0.6'
61
+ type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: '0'
68
- - !ruby/object:Gem::Dependency
69
- name: rake
70
- requirement: !ruby/object:Gem::Requirement
71
- requirements:
72
- - - "~>"
73
- - !ruby/object:Gem::Version
74
- version: '13.0'
75
- type: :development
76
- prerelease: false
77
- version_requirements: !ruby/object:Gem::Requirement
78
- requirements:
79
- - - "~>"
80
- - !ruby/object:Gem::Version
81
- version: '13.0'
67
+ version: '0.6'
82
68
  description: Send beautiful, simple transactional emails with zero HTML hell. Goodmail
83
69
  is a minimal, opinionated, expressive Ruby DSL for sending production-grade transactional
84
70
  emails in Rails apps that look good in any email client out of the box.
@@ -88,11 +74,17 @@ executables: []
88
74
  extensions: []
89
75
  extra_rdoc_files: []
90
76
  files:
77
+ - ".rubocop.yml"
78
+ - ".simplecov"
91
79
  - CHANGELOG.md
92
80
  - LICENSE.txt
93
81
  - README.md
94
82
  - Rakefile
83
+ - context7.json
84
+ - examples/PAY_TESTING_GUIDE.md
85
+ - examples/pay_goodmailer.rb
95
86
  - lib/goodmail.rb
87
+ - lib/goodmail/action_mailer_integration.rb
96
88
  - lib/goodmail/builder.rb
97
89
  - lib/goodmail/configuration.rb
98
90
  - lib/goodmail/dispatcher.rb
@@ -101,6 +93,7 @@ files:
101
93
  - lib/goodmail/layout.erb
102
94
  - lib/goodmail/layout.rb
103
95
  - lib/goodmail/mailer.rb
96
+ - lib/goodmail/plaintext.rb
104
97
  - lib/goodmail/version.rb
105
98
  - mailgood.webp
106
99
  - sig/goodmail.rbs