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.
- checksums.yaml +4 -4
- data/.rubocop.yml +199 -0
- data/.simplecov +55 -0
- data/CHANGELOG.md +73 -0
- data/README.md +127 -55
- data/Rakefile +12 -1
- data/context7.json +4 -0
- data/examples/PAY_TESTING_GUIDE.md +497 -0
- data/examples/pay_goodmailer.rb +518 -0
- data/lib/goodmail/action_mailer_integration.rb +286 -0
- data/lib/goodmail/builder.rb +304 -15
- data/lib/goodmail/configuration.rb +40 -3
- data/lib/goodmail/dispatcher.rb +41 -42
- data/lib/goodmail/email.rb +82 -46
- data/lib/goodmail/layout.erb +0 -1
- data/lib/goodmail/layout.rb +1 -1
- data/lib/goodmail/mailer.rb +19 -44
- data/lib/goodmail/plaintext.rb +236 -0
- data/lib/goodmail/version.rb +1 -1
- data/lib/goodmail.rb +8 -4
- metadata +22 -29
|
@@ -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
|
|
33
|
-
validate_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
|
data/lib/goodmail/dispatcher.rb
CHANGED
|
@@ -1,60 +1,59 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "action_mailer"
|
|
3
|
-
|
|
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
|
|
6
|
+
# Responsible for orchestrating the building of the Action Mailer delivery.
|
|
8
7
|
module Dispatcher
|
|
9
8
|
extend self
|
|
10
9
|
|
|
11
|
-
# Builds
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
#
|
|
53
|
-
#
|
|
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
|
-
|
|
42
|
+
Goodmail.action_mailer_headers(h)
|
|
56
43
|
end
|
|
57
44
|
|
|
58
|
-
|
|
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
|
data/lib/goodmail/email.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
96
|
+
def self.evaluate_builder_dsl(builder, locale, &dsl_block)
|
|
97
|
+
return unless block_given?
|
|
67
98
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
data/lib/goodmail/layout.erb
CHANGED
data/lib/goodmail/layout.rb
CHANGED
|
@@ -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,
|
|
35
|
+
unsubscribe_url: unsubscribe_url, # Pass unsubscribe URL to template
|
|
36
36
|
preheader: preheader # Pass preheader to template
|
|
37
37
|
)
|
|
38
38
|
rescue => e
|
data/lib/goodmail/mailer.rb
CHANGED
|
@@ -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(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
#
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
#
|
|
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
|
-
|
|
61
|
-
format.
|
|
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
|