goodmail 0.3.1 → 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.
@@ -4,6 +4,8 @@ require "ostruct"
4
4
  module Goodmail
5
5
  # Handles configuration settings for the Goodmail gem.
6
6
  module Configuration
7
+ THREAD_CONFIG_KEY = :goodmail_current_config
8
+
7
9
  # Default configuration values
8
10
  DEFAULT_CONFIG = OpenStruct.new(
9
11
  brand_color: "#348eda",
@@ -29,22 +31,57 @@ module Goodmail
29
31
  # Provides the configuration block helper.
30
32
  # Ensures validation runs after the block is executed.
31
33
  def configure
32
- yield config # Ensures config is initialized via accessor
33
- validate_config!(config)
34
+ yield global_config # Ensures global config is initialized via accessor
35
+ validate_config!(global_config)
34
36
  end
35
37
 
36
38
  # Returns the current configuration object.
37
39
  # Initializes with a copy of the defaults if not already configured.
38
40
  def config
41
+ Thread.current[THREAD_CONFIG_KEY] || global_config
42
+ end
43
+ alias_method :configuration, :config
44
+
45
+ # Runs a block with a per-render configuration override in the current
46
+ # thread. This keeps whitelabel / tenant-specific emails from mutating the
47
+ # process-wide config while preserving the existing `Goodmail.config` read
48
+ # path used by Builder, Layout, and Plaintext.
49
+ #
50
+ # Source: Ruby thread-local variables:
51
+ # https://docs.ruby-lang.org/en/3.4/Thread.html#method-i-5B-5D
52
+ def with_config(overrides)
53
+ return yield if overrides.nil?
54
+
55
+ previous_config = Thread.current[THREAD_CONFIG_KEY]
56
+ Thread.current[THREAD_CONFIG_KEY] = config_with(overrides)
57
+ yield
58
+ ensure
59
+ Thread.current[THREAD_CONFIG_KEY] = previous_config if defined?(previous_config)
60
+ end
61
+
62
+ def config_with(overrides)
63
+ override_config = config.dup
64
+ if overrides.respond_to?(:to_h)
65
+ overrides.to_h.each { |key, value| override_config[key] = value }
66
+ else
67
+ overrides.each_pair { |key, value| override_config[key] = value }
68
+ end
69
+ validate_config!(override_config)
70
+ override_config
71
+ end
72
+
73
+ # Returns the process-wide configuration object, ignoring any temporary
74
+ # per-render override installed by `with_config`.
75
+ def global_config
39
76
  @config = DEFAULT_CONFIG.dup unless defined?(@config) && @config
40
77
  @config
41
78
  end
42
- alias_method :configuration, :config
43
79
 
44
80
  # Resets the configuration back to the default values.
45
81
  # Primarily useful for testing environments.
46
82
  def reset_config!
47
83
  @config = nil
84
+ Thread.current[THREAD_CONFIG_KEY] = nil
48
85
  end
49
86
 
50
87
  private
@@ -1,60 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
  require "action_mailer"
3
- require "cgi" # For unescaping HTML in plaintext generation
4
- require_relative "mailer" # Require the internal mailer
3
+ require_relative "mailer"
5
4
 
6
5
  module Goodmail
7
- # Responsible for orchestrating the building of the Mail::Message object.
6
+ # Responsible for orchestrating the building of the Action Mailer delivery.
8
7
  module Dispatcher
9
8
  extend self
10
9
 
11
- # Builds the Mail::Message object with HTML and Text parts, wrapped in
12
- # an ActionMailer::MessageDelivery object.
10
+ # Builds an ActionMailer::MessageDelivery with HTML and text parts.
13
11
  # @api private
14
12
  def build_message(headers, &block)
15
- # 1. Initialize the Builder
16
- builder = Goodmail::Builder.new
17
-
18
- # 2. Execute the DSL block within the Builder instance
19
- builder.instance_eval(&block) if block_given?
20
-
21
- # 3. Determine the final unsubscribe URL (user-provided)
22
- unsubscribe_url = headers[:unsubscribe_url] || Goodmail.config.unsubscribe_url
23
-
24
- # 4. Determine preheader text (priority: header > config > subject)
25
- preheader = headers[:preheader] || Goodmail.config.default_preheader || headers[:subject]
26
-
27
- # 5. Render the raw HTML body using the Layout
28
- raw_html_body = Goodmail::Layout.render(
29
- builder.html_output,
30
- headers[:subject],
31
- unsubscribe_url: unsubscribe_url,
32
- preheader: preheader # Pass preheader to layout
33
- )
34
-
35
- # 6. Slice standard headers for the mailer action
36
- mailer_headers = slice_mail_headers(headers)
37
-
38
- # 7. Build the mail object via the internal Mailer class action.
39
- delivery_object = Goodmail::Mailer.compose_message(
40
- mailer_headers,
41
- raw_html_body,
42
- nil, # Pass nil for raw_text_body - Premailer generates it
43
- unsubscribe_url
44
- )
45
-
46
- # 8. Return the ActionMailer::MessageDelivery object
47
- delivery_object
13
+ headers = headers.dup
14
+ render_config_overrides = render_config(headers)
15
+
16
+ Goodmail.with_config(render_config_overrides) do
17
+ parts = Goodmail.render(headers, &block)
18
+
19
+ # ActionMailer::MessageDelivery processes this mailer action lazily and
20
+ # `deliver_later` serializes only action arguments, so pass already
21
+ # rendered strings and plain attachment descriptor hashes into the action.
22
+ # Sources:
23
+ # - MessageDelivery laziness:
24
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/message_delivery.rb#L22-L35
25
+ # - deliver_later serializes mailer action arguments:
26
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/message_delivery.rb#L142-L155
27
+ Goodmail::Mailer.compose_message(
28
+ slice_mail_headers(headers),
29
+ parts.html,
30
+ parts.text,
31
+ resolved_unsubscribe_url(headers),
32
+ parts.attachments
33
+ )
34
+ end
48
35
  end
49
36
 
50
37
  private
51
38
 
52
- # Whitelist standard headers to pass to ActionMailer's mail() method
53
- # Excludes custom headers like :unsubscribe_url, :preheader
39
+ # Pass Action Mailer's normal header surface through, excluding only
40
+ # Goodmail render-only options such as :unsubscribe_url and :preheader.
54
41
  def slice_mail_headers(h)
55
- h.slice(:to, :from, :cc, :bcc, :reply_to, :subject)
42
+ Goodmail.action_mailer_headers(h)
56
43
  end
57
44
 
58
- # Removed generate_plaintext - now handled by Premailer in Mailer#compose_message
45
+ def render_config(headers)
46
+ render_option(headers, :config) || render_option(headers, :configuration)
47
+ end
48
+
49
+ def resolved_unsubscribe_url(headers)
50
+ render_option(headers, :unsubscribe_url) || Goodmail.config.unsubscribe_url
51
+ end
52
+
53
+ def render_option(headers, key)
54
+ return headers[key] if headers.key?(key)
55
+
56
+ headers[key.to_s] if headers.key?(key.to_s)
57
+ end
59
58
  end
60
59
  end
@@ -3,69 +3,105 @@ require "premailer"
3
3
  require "cgi" # For unescaping HTML in plaintext generation (though Premailer might handle most)
4
4
 
5
5
  module Goodmail
6
- # Simple struct to hold the rendered HTML and text parts of an email.
7
- EmailParts = Struct.new(:html, :text, keyword_init: true)
6
+ # Simple struct to hold the rendered HTML and text parts of an email, plus
7
+ # any attachments collected via the `attach` / `inline_image` DSL helpers.
8
+ # Custom Action Mailer classes can call Goodmail's auto-installed
9
+ # `goodmail_mail_parts(parts, headers)` helper to apply attachments, pin
10
+ # inline Content-IDs, add unsubscribe headers, and send the multipart body.
11
+ #
12
+ # `attachments` defaults to `[]` for backwards compatibility — callers
13
+ # written against 0.3.x keep working unchanged.
14
+ EmailParts = Struct.new(:html, :text, :attachments, keyword_init: true) do
15
+ def initialize(html: nil, text: nil, attachments: [])
16
+ super(html: html, text: text, attachments: attachments || [])
17
+ end
18
+ end
8
19
 
9
20
  # Renders the email content using the Goodmail DSL and returns HTML and text parts.
10
21
  # This method does not send the email but prepares its content for sending.
11
22
  #
12
23
  # @param headers [Hash] Mail headers. Expected to contain :subject.
13
- # Can also contain :unsubscribe_url and :preheader to override defaults.
24
+ # Can also contain :unsubscribe_url, :preheader, :locale,
25
+ # :context, :config, and :layout_path to override
26
+ # render-only behavior.
14
27
  # @param dsl_block [Proc] Block containing Goodmail DSL calls (text, button, etc.)
15
28
  # @return [Goodmail::EmailParts] An object containing the :html and :text email parts.
16
29
  def self.render(headers = {}, &dsl_block)
17
30
  # 1. Initialize the Builder and execute the DSL block
18
- builder = Goodmail::Builder.new
19
- builder.instance_eval(&dsl_block) if block_given?
20
- core_html_content = builder.html_output
21
-
22
- # 2. Determine unsubscribe_url and preheader
23
- # These are removed from headers as they are Goodmail-specific, not standard mail headers.
24
31
  current_headers = headers.dup # Avoid modifying the original headers hash directly
25
- unsubscribe_url = current_headers.delete(:unsubscribe_url) || Goodmail.config.unsubscribe_url
26
- preheader = current_headers.delete(:preheader) || Goodmail.config.default_preheader || current_headers[:subject]
32
+ context = render_header_value!(current_headers, :context)
33
+ locale = render_header_value!(current_headers, :locale)
34
+ render_config = render_header_value!(current_headers, :config)
35
+ render_config = render_header_value!(current_headers, :configuration) if render_config.nil?
36
+ layout_path = render_header_value!(current_headers, :layout_path)
37
+ subject = render_header_value!(current_headers, :subject)
27
38
 
28
- # 3. Render the raw HTML body using the Layout
29
- # The subject is passed for the <title> tag and potentially other uses in layout.
30
- # Unsubscribe URL and preheader are passed for inclusion in the layout.
31
- raw_html_body = Goodmail::Layout.render(
32
- core_html_content,
33
- current_headers[:subject], # Use subject from (potentially modified) current_headers
34
- unsubscribe_url: unsubscribe_url,
35
- preheader: preheader
36
- )
39
+ Goodmail.with_config(render_config) do
40
+ builder = Goodmail::Builder.new(context: context)
41
+ evaluate_builder_dsl(builder, locale, &dsl_block)
42
+ core_html_content = builder.html_output
37
43
 
38
- # 4. Use Premailer to inline CSS and generate plaintext
39
- premailer = Premailer.new(
40
- raw_html_body,
41
- with_html_string: true,
42
- adapter: :nokogiri,
43
- preserve_styles: false, # Force inlining and remove <style> block
44
- remove_ids: true, # Remove IDs
45
- remove_comments: false # Keep MSO conditional comments
46
- )
44
+ # 2. Determine unsubscribe_url and preheader
45
+ # These are removed from headers as they are Goodmail-specific, not standard mail headers.
46
+ unsubscribe_url = render_header_value!(current_headers, :unsubscribe_url) || Goodmail.config.unsubscribe_url
47
+ preheader = render_header_value!(current_headers, :preheader) || Goodmail.config.default_preheader || subject
47
48
 
48
- final_inlined_html = premailer.to_inline_css
49
- generated_plain_text = premailer.to_plain_text
49
+ # 3. Render the raw HTML body using the Layout
50
+ # The subject is passed for the <title> tag and potentially other uses in layout.
51
+ # Unsubscribe URL and preheader are passed for inclusion in the layout.
52
+ raw_html_body = Goodmail::Layout.render(
53
+ core_html_content,
54
+ subject,
55
+ layout_path: layout_path,
56
+ unsubscribe_url: unsubscribe_url,
57
+ preheader: preheader
58
+ )
50
59
 
51
- # 5. Perform refined plaintext cleanup (ported from Goodmail::Mailer)
52
- # 5.1. Remove logo alt text line (if logo exists and has associated URL)
53
- if Goodmail.config.logo_url.present? && Goodmail.config.company_url.present? && Goodmail.config.company_name.present?
54
- company_name_escaped = Regexp.escape(Goodmail.config.company_name)
55
- company_url_escaped = Regexp.escape(Goodmail.config.company_url)
56
- # Regex to match the typical alt text pattern for a linked logo image
57
- logo_alt_pattern = /^\s*#{company_name_escaped}\s+Logo\s*\(.*?#{company_url_escaped}.*?\).*\n?/i
58
- generated_plain_text.gsub!(logo_alt_pattern, "")
60
+ # 4. Run Premailer for CSS inlining (HTML part). Plaintext goes
61
+ # through `Goodmail::Plaintext` which pre-processes the source
62
+ # HTML to neutralize MSO-only markup and the hidden preheader
63
+ # span both of which Premailer's plaintext extractor would
64
+ # otherwise leak into the text body.
65
+ premailer = Premailer.new(
66
+ raw_html_body,
67
+ with_html_string: true,
68
+ adapter: :nokogiri,
69
+ preserve_styles: false, # Force inlining and remove <style> block
70
+ remove_ids: true, # Remove IDs
71
+ remove_comments: false, # Keep MSO conditional comments in HTML
72
+ input_encoding: "UTF-8" # See Goodmail::Plaintext for the full
73
+ # rationale — short version: Premailer
74
+ # double-encodes accented characters when
75
+ # the source has no <meta charset>.
76
+ )
77
+ final_inlined_html = premailer.to_inline_css
78
+ final_plain_text = Goodmail::Plaintext.generate(raw_html_body, preheader: preheader)
79
+
80
+ # 5. Return the structured parts
81
+ EmailParts.new(
82
+ html: final_inlined_html,
83
+ text: final_plain_text,
84
+ attachments: builder.attachments
85
+ )
59
86
  end
87
+ end
60
88
 
61
- # 5.2. Remove any remaining standalone URL lines (often from logo links or similar artifacts)
62
- # This targets lines that consist *only* of a URL.
63
- generated_plain_text.gsub!(/^\s*https?:\/\/\S+\s*$\n?/i, "")
89
+ def self.render_header_value!(headers, key)
90
+ value = headers.delete(key)
91
+ value = headers.delete(key.to_s) if value.nil? && headers.key?(key.to_s)
92
+ value
93
+ end
94
+ private_class_method :render_header_value!
64
95
 
65
- # 5.3. Compact excess blank lines (more than two consecutive newlines)
66
- generated_plain_text.gsub!(/\n{3,}/, "\n\n")
96
+ def self.evaluate_builder_dsl(builder, locale, &dsl_block)
97
+ return unless block_given?
67
98
 
68
- # 6. Return the structured parts
69
- EmailParts.new(html: final_inlined_html, text: generated_plain_text.strip)
99
+ render_block = proc { builder.instance_eval(&dsl_block) }
100
+ if !locale.nil? && !locale.to_s.empty? && defined?(I18n)
101
+ I18n.with_locale(locale, &render_block)
102
+ else
103
+ render_block.call
104
+ end
70
105
  end
106
+ private_class_method :evaluate_builder_dsl
71
107
  end
@@ -107,7 +107,6 @@
107
107
  cursor: pointer;
108
108
  display: inline-block;
109
109
  border-radius: 5px;
110
- text-transform: capitalize;
111
110
  background-color: <%= config.brand_color %>;
112
111
  margin: 0;
113
112
  border-color: <%= config.brand_color %>;
@@ -32,7 +32,7 @@ module Goodmail
32
32
  body_html: body_html,
33
33
  subject: subject || "",
34
34
  config: Goodmail.config, # Make config available
35
- unsubscribe_url: unsubscribe_url, # Pass unsubscribe URL to template
35
+ unsubscribe_url: unsubscribe_url, # Pass unsubscribe URL to template
36
36
  preheader: preheader # Pass preheader to template
37
37
  )
