activemail 1.2.3 → 2.1.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/CHANGELOG.md +107 -2
- data/README.md +141 -56
- data/UPGRADING.md +185 -0
- data/app/assets/stylesheets/activemail/_components.scss +33 -42
- data/app/assets/stylesheets/activemail/_dark.scss +44 -24
- data/app/assets/stylesheets/activemail/_grid.scss +40 -14
- data/app/assets/stylesheets/activemail/_settings.scss +23 -10
- data/app/assets/stylesheets/activemail/_utilities.scss +3 -1
- data/app/assets/stylesheets/activemail/activemail.scss +6 -5
- data/app/helpers/active_mail/styles_helper.rb +14 -15
- data/app/views/layouts/activemail/_document_head.html.erb +18 -0
- data/app/views/layouts/activemail/mailer.html.inky-erb +1 -22
- data/lib/activemail/asset_pipeline/base.rb +47 -0
- data/lib/activemail/asset_pipeline/null.rb +19 -0
- data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
- data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
- data/lib/activemail/asset_pipeline.rb +35 -0
- data/lib/activemail/components/base.rb +35 -39
- data/lib/activemail/components/block_grid.rb +11 -4
- data/lib/activemail/components/button.rb +5 -28
- data/lib/activemail/components/button_renderer.rb +69 -0
- data/lib/activemail/components/callout.rb +4 -5
- data/lib/activemail/components/center.rb +10 -10
- data/lib/activemail/components/columns.rb +58 -15
- data/lib/activemail/components/container.rb +13 -6
- data/lib/activemail/components/cta.rb +9 -33
- data/lib/activemail/components/h_line.rb +3 -5
- data/lib/activemail/components/info_box.rb +6 -12
- data/lib/activemail/components/menu.rb +3 -4
- data/lib/activemail/components/menu_item.rb +10 -3
- data/lib/activemail/components/row.rb +3 -4
- data/lib/activemail/components/spacer.rb +16 -12
- data/lib/activemail/components/table_component.rb +23 -0
- data/lib/activemail/components/wrapper.rb +10 -4
- data/lib/activemail/configuration.rb +43 -57
- data/lib/activemail/errors.rb +15 -0
- data/lib/activemail/inliner/base.rb +2 -10
- data/lib/activemail/inliner/null.rb +0 -5
- data/lib/activemail/inliner/premailer/document.rb +100 -0
- data/lib/activemail/inliner/premailer.rb +3 -2
- data/lib/activemail/inliner/roadie.rb +1 -1
- data/lib/activemail/inliner.rb +33 -0
- data/lib/activemail/parse_error_reporter.rb +5 -4
- data/lib/activemail/pipeline.rb +26 -0
- data/lib/activemail/quality/builtin_message_rules.rb +58 -0
- data/lib/activemail/quality/builtin_rules.rb +165 -0
- data/lib/activemail/quality/configuration.rb +103 -8
- data/lib/activemail/quality/guard.rb +61 -83
- data/lib/activemail/quality/minitest.rb +56 -27
- data/lib/activemail/quality/output_dir.rb +52 -0
- data/lib/activemail/quality/output_filter.rb +24 -0
- data/lib/activemail/quality/preview_renderer.rb +46 -33
- data/lib/activemail/quality/render_all.rb +95 -39
- data/lib/activemail/quality/rspec.rb +14 -13
- data/lib/activemail/quality/rule.rb +47 -0
- data/lib/activemail/quality/rule_exemptions.rb +49 -0
- data/lib/activemail/rails/engine.rb +13 -13
- data/lib/activemail/rails/rendering.rb +51 -0
- data/lib/activemail/rails/template_handler.rb +3 -1
- data/lib/activemail/tokens/button_style.rb +1 -2
- data/lib/activemail/tokens/defaults.rb +78 -0
- data/lib/activemail/tokens/scss_serializer.rb +4 -4
- data/lib/activemail/tokens.rb +19 -58
- data/lib/activemail/version.rb +1 -1
- data/lib/activemail.rb +89 -61
- data/lib/generators/activemail/templates/component.rb.tt +2 -3
- data/lib/generators/activemail/templates/initializer.rb +0 -3
- data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +3 -19
- data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +3 -11
- data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +3 -10
- data/lib/tasks/activemail.rake +4 -5
- metadata +22 -4
- data/lib/activemail/components/inky.rb +0 -18
- data/lib/activemail/inliner/interceptor.rb +0 -51
- data/lib/activemail/rails/compiled_stylesheet.rb +0 -62
|
@@ -4,127 +4,105 @@
|
|
|
4
4
|
require 'nokogiri'
|
|
5
5
|
require 'sorbet-runtime'
|
|
6
6
|
|
|
7
|
-
require_relative '
|
|
7
|
+
require_relative 'builtin_rules'
|
|
8
|
+
require_relative 'preview_renderer'
|
|
9
|
+
require_relative 'rule'
|
|
8
10
|
|
|
9
11
|
module ActiveMail
|
|
10
12
|
module Quality
|
|
11
13
|
class Guard
|
|
12
14
|
extend T::Sig
|
|
13
15
|
|
|
14
|
-
# One failed invariant. :rule is a stable symbol for programmatic matching;
|
|
15
|
-
# :message is human-facing.
|
|
16
16
|
class Violation < T::Struct
|
|
17
|
+
extend T::Sig
|
|
18
|
+
|
|
17
19
|
const :rule, Symbol
|
|
18
20
|
const :message, String
|
|
21
|
+
|
|
22
|
+
sig { returns(String) }
|
|
23
|
+
def to_s
|
|
24
|
+
"[#{rule}] #{message}"
|
|
25
|
+
end
|
|
19
26
|
end
|
|
20
27
|
|
|
21
|
-
DISABLEABLE =
|
|
28
|
+
DISABLEABLE = BuiltinRules::NAMES
|
|
22
29
|
# Gmail clips messages past ~102KB.
|
|
23
30
|
DEFAULT_MAX_BYTES = 102_400
|
|
24
31
|
# A full HTML document smaller than this carries no real layout and is suspect.
|
|
25
32
|
DEFAULT_MIN_FULL_DOC_BYTES = 1_024
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
sig { params(max_bytes: Integer, min_full_doc_bytes: Integer, disable: T::Array[Symbol]).void }
|
|
29
|
-
def initialize(max_bytes: DEFAULT_MAX_BYTES, min_full_doc_bytes: DEFAULT_MIN_FULL_DOC_BYTES, disable: [])
|
|
30
|
-
|
|
31
|
-
@
|
|
32
|
-
@
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
@disabled = T.let(disable.to_set, T::Set[Symbol])
|
|
33
|
+
NO_SKIP = T.let([].freeze, T::Array[Symbol])
|
|
34
|
+
|
|
35
|
+
sig { params(max_bytes: Integer, min_full_doc_bytes: Integer, disable: T::Array[Symbol], rules: T::Array[Rule]).void }
|
|
36
|
+
def initialize(max_bytes: DEFAULT_MAX_BYTES, min_full_doc_bytes: DEFAULT_MIN_FULL_DOC_BYTES, disable: [], rules: [])
|
|
37
|
+
all = assemble_rules(BuiltinRules.new(max_bytes: max_bytes, min_full_doc_bytes: min_full_doc_bytes), disable, rules)
|
|
38
|
+
@html_rules = T.let(all.select { |rule| rule.level == :html }, T::Array[Rule])
|
|
39
|
+
@message_rules = T.let(all.select { |rule| rule.level == :message }, T::Array[Rule])
|
|
40
|
+
# A disabled built-in rule stays a known name: exempting a preview from it is harmless, not a typo.
|
|
41
|
+
@rule_names = T.let((DISABLEABLE + rules.map(&:name)).freeze, T::Array[Symbol])
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
check_well_formed(doc, violations)
|
|
46
|
-
check_table_roles(doc, violations)
|
|
47
|
-
check_img_alts(doc, violations)
|
|
48
|
-
check_full_document(html, doc, violations) if ::ActiveMail.full_document?(html)
|
|
49
|
-
violations
|
|
50
|
-
end
|
|
44
|
+
# A String only gets the HTML rules; a message gets both levels. A declined send
|
|
45
|
+
# (NullMail) has nothing to check, but it is a violation unless the caller expects it.
|
|
46
|
+
sig { params(html_or_message: T.untyped, allow_declined: T::Boolean, skip: T::Array[Symbol]).returns(T::Array[Violation]) }
|
|
47
|
+
def violations(html_or_message, allow_declined: false, skip: NO_SKIP)
|
|
48
|
+
check_skippable!(skip)
|
|
49
|
+
return html_violations(html_or_message, skip) if html_or_message.is_a?(String)
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
violations(html).empty?
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
private
|
|
51
|
+
mail = PreviewRenderer.mail(html_or_message)
|
|
52
|
+
return declined_violations(allow_declined) if PreviewRenderer.declined?(mail)
|
|
58
53
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
!@disabled.include?(rule)
|
|
54
|
+
html = PreviewRenderer.html_body(mail)
|
|
55
|
+
(html.empty? ? [] : html_violations(html, skip)) + apply(@message_rules, [mail], skip)
|
|
62
56
|
end
|
|
63
57
|
|
|
64
|
-
sig { params(
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
value
|
|
58
|
+
sig { params(html_or_message: T.untyped, allow_declined: T::Boolean, skip: T::Array[Symbol]).returns(T::Boolean) }
|
|
59
|
+
def valid?(html_or_message, allow_declined: false, skip: NO_SKIP)
|
|
60
|
+
violations(html_or_message, allow_declined: allow_declined, skip: skip).empty?
|
|
69
61
|
end
|
|
70
62
|
|
|
71
|
-
sig { params(
|
|
72
|
-
def
|
|
73
|
-
return
|
|
74
|
-
return if html.bytesize <= @max_bytes
|
|
63
|
+
sig { params(skip: T::Array[Symbol]).void }
|
|
64
|
+
def check_skippable!(skip)
|
|
65
|
+
return if skip.empty?
|
|
75
66
|
|
|
76
|
-
|
|
67
|
+
unknown = skip - @rule_names
|
|
68
|
+
raise ArgumentError, "unknown rule(s) to skip: #{unknown.inspect}, expected a subset of #{@rule_names.inspect}" unless unknown.empty?
|
|
77
69
|
end
|
|
78
70
|
|
|
79
|
-
|
|
80
|
-
# silently bless malformed *output* (mismatched tags, bad entities) before send.
|
|
81
|
-
sig { params(doc: Nokogiri::XML::Document, violations: T::Array[Violation]).void }
|
|
82
|
-
def check_well_formed(doc, violations)
|
|
83
|
-
return unless enabled?(:parse_error)
|
|
84
|
-
|
|
85
|
-
errors = doc.errors.reject { |e| e.code == LIBXML_UNKNOWN_TAG_CODE }
|
|
86
|
-
return if errors.empty?
|
|
71
|
+
private
|
|
87
72
|
|
|
88
|
-
|
|
73
|
+
sig { params(allowed: T::Boolean).returns(T::Array[Violation]) }
|
|
74
|
+
def declined_violations(allowed)
|
|
75
|
+
allowed ? [] : [Violation.new(rule: :declined, message: 'the mailer declined to send (NullMail)')]
|
|
89
76
|
end
|
|
90
77
|
|
|
91
|
-
sig { params(
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
offenders = doc.css('table').count { |table| table['role'] != 'presentation' }
|
|
96
|
-
return if offenders.zero?
|
|
78
|
+
sig { params(builtin: BuiltinRules, disable: T::Array[Symbol], custom: T::Array[Rule]).returns(T::Array[Rule]) }
|
|
79
|
+
def assemble_rules(builtin, disable, custom)
|
|
80
|
+
unknown = disable - DISABLEABLE
|
|
81
|
+
raise ArgumentError, "unknown rule(s): #{unknown.inspect}, expected a subset of #{DISABLEABLE.inspect}" unless unknown.empty?
|
|
97
82
|
|
|
98
|
-
|
|
83
|
+
unique_names!(builtin.rules.reject { |rule| disable.include?(rule.name) } + custom)
|
|
99
84
|
end
|
|
100
85
|
|
|
101
|
-
sig { params(
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
offenders = doc.css('img').count { |img| !img.key?('alt') }
|
|
106
|
-
return if offenders.zero?
|
|
86
|
+
sig { params(rules: T::Array[Rule]).returns(T::Array[Rule]) }
|
|
87
|
+
def unique_names!(rules)
|
|
88
|
+
duplicates = rules.map(&:name).tally.select { |_name, count| count > 1 }.keys
|
|
89
|
+
raise ArgumentError, "duplicate rule name(s): #{duplicates.inspect}" unless duplicates.empty?
|
|
107
90
|
|
|
108
|
-
|
|
91
|
+
rules
|
|
109
92
|
end
|
|
110
93
|
|
|
111
|
-
sig { params(html: String,
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
violations << Violation.new(rule: :min_full_doc_bytes, message: "full document is only #{html.bytesize} bytes (under #{@min_full_doc_bytes})")
|
|
115
|
-
end
|
|
116
|
-
check_lang(doc, violations)
|
|
94
|
+
sig { params(html: String, skip: T::Array[Symbol]).returns(T::Array[Violation]) }
|
|
95
|
+
def html_violations(html, skip)
|
|
96
|
+
apply(@html_rules, [Nokogiri::HTML(html), html], skip)
|
|
117
97
|
end
|
|
118
98
|
|
|
119
|
-
sig { params(
|
|
120
|
-
def
|
|
121
|
-
|
|
99
|
+
sig { params(rules: T::Array[Rule], args: T::Array[T.untyped], skip: T::Array[Symbol]).returns(T::Array[Violation]) }
|
|
100
|
+
def apply(rules, args, skip)
|
|
101
|
+
rules.flat_map do |rule|
|
|
102
|
+
next [] if skip.include?(rule.name)
|
|
122
103
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return if lang && !lang.strip.empty?
|
|
126
|
-
|
|
127
|
-
violations << Violation.new(rule: :lang, message: '<html> must declare a non-blank lang attribute')
|
|
104
|
+
rule.messages(args).map { |message| Violation.new(rule: rule.name, message: message) }
|
|
105
|
+
end
|
|
128
106
|
end
|
|
129
107
|
end
|
|
130
108
|
end
|
|
@@ -8,37 +8,42 @@ module ActiveMail
|
|
|
8
8
|
module Minitest
|
|
9
9
|
extend T::Sig
|
|
10
10
|
|
|
11
|
-
sig { params(
|
|
12
|
-
def assert_email_quality(
|
|
13
|
-
|
|
14
|
-
# assert is injected by the host's Minitest test class.
|
|
15
|
-
T.unsafe(self).assert violations.empty?,
|
|
16
|
-
"email quality violations:\n#{violations.map { |v| " - [#{v.rule}] #{v.message}" }.join("\n")}"
|
|
11
|
+
sig { params(html_or_message: T.untyped, guard: Guard, allow_declined: T::Boolean, skip: T::Array[Symbol]).void }
|
|
12
|
+
def assert_email_quality(html_or_message, guard: ActiveMail::Quality.guard, allow_declined: false, skip: Guard::NO_SKIP)
|
|
13
|
+
assert_no_quality_violations(guard.violations(html_or_message, allow_declined: allow_declined, skip: skip))
|
|
17
14
|
end
|
|
18
15
|
|
|
19
|
-
sig
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
sig do
|
|
17
|
+
params(preview: T.untyped, email: String, guard: Guard, locale: Symbol, around_each: T.nilable(Proc)).void
|
|
18
|
+
end
|
|
19
|
+
def assert_preview_quality(preview, email, guard: ActiveMail::Quality.guard, locale: I18n.default_locale,
|
|
20
|
+
around_each: ActiveMail::Quality.config.around_each)
|
|
21
|
+
assert_no_quality_violations(preview_violations(preview, email, locale, guard, around_each))
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
module ClassMethods
|
|
25
25
|
extend T::Sig
|
|
26
26
|
|
|
27
|
-
# One test per discovered preview; a non-required preview that can't render
|
|
28
|
-
# is skipped, a required one fails (see #render_preview_or_skip).
|
|
29
27
|
sig { params(guard: Guard).void }
|
|
30
28
|
def assert_quality_for_all_previews(guard: ActiveMail::Quality.guard)
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
config = ActiveMail::Quality.config
|
|
30
|
+
config.check_exemptions!(guard)
|
|
31
|
+
cases = PreviewRenderer.cases(config)
|
|
33
32
|
# A silent no-test run would look like everything passed.
|
|
34
|
-
Kernel.warn('[activemail] assert_quality_for_all_previews: no previews discovered.') if
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
Kernel.warn('[activemail] assert_quality_for_all_previews: no previews discovered.') if cases.empty?
|
|
34
|
+
cases.each_with_index { |test_case, index| define_preview_test(index, test_case, guard, config.required_previews) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
sig { params(index: Integer, test_case: [T.untyped, String, Symbol], guard: Guard, required: T::Array[String]).void }
|
|
40
|
+
def define_preview_test(index, test_case, guard, required)
|
|
41
|
+
preview, email, locale = test_case
|
|
42
|
+
# Index prefix: distinct keys can normalize to the same method name.
|
|
43
|
+
name = "test_#{index}_#{PreviewRenderer.key(preview, email).gsub(/\W/, '_')}_#{locale}_email_quality"
|
|
44
|
+
T.unsafe(self).define_method(name) do
|
|
45
|
+
violations = T.unsafe(self).send(:render_preview_or_skip, preview, email, locale, guard, required)
|
|
46
|
+
T.unsafe(self).send(:assert_no_quality_violations, violations)
|
|
42
47
|
end
|
|
43
48
|
end
|
|
44
49
|
end
|
|
@@ -50,13 +55,37 @@ module ActiveMail
|
|
|
50
55
|
|
|
51
56
|
private
|
|
52
57
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
sig { params(violations: T::Array[Guard::Violation]).void }
|
|
59
|
+
def assert_no_quality_violations(violations)
|
|
60
|
+
# assert is injected by the host's Minitest test class.
|
|
61
|
+
T.unsafe(self).assert violations.empty?,
|
|
62
|
+
"email quality violations:\n#{violations.map { |v| " - #{v}" }.join("\n")}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig do
|
|
66
|
+
params(preview: T.untyped, email: String, locale: Symbol, guard: Guard, around_each: T.nilable(Proc))
|
|
67
|
+
.returns(T::Array[Guard::Violation])
|
|
68
|
+
end
|
|
69
|
+
def preview_violations(preview, email, locale, guard, around_each)
|
|
70
|
+
config = ActiveMail::Quality.config
|
|
71
|
+
key = PreviewRenderer.key(preview, email)
|
|
72
|
+
allowed = config.allow_declined?(key)
|
|
73
|
+
skip = config.exempt_rules_for(key)
|
|
74
|
+
PreviewRenderer.within(preview, email, locale, around_each) do |mail|
|
|
75
|
+
guard.violations(mail, allow_declined: allowed, skip: skip)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A non-required preview that can't render is skipped, not a silent pass.
|
|
80
|
+
sig do
|
|
81
|
+
params(preview: T.untyped, email: String, locale: Symbol, guard: Guard, required: T::Array[String])
|
|
82
|
+
.returns(T::Array[Guard::Violation])
|
|
83
|
+
end
|
|
84
|
+
def render_preview_or_skip(preview, email, locale, guard, required)
|
|
85
|
+
preview_violations(preview, email, locale, guard, ActiveMail::Quality.config.around_each)
|
|
58
86
|
rescue StandardError => e
|
|
59
|
-
|
|
87
|
+
key = PreviewRenderer.key(preview, email)
|
|
88
|
+
msg = "#{key} [#{locale}] did not render (#{e.class}: #{e.message})"
|
|
60
89
|
required.include?(key) ? T.unsafe(self).flunk("required preview #{msg}") : T.unsafe(self).skip(msg)
|
|
61
90
|
end
|
|
62
91
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'pathname'
|
|
5
|
+
require 'sorbet-runtime'
|
|
6
|
+
|
|
7
|
+
module ActiveMail
|
|
8
|
+
module Quality
|
|
9
|
+
# RenderAll deletes inside this directory, so it must not escape the host root.
|
|
10
|
+
module OutputDir
|
|
11
|
+
class << self
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
sig { params(value: String).returns(String) }
|
|
15
|
+
def validate!(value)
|
|
16
|
+
raise ArgumentError, 'output_dir must not be blank' if value.strip.empty?
|
|
17
|
+
raise ArgumentError, "output_dir must be relative, got #{value.inspect}" if Pathname(value).absolute?
|
|
18
|
+
|
|
19
|
+
segments = value.split(%r{[/\\]}).reject(&:empty?)
|
|
20
|
+
raise ArgumentError, "output_dir must not contain '..', got #{value.inspect}" if segments.include?('..')
|
|
21
|
+
raise ArgumentError, "output_dir must not be '.', got #{value.inspect}" if segments.all?('.')
|
|
22
|
+
|
|
23
|
+
value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { params(value: String, root: T.any(String, Pathname)).returns(Pathname) }
|
|
27
|
+
def resolve(value, root)
|
|
28
|
+
validate!(value)
|
|
29
|
+
base = real(root.to_s)
|
|
30
|
+
resolved = real(File.expand_path(value, base.to_s))
|
|
31
|
+
raise ArgumentError, "output_dir #{value.inspect} must resolve strictly under #{base}" unless resolved.to_s.start_with?("#{base}/")
|
|
32
|
+
|
|
33
|
+
resolved
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Symlinks are resolved on the deepest existing ancestor, so a link cannot smuggle the target out.
|
|
39
|
+
sig { params(path: String).returns(Pathname) }
|
|
40
|
+
def real(path)
|
|
41
|
+
missing = []
|
|
42
|
+
existing = Pathname(File.expand_path(path))
|
|
43
|
+
until existing.exist?
|
|
44
|
+
missing.unshift(existing.basename)
|
|
45
|
+
existing = existing.dirname
|
|
46
|
+
end
|
|
47
|
+
missing.reduce(existing.realpath) { |acc, segment| acc.join(segment) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Quality
|
|
8
|
+
module OutputFilter
|
|
9
|
+
class << self
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
sig { params(filter: T.untyped).returns(Proc) }
|
|
13
|
+
def coerce(filter)
|
|
14
|
+
return filter if filter.is_a?(Proc)
|
|
15
|
+
|
|
16
|
+
pattern, replacement = filter if filter.is_a?(Array) && filter.size == 2
|
|
17
|
+
raise TypeError, "output filter must be a Proc or [Regexp, String], got #{filter.inspect}" unless pattern.is_a?(Regexp) && replacement.is_a?(String)
|
|
18
|
+
|
|
19
|
+
->(content) { content.gsub(pattern, replacement) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
require '
|
|
5
|
-
require 'pathname'
|
|
4
|
+
require 'i18n'
|
|
6
5
|
require 'sorbet-runtime'
|
|
7
6
|
|
|
8
7
|
module ActiveMail
|
|
@@ -11,29 +10,29 @@ module ActiveMail
|
|
|
11
10
|
class << self
|
|
12
11
|
extend T::Sig
|
|
13
12
|
|
|
14
|
-
# preview and message are external/dynamic ActionMailer & Mail objects.
|
|
15
|
-
sig { params(preview: T.untyped, email: String).returns(String) }
|
|
16
|
-
def render(preview, email)
|
|
17
|
-
html_body(preview.call(email))
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# The single source for the "preview_name#email" key format (config keys,
|
|
21
|
-
# render_all, the Minitest helper).
|
|
22
13
|
sig { params(preview: T.untyped, email: String).returns(String) }
|
|
23
14
|
def key(preview, email)
|
|
24
15
|
"#{preview.preview_name}##{email}"
|
|
25
16
|
end
|
|
26
17
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
18
|
+
# The message renders lazily: every read of it must happen inside the block.
|
|
19
|
+
sig do
|
|
20
|
+
params(preview: T.untyped, email: String, locale: Symbol, around_each: T.nilable(Proc), block: T.proc.params(mail: T.untyped).returns(T.untyped))
|
|
21
|
+
.returns(T.untyped)
|
|
22
|
+
end
|
|
23
|
+
def within(preview, email, locale, around_each, &block)
|
|
24
|
+
ran = T.let(false, T::Boolean)
|
|
25
|
+
result = T.let(nil, T.untyped)
|
|
26
|
+
run = lambda do
|
|
27
|
+
ran = true
|
|
28
|
+
result = I18n.with_locale(locale) { block.call(mail(preview.call(email, locale: locale))) }
|
|
29
|
+
end
|
|
30
|
+
around_each ? around_each.call(preview, email, locale, &run) : run.call
|
|
31
|
+
raise ArgumentError, 'around_each must yield' unless ran
|
|
32
|
+
|
|
33
|
+
result
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
# [] when ActionMailer previews aren't loaded (host has none / previews disabled).
|
|
37
36
|
sig { returns(T::Array[[T.untyped, String]]) }
|
|
38
37
|
def all
|
|
39
38
|
return [] unless defined?(ActionMailer::Preview)
|
|
@@ -43,26 +42,40 @@ module ActiveMail
|
|
|
43
42
|
end
|
|
44
43
|
end
|
|
45
44
|
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
45
|
+
# '' for a plain-text-only message: plain text must not be validated as HTML.
|
|
46
|
+
sig { params(mail: T.untyped).returns(String) }
|
|
47
|
+
def html_body(mail)
|
|
48
|
+
body_of(mail, mail.html_part, [nil, 'text/html'])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { params(mail: T.untyped).returns(String) }
|
|
52
|
+
def text_body(mail)
|
|
53
|
+
body_of(mail, mail.text_part, ['text/plain'])
|
|
54
|
+
end
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
# NullMail answers every respond_to? and returns nil: it is its own message.
|
|
57
|
+
sig { params(message: T.untyped).returns(T.untyped) }
|
|
58
|
+
def mail(message)
|
|
59
|
+
(message.message if message.respond_to?(:message)) || message
|
|
55
60
|
end
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
sig { params(mail: T.untyped).returns(T::Boolean) }
|
|
63
|
+
def declined?(mail)
|
|
64
|
+
mail.class.name.to_s.end_with?('::NullMail')
|
|
65
|
+
end
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
sig { params(config: Configuration, previews: T::Array[[T.untyped, String]]).returns(T::Array[[T.untyped, String, Symbol]]) }
|
|
68
|
+
def cases(config, previews = all)
|
|
69
|
+
previews.flat_map { |preview, email| config.locales_for(preview).map { |locale| [preview, email, locale] } }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
64
73
|
|
|
65
|
-
|
|
74
|
+
# A single-part body counts only when its content-type says so (unset counts as HTML).
|
|
75
|
+
sig { params(mail: T.untyped, part: T.untyped, mime_types: T::Array[T.nilable(String)]).returns(String) }
|
|
76
|
+
def body_of(mail, part, mime_types)
|
|
77
|
+
body = part&.body || (mail.body if !mail.multipart? && mime_types.include?(mail.mime_type))
|
|
78
|
+
body ? body.decoded : ''
|
|
66
79
|
end
|
|
67
80
|
end
|
|
68
81
|
end
|