goodmail 0.3.0 → 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.
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Goodmail
6
+ LIST_UNSUBSCRIBE_HEADER = "List-Unsubscribe"
7
+ LIST_UNSUBSCRIBE_POST_HEADER = "List-Unsubscribe-Post"
8
+ LIST_UNSUBSCRIBE_ONE_CLICK_VALUE = "List-Unsubscribe=One-Click"
9
+ GOODMAIL_RENDER_HEADER_KEYS = %i[
10
+ preheader
11
+ unsubscribe_url
12
+ locale
13
+ context
14
+ config
15
+ configuration
16
+ layout_path
17
+ ].freeze
18
+
19
+ # Returns the deliverability headers for a configured unsubscribe URL.
20
+ #
21
+ # RFC 8058 one-click unsubscribe is only eligible for HTTPS
22
+ # List-Unsubscribe URLs and uses the exact `List-Unsubscribe=One-Click`
23
+ # POST body. Keep the classic List-Unsubscribe header for other non-blank
24
+ # values, but don't advertise one-click POST support unless the URL qualifies.
25
+ #
26
+ # Sources:
27
+ # - RFC 8058 §3.1:
28
+ # https://www.rfc-editor.org/rfc/rfc8058#section-3.1
29
+ # - Gmail sender guidelines:
30
+ # https://support.google.com/mail/answer/81126
31
+ # - Yahoo sender best practices:
32
+ # https://senders.yahooinc.com/best-practices/
33
+ def self.list_unsubscribe_headers(unsubscribe_url)
34
+ return {} unless unsubscribe_url.is_a?(String)
35
+
36
+ stripped_url = unsubscribe_url.strip
37
+ return {} if stripped_url.empty?
38
+
39
+ headers = { LIST_UNSUBSCRIBE_HEADER => "<#{stripped_url}>" }
40
+ if one_click_unsubscribe_url?(stripped_url)
41
+ headers[LIST_UNSUBSCRIBE_POST_HEADER] = LIST_UNSUBSCRIBE_ONE_CLICK_VALUE
42
+ end
43
+ headers
44
+ end
45
+
46
+ def self.one_click_unsubscribe_url?(url)
47
+ uri = URI.parse(url)
48
+ uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty?
49
+ rescue URI::InvalidURIError
50
+ false
51
+ end
52
+
53
+ # Returns the header hash Goodmail should hand to Action Mailer's `mail`.
54
+ # Rails intentionally accepts arbitrary message headers and filters only
55
+ # framework-only render keys internally; Goodmail should follow that shape
56
+ # instead of maintaining a narrow whitelist of envelope fields.
57
+ # Source:
58
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L972-L976
59
+ def self.action_mailer_headers(headers)
60
+ headers.each_with_object({}) do |(key, value), result|
61
+ next if render_header_key?(key)
62
+
63
+ result[key] = value
64
+ end
65
+ end
66
+
67
+ def self.render_header_key?(key)
68
+ GOODMAIL_RENDER_HEADER_KEYS.include?(key) ||
69
+ (key.is_a?(String) && GOODMAIL_RENDER_HEADER_KEYS.include?(key.to_sym))
70
+ end
71
+
72
+ # Installs Goodmail's mailer helpers once on ActionMailer::Base so app
73
+ # mailers, Devise mailers, Pay mailers, and other custom Action Mailer
74
+ # subclasses can call `goodmail_mail` without per-class include glue.
75
+ #
76
+ # The helper methods stay private because Action Mailer dispatches mailer
77
+ # actions through `action_methods`; keeping the API private prevents Rails
78
+ # from treating helpers as deliverable actions.
79
+ # Source:
80
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L614-L618
81
+ def self.install_action_mailer_integration!(base = ActionMailer::Base)
82
+ return if base < ActionMailerIntegration
83
+
84
+ base.include(ActionMailerIntegration)
85
+ end
86
+
87
+ # Private helpers for Action Mailer classes that use `Goodmail.render` and
88
+ # then call `mail()` themselves. Goodmail installs this module into
89
+ # ActionMailer::Base at load time; apps should not need to include it.
90
+ module ActionMailerIntegration
91
+ DEFAULT_UNSUBSCRIBE_URL = Object.new.freeze
92
+
93
+ private
94
+
95
+ # High-level wrapper for the common custom-mailer path:
96
+ #
97
+ # goodmail_mail(to: user.email, subject: "Hello", preheader: "...") do
98
+ # text "Body"
99
+ # end
100
+ #
101
+ # Goodmail-specific keys (`:preheader`, `:unsubscribe_url`) are passed to
102
+ # `Goodmail.render` and stripped before calling Action Mailer's `mail()`.
103
+ # Attachments and inline CIDs are applied before the final `mail()` call
104
+ # because Rails rejects attachment writes after `mail` has materialized the
105
+ # message.
106
+ # Sources:
107
+ # - `mail` creates parts and finalizes content type:
108
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L875-L907
109
+ # - late attachments raise:
110
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L766-L781
111
+ def goodmail_mail(
112
+ mail_headers = {},
113
+ render_options: nil,
114
+ unsubscribe_url: DEFAULT_UNSUBSCRIBE_URL,
115
+ **headers,
116
+ &block
117
+ )
118
+ raise ArgumentError, "goodmail_mail requires a block" unless block_given?
119
+
120
+ mail_headers = mail_headers.merge(headers)
121
+ render_headers = goodmail_render_options(mail_headers, render_options)
122
+ render_config = goodmail_render_config(render_headers)
123
+
124
+ Goodmail.with_config(render_config) do
125
+ resolved_unsubscribe_url = goodmail_resolve_unsubscribe_url(
126
+ mail_headers,
127
+ render_headers,
128
+ unsubscribe_url
129
+ )
130
+ render_headers[:unsubscribe_url] = resolved_unsubscribe_url if goodmail_present?(resolved_unsubscribe_url)
131
+ # Preserve the Action Mailer instance as Goodmail's render context so
132
+ # DSL blocks can still read mailer ivars and helper methods even though
133
+ # Goodmail evaluates them on its Builder receiver.
134
+ # Source:
135
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/README.rdoc#L20-L37
136
+ render_headers[:context] = self unless goodmail_header_key?(render_headers, :context)
137
+
138
+ parts = goodmail_render_parts(render_headers, &block)
139
+ goodmail_mail_parts(parts, mail_headers, unsubscribe_url: resolved_unsubscribe_url)
140
+ end
141
+ end
142
+
143
+ # Context-aware render helper for mailers that truly need to render first
144
+ # and inspect or mutate generated parts before calling `mail()` later.
145
+ # It injects the current mailer as the Goodmail render context so app code
146
+ # does not need to repeat `Goodmail.render(..., context: self)` everywhere.
147
+ # Source:
148
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/README.rdoc#L20-L37
149
+ def goodmail_render_parts(render_options = {}, **headers, &block)
150
+ raise ArgumentError, "goodmail_render_parts requires a block" unless block_given?
151
+
152
+ render_headers = render_options.merge(headers)
153
+ render_headers[:context] = self unless goodmail_header_key?(render_headers, :context)
154
+
155
+ Goodmail.render(render_headers, &block)
156
+ end
157
+
158
+ # Lower-level wrapper for apps that must call `Goodmail.render` separately
159
+ # but still want Goodmail to own the mechanical Action Mailer handoff. This
160
+ # stays inside the mailer method, so Action Mailer's lazy `MessageDelivery`
161
+ # and `deliver_later` serialization model remain intact.
162
+ # Source:
163
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/message_delivery.rb#L142-L155
164
+ def goodmail_mail_parts(parts, mail_headers = {}, unsubscribe_url: DEFAULT_UNSUBSCRIBE_URL, **headers)
165
+ goodmail_apply_parts!(parts)
166
+
167
+ mail_headers = mail_headers.merge(headers)
168
+ final_headers = goodmail_mail_headers(mail_headers)
169
+ resolved_unsubscribe_url = goodmail_resolve_unsubscribe_url(
170
+ mail_headers,
171
+ {},
172
+ unsubscribe_url
173
+ )
174
+ goodmail_add_list_unsubscribe_headers!(final_headers, resolved_unsubscribe_url)
175
+
176
+ # Rails' documented block form builds explicit text/html responses via
177
+ # ActionMailer::Collector and then lets `mail` assemble the MIME tree.
178
+ # Sources:
179
+ # - block-form `mail`: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L851-L873
180
+ # - collector response body: https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/collector.rb#L25-L29
181
+ mail(final_headers) do |format|
182
+ format.text { render plain: parts.text.to_s }
183
+ format.html { render html: parts.html.to_s.html_safe }
184
+ end
185
+ end
186
+
187
+ # Applies `Goodmail.render(...).attachments` to Action Mailer's attachment
188
+ # collection. Old Goodmail versions returned only html/text, so a missing
189
+ # `attachments` method is a no-op for compatibility with older apps.
190
+ def goodmail_apply_parts!(parts)
191
+ return parts unless parts.respond_to?(:attachments)
192
+
193
+ goodmail_apply_attachments!(parts.attachments)
194
+ parts
195
+ end
196
+
197
+ def goodmail_apply_attachments!(attachment_descriptors)
198
+ Array(attachment_descriptors).each do |attachment|
199
+ # Use Action Mailer's public attachment APIs. Rails chooses the final
200
+ # MIME container afterwards (`multipart/related` for inline-only,
201
+ # `multipart/mixed` plus nested related parts for mixed attachments).
202
+ # Source:
203
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/base.rb#L1024-L1042
204
+ target = attachment[:inline] ? attachments.inline : attachments
205
+ payload =
206
+ if attachment[:mime_type].to_s.strip.empty?
207
+ attachment[:content]
208
+ else
209
+ { mime_type: attachment[:mime_type], content: attachment[:content] }
210
+ end
211
+ target[attachment[:filename]] = payload
212
+
213
+ next unless attachment[:inline]
214
+
215
+ # `inline_image` emits `<img src="cid:GENERATED_ID">` before Action
216
+ # Mailer materializes the part. Pin the Mail::Part to that generated
217
+ # Content-ID so RFC 2392 `cid:` resolution lands on this exact part.
218
+ # Rails' preview interceptor also resolves `cid:` URLs by matching
219
+ # against attachment CIDs, so this keeps previews and deliveries aligned.
220
+ # Sources:
221
+ # - RFC 2392: https://www.rfc-editor.org/rfc/rfc2392
222
+ # - Rails CID preview lookup:
223
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/lib/action_mailer/inline_preview_interceptor.rb#L33-L57
224
+ content_id = attachment[:content_id].to_s.strip
225
+ content_id = attachment[:filename].to_s if content_id.empty?
226
+ attachments[attachment[:filename]].content_id = "<#{content_id}>"
227
+ end
228
+ end
229
+
230
+ def goodmail_add_list_unsubscribe_headers!(headers, unsubscribe_url)
231
+ headers.merge!(Goodmail.list_unsubscribe_headers(unsubscribe_url))
232
+ end
233
+
234
+ def goodmail_list_unsubscribe_headers(unsubscribe_url)
235
+ Goodmail.list_unsubscribe_headers(unsubscribe_url)
236
+ end
237
+
238
+ def goodmail_render_options(mail_headers, render_options)
239
+ render_headers = {}
240
+ if goodmail_header_key?(mail_headers, :subject)
241
+ render_headers[:subject] = goodmail_header_value(mail_headers, :subject)
242
+ end
243
+ render_headers.merge!(render_options || {})
244
+
245
+ GOODMAIL_RENDER_HEADER_KEYS.each do |key|
246
+ next if render_headers.key?(key) || !goodmail_header_key?(mail_headers, key)
247
+
248
+ render_headers[key] = goodmail_header_value(mail_headers, key)
249
+ end
250
+
251
+ render_headers
252
+ end
253
+
254
+ def goodmail_mail_headers(mail_headers)
255
+ Goodmail.action_mailer_headers(mail_headers)
256
+ end
257
+
258
+ def goodmail_resolve_unsubscribe_url(mail_headers, render_headers, unsubscribe_url)
259
+ return unsubscribe_url unless unsubscribe_url.equal?(DEFAULT_UNSUBSCRIBE_URL)
260
+ return render_headers[:unsubscribe_url] if render_headers.key?(:unsubscribe_url)
261
+ if goodmail_header_key?(mail_headers, :unsubscribe_url)
262
+ return goodmail_header_value(mail_headers, :unsubscribe_url)
263
+ end
264
+
265
+ Goodmail.config.unsubscribe_url
266
+ end
267
+
268
+ def goodmail_header_key?(headers, key)
269
+ headers.key?(key) || headers.key?(key.to_s)
270
+ end
271
+
272
+ def goodmail_header_value(headers, key)
273
+ headers.key?(key) ? headers[key] : headers[key.to_s]
274
+ end
275
+
276
+ def goodmail_render_config(headers)
277
+ return goodmail_header_value(headers, :config) if goodmail_header_key?(headers, :config)
278
+
279
+ goodmail_header_value(headers, :configuration) if goodmail_header_key?(headers, :configuration)
280
+ end
281
+
282
+ def goodmail_present?(value)
283
+ !value.nil? && (!value.respond_to?(:empty?) || !value.empty?)
284
+ end
285
+ end
286
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require "erb"
3
3
  require "rails-html-sanitizer" # Require the sanitizer