38
38
  rescue => e
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  require "action_mailer"
3
- require "premailer" # Require premailer library
4
3
 
5
4
  module Goodmail
6
5
  # Internal Mailer class.
@@ -16,53 +15,29 @@ module Goodmail
16
15
  # This instance method acts as the mailer action.
17
16
  # It's called via Goodmail::Mailer.compose_message(...)
18
17
  # Action Mailer wraps the result in a MessageDelivery object.
19
- # It uses Premailer to inline CSS and generate plaintext.
20
18
  # @api internal
21
- def compose_message(headers, raw_html_body, _raw_text_body, unsubscribe_url)
22
- # Initialize Premailer with the raw HTML body from the layout
23
- premailer = Premailer.new(
24
- raw_html_body,
25
- with_html_string: true,
26
- # Common options:
27
- adapter: :nokogiri,
28
- preserve_styles: false, # Set to false to force inlining *and* remove <style> block
29
- remove_ids: true, # Can usually remove IDs
30
- remove_comments: false, # KEEP conditional comments so MSO conditionals in the template work!
31
- # Note: `plain_text_images: false` might exist but is not standard;
32
- # relying on gsub cleanup below.
33
- )
19
+ def compose_message(
20
+ headers,
21
+ html_body,
22
+ text_body,
23
+ unsubscribe_url,
24
+ dsl_attachments = []
25
+ )
26
+ headers = headers.dup
27
+ goodmail_add_list_unsubscribe_headers!(headers, unsubscribe_url)
28
+ goodmail_apply_attachments!(dsl_attachments)
34
29
 
