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.
@@ -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
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta name="viewport" content="width=device-width" />
5
5
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
6
- <title><%= subject %></title>
6
+ <title><%= ERB::Util.html_escape(subject) %></title>
7
7
  <style type="text/css">
8
8
  /* Base Styles */
9
9
  img {
@@ -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 %>;
@@ -163,7 +162,7 @@
163
162
 
164
163
  <!-- Hidden Preheader Text -->
165
164
  <span style="display:none !important; font-size:1px; color:#ffffff; line-height:1px; max-height:0px; max-width:0px; opacity:0; overflow:hidden;">
166
- <%= preheader || subject %>
165
+ <%= ERB::Util.html_escape(preheader || subject) %>
167
166
  </span>
168
167
 
169
168
  <table class="body-wrap" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; background-color: #f6f6f6; margin: 0;" bgcolor="#f6f6f6">
@@ -193,7 +192,7 @@
193
192
  <table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; border-radius: 3px; background-color: #fff; margin: 0; border: 1px solid #e9e9e9;" bgcolor="#fff">
194
193
  <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
195
194
  <td class="content-wrap" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 30px;" valign="top">
196
- <meta itemprop="name" content="<%= subject %>" />
195
+ <meta itemprop="name" content="<%= ERB::Util.html_escape(subject) %>" />
197
196
  <table width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
198
197
  <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
199
198
  <td style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0;" valign="top">
@@ -229,7 +228,7 @@
229
228
  <% if config.show_footer_unsubscribe_link && defined?(unsubscribe_url) && unsubscribe_url.present? %>
230
229
  <tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
231
230
  <td class="aligncenter content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 12px; vertical-align: top; text-align: center; margin: 0; padding: 0 0 10px; color: #999;" align="center" valign="top">
232
- <a href="<%= unsubscribe_url %>" style="color: #999;"><%= config.footer_unsubscribe_link_text %></a>
231
+ <a href="<%= ERB::Util.html_escape(unsubscribe_url) %>" style="color: #999;"><%= config.footer_unsubscribe_link_text %></a>
233
232
  </td>
234
233
  </tr>
235
234
  <% end %>
@@ -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