4
+ require "securerandom"
4
5
 
5
6
  module Goodmail
6
7
  # Builds the HTML content string based on DSL method calls.
@@ -9,16 +10,51 @@ module Goodmail
9
10
  # The h helper, included from ERB::Util, stands for html_escape.
10
11
  # It converts special characters (&, <, >, ", ') into their HTML entity equivalents (&amp;, &lt;, &gt;, &quot;, &#39;). This prevents Cross-Site Scripting (XSS) by ensuring dynamic content is displayed as literal text rather than being interpreted as HTML.
11
12
 
12
-
13
- # Initialize a basic sanitizer allowing only <a> tags with href
13
+ # Initialize a basic sanitizer allowing inline emphasis (<a>, <strong>,
14
+ # <em>, <b>, <i>) the formatting tags every email client renders
15
+ # consistently and that don't add layout risk to the table-based template.
16
+ #
17
+ # Why these specific tags:
18
+ # - `a[href]`: clickable links, basic.
19
+ # - `strong` / `em`: semantic emphasis. Both are universally supported
20
+ # in email clients (including Outlook 2007+ which famously drops
21
+ # more exotic tags). Source: https://www.caniemail.com/features/html-strong/
22
+ # - `b` / `i`: legacy non-semantic equivalents that some translation
23
+ # workflows still emit. Allowed for symmetry — they render identically
24
+ # to `strong` / `em` in every modern client.
25
+ #
26
+ # Anything more (h1/h2 inside text, ul/li, span/div) belongs in dedicated
27
+ # DSL helpers (`h1`, `h2`, `h3`, `code_box`, …) that compose proper
28
+ # styled blocks with table-safe markup, not in inline text.
14
29
  HTML_SANITIZER = Rails::Html::SafeListSanitizer.new
