, , ) — the formatting tags every email client renders
+ # consistently and that don't add layout risk to the table-based template.
+ #
+ # Why these specific tags:
+ # - `a[href]`: clickable links, basic.
+ # - `strong` / `em`: semantic emphasis. Both are universally supported
+ # in email clients (including Outlook 2007+ which famously drops
+ # more exotic tags). Source: https://www.caniemail.com/features/html-strong/
+ # - `b` / `i`: legacy non-semantic equivalents that some translation
+ # workflows still emit. Allowed for symmetry — they render identically
+ # to `strong` / `em` in every modern client.
+ #
+ # Anything more (h1/h2 inside text, ul/li, span/div) belongs in dedicated
+ # DSL helpers (`h1`, `h2`, `h3`, `code_box`, …) that compose proper
+ # styled blocks with table-safe markup, not in inline text.
HTML_SANITIZER = Rails::Html::SafeListSanitizer.new
- ALLOWED_TAGS = %w(a).freeze
+ ALLOWED_TAGS = %w(a strong em b i).freeze
ALLOWED_ATTRIBUTES = %w(href).freeze
+ # RFC 2606 / RFC 6761 reserve `.invalid` for names that should not collide
+ # with real DNS. Goodmail only needs a stable addr-spec domain for generated
+ # Content-IDs; it should never imply a routable host.
+ # Sources:
+ # - https://www.rfc-editor.org/rfc/rfc2606#section-2
+ # - https://www.rfc-editor.org/rfc/rfc6761#section-6.4
+ INLINE_CONTENT_ID_DOMAIN = "inline.goodmail.invalid"
+
+ attr_reader :parts, :attachments
- attr_reader :parts
+ INTERNAL_INSTANCE_VARIABLES = %i[@parts @attachments @goodmail_context].freeze
- def initialize
+ def initialize(context: nil)
+ copy_context_instance_variables(context)
+ @goodmail_context = context
@parts = []
+ # Email-level attachments collected via the `attach` DSL method. Stored as
+ # `[{ filename:, content:, mime_type: }, ...]` and consumed by the
+ # internal `Goodmail::Mailer` (via `Goodmail::Dispatcher`) before the
+ # `mail()` call so they are forwarded on the outgoing message. We collect
+ # here (rather than calling `attachments[]=` directly on a mailer
+ # instance) because the DSL block is `instance_eval`'d on the Builder
+ # — it has no Mailer context and can't reach into ActionMailer's
+ # attachments hash. See `Mailer#compose_message` for how these are
+ # applied.
+ @attachments = []
end
# DSL Methods
@@ -84,11 +120,73 @@ def image(src, alt = "", width: nil, height: nil)
end
# Adds a simple price row as a styled paragraph.
- # NOTE: This does not create a table structure.
+ # NOTE: This does not create a table structure. The visual is bold,
+ # centered, and separator-bordered — designed for receipt-style line
+ # items where the LABEL and the AMOUNT carry equal weight ("Premium
+ # plan - $49.00", "Tax - $4.90"). For label/value rows where the
+ # label is supporting context and the value is the primary content
+ # ("Plan - Pro", "Status - Active"), prefer `info_row` below.
def price_row(name, price)
parts << %(#{h name} – #{h price}
)
end
+ # Adds a label/value row using the two-column table pattern that
+ # Stripe / Linear / Square / Resend all converge on for transactional
+ # info cards: muted label on the left, dark right-aligned value on
+ # the right, 1px hairline at the bottom for visual separation.
+ #
+ # Why a TWO-CELL TABLE (and not a flexbox/grid div):
+ # - Outlook on Windows uses Word's HTML rendering engine (no
+ # `display: flex` / `grid`, no `gap`). Tables are the only
+ # layout primitive that renders consistently across every modern
+ # and legacy client. Source: https://www.caniemail.com/features/css-display-flex/
+ # - `cellpadding=0 cellspacing=0 border=0` + `border-collapse:
+ # collapse` neutralizes the historical browser defaults and
+ # gives us pixel control via the inline `padding`.
+ # - `role="presentation"` tells screen readers to skip the table
+ # semantics — this is layout, not data. Source: WAI-ARIA 1.2
+ # `presentation` / `none` role:
+ # https://www.w3.org/TR/wai-aria-1.2/#presentation
+ #
+ # Why two SEPARATE tables per call (vs. one table with many rows):
+ # - The block-level DSL emits each call as a self-contained unit,
+ # same as `price_row` / `text` / `button`. Mixing rows from
+ # different DSL calls into one shared table would require a
+ # `Builder` flush phase that mutates earlier output — complex
+ # and surprising. Adjacent two-cell tables visually collapse
+ # into one continuous list when their bottom border meets the
+ # next row's top edge, so the user sees a single list anyway.
+ #
+ # Sources on email-safe table-row patterns:
+ # - https://www.cerberusemail.com/templates (responsive table patterns)
+ # - https://www.litmus.com/blog/the-ultimate-guide-to-css/
+ # - https://htmlemail.io/blog/responsive-html-emails-creating-a-simple-responsive-email/
+ def info_row(label, value)
+ label_html = h(label.to_s)
+ value_html = h(value.to_s)
+ # The `class="goodmail-info-row"` hook is the marker
+ # `Goodmail::Plaintext` looks for to flatten this two-cell table
+ # into a single `Label: Value` line in the plaintext part. HTML
+ # email clients render the visible table; text-only clients see
+ # the readable colon-form. Without the marker, Premailer would
+ # emit the cells on two separate lines:
+ #
+ # Label
+ # Value
+ #
+ # which is correct table-extraction behavior but a worse
+ # plaintext UX than the conventional `Label: Value` shape every
+ # other transactional sender uses.
+ parts << <<~HTML.strip
+
+
+ | #{label_html} |
+ #{value_html} |
+
+
+ HTML
+ end
+
# Adds a simple code box with background styling.
def code_box(text)
# Re-added background/padding; content is simple, should survive Premailer plain text.
@@ -105,16 +203,122 @@ def sign(name = Goodmail.config.company_name)
parts << %(– #{h name}
)
end
- %i[h1 h2 h3].each do |heading_tag|
+ # Inline styled link as a paragraph. Wraps `button` for cases where a full
+ # call-to-action button is too heavy — e.g. "View receipt", "Open the
+ # account", "Read the full policy". The link is rendered in the
+ # configured brand color and underlined, matching the layout's `a {}` rule
+ # so the visual stays consistent in clients that strip inline styles.
+ #
+ # Both `text` and `url` are HTML-escaped to prevent any accidental injection
+ # from interpolated user content (e.g. a customer name in a label, a
+ # URL with arbitrary query strings).
+ def link(text, url)
+ parts << %(#{h text}
)
+ end
+
+ # Small/disclaimer text. Designed for legal language, fine print, "you
+ # received this because…", and similar secondary content. Uses the same
+ # neutral grey as the footer (`#777`) and a slightly smaller font size.
+ # Newlines become
, mirroring `text` so callers don't have to think
+ # about which helper handles which.
+ def small(str)
+ sanitized_content = HTML_SANITIZER.sanitize(
+ str.to_s,
+ tags: ALLOWED_TAGS,
+ attributes: ALLOWED_ATTRIBUTES
+ )
+ parts << %(#{sanitized_content.gsub(/\n/, "
")}
)
+ end
+
+ # Adds an email attachment (file) to the outgoing message. Use for PDFs,
+ # .ics calendar invites, .csv exports, summary images you want stored on
+ # the recipient's machine, etc.
+ #
+ # Sources:
+ # - ActionMailer attachments docs:
+ # https://guides.rubyonrails.org/action_mailer_basics.html#sending-emails-with-attachments
+ # - RFC 2392 (Content-ID URLs for inline images):
+ # https://www.rfc-editor.org/rfc/rfc2392
+ #
+ # Parameters:
+ # filename — the name the recipient sees (e.g. "receipt.pdf").
+ # content — either the raw bytes (String, IO) or a filesystem path
+ # (String). Strings that point at an existing file path are
+ # read from disk; otherwise the String is used as-is.
+ # mime_type — optional Content-Type override. When omitted, Action Mailer
+ # infers it from the filename via Mime::Type.lookup_by_extension.
+ # inline — when true, the attachment is marked as `inline` so the
+ # email body can reference it via `cid:`. Useful for
+ # embedding logos / maps when you can't (or don't want to)
+ # host them publicly. Prefer `inline_image` below when you
+ # also want Goodmail to emit the matching
tag.
+ def attach(filename, content, mime_type: nil, inline: false)
+ filename = filename.to_s
+
+ # Inline attachments are referenced from the email body via `cid:`.
+ # Duplicate filenames are ambiguous in custom `Goodmail.render`
+ # fan-out code because Action Mailer's attachment hash is keyed by
+ # filename, so keep the documented "one inline filename per message"
+ # contract even though Goodmail generates distinct Content-IDs.
+ # Source: https://guides.rubyonrails.org/action_mailer_basics.html#sending-emails-with-attachments
+ #
+ # Non-inline attachments don't have the same problem — they're
+ # downloaded by the recipient by filename, so a duplicate
+ # produces two files with the same name (annoying UX but not a
+ # rendering bug). We allow those.
+ if inline && attachments.any? { |a| a[:inline] && a[:filename] == filename }
+ raise Goodmail::Error, "duplicate inline filename #{filename.inspect}. Use a distinct filename per inline_image call."
+ end
+
+ descriptor = {
+ filename: filename,
+ content: resolve_attachment_content(content),
+ mime_type: mime_type,
+ inline: inline,
+ content_id: (generate_inline_content_id(filename) if inline)
+ }
+ attachments << descriptor
+ descriptor
+ end
+
+ # Embeds an inline image and emits the matching
tag at this point in
+ # the email body, referencing the attachment via `cid:`. Goodmail assigns
+ # a globally unique RFC 2392-shaped Content-ID and pins the Mail part to
+ # that same ID when the message is materialized.
+ # Sources:
+ # - RFC 2392 `cid:` URL / Content-ID mapping:
+ # https://www.rfc-editor.org/rfc/rfc2392
+ # - Rails inline attachment pattern:
+ # https://guides.rubyonrails.org/action_mailer_basics.html#making-inline-attachments
+ #
+ # `inline_image` is the right tool when:
+ # - the image must travel WITH the email so it renders in offline /
+ # end-of-cache scenarios (e.g. an Outlook user reading three months
+ # later when the public URL has expired),
+ # - or when you don't have a public URL to point at (private S3
+ # bucket, dev environment with localhost URLs, etc).
+ #
+ # When the asset already has a public URL you control, prefer the regular
+ # `image(src, alt)` helper — it's lighter on the wire and avoids attaching
+ # binary parts to every send.
+ def inline_image(filename, content, alt: "", width: nil, height: nil, mime_type: nil)
+ attachment = attach(filename, content, mime_type: mime_type, inline: true)
+ image("cid:#{attachment[:content_id]}", alt, width: width, height: height)
+ end
+
+ # The `case` only ever sees the three keys we iterate over below, so
+ # the inline lookup is exhaustive by construction — no defensive
+ # `else` clause needed.
+ HEADING_STYLES = {
+ h1: "margin: 40px 0 10px; font-size: 32px; font-weight: 500; line-height: 1.2em;",
+ h2: "margin: 40px 0 10px; font-size: 24px; font-weight: 400; line-height: 1.2em;",
+ h3: "margin: 40px 0 10px; font-size: 18px; font-weight: 400; line-height: 1.2em;"
+ }.freeze
+
+ HEADING_STYLES.each do |heading_tag, style|
define_method(heading_tag) do |str|
- # Added basic heading styles, consistent with layout.erb
- style = case heading_tag
- when :h1 then "margin: 40px 0 10px; font-size: 32px; font-weight: 500; line-height: 1.2em;"
- when :h2 then "margin: 40px 0 10px; font-size: 24px; font-weight: 400; line-height: 1.2em;"
- when :h3 then "margin: 40px 0 10px; font-size: 18px; font-weight: 400; line-height: 1.2em;"
- else "margin: 16px 0; line-height: 1.6;"
- end
- # Headings should still have their content escaped
+ # Headings still escape their content — only the surrounding tag
+ # markup is trusted.
parts << tag(heading_tag, h(str), style: style)
end
end
@@ -140,6 +344,52 @@ def html_output
private
+ # Loads file contents when `content` is a path to an existing file,
+ # otherwise returns it unchanged so callers can pass raw bytes / IO
+ # streams transparently. Paths win over byte-strings that happen to
+ # match a filename: this is intentional — the README's documented
+ # contract is "pass a path, we'll read it for you".
+ #
+ # Defensive checks before reaching `File.file?`:
+ #
+ # 1. NUL bytes — `File.file?` raises `ArgumentError: path name
+ # contains null byte` on any String containing `\0`, and that's
+ # exactly what binary file content (PNG / PDF / .ics) looks
+ # like. Treat NUL-containing Strings as "definitely not a
+ # path" so callers can pass `inline_image("logo.png", png_bytes)`
+ # without us blowing up trying to look up `png_bytes` as a path.
+ # 2. PATH_MAX — most filesystems cap paths at 4096 bytes (Linux
+ # `PATH_MAX`); macOS HFS+ at 1024. A String longer than that is
+ # structurally not a path and almost certainly file contents.
+ # We pick 4096 as the cutoff to be the most generous to legit
+ # paths while still cheaply screening out anything bigger.
+ #
+ # Source on `File.file?` and the NUL byte error:
+ # https://docs.ruby-lang.org/en/3.4/File.html#method-c-file-3F
+ def resolve_attachment_content(content)
+ return content unless content.is_a?(String)
+ return content if content.include?("\0")
+ return content if content.bytesize > 4096
+ return content unless File.file?(content)
+
+ File.binread(content)
+ end
+
+ # RFC 2392 maps `cid:` URLs to Content-ID headers using an addr-spec and
+ # says Content-IDs should be globally unique. Use a random local part plus
+ # a reserved `.invalid` domain rather than the filename itself; filenames
+ # can contain spaces/non-URL characters and are often reused across emails.
+ # Sources:
+ # - https://www.rfc-editor.org/rfc/rfc2392
+ # - https://www.rfc-editor.org/rfc/rfc6761#section-6.4
+ def generate_inline_content_id(filename)
+ safe_filename = filename.gsub(/[^A-Za-z0-9._+-]/, "-")
+ safe_filename = "attachment" if safe_filename.empty?
+ safe_filename = safe_filename[0, 64]
+
+ "#{SecureRandom.hex(12)}.#{safe_filename}@#{INLINE_CONTENT_ID_DOMAIN}"
+ end
+
# Helper for creating simple HTML tags with optional style
# Assumes content is already appropriately escaped or marked safe.
def tag(name, content, style: nil)
@@ -162,5 +412,44 @@ def wrap(tag_name, style, &block)
# Prevent external modification of the parts array directly
attr_writer :parts
+
+ def copy_context_instance_variables(context)
+ return unless context
+
+ # Goodmail evaluates DSL blocks with `instance_eval` so calls like
+ # `text "..."` remain terse. That changes `self` from the mailer to the
+ # builder, which would normally hide mailer ivars such as `@user`.
+ # Snapshot public mailer state onto the transient builder so Action
+ # Mailer users can write the same instance-variable style Rails
+ # documents for mailer views/actions while Goodmail still owns the DSL
+ # receiver.
+ # Sources:
+ # - Action Mailer actions assign instance variables for templates:
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/README.rdoc#L20-L37
+ # - Ruby `instance_eval` changes the block receiver:
+ # https://docs.ruby-lang.org/en/3.4/BasicObject.html#method-i-instance_eval
+ context.instance_variables.each do |ivar|
+ next if internal_instance_variable?(ivar)
+
+ instance_variable_set(ivar, context.instance_variable_get(ivar))
+ end
+ end
+
+ def internal_instance_variable?(ivar)
+ INTERNAL_INSTANCE_VARIABLES.include?(ivar) || ivar.to_s.start_with?("@_")
+ end
+
+ def method_missing(method_name, *args, **kwargs, &block)
+ context = @goodmail_context
+ if context.respond_to?(method_name, true)
+ return context.__send__(method_name, *args, **kwargs, &block)
+ end
+
+ super
+ end
+
+ def respond_to_missing?(method_name, include_private = false)
+ @goodmail_context.respond_to?(method_name, true) || super
+ end
end
end
diff --git a/lib/goodmail/configuration.rb b/lib/goodmail/configuration.rb
index b615329..5179e7e 100644
--- a/lib/goodmail/configuration.rb
+++ b/lib/goodmail/configuration.rb
@@ -4,6 +4,8 @@
module Goodmail
# Handles configuration settings for the Goodmail gem.
module Configuration
+ THREAD_CONFIG_KEY = :goodmail_current_config
+
# Default configuration values
DEFAULT_CONFIG = OpenStruct.new(
brand_color: "#348eda",
@@ -29,22 +31,57 @@ module Configuration
# Provides the configuration block helper.
# Ensures validation runs after the block is executed.
def configure
- yield config # Ensures config is initialized via accessor
- validate_config!(config)
+ yield global_config # Ensures global config is initialized via accessor
+ validate_config!(global_config)
end
# Returns the current configuration object.
# Initializes with a copy of the defaults if not already configured.
def config
+ Thread.current[THREAD_CONFIG_KEY] || global_config
+ end
+ alias_method :configuration, :config
+
+ # Runs a block with a per-render configuration override in the current
+ # thread. This keeps whitelabel / tenant-specific emails from mutating the
+ # process-wide config while preserving the existing `Goodmail.config` read
+ # path used by Builder, Layout, and Plaintext.
+ #
+ # Source: Ruby thread-local variables:
+ # https://docs.ruby-lang.org/en/3.4/Thread.html#method-i-5B-5D
+ def with_config(overrides)
+ return yield if overrides.nil?
+
+ previous_config = Thread.current[THREAD_CONFIG_KEY]
+ Thread.current[THREAD_CONFIG_KEY] = config_with(overrides)
+ yield
+ ensure
+ Thread.current[THREAD_CONFIG_KEY] = previous_config if defined?(previous_config)
+ end
+
+ def config_with(overrides)
+ override_config = config.dup
+ if overrides.respond_to?(:to_h)
+ overrides.to_h.each { |key, value| override_config[key] = value }
+ else
+ overrides.each_pair { |key, value| override_config[key] = value }
+ end
+ validate_config!(override_config)
+ override_config
+ end
+
+ # Returns the process-wide configuration object, ignoring any temporary
+ # per-render override installed by `with_config`.
+ def global_config
@config = DEFAULT_CONFIG.dup unless defined?(@config) && @config
@config
end
- alias_method :configuration, :config
# Resets the configuration back to the default values.
# Primarily useful for testing environments.
def reset_config!
@config = nil
+ Thread.current[THREAD_CONFIG_KEY] = nil
end
private
diff --git a/lib/goodmail/dispatcher.rb b/lib/goodmail/dispatcher.rb
index cc9c5ba..1fbeb91 100644
--- a/lib/goodmail/dispatcher.rb
+++ b/lib/goodmail/dispatcher.rb
@@ -1,60 +1,59 @@
# frozen_string_literal: true
require "action_mailer"
-require "cgi" # For unescaping HTML in plaintext generation
-require_relative "mailer" # Require the internal mailer
+require_relative "mailer"
module Goodmail
- # Responsible for orchestrating the building of the Mail::Message object.
+ # Responsible for orchestrating the building of the Action Mailer delivery.
module Dispatcher
extend self
- # Builds the Mail::Message object with HTML and Text parts, wrapped in
- # an ActionMailer::MessageDelivery object.
+ # Builds an ActionMailer::MessageDelivery with HTML and text parts.
# @api private
def build_message(headers, &block)
- # 1. Initialize the Builder
- builder = Goodmail::Builder.new
-
- # 2. Execute the DSL block within the Builder instance
- builder.instance_eval(&block) if block_given?
-
- # 3. Determine the final unsubscribe URL (user-provided)
- unsubscribe_url = headers[:unsubscribe_url] || Goodmail.config.unsubscribe_url
-
- # 4. Determine preheader text (priority: header > config > subject)
- preheader = headers[:preheader] || Goodmail.config.default_preheader || headers[:subject]
-
- # 5. Render the raw HTML body using the Layout
- raw_html_body = Goodmail::Layout.render(
- builder.html_output,
- headers[:subject],
- unsubscribe_url: unsubscribe_url,
- preheader: preheader # Pass preheader to layout
- )
-
- # 6. Slice standard headers for the mailer action
- mailer_headers = slice_mail_headers(headers)
-
- # 7. Build the mail object via the internal Mailer class action.
- delivery_object = Goodmail::Mailer.compose_message(
- mailer_headers,
- raw_html_body,
- nil, # Pass nil for raw_text_body - Premailer generates it
- unsubscribe_url
- )
-
- # 8. Return the ActionMailer::MessageDelivery object
- delivery_object
+ headers = headers.dup
+ render_config_overrides = render_config(headers)
+
+ Goodmail.with_config(render_config_overrides) do
+ parts = Goodmail.render(headers, &block)
+
+ # ActionMailer::MessageDelivery processes this mailer action lazily and
+ # `deliver_later` serializes only action arguments, so pass already
+ # rendered strings and plain attachment descriptor hashes into the action.
+ # Sources:
+ # - MessageDelivery laziness:
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/message_delivery.rb#L22-L35
+ # - deliver_later serializes mailer action arguments:
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/message_delivery.rb#L142-L155
+ Goodmail::Mailer.compose_message(
+ slice_mail_headers(headers),
+ parts.html,
+ parts.text,
+ resolved_unsubscribe_url(headers),
+ parts.attachments
+ )
+ end
end
private
- # Whitelist standard headers to pass to ActionMailer's mail() method
- # Excludes custom headers like :unsubscribe_url, :preheader
+ # Pass Action Mailer's normal header surface through, excluding only
+ # Goodmail render-only options such as :unsubscribe_url and :preheader.
def slice_mail_headers(h)
- h.slice(:to, :from, :cc, :bcc, :reply_to, :subject)
+ Goodmail.action_mailer_headers(h)
end
- # Removed generate_plaintext - now handled by Premailer in Mailer#compose_message
+ def render_config(headers)
+ render_option(headers, :config) || render_option(headers, :configuration)
+ end
+
+ def resolved_unsubscribe_url(headers)
+ render_option(headers, :unsubscribe_url) || Goodmail.config.unsubscribe_url
+ end
+
+ def render_option(headers, key)
+ return headers[key] if headers.key?(key)
+
+ headers[key.to_s] if headers.key?(key.to_s)
+ end
end
end
diff --git a/lib/goodmail/email.rb b/lib/goodmail/email.rb
index 5a83909..6c60ff6 100644
--- a/lib/goodmail/email.rb
+++ b/lib/goodmail/email.rb
@@ -3,69 +3,105 @@
require "cgi" # For unescaping HTML in plaintext generation (though Premailer might handle most)
module Goodmail
- # Simple struct to hold the rendered HTML and text parts of an email.
- EmailParts = Struct.new(:html, :text, keyword_init: true)
+ # Simple struct to hold the rendered HTML and text parts of an email, plus
+ # any attachments collected via the `attach` / `inline_image` DSL helpers.
+ # Custom Action Mailer classes can call Goodmail's auto-installed
+ # `goodmail_mail_parts(parts, headers)` helper to apply attachments, pin
+ # inline Content-IDs, add unsubscribe headers, and send the multipart body.
+ #
+ # `attachments` defaults to `[]` for backwards compatibility — callers
+ # written against 0.3.x keep working unchanged.
+ EmailParts = Struct.new(:html, :text, :attachments, keyword_init: true) do
+ def initialize(html: nil, text: nil, attachments: [])
+ super(html: html, text: text, attachments: attachments || [])
+ end
+ end
# Renders the email content using the Goodmail DSL and returns HTML and text parts.
# This method does not send the email but prepares its content for sending.
#
# @param headers [Hash] Mail headers. Expected to contain :subject.
- # Can also contain :unsubscribe_url and :preheader to override defaults.
+ # Can also contain :unsubscribe_url, :preheader, :locale,
+ # :context, :config, and :layout_path to override
+ # render-only behavior.
# @param dsl_block [Proc] Block containing Goodmail DSL calls (text, button, etc.)
# @return [Goodmail::EmailParts] An object containing the :html and :text email parts.
def self.render(headers = {}, &dsl_block)
# 1. Initialize the Builder and execute the DSL block
- builder = Goodmail::Builder.new
- builder.instance_eval(&dsl_block) if block_given?
- core_html_content = builder.html_output
-
- # 2. Determine unsubscribe_url and preheader
- # These are removed from headers as they are Goodmail-specific, not standard mail headers.
current_headers = headers.dup # Avoid modifying the original headers hash directly
- unsubscribe_url = current_headers.delete(:unsubscribe_url) || Goodmail.config.unsubscribe_url
- preheader = current_headers.delete(:preheader) || Goodmail.config.default_preheader || current_headers[:subject]
+ context = render_header_value!(current_headers, :context)
+ locale = render_header_value!(current_headers, :locale)
+ render_config = render_header_value!(current_headers, :config)
+ render_config = render_header_value!(current_headers, :configuration) if render_config.nil?
+ layout_path = render_header_value!(current_headers, :layout_path)
+ subject = render_header_value!(current_headers, :subject)
- # 3. Render the raw HTML body using the Layout
- # The subject is passed for the tag and potentially other uses in layout.
- # Unsubscribe URL and preheader are passed for inclusion in the layout.
- raw_html_body = Goodmail::Layout.render(
- core_html_content,
- current_headers[:subject], # Use subject from (potentially modified) current_headers
- unsubscribe_url: unsubscribe_url,
- preheader: preheader
- )
+ Goodmail.with_config(render_config) do
+ builder = Goodmail::Builder.new(context: context)
+ evaluate_builder_dsl(builder, locale, &dsl_block)
+ core_html_content = builder.html_output
- # 4. Use Premailer to inline CSS and generate plaintext
- premailer = Premailer.new(
- raw_html_body,
- with_html_string: true,
- adapter: :nokogiri,
- preserve_styles: false, # Force inlining and remove