35
- # Get processed content
36
- inlined_html = premailer.to_inline_css
37
- # Generate plain text, skipping image conversion
38
- generated_plain_text = premailer.to_plain_text
39
-
40
- # Clean up plaintext:
41
- # 1. Remove logo alt text line (if logo exists and has associated URL)
42
- if Goodmail.config.logo_url.present? && Goodmail.config.company_url.present? && Goodmail.config.company_name.present?
43
- company_name_escaped = Regexp.escape(Goodmail.config.company_name)
44
- company_url_escaped = Regexp.escape(Goodmail.config.company_url)
45
- logo_alt_pattern = /^\s*#{company_name_escaped}\s+Logo\s+\(.*?#{company_url_escaped}.*?\).*\n?/i
46
- generated_plain_text.gsub!(logo_alt_pattern, '')
47
- end
48
- # 2. Remove any remaining standalone URL lines (often from logo links)
49
- generated_plain_text.gsub!(/^\s*https?:\/\/\S+\s*$\n?/i, '')
50
- # 3. Compact excess blank lines created by gsubbing
51
- generated_plain_text.gsub!(/\n{3,}/, "\n\n")
52
-
53
- # Add List-Unsubscribe header to the headers hash *before* calling mail()
54
- if unsubscribe_url.is_a?(String) && !unsubscribe_url.strip.empty?
55
- headers["List-Unsubscribe"] = "<#{unsubscribe_url.strip}>"
56
- end
57
-
58
- # Call the instance-level `mail` method
30
+ # Attachments must be registered before `mail` materializes the message;
31
+ # the block form below lets Rails build the multipart/alternative tree.
32
+ # Sources:
33
+ # - late attachments raise:
34
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L766-L781
35
+ # - block-form `mail` with direct plain/html rendering:
36
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L851-L873
59
37
  mail(headers) do |format|
60
- # Use the premailer-generated plaintext
61
- format.text { render plain: generated_plain_text.strip }
62
- # Use the CSS-inlined HTML
63
- format.html { render html: inlined_html.html_safe }
38
+ format.text { render plain: text_body.to_s }
39
+ format.html { render html: html_body.to_s.html_safe }
64
40
  end
65
- # Action Mailer automatically returns the MessageDelivery object
66
41
  end
67
42
  end
68
43
  end
@@ -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