activemail 1.0.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 +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +23 -0
- data/README.md +375 -0
- data/app/assets/stylesheets/active_mail/_active_mail_tokens.scss.erb +3 -0
- data/app/assets/stylesheets/active_mail/_components.scss +58 -0
- data/app/assets/stylesheets/active_mail/_dark.scss +123 -0
- data/app/assets/stylesheets/active_mail/_grid.scss +49 -0
- data/app/assets/stylesheets/active_mail/_settings.scss +25 -0
- data/app/assets/stylesheets/active_mail/_utilities.scss +47 -0
- data/app/assets/stylesheets/active_mail/active_mail.scss +44 -0
- data/app/helpers/active_mail/styles_helper.rb +29 -0
- data/app/views/layouts/active_mail/_footer.html.inky-erb +10 -0
- data/app/views/layouts/active_mail/_head.html.inky-erb +6 -0
- data/app/views/layouts/active_mail/mailer.html.inky-erb +35 -0
- data/lib/active_mail/components/base.rb +119 -0
- data/lib/active_mail/components/block_grid.rb +19 -0
- data/lib/active_mail/components/button.rb +39 -0
- data/lib/active_mail/components/callout.rb +23 -0
- data/lib/active_mail/components/center.rb +24 -0
- data/lib/active_mail/components/columns.rb +72 -0
- data/lib/active_mail/components/container.rb +25 -0
- data/lib/active_mail/components/cta.rb +36 -0
- data/lib/active_mail/components/h_line.rb +19 -0
- data/lib/active_mail/components/info_box.rb +32 -0
- data/lib/active_mail/components/inky.rb +18 -0
- data/lib/active_mail/components/menu.rb +22 -0
- data/lib/active_mail/components/menu_item.rb +21 -0
- data/lib/active_mail/components/row.rb +18 -0
- data/lib/active_mail/components/spacer.rb +45 -0
- data/lib/active_mail/components/wrapper.rb +21 -0
- data/lib/active_mail/configuration.rb +229 -0
- data/lib/active_mail/inliner/base.rb +29 -0
- data/lib/active_mail/inliner/interceptor.rb +51 -0
- data/lib/active_mail/inliner/null.rb +23 -0
- data/lib/active_mail/inliner/premailer.rb +22 -0
- data/lib/active_mail/inliner/roadie.rb +30 -0
- data/lib/active_mail/libxml.rb +8 -0
- data/lib/active_mail/parse_error_reporter.rb +47 -0
- data/lib/active_mail/quality/configuration.rb +56 -0
- data/lib/active_mail/quality/guard.rb +131 -0
- data/lib/active_mail/quality/minitest.rb +64 -0
- data/lib/active_mail/quality/preview_renderer.rb +70 -0
- data/lib/active_mail/quality/render_all.rb +90 -0
- data/lib/active_mail/quality/rspec.rb +65 -0
- data/lib/active_mail/quality.rb +38 -0
- data/lib/active_mail/rails/compiled_stylesheet.rb +62 -0
- data/lib/active_mail/rails/engine.rb +36 -0
- data/lib/active_mail/rails/template_handler.rb +62 -0
- data/lib/active_mail/tokens.rb +139 -0
- data/lib/active_mail/version.rb +6 -0
- data/lib/active_mail.rb +161 -0
- data/lib/activemail.rb +5 -0
- data/lib/generators/active_mail/component_generator.rb +31 -0
- data/lib/generators/active_mail/install_generator.rb +59 -0
- data/lib/generators/active_mail/styles_generator.rb +30 -0
- data/lib/generators/active_mail/templates/component.rb.tt +13 -0
- data/lib/generators/active_mail/templates/initializer.rb +32 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-erb +30 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-haml +17 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-slim +16 -0
- data/lib/generators/active_mail/views_generator.rb +16 -0
- data/lib/tasks/active_mail.rake +36 -0
- metadata +151 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Components
|
|
8
|
+
# Renders a bare <tr> (mirrors inky.js).
|
|
9
|
+
class Inky < Base
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { override.params(_node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
13
|
+
def transform(_node, inner)
|
|
14
|
+
%(<tr>#{inner}</tr>)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Components
|
|
8
|
+
class Menu < Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
12
|
+
def transform(node, inner)
|
|
13
|
+
attributes = combine_attributes(node, 'menu')
|
|
14
|
+
[
|
|
15
|
+
%(<table #{attributes} #{TABLE_RESET}#{style_attribute(node)}><tbody><tr><td>),
|
|
16
|
+
%(<table #{TABLE_RESET}><tbody><tr>#{inner}</tr></tbody></table>),
|
|
17
|
+
'</td></tr></tbody></table>'
|
|
18
|
+
].join
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Components
|
|
8
|
+
class MenuItem < Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
12
|
+
def transform(node, inner)
|
|
13
|
+
attributes = combine_attributes(node, 'menu-item')
|
|
14
|
+
# No href → a non-link item, not a broken <a href="">; mirrors <button>.
|
|
15
|
+
content = node.attr('href') ? %(<a href="#{escape_attr(node.attr('href'))}"#{target_attribute(node)}>#{inner}</a>) : inner
|
|
16
|
+
th = ::ActiveMail::Core::INTERIM_TH_TAG
|
|
17
|
+
%(<#{th} #{attributes}#{style_attribute(node)}>#{content}</#{th}>)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Components
|
|
8
|
+
class Row < Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
12
|
+
def transform(node, inner)
|
|
13
|
+
attributes = combine_attributes(node, 'row')
|
|
14
|
+
%(<table #{attributes} #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>#{inner}</tr></tbody></table>)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Components
|
|
8
|
+
class Spacer < Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { override.params(node: Nokogiri::XML::Node, _inner: String).returns(String) }
|
|
12
|
+
def transform(node, _inner)
|
|
13
|
+
classes = combine_classes(node, 'spacer')
|
|
14
|
+
size_sm = node.attr('size-sm')
|
|
15
|
+
size_lg = node.attr('size-lg')
|
|
16
|
+
|
|
17
|
+
return build_table(node, classes, nil, size_for(node.attr('size'))) unless size_sm || size_lg
|
|
18
|
+
|
|
19
|
+
html = +''
|
|
20
|
+
html << build_table(node, classes, 'hide-for-large', size_for(size_sm)) if size_sm
|
|
21
|
+
html << build_table(node, classes, 'show-for-large', size_for(size_lg)) if size_lg
|
|
22
|
+
html
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
sig { params(value: T.untyped).returns(Integer) }
|
|
28
|
+
def size_for(value)
|
|
29
|
+
positive_int(value) || 16
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { params(node: Nokogiri::XML::Node, classes: String, extra: T.nilable(String), size: Integer).returns(String) }
|
|
33
|
+
def build_table(node, classes, extra, size)
|
|
34
|
+
css_class = extra ? "#{classes} #{extra}" : classes
|
|
35
|
+
# mso-line-height-rule:exactly keeps Outlook from inflating the spacer.
|
|
36
|
+
style = "font-size:#{size}px;line-height:#{size}px;mso-line-height-rule:exactly;"
|
|
37
|
+
[
|
|
38
|
+
%(<table class="#{css_class}" #{TABLE_RESET}#{style_attribute(node, 'width:100%;')}><tbody><tr>),
|
|
39
|
+
%(<td height="#{size}" style="#{style}"> </td>),
|
|
40
|
+
'</tr></tbody></table>'
|
|
41
|
+
].join
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Components
|
|
8
|
+
class Wrapper < Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { override.params(node: Nokogiri::XML::Node, inner: String).returns(String) }
|
|
12
|
+
def transform(node, inner)
|
|
13
|
+
attributes = combine_attributes(node, 'wrapper')
|
|
14
|
+
[
|
|
15
|
+
%(<table #{attributes} #{TABLE_RESET} align="center"#{style_attribute(node, 'width:100%;')}><tbody><tr>),
|
|
16
|
+
%(<td class="wrapper-inner">#{inner}</td></tr></tbody></table>)
|
|
17
|
+
].join
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require_relative 'tokens'
|
|
7
|
+
require_relative 'inliner/base'
|
|
8
|
+
require_relative 'inliner/premailer'
|
|
9
|
+
require_relative 'inliner/roadie'
|
|
10
|
+
require_relative 'inliner/null'
|
|
11
|
+
|
|
12
|
+
module ActiveMail
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
ComponentMap = T.type_alias { T::Hash[String, T.class_of(ActiveMail::Components::Base)] }
|
|
16
|
+
|
|
17
|
+
sig { returns(ActiveMail::Configuration) }
|
|
18
|
+
def self.configuration
|
|
19
|
+
@configuration ||= T.let(Configuration.new, T.nilable(ActiveMail::Configuration))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { returns(ActiveMail::Tokens) }
|
|
23
|
+
def self.tokens
|
|
24
|
+
configuration.tokens
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(config: T.untyped).returns(ActiveMail::Configuration) }
|
|
28
|
+
def self.configuration=(config)
|
|
29
|
+
raise TypeError, 'Not an ActiveMail::Configuration' unless config.is_a?(Configuration)
|
|
30
|
+
|
|
31
|
+
@configuration = config
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { params(block: T.proc.params(config: ActiveMail::Configuration).void).void }
|
|
35
|
+
def self.configure(&block)
|
|
36
|
+
block.call(configuration)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parse mode (and the Guard's same decision): a full document carries <html.
|
|
40
|
+
sig { params(html: String).returns(T::Boolean) }
|
|
41
|
+
def self.full_document?(html)
|
|
42
|
+
html.match?(/<html/i)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The complete SCSS variable bridge — tokens + the container_width layout knob.
|
|
46
|
+
# Single source for the .scss.erb bridge (Sprockets) and tokens:export (Propshaft).
|
|
47
|
+
sig { returns(String) }
|
|
48
|
+
def self.scss_variables
|
|
49
|
+
"#{tokens.to_scss}$am-grid-container-width: #{configuration.container_width}px !default;\n"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Route warnings to the app logger when present (Sentry breadcrumbs, log
|
|
53
|
+
# aggregation), else $stderr — which is often dropped in production.
|
|
54
|
+
sig { params(message: String).void }
|
|
55
|
+
def self.log_warning(message)
|
|
56
|
+
logger = rails_logger
|
|
57
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig { returns(T.untyped) }
|
|
61
|
+
def self.rails_logger
|
|
62
|
+
return unless Object.const_defined?(:Rails)
|
|
63
|
+
|
|
64
|
+
rails = Object.const_get(:Rails)
|
|
65
|
+
rails.respond_to?(:logger) ? rails.logger : nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Invalid bytes degrade deterministically to U+FFFD, but that silently corrupts
|
|
69
|
+
# content — surface it per on_parse_error before scrubbing.
|
|
70
|
+
sig { params(html_string: String).returns(String) }
|
|
71
|
+
def self.scrub_invalid_bytes(html_string)
|
|
72
|
+
mode = configuration.on_parse_error
|
|
73
|
+
message = '[activemail] input had invalid byte sequences; scrubbed to U+FFFD'
|
|
74
|
+
raise ParseError, message if mode == :raise
|
|
75
|
+
|
|
76
|
+
log_warning(message) unless mode == :ignore
|
|
77
|
+
html_string.scrub
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Zero or negative dimensions blow up at transform time (Infinity ghost width).
|
|
81
|
+
# Reject non-integers rather than silently coercing ("abc"→0, 12.9→12).
|
|
82
|
+
sig { params(name: Symbol, value: T.untyped).returns(Integer) }
|
|
83
|
+
def self.assert_positive_dimension!(name, value)
|
|
84
|
+
raise ArgumentError, "#{name} must be a positive integer, got #{value.inspect}" unless value.is_a?(Integer) && value.positive?
|
|
85
|
+
|
|
86
|
+
value
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class Configuration
|
|
90
|
+
extend T::Sig
|
|
91
|
+
|
|
92
|
+
ON_PARSE_ERROR_MODES = T.let(%i[ignore warn raise].freeze, T::Array[Symbol])
|
|
93
|
+
|
|
94
|
+
INLINERS = T.let(
|
|
95
|
+
{ premailer: ActiveMail::Inliner::Premailer, roadie: ActiveMail::Inliner::Roadie, null: ActiveMail::Inliner::Null }.freeze,
|
|
96
|
+
T::Hash[Symbol, T.class_of(ActiveMail::Inliner::Base)]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
InlinerSetting = T.type_alias { T.any(Symbol, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base)) }
|
|
100
|
+
|
|
101
|
+
sig { returns(InlinerSetting) }
|
|
102
|
+
attr_reader :inliner
|
|
103
|
+
|
|
104
|
+
# Lets a host already running another inliner (e.g. premailer-rails) opt out.
|
|
105
|
+
sig { returns(T::Boolean) }
|
|
106
|
+
attr_reader :register_inline_interceptor
|
|
107
|
+
|
|
108
|
+
sig { params(value: T.untyped).void }
|
|
109
|
+
def register_inline_interceptor=(value)
|
|
110
|
+
raise TypeError, 'register_inline_interceptor must be true or false' unless [true, false].include?(value)
|
|
111
|
+
|
|
112
|
+
@register_inline_interceptor = value
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sig { returns(Symbol) }
|
|
116
|
+
attr_reader :template_engine
|
|
117
|
+
|
|
118
|
+
sig { returns(Symbol) }
|
|
119
|
+
attr_reader :on_parse_error
|
|
120
|
+
|
|
121
|
+
sig { returns(Integer) }
|
|
122
|
+
attr_reader :column_count
|
|
123
|
+
|
|
124
|
+
sig { returns(Integer) }
|
|
125
|
+
attr_reader :container_width
|
|
126
|
+
|
|
127
|
+
# Mutating the returned hash would bypass validate_component!.
|
|
128
|
+
sig { returns(ActiveMail::ComponentMap) }
|
|
129
|
+
def components
|
|
130
|
+
@components.dup.freeze
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
sig { returns(ActiveMail::Tokens) }
|
|
134
|
+
def tokens
|
|
135
|
+
@tokens ||= T.let(ActiveMail::Tokens.new, T.nilable(ActiveMail::Tokens))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
sig { void }
|
|
139
|
+
def initialize
|
|
140
|
+
@template_engine = T.let(:erb, Symbol)
|
|
141
|
+
@column_count = T.let(12, Integer)
|
|
142
|
+
@container_width = T.let(600, Integer)
|
|
143
|
+
@components = T.let({}, ActiveMail::ComponentMap)
|
|
144
|
+
@on_parse_error = T.let(:warn, Symbol)
|
|
145
|
+
@tokens = T.let(nil, T.nilable(ActiveMail::Tokens))
|
|
146
|
+
@inliner = T.let(:premailer, InlinerSetting)
|
|
147
|
+
@resolved_inliner = T.let(nil, T.nilable(ActiveMail::Inliner::Base))
|
|
148
|
+
@register_inline_interceptor = T.let(true, T::Boolean)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Validates eagerly (like sibling setters): a typo fails at boot, not silently mid-delivery.
|
|
152
|
+
sig { params(value: T.any(Symbol, String, ActiveMail::Inliner::Base, T.class_of(ActiveMail::Inliner::Base))).returns(InlinerSetting) }
|
|
153
|
+
def inliner=(value)
|
|
154
|
+
resolved = value.is_a?(String) ? value.to_sym : value
|
|
155
|
+
resolve_inliner(resolved) # raises on an invalid value, eagerly
|
|
156
|
+
@resolved_inliner = nil # a new setting invalidates the memoized instance
|
|
157
|
+
@inliner = resolved
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Memoized so the lifecycle is consistent (one instance reused, not re-built per call).
|
|
161
|
+
sig { returns(ActiveMail::Inliner::Base) }
|
|
162
|
+
def resolved_inliner
|
|
163
|
+
@resolved_inliner ||= resolve_inliner(@inliner)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
167
|
+
def on_parse_error=(value)
|
|
168
|
+
mode = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
169
|
+
raise ArgumentError, "on_parse_error must be one of #{ON_PARSE_ERROR_MODES.inspect}" unless ON_PARSE_ERROR_MODES.include?(mode)
|
|
170
|
+
|
|
171
|
+
@on_parse_error = mode
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
sig { params(value: T.untyped).returns(Symbol) }
|
|
175
|
+
def template_engine=(value)
|
|
176
|
+
raise TypeError, "#{value.inspect} (#{value.class}) does not respond to 'to_sym'" unless value.respond_to?(:to_sym)
|
|
177
|
+
|
|
178
|
+
@template_engine = value.to_sym
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
sig { params(value: T.untyped).returns(Integer) }
|
|
182
|
+
def column_count=(value)
|
|
183
|
+
@column_count = positive_integer!(:column_count, value)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
sig { params(value: T.untyped).returns(Integer) }
|
|
187
|
+
def container_width=(value)
|
|
188
|
+
@container_width = positive_integer!(:container_width, value)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
sig { params(value: T.untyped).returns(ActiveMail::ComponentMap) }
|
|
192
|
+
def components=(value)
|
|
193
|
+
raise TypeError, "#{value.inspect} (#{value.class}) does not respond to 'to_hash'" unless value.respond_to?(:to_hash)
|
|
194
|
+
|
|
195
|
+
# Lookup is by node name (String); a Symbol key would never match.
|
|
196
|
+
normalized = value.to_hash.transform_keys(&:to_s)
|
|
197
|
+
normalized.each { |tag, klass| ActiveMail::Components.validate_component!(tag, klass) }
|
|
198
|
+
@components = normalized
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
sig { params(tag: T.any(String, Symbol), component_class: T.class_of(ActiveMail::Components::Base)).void }
|
|
202
|
+
def register_component(tag, component_class)
|
|
203
|
+
ActiveMail::Components.validate_component!(tag, component_class)
|
|
204
|
+
|
|
205
|
+
@components = @components.merge(tag.to_s => component_class)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
# Single source for both eager validation (in the setter) and resolution.
|
|
211
|
+
sig { params(value: InlinerSetting).returns(ActiveMail::Inliner::Base) }
|
|
212
|
+
def resolve_inliner(value)
|
|
213
|
+
return value if value.is_a?(ActiveMail::Inliner::Base)
|
|
214
|
+
return value.new if value.is_a?(Class) && value < ActiveMail::Inliner::Base
|
|
215
|
+
return T.must(INLINERS[value]).new if value.is_a?(Symbol) && INLINERS.key?(value)
|
|
216
|
+
|
|
217
|
+
raise ArgumentError, "unknown inliner #{value.inspect}, expected one of #{INLINERS.keys.inspect}, an Inliner::Base subclass, or an instance"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Integer-only (no to_int): a Float would otherwise be silently truncated
|
|
221
|
+
# (12.9 -> 12), contradicting assert_positive_dimension!'s invariant.
|
|
222
|
+
sig { params(name: Symbol, value: T.untyped).returns(Integer) }
|
|
223
|
+
def positive_integer!(name, value)
|
|
224
|
+
raise TypeError, "#{name} must be an Integer, got #{value.inspect} (#{value.class})" unless value.is_a?(Integer)
|
|
225
|
+
|
|
226
|
+
ActiveMail.assert_positive_dimension!(name, value)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Inliner
|
|
8
|
+
# Raised when an adapter fails; the original (premailer/roadie/nokogiri)
|
|
9
|
+
# exception is preserved as Exception#cause with its backtrace intact.
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
# DIP seam: ActiveMail depends on this abstraction, not premailer/roadie.
|
|
13
|
+
class Base
|
|
14
|
+
extend T::Sig
|
|
15
|
+
extend T::Helpers
|
|
16
|
+
|
|
17
|
+
abstract!
|
|
18
|
+
|
|
19
|
+
sig { abstract.params(html: String).returns(String) }
|
|
20
|
+
def inline(html); end
|
|
21
|
+
|
|
22
|
+
# Lets the interceptor skip work without type-checking concrete adapters.
|
|
23
|
+
sig { returns(T::Boolean) }
|
|
24
|
+
def noop?
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Inliner
|
|
8
|
+
class Interceptor
|
|
9
|
+
class << self
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(message: T.untyped).void }
|
|
13
|
+
def delivering_email(message)
|
|
14
|
+
config = ActiveMail.configuration
|
|
15
|
+
# Runtime check: an engine initializer runs before the host's, so a
|
|
16
|
+
# boot-time flag read would always see the default.
|
|
17
|
+
return unless config.register_inline_interceptor
|
|
18
|
+
|
|
19
|
+
inliner = config.resolved_inliner
|
|
20
|
+
return if inliner.noop?
|
|
21
|
+
|
|
22
|
+
html_parts(message).each do |part|
|
|
23
|
+
part.body = inliner.inline(part.body.to_s)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
# Surface which inliner failed; do not swallow (a silently non-inlined
|
|
26
|
+
# mail is worse). Ruby sets e as #cause, preserving the original backtrace.
|
|
27
|
+
raise ActiveMail::Inliner::Error, "[#{inliner.class}] CSS inlining failed: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# The html part(s) to inline: a single text/html message, or every text/html
|
|
34
|
+
# part of a multipart message. Attachments are never inlined.
|
|
35
|
+
sig { params(message: T.untyped).returns(T::Array[T.untyped]) }
|
|
36
|
+
def html_parts(message)
|
|
37
|
+
return message.all_parts.select { |part| html_body_part?(part) } if message.multipart?
|
|
38
|
+
|
|
39
|
+
html_body_part?(message) ? [message] : []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { params(part: T.untyped).returns(T::Boolean) }
|
|
43
|
+
def html_body_part?(part)
|
|
44
|
+
return false unless part.respond_to?(:mime_type) && part.mime_type == 'text/html'
|
|
45
|
+
|
|
46
|
+
!(part.respond_to?(:attachment?) && part.attachment?)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Inliner
|
|
8
|
+
# Opt-out / test adapter: returns HTML untouched.
|
|
9
|
+
class Null < Base
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { override.params(html: String).returns(String) }
|
|
13
|
+
def inline(html)
|
|
14
|
+
html
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { override.returns(T::Boolean) }
|
|
18
|
+
def noop?
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Inliner
|
|
8
|
+
class Premailer < Base
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { override.params(html: String).returns(String) }
|
|
12
|
+
def inline(html)
|
|
13
|
+
# premailer is a hard runtime dependency (the default inliner), so require
|
|
14
|
+
# can't fail — unlike the optional Roadie adapter, which guards LoadError.
|
|
15
|
+
require 'premailer'
|
|
16
|
+
# warn_level NONE: premailer's un-inlinable-CSS warnings are noise at delivery
|
|
17
|
+
# time; the quality layer is where coverage gaps should surface.
|
|
18
|
+
::Premailer.new(html, with_html_string: true, warn_level: ::Premailer::Warnings::NONE).to_inline_css
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Inliner
|
|
8
|
+
# Optional adapter — roadie is not a runtime dependency of the gem.
|
|
9
|
+
class Roadie < Base
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { override.params(html: String).returns(String) }
|
|
13
|
+
def inline(html)
|
|
14
|
+
require_roadie!
|
|
15
|
+
::Roadie::Document.new(html).transform
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
sig { void }
|
|
21
|
+
def require_roadie!
|
|
22
|
+
require 'roadie'
|
|
23
|
+
rescue LoadError
|
|
24
|
+
# Unify on Inliner::Error (LoadError is not a StandardError, so the
|
|
25
|
+
# interceptor's rescue would otherwise miss it); the LoadError is #cause.
|
|
26
|
+
raise ActiveMail::Inliner::Error, "ActiveMail::Inliner::Roadie requires the 'roadie' gem. Add it to your Gemfile."
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module ActiveMail
|
|
5
|
+
# libxml2 XML_HTML_UNKNOWN_TAG: emitted for every non-HTML4 tag (HTML5/custom
|
|
6
|
+
# tags), not actual malformedness. Shared by the engine and the quality layer.
|
|
7
|
+
LIBXML_UNKNOWN_TAG_CODE = 801
|
|
8
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require_relative 'libxml'
|
|
7
|
+
|
|
8
|
+
module ActiveMail
|
|
9
|
+
# Surfaces libxml2 recover-mode repairs that would otherwise silently change
|
|
10
|
+
# the rendered email, honoring ActiveMail.configuration.on_parse_error.
|
|
11
|
+
class ParseErrorReporter
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
sig { params(known_tags: T::Array[String]).void }
|
|
15
|
+
def initialize(known_tags)
|
|
16
|
+
# dup.freeze: the caller's array stays decoupled and cannot be mutated under us.
|
|
17
|
+
@known_tags = T.let(known_tags.dup.freeze, T::Array[String])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(errors: T::Array[Nokogiri::XML::SyntaxError]).void }
|
|
21
|
+
def call(errors)
|
|
22
|
+
mode = ::ActiveMail.configuration.on_parse_error
|
|
23
|
+
return if mode == :ignore
|
|
24
|
+
|
|
25
|
+
relevant = errors.reject { |error| known_tag_error?(error) }
|
|
26
|
+
return if relevant.empty?
|
|
27
|
+
|
|
28
|
+
messages = relevant.map { |error| error.message.to_s.strip }.join('; ')
|
|
29
|
+
raise ActiveMail::ParseError, messages if mode == :raise
|
|
30
|
+
|
|
31
|
+
::ActiveMail.log_warning("[activemail] HTML parse issues: #{messages}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
sig { params(error: Nokogiri::XML::SyntaxError).returns(T::Boolean) }
|
|
37
|
+
def known_tag_error?(error)
|
|
38
|
+
return false unless error.code == LIBXML_UNKNOWN_TAG_CODE
|
|
39
|
+
|
|
40
|
+
# Fragile by necessity: the 801 error does not carry the tag name, only
|
|
41
|
+
# the libxml2 message text does. Locked by test against the pinned
|
|
42
|
+
# Nokogiri; revisit on bump if that test breaks.
|
|
43
|
+
tag = error.message.to_s[/Tag (\S+) invalid/, 1]
|
|
44
|
+
!tag.nil? && @known_tags.include?(tag)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require_relative 'guard'
|
|
7
|
+
|
|
8
|
+
module ActiveMail
|
|
9
|
+
module Quality
|
|
10
|
+
class Configuration
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { returns(Guard) }
|
|
14
|
+
attr_reader :guard
|
|
15
|
+
|
|
16
|
+
sig { params(value: T.untyped).void }
|
|
17
|
+
def guard=(value)
|
|
18
|
+
raise TypeError, "guard must be an ActiveMail::Quality::Guard, got #{value.class}" unless value.is_a?(Guard)
|
|
19
|
+
|
|
20
|
+
@guard = value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { returns(String) }
|
|
24
|
+
attr_reader :output_dir
|
|
25
|
+
|
|
26
|
+
sig { params(value: String).void }
|
|
27
|
+
def output_dir=(value)
|
|
28
|
+
raise ArgumentError, 'output_dir must not be blank' if value.strip.empty?
|
|
29
|
+
|
|
30
|
+
@output_dir = value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Preview keys ("preview_name#email") that MUST render and pass the guard;
|
|
34
|
+
# a failure on any of these aborts the rake task. Other previews are only
|
|
35
|
+
# reported.
|
|
36
|
+
sig { returns(T::Array[String]) }
|
|
37
|
+
def required_previews
|
|
38
|
+
@required_previews.dup.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(value: T.untyped).returns(T::Array[String]) }
|
|
42
|
+
def required_previews=(value)
|
|
43
|
+
raise TypeError, "#{value.inspect} (#{value.class}) does not respond to 'to_a'" unless value.respond_to?(:to_a)
|
|
44
|
+
|
|
45
|
+
@required_previews = value.to_a.map(&:to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { void }
|
|
49
|
+
def initialize
|
|
50
|
+
@guard = T.let(Guard.new, Guard)
|
|
51
|
+
@output_dir = T.let('tmp/active_mail_previews', String)
|
|
52
|
+
@required_previews = T.let([], T::Array[String])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|