Class: Goodmail::Builder
- Inherits:
-
Object
- Object
- Goodmail::Builder
- Includes:
- ERB::Util
- Defined in:
- lib/goodmail/builder.rb
Overview
Builds the HTML content string based on DSL method calls.
Constant Summary collapse
- HTML_SANITIZER =
Initialize a basic sanitizer allowing inline emphasis (<a>, <strong>, <em>, <b>, <i>) — 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.
Rails::Html::SafeListSanitizer.new
- ALLOWED_TAGS =
%w(a strong em b i).freeze
- ALLOWED_ATTRIBUTES =
%w(href).freeze
- INLINE_CONTENT_ID_DOMAIN =
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.goodmail.invalid"- INTERNAL_INSTANCE_VARIABLES =
%i[@parts @attachments @goodmail_context].freeze
- HEADING_STYLES =
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.
{ 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
Instance Attribute Summary collapse
-
#attachments ⇒ Object
readonly
Returns the value of attribute attachments.
-
#parts ⇒ Object
readonly
Returns the value of attribute parts.
Instance Method Summary collapse
-
#attach(filename, content, mime_type: nil, inline: false) ⇒ Object
Adds an email attachment (file) to the outgoing message.
- #button(text, url) ⇒ Object
- #center(&block) ⇒ Object
-
#code_box(text) ⇒ Object
Adds a simple code box with background styling.
-
#html(raw_html_string) ⇒ Object
Allows inserting raw, trusted HTML.
-
#html_output ⇒ Object
Returns the collected HTML parts joined together.
- #image(src, alt = "", width: nil, height: nil) ⇒ Object
-
#info_row(label, value) ⇒ Object
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.
-
#initialize(context: nil) ⇒ Builder
constructor
A new instance of Builder.
-
#inline_image(filename, content, alt: "", width: nil, height: nil, mime_type: nil) ⇒ Object
Embeds an inline image and emits the matching <img> tag at this point in the email body, referencing the attachment via ‘cid:`.
- #line ⇒ Object
-
#link(text, url) ⇒ Object
Inline styled link as a paragraph.
-
#price_row(name, price) ⇒ Object
Adds a simple price row as a styled paragraph.
- #sign(name = Goodmail.config.company_name) ⇒ Object
-
#small(str) ⇒ Object
Small/disclaimer text.
- #space(px = 16) ⇒ Object
-
#text(str) ⇒ Object
Adds a paragraph of text.
Constructor Details
#initialize(context: nil) ⇒ Builder
Returns a new instance of Builder.
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
# File 'lib/goodmail/builder.rb', line 44 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 |
Dynamic Method Handling
This class handles dynamic methods through the method_missing method
#method_missing(method_name, *args, **kwargs, &block) ⇒ Object (private)
442 443 444 445 446 447 448 449 |
# File 'lib/goodmail/builder.rb', line 442 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 |
Instance Attribute Details
#attachments ⇒ Object (readonly)
Returns the value of attribute attachments.
40 41 42 |
# File 'lib/goodmail/builder.rb', line 40 def @attachments end |
#parts ⇒ Object
Returns the value of attribute parts.
40 41 42 |
# File 'lib/goodmail/builder.rb', line 40 def parts @parts end |
Instance Method Details
#attach(filename, content, mime_type: nil, inline: false) ⇒ Object
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 <img> tag.
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/goodmail/builder.rb', line 255 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 && .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: (content), mime_type: mime_type, inline: inline, content_id: (generate_inline_content_id(filename) if inline) } << descriptor descriptor end |
#button(text, url) ⇒ Object
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/goodmail/builder.rb', line 75 def (text, url) # Standard HTML button link = %(<a href="#{h url}" class="goodmail-button-link" style="color:#ffffff;">#{h text}</a>) # VML fallback for Outlook = <<~VML <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="#{h url}" style="height:44px; v-text-anchor:middle; width:200px;" arcsize="10%" stroke="f" fillcolor="#{Goodmail.config.brand_color}"> <w:anchorlock/> <center style="color:#ffffff; font-family:sans-serif; font-size:14px; font-weight:bold;"> #{h text} </center> </v:roundrect> VML # MSO conditional wrapper mso_wrapper = <<~MSO <!--[if mso]> <table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;"><tr><td style="padding: 10px 0;" align="center"> #{.strip} </td></tr></table> <![endif]--> <!--[if !mso]><!--> #{} <!--<![endif]--> MSO # Final container div with class for primary CSS styling parts << %(<div class="goodmail-button" style="text-align: center; margin: 24px 0;">#{mso_wrapper.strip.html_safe}</div>) end |
#center(&block) ⇒ Object
326 327 328 |
# File 'lib/goodmail/builder.rb', line 326 def center(&block) wrap("div", "text-align:center;", &block) end |
#code_box(text) ⇒ Object
Adds a simple code box with background styling.
191 192 193 194 |
# File 'lib/goodmail/builder.rb', line 191 def code_box(text) # Re-added background/padding; content is simple, should survive Premailer plain text. parts << %(<p style="background:#F8F8F8; padding:20px; font-style:italic; text-align:center; color:#404040; margin:16px 0; border-radius: 4px;"><strong>#{h text}</strong></p>) end |
#html(raw_html_string) ⇒ Object
Allows inserting raw, trusted HTML. Use with extreme caution.
336 337 338 |
# File 'lib/goodmail/builder.rb', line 336 def html(raw_html_string) parts << raw_html_string.to_s end |
#html_output ⇒ Object
Returns the collected HTML parts joined together.
341 342 343 |
# File 'lib/goodmail/builder.rb', line 341 def html_output parts.join("\n") end |
#image(src, alt = "", width: nil, height: nil) ⇒ Object
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/goodmail/builder.rb', line 102 def image(src, alt = "", width: nil, height: nil) alt_text = alt.present? ? alt : Goodmail.config.company_name # Default alt text style = "max-width:100%; height:auto; display: block; margin: 0 auto;" style += " width:#{width}px;" if width style += " height:#{height}px;" if height # Standard image tag img_tag = %(<img class="goodmail-image" src="#{h src}" alt="#{h alt_text}" style="#{style}">) # MSO conditional wrapper for centering mso_wrapper = <<~MSO <!--[if mso]> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing:0; border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;"><tr><td style="padding: 20px 0;" align="center"> <![endif]--> #{img_tag} <!--[if mso]> </td></tr></table> <![endif]--> MSO parts << mso_wrapper.strip.html_safe end |
#info_row(label, value) ⇒ Object
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/
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/goodmail/builder.rb', line 164 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 <table class="goodmail-info-row" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse; width:100%; margin:0;"> <tr> <td valign="top" style="padding:12px 0; border-bottom:1px solid #eaeaea; color:#6b7280; font-size:14px; line-height:1.4; font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight:400; vertical-align:top;">#{label_html}</td> <td valign="top" align="right" style="padding:12px 0; border-bottom:1px solid #eaeaea; color:#111827; font-size:14px; line-height:1.4; font-family:'Helvetica Neue',Helvetica,Arial,sans-serif; font-weight:600; text-align:right; vertical-align:top;">#{value_html}</td> </tr> </table> HTML end |
#inline_image(filename, content, alt: "", width: nil, height: nil, mime_type: nil) ⇒ Object
Embeds an inline image and emits the matching <img> 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.
304 305 306 307 |
# File 'lib/goodmail/builder.rb', line 304 def inline_image(filename, content, alt: "", width: nil, height: nil, mime_type: nil) = attach(filename, content, mime_type: mime_type, inline: true) image("cid:#{[:content_id]}", alt, width: width, height: height) end |
#line ⇒ Object
330 331 332 333 |
# File 'lib/goodmail/builder.rb', line 330 def line # Use a class for easier styling via layout CSS parts << %(<hr class="goodmail-hr">) end |
#link(text, url) ⇒ Object
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).
215 216 217 |
# File 'lib/goodmail/builder.rb', line 215 def link(text, url) parts << %(<p style="margin:16px 0; line-height: 1.6;"><a href="#{h url}" style="color:#{Goodmail.config.brand_color}; text-decoration:underline;">#{h text}</a></p>) end |
#price_row(name, price) ⇒ Object
Adds a simple price row as a styled paragraph. 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.
129 130 131 |
# File 'lib/goodmail/builder.rb', line 129 def price_row(name, price) parts << %(<p style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; font-size: 14px; font-weight:bold; text-align:center; border-top:1px solid #eaeaea; padding:14px 0; margin: 0;">#{h name} – #{h price}</p>) end |
#sign(name = Goodmail.config.company_name) ⇒ Object
201 202 203 204 |
# File 'lib/goodmail/builder.rb', line 201 def sign(name = Goodmail.config.company_name) # Use #777 for better contrast than #888 parts << %(<p style="margin:16px 0; line-height: 1.6;"><span style="color: #777;">– #{h name}</span></p>) end |
#small(str) ⇒ Object
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.
224 225 226 227 228 229 230 231 |
# File 'lib/goodmail/builder.rb', line 224 def small(str) sanitized_content = HTML_SANITIZER.sanitize( str.to_s, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES ) parts << %(<p style="margin:12px 0; line-height: 1.5; font-size: 12px; color: #777;">#{sanitized_content.gsub(/\n/, "<br>")}</p>) end |
#space(px = 16) ⇒ Object
196 197 198 199 |
# File 'lib/goodmail/builder.rb', line 196 def space(px = 16) # Rely on CSS height for spacing, avoid if possible parts << %(<div style="height:#{Integer(px)}px; line-height: #{Integer(px)}px; font-size: 1px;"></div>) end |
#text(str) ⇒ Object
Adds a paragraph of text. Handles newline characters for
tags. Allows safe inline <a> tags with href attributes; strips other HTML.
64 65 66 67 68 69 70 71 72 73 |
# File 'lib/goodmail/builder.rb', line 64 def text(str) # Sanitize first, allowing only safe tags like <a> sanitized_content = HTML_SANITIZER.sanitize( str.to_s, # Ensure input is a string tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES ) # Then handle newlines and wrap in paragraph parts << tag(:p, sanitized_content.gsub(/\n/, "<br>"), style: "margin:16px 0; line-height: 1.6;") end |