15
- ALLOWED_TAGS = %w(a).freeze
30
+ ALLOWED_TAGS = %w(a strong em b i).freeze
16
31
  ALLOWED_ATTRIBUTES = %w(href).freeze
32
+ # RFC 2606 / RFC 6761 reserve `.invalid` for names that should not collide
33
+ # with real DNS. Goodmail only needs a stable addr-spec domain for generated
34
+ # Content-IDs; it should never imply a routable host.
35
+ # Sources:
36
+ # - https://www.rfc-editor.org/rfc/rfc2606#section-2
37
+ # - https://www.rfc-editor.org/rfc/rfc6761#section-6.4
38
+ INLINE_CONTENT_ID_DOMAIN = "inline.goodmail.invalid"
39
+
40
+ attr_reader :parts, :attachments
17
41
 
18
- attr_reader :parts
42
+ INTERNAL_INSTANCE_VARIABLES = %i[@parts @attachments @goodmail_context].freeze
19
43
 
20
- def initialize
44
+ def initialize(context: nil)
45
+ copy_context_instance_variables(context)
46
+ @goodmail_context = context
21
47
  @parts = []
48
+ # Email-level attachments collected via the `attach` DSL method. Stored as
49
+ # `[{ filename:, content:, mime_type: }, ...]` and consumed by the
50
+ # internal `Goodmail::Mailer` (via `Goodmail::Dispatcher`) before the
51
+ # `mail()` call so they are forwarded on the outgoing message. We collect
52
+ # here (rather than calling `attachments[]=` directly on a mailer
53
+ # instance) because the DSL block is `instance_eval`'d on the Builder
54
+ # — it has no Mailer context and can't reach into ActionMailer's
55
+ # attachments hash. See `Mailer#compose_message` for how these are
56
+ # applied.
57
+ @attachments = []
22
58
  end
