goodmail 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +199 -0
- data/.simplecov +55 -0
- data/CHANGELOG.md +73 -0
- data/README.md +127 -55
- data/Rakefile +12 -1
- data/context7.json +4 -0
- data/examples/PAY_TESTING_GUIDE.md +497 -0
- data/examples/pay_goodmailer.rb +518 -0
- data/lib/goodmail/action_mailer_integration.rb +286 -0
- data/lib/goodmail/builder.rb +304 -15
- data/lib/goodmail/configuration.rb +40 -3
- data/lib/goodmail/dispatcher.rb +41 -42
- data/lib/goodmail/email.rb +82 -46
- data/lib/goodmail/layout.erb +0 -1
- data/lib/goodmail/layout.rb +1 -1
- data/lib/goodmail/mailer.rb +19 -44
- data/lib/goodmail/plaintext.rb +236 -0
- data/lib/goodmail/version.rb +1 -1
- data/lib/goodmail.rb +8 -4
- metadata +22 -29
|
@@ -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
|
data/lib/goodmail/builder.rb
CHANGED
|
@@ -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 (&, <, >, ", '). 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
|
-
#
|
|
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
|
-
|
|
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} – #{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
|
-
|
|
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
|
-
#
|
|
111
|
-
|
|
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
|