23
59
 
24
60
  # DSL Methods
@@ -84,11 +120,73 @@ module Goodmail
84
120
  end
85
121
 
86
122
  # Adds a simple price row as a styled paragraph.
87
- # NOTE: This does not create a table structure.
123
+ # NOTE: This does not create a table structure. The visual is bold,
124
+ # centered, and separator-bordered — designed for receipt-style line
125
+ # items where the LABEL and the AMOUNT carry equal weight ("Premium
126
+ # plan - $49.00", "Tax - $4.90"). For label/value rows where the
127
+ # label is supporting context and the value is the primary content
128
+ # ("Plan - Pro", "Status - Active"), prefer `info_row` below.
88
129
  def price_row(name, price)
89
130
  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} &nbsp; &ndash; &nbsp; #{h price}</p>)
90
131
  end
91
132
 
133
+ # Adds a label/value row using the two-column table pattern that
134
+ # Stripe / Linear / Square / Resend all converge on for transactional
135
+ # info cards: muted label on the left, dark right-aligned value on
136
+ # the right, 1px hairline at the bottom for visual separation.
137
+ #
138
+ # Why a TWO-CELL TABLE (and not a flexbox/grid div):
139
+ # - Outlook on Windows uses Word's HTML rendering engine (no
140
+ # `display: flex` / `grid`, no `gap`). Tables are the only
141
+ # layout primitive that renders consistently across every modern
142
+ # and legacy client. Source: https://www.caniemail.com/features/css-display-flex/
143
+ # - `cellpadding=0 cellspacing=0 border=0` + `border-collapse:
144
+ # collapse` neutralizes the historical browser defaults and
145
+ # gives us pixel control via the inline `padding`.
146
+ # - `role="presentation"` tells screen readers to skip the table
147
+ # semantics — this is layout, not data. Source: WAI-ARIA 1.2
148
+ # `presentation` / `none` role:
149
+ # https://www.w3.org/TR/wai-aria-1.2/#presentation
150
+ #
151
+ # Why two SEPARATE tables per call (vs. one table with many rows):
152
+ # - The block-level DSL emits each call as a self-contained unit,
153
+ # same as `price_row` / `text` / `button`. Mixing rows from
154
+ # different DSL calls into one shared table would require a
155
+ # `Builder` flush phase that mutates earlier output — complex
156
+ # and surprising. Adjacent two-cell tables visually collapse
157
+ # into one continuous list when their bottom border meets the
158
+ # next row's top edge, so the user sees a single list anyway.
159
+ #
160
+ # Sources on email-safe table-row patterns:
161
+ # - https://www.cerberusemail.com/templates (responsive table patterns)
162
+ # - https://www.litmus.com/blog/the-ultimate-guide-to-css/
163
+ # - https://htmlemail.io/blog/responsive-html-emails-creating-a-simple-responsive-email/
164
+ def info_row(label, value)
165
+ label_html = h(label.to_s)
166
+ value_html = h(value.to_s)
167
+ # The `class="goodmail-info-row"` hook is the marker
168
+ # `Goodmail::Plaintext` looks for to flatten this two-cell table
169
+ # into a single `Label: Value` line in the plaintext part. HTML
170
+ # email clients render the visible table; text-only clients see
171
+ # the readable colon-form. Without the marker, Premailer would
172
+ # emit the cells on two separate lines:
173
+ #
174
+ # Label
175
+ # Value
176
+ #
177
+ # which is correct table-extraction behavior but a worse
178
+ # plaintext UX than the conventional `Label: Value` shape every
179
+ # other transactional sender uses.
180
+ parts << <<~HTML.strip
181
+ <table class="goodmail-info-row" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse; width:100%; margin:0;">
182
+ <tr>
183
+ <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>
184
+ <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>
185
+ </tr>
186
+ </table>
187
+ HTML
188
+ end
189
+
92
190
  # Adds a simple code box with background styling.
93
191
  def code_box(text)
94
192
  # Re-added background/padding; content is simple, should survive Premailer plain text.
@@ -105,16 +203,122 @@ module Goodmail
105
203
  parts << %(<p style="margin:16px 0; line-height: 1.6;"><span style="color: #777;">– #{h name}</span></p>)
106
204
  end
107
205
 
108
- %i[h1 h2 h3].each do |heading_tag|
206
+ # Inline styled link as a paragraph. Wraps `button` for cases where a full
207
+ # call-to-action button is too heavy — e.g. "View receipt", "Open the
208
+ # account", "Read the full policy". The link is rendered in the
209
+ # configured brand color and underlined, matching the layout's `a {}` rule
210
+ # so the visual stays consistent in clients that strip inline styles.
211
+ #
212
+ # Both `text` and `url` are HTML-escaped to prevent any accidental injection
213
+ # from interpolated user content (e.g. a customer name in a label, a
214
+ # URL with arbitrary query strings).
215
+ def link(text, url)
216
+ 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>)
217
+ end
218
+
219
+ # Small/disclaimer text. Designed for legal language, fine print, "you
220
+ # received this because…", and similar secondary content. Uses the same
221
+ # neutral grey as the footer (`#777`) and a slightly smaller font size.
222
+ # Newlines become <br>, mirroring `text` so callers don't have to think
223
+ # about which helper handles which.
224
+ def small(str)
225
+ sanitized_content = HTML_SANITIZER.sanitize(
226
+ str.to_s,
227
+ tags: ALLOWED_TAGS,
228
+ attributes: ALLOWED_ATTRIBUTES
229
+ )
230
+ parts << %(<p style="margin:12px 0; line-height: 1.5; font-size: 12px; color: #777;">#{sanitized_content.gsub(/\n/, "<br>")}</p>)
231
+ end
232
+
233
+ # Adds an email attachment (file) to the outgoing message. Use for PDFs,
234
+ # .ics calendar invites, .csv exports, summary images you want stored on
235
+ # the recipient's machine, etc.
236
+ #
237
+ # Sources:
238
+ # - ActionMailer attachments docs:
239
+ # https://guides.rubyonrails.org/action_mailer_basics.html#sending-emails-with-attachments
240
+ # - RFC 2392 (Content-ID URLs for inline images):
241
+ # https://www.rfc-editor.org/rfc/rfc2392
242
+ #
243
+ # Parameters:
244
+ # filename — the name the recipient sees (e.g. "receipt.pdf").
245
+ # content — either the raw bytes (String, IO) or a filesystem path
246
+ # (String). Strings that point at an existing file path are
247
+ # read from disk; otherwise the String is used as-is.
248
+ # mime_type — optional Content-Type override. When omitted, Action Mailer
249
+ # infers it from the filename via Mime::Type.lookup_by_extension.
250
+ # inline — when true, the attachment is marked as `inline` so the
251
+ # email body can reference it via `cid:`. Useful for
252
+ # embedding logos / maps when you can't (or don't want to)
253
+ # host them publicly. Prefer `inline_image` below when you
254
+ # also want Goodmail to emit the matching <img> tag.
255
+ def attach(filename, content, mime_type: nil, inline: false)
256
+ filename = filename.to_s
257
+
258
+ # Inline attachments are referenced from the email body via `cid:`.
259
+ # Duplicate filenames are ambiguous in custom `Goodmail.render`
260
+ # fan-out code because Action Mailer's attachment hash is keyed by
261
+ # filename, so keep the documented "one inline filename per message"
262
+ # contract even though Goodmail generates distinct Content-IDs.
263
+ # Source: https://guides.rubyonrails.org/action_mailer_basics.html#sending-emails-with-attachments
264
+ #
265
+ # Non-inline attachments don't have the same problem — they're
266
+ # downloaded by the recipient by filename, so a duplicate
267
+ # produces two files with the same name (annoying UX but not a
268
+ # rendering bug). We allow those.
269
+ if inline && attachments.any? { |a| a[:inline] && a[:filename] == filename }
270
+ raise Goodmail::Error, "duplicate inline filename #{filename.inspect}. Use a distinct filename per inline_image call."
271
+ end
272
+
273
+ descriptor = {
274
+ filename: filename,
275
+ content: resolve_attachment_content(content),
276
+ mime_type: mime_type,
277
+ inline: inline,
278
+ content_id: (generate_inline_content_id(filename) if inline)
279
+ }
280
+ attachments << descriptor
281
+ descriptor
282
+ end
283
+
284
+ # Embeds an inline image and emits the matching <img> tag at this point in
285
+ # the email body, referencing the attachment via `cid:`. Goodmail assigns
286
+ # a globally unique RFC 2392-shaped Content-ID and pins the Mail part to
287
+ # that same ID when the message is materialized.
288
+ # Sources:
289
+ # - RFC 2392 `cid:` URL / Content-ID mapping:
290
+ # https://www.rfc-editor.org/rfc/rfc2392
291
+ # - Rails inline attachment pattern:
292
+ # https://guides.rubyonrails.org/action_mailer_basics.html#making-inline-attachments
293
+ #
294
+ # `inline_image` is the right tool when:
295
+ # - the image must travel WITH the email so it renders in offline /
296
+ # end-of-cache scenarios (e.g. an Outlook user reading three months
297
+ # later when the public URL has expired),
298
+ # - or when you don't have a public URL to point at (private S3
299
+ # bucket, dev environment with localhost URLs, etc).
300
+ #
301
+ # When the asset already has a public URL you control, prefer the regular
302
+ # `image(src, alt)` helper — it's lighter on the wire and avoids attaching
303
+ # binary parts to every send.
304
+ def inline_image(filename, content, alt: "", width: nil, height: nil, mime_type: nil)
305
+ attachment = attach(filename, content, mime_type: mime_type, inline: true)
306
+ image("cid:#{attachment[:content_id]}", alt, width: width, height: height)
307
+ end
308
+
309
+ # The `case` only ever sees the three keys we iterate over below, so
310
+ # the inline lookup is exhaustive by construction — no defensive
311
+ # `else` clause needed.
312
+ HEADING_STYLES = {
313
+ h1: "margin: 40px 0 10px; font-size: 32px; font-weight: 500; line-height: 1.2em;",
314
+ h2: "margin: 40px 0 10px; font-size: 24px; font-weight: 400; line-height: 1.2em;",
315
+ h3: "margin: 40px 0 10px; font-size: 18px; font-weight: 400; line-height: 1.2em;"
316
+ }.freeze
317
+
318
+ HEADING_STYLES.each do |heading_tag, style|
109
319
  define_method(heading_tag) do |str|
110
- # Added basic heading styles, consistent with layout.erb
111
- style = case heading_tag
112
- when :h1 then "margin: 40px 0 10px; font-size: 32px; font-weight: 500; line-height: 1.2em;"
113
- when :h2 then "margin: 40px 0 10px; font-size: 24px; font-weight: 400; line-height: 1.2em;"
114
- when :h3 then "margin: 40px 0 10px; font-size: 18px; font-weight: 400; line-height: 1.2em;"
115
- else "margin: 16px 0; line-height: 1.6;"
116
- end
117
- # Headings should still have their content escaped
320
+ # Headings still escape their content only the surrounding tag
321
+ # markup is trusted.
118
322
  parts << tag(heading_tag, h(str), style: style)
119
323
  end
120
324
  end
@@ -140,6 +344,52 @@ module Goodmail
140
344
 
141
345
  private
142
346
 
347
+ # Loads file contents when `content` is a path to an existing file,
348
+ # otherwise returns it unchanged so callers can pass raw bytes / IO
349
+ # streams transparently. Paths win over byte-strings that happen to
350
+ # match a filename: this is intentional — the README's documented
351
+ # contract is "pass a path, we'll read it for you".
352
+ #
353
+ # Defensive checks before reaching `File.file?`:
354
+ #
355
+ # 1. NUL bytes — `File.file?` raises `ArgumentError: path name
356
+ # contains null byte` on any String containing `\0`, and that's
357
+ # exactly what binary file content (PNG / PDF / .ics) looks
358
+ # like. Treat NUL-containing Strings as "definitely not a
359
+ # path" so callers can pass `inline_image("logo.png", png_bytes)`
360
+ # without us blowing up trying to look up `png_bytes` as a path.
361
+ # 2. PATH_MAX — most filesystems cap paths at 4096 bytes (Linux
362
+ # `PATH_MAX`); macOS HFS+ at 1024. A String longer than that is
363
+ # structurally not a path and almost certainly file contents.
364
+ # We pick 4096 as the cutoff to be the most generous to legit
365
+ # paths while still cheaply screening out anything bigger.
366
+ #
367
+ # Source on `File.file?` and the NUL byte error:
368
+ # https://docs.ruby-lang.org/en/3.4/File.html#method-c-file-3F
369
+ def resolve_attachment_content(content)
370
+ return content unless content.is_a?(String)
371
+ return content if content.include?("\0")
372
+ return content if content.bytesize > 4096
373
+ return content unless File.file?(content)
374
+
375
+ File.binread(content)
376
+ end
377
+
378
+ # RFC 2392 maps `cid:` URLs to Content-ID headers using an addr-spec and
379
+ # says Content-IDs should be globally unique. Use a random local part plus
380
+ # a reserved `.invalid` domain rather than the filename itself; filenames
381
+ # can contain spaces/non-URL characters and are often reused across emails.
382
+ # Sources:
383
+ # - https://www.rfc-editor.org/rfc/rfc2392
384
+ # - https://www.rfc-editor.org/rfc/rfc6761#section-6.4
385
+ def generate_inline_content_id(filename)
386
+ safe_filename = filename.gsub(/[^A-Za-z0-9._+-]/, "-")
387
+ safe_filename = "attachment" if safe_filename.empty?
388
+ safe_filename = safe_filename[0, 64]
389
+
390
+ "#{SecureRandom.hex(12)}.#{safe_filename}@#{INLINE_CONTENT_ID_DOMAIN}"
391
+ end
392
+
143
393
  # Helper for creating simple HTML tags with optional style
144
394
  # Assumes content is already appropriately escaped or marked safe.
145
395
  def tag(name, content, style: nil)
@@ -162,5 +412,44 @@ module Goodmail
162
412
 
163
413
  # Prevent external modification of the parts array directly
164
414
  attr_writer :parts
415
+
416
+ def copy_context_instance_variables(context)
417
+ return unless context
418
+
419
+ # Goodmail evaluates DSL blocks with `instance_eval` so calls like
420
+ # `text "..."` remain terse. That changes `self` from the mailer to the
421
+ # builder, which would normally hide mailer ivars such as `@user`.
422
+ # Snapshot public mailer state onto the transient builder so Action
423
+ # Mailer users can write the same instance-variable style Rails
424
+ # documents for mailer views/actions while Goodmail still owns the DSL
425
+ # receiver.
426
+ # Sources:
427
+ # - Action Mailer actions assign instance variables for templates:
428
+ # https://github.com/rails/rails/blob/debbd18c562df17d01944c475e9291d927910b58/actionmailer/README.rdoc#L20-L37
429
+ # - Ruby `instance_eval` changes the block receiver:
430
+ # https://docs.ruby-lang.org/en/3.4/BasicObject.html#method-i-instance_eval
431
+ context.instance_variables.each do |ivar|
432
+ next if internal_instance_variable?(ivar)
433
+
434
+ instance_variable_set(ivar, context.instance_variable_get(ivar))
435
+ end
436
+ end
437
+
438
+ def internal_instance_variable?(ivar)
439
+ INTERNAL_INSTANCE_VARIABLES.include?(ivar) || ivar.to_s.start_with?("@_")
440
+ end
441
+
442
+ def method_missing(method_name, *args, **kwargs, &block)
443
+ context = @goodmail_context
444
+ if context.respond_to?(method_name, true)
445
+ return context.__send__(method_name, *args, **kwargs, &block)
446
+ end
447
+
448
+ super
449
+ end
450
+
451
+ def respond_to_missing?(method_name, include_private = false)
452
+ @goodmail_context.respond_to?(method_name, true) || super
453
+ end
165
454
  end
166
455
  end