activemail 1.2.2 → 2.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 +4 -4
- data/CHANGELOG.md +65 -2
- data/README.md +105 -47
- data/UPGRADING.md +112 -0
- data/app/assets/stylesheets/activemail/_components.scss +33 -42
- data/app/assets/stylesheets/activemail/_dark.scss +31 -20
- data/app/assets/stylesheets/activemail/_grid.scss +2 -0
- data/app/assets/stylesheets/activemail/_settings.scss +22 -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/mailer.html.inky-erb +0 -5
- 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 +65 -0
- data/lib/activemail/components/callout.rb +4 -5
- data/lib/activemail/components/center.rb +5 -7
- data/lib/activemail/components/columns.rb +26 -7
- data/lib/activemail/components/container.rb +11 -4
- 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/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_rules.rb +154 -0
- data/lib/activemail/quality/configuration.rb +83 -3
- data/lib/activemail/quality/guard.rb +45 -87
- data/lib/activemail/quality/minitest.rb +41 -25
- data/lib/activemail/quality/output_dir.rb +52 -0
- data/lib/activemail/quality/preview_renderer.rb +46 -33
- data/lib/activemail/quality/render_all.rb +96 -40
- data/lib/activemail/quality/rspec.rb +4 -8
- data/lib/activemail/quality/rule.rb +47 -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 +77 -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 +0 -2
- data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +0 -2
- data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +0 -2
- data/lib/tasks/activemail.rake +4 -5
- metadata +38 -7
- 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,85 @@
|
|
|
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
33
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@
|
|
32
|
-
@min_full_doc_bytes = T.let(positive_threshold!(:min_full_doc_bytes, min_full_doc_bytes), Integer)
|
|
33
|
-
unknown = disable - DISABLEABLE
|
|
34
|
-
raise ArgumentError, "unknown rule(s): #{unknown.inspect}, expected a subset of #{DISABLEABLE.inspect}" unless unknown.empty?
|
|
35
|
-
|
|
36
|
-
@disabled = T.let(disable.to_set, T::Set[Symbol])
|
|
34
|
+
sig { params(max_bytes: Integer, min_full_doc_bytes: Integer, disable: T::Array[Symbol], rules: T::Array[Rule]).void }
|
|
35
|
+
def initialize(max_bytes: DEFAULT_MAX_BYTES, min_full_doc_bytes: DEFAULT_MIN_FULL_DOC_BYTES, disable: [], rules: [])
|
|
36
|
+
all = assemble_rules(BuiltinRules.new(max_bytes: max_bytes, min_full_doc_bytes: min_full_doc_bytes), disable, rules)
|
|
37
|
+
@html_rules = T.let(all.select { |rule| rule.level == :html }, T::Array[Rule])
|
|
38
|
+
@message_rules = T.let(all.select { |rule| rule.level == :message }, T::Array[Rule])
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
doc = Nokogiri::HTML(html)
|
|
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
|
|
51
|
-
|
|
52
|
-
sig { params(html: String).returns(T::Boolean) }
|
|
53
|
-
def valid?(html)
|
|
54
|
-
violations(html).empty?
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
private
|
|
58
|
-
|
|
59
|
-
sig { params(rule: Symbol).returns(T::Boolean) }
|
|
60
|
-
def enabled?(rule)
|
|
61
|
-
!@disabled.include?(rule)
|
|
62
|
-
end
|
|
41
|
+
# A String only gets the HTML rules; a message gets both levels, except a declined send.
|
|
42
|
+
sig { params(html_or_message: T.untyped).returns(T::Array[Violation]) }
|
|
43
|
+
def violations(html_or_message)
|
|
44
|
+
return html_violations(html_or_message) if html_or_message.is_a?(String)
|
|
63
45
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
raise ArgumentError, "#{name} must be a positive integer, got #{value}" unless value.positive?
|
|
46
|
+
mail = PreviewRenderer.mail(html_or_message)
|
|
47
|
+
return [] if PreviewRenderer.declined?(mail)
|
|
67
48
|
|
|
68
|
-
|
|
49
|
+
html = PreviewRenderer.html_body(mail)
|
|
50
|
+
(html.empty? ? [] : html_violations(html)) + apply(@message_rules, [mail])
|
|
69
51
|
end
|
|
70
52
|
|
|
71
|
-
sig { params(
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
return if html.bytesize <= @max_bytes
|
|
75
|
-
|
|
76
|
-
violations << Violation.new(rule: :max_bytes, message: "HTML is #{html.bytesize} bytes, exceeds #{@max_bytes} (Gmail clipping)")
|
|
53
|
+
sig { params(html_or_message: T.untyped).returns(T::Boolean) }
|
|
54
|
+
def valid?(html_or_message)
|
|
55
|
+
violations(html_or_message).empty?
|
|
77
56
|
end
|
|
78
57
|
|
|
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)
|
|
58
|
+
private
|
|
84
59
|
|
|
85
|
-
|
|
86
|
-
|
|
60
|
+
sig { params(builtin: BuiltinRules, disable: T::Array[Symbol], custom: T::Array[Rule]).returns(T::Array[Rule]) }
|
|
61
|
+
def assemble_rules(builtin, disable, custom)
|
|
62
|
+
unknown = disable - DISABLEABLE
|
|
63
|
+
raise ArgumentError, "unknown rule(s): #{unknown.inspect}, expected a subset of #{DISABLEABLE.inspect}" unless unknown.empty?
|
|
87
64
|
|
|
88
|
-
|
|
65
|
+
unique_names!(builtin.rules.reject { |rule| disable.include?(rule.name) } + custom)
|
|
89
66
|
end
|
|
90
67
|
|
|
91
|
-
sig { params(
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
offenders = doc.css('table').count { |table| table['role'] != 'presentation' }
|
|
96
|
-
return if offenders.zero?
|
|
68
|
+
sig { params(rules: T::Array[Rule]).returns(T::Array[Rule]) }
|
|
69
|
+
def unique_names!(rules)
|
|
70
|
+
duplicates = rules.map(&:name).tally.select { |_name, count| count > 1 }.keys
|
|
71
|
+
raise ArgumentError, "duplicate rule name(s): #{duplicates.inspect}" unless duplicates.empty?
|
|
97
72
|
|
|
98
|
-
|
|
73
|
+
rules
|
|
99
74
|
end
|
|
100
75
|
|
|
101
|
-
sig { params(
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
offenders = doc.css('img').count { |img| !img.key?('alt') }
|
|
106
|
-
return if offenders.zero?
|
|
107
|
-
|
|
108
|
-
violations << Violation.new(rule: :img_alt, message: "#{offenders} <img> missing an alt attribute")
|
|
76
|
+
sig { params(html: String).returns(T::Array[Violation]) }
|
|
77
|
+
def html_violations(html)
|
|
78
|
+
apply(@html_rules, [Nokogiri::HTML(html), html])
|
|
109
79
|
end
|
|
110
80
|
|
|
111
|
-
sig { params(
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
|
|
81
|
+
sig { params(rules: T::Array[Rule], args: T::Array[T.untyped]).returns(T::Array[Violation]) }
|
|
82
|
+
def apply(rules, args)
|
|
83
|
+
rules.flat_map do |rule|
|
|
84
|
+
rule.messages(args).map { |message| Violation.new(rule: rule.name, message: message) }
|
|
115
85
|
end
|
|
116
|
-
check_lang(doc, violations)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
sig { params(doc: Nokogiri::XML::Document, violations: T::Array[Violation]).void }
|
|
120
|
-
def check_lang(doc, violations)
|
|
121
|
-
return unless enabled?(:lang)
|
|
122
|
-
|
|
123
|
-
html_tag = doc.at_css('html')
|
|
124
|
-
lang = html_tag && html_tag['lang']
|
|
125
|
-
return if lang && !lang.strip.empty?
|
|
126
|
-
|
|
127
|
-
violations << Violation.new(rule: :lang, message: '<html> must declare a non-blank lang attribute')
|
|
128
86
|
end
|
|
129
87
|
end
|
|
130
88
|
end
|
|
@@ -8,36 +8,34 @@ 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).void }
|
|
12
|
+
def assert_email_quality(html_or_message, guard: ActiveMail::Quality.guard)
|
|
13
|
+
assert_no_quality_violations(guard.violations(html_or_message))
|
|
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
|
+
cases = PreviewRenderer.cases(config)
|
|
33
31
|
# 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
|
-
key = PreviewRenderer.key(preview, email)
|
|
32
|
+
Kernel.warn('[activemail] assert_quality_for_all_previews: no previews discovered.') if cases.empty?
|
|
33
|
+
cases.each_with_index do |(preview, email, locale), i|
|
|
37
34
|
# Index prefix: distinct keys can normalize to the same method name.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
T.unsafe(self).
|
|
35
|
+
name = "test_#{i}_#{PreviewRenderer.key(preview, email).gsub(/\W/, '_')}_#{locale}_email_quality"
|
|
36
|
+
T.unsafe(self).define_method(name) do
|
|
37
|
+
violations = T.unsafe(self).send(:render_preview_or_skip, preview, email, locale, guard, config.required_previews)
|
|
38
|
+
T.unsafe(self).send(:assert_no_quality_violations, violations)
|
|
41
39
|
end
|
|
42
40
|
end
|
|
43
41
|
end
|
|
@@ -50,13 +48,31 @@ module ActiveMail
|
|
|
50
48
|
|
|
51
49
|
private
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
51
|
+
sig { params(violations: T::Array[Guard::Violation]).void }
|
|
52
|
+
def assert_no_quality_violations(violations)
|
|
53
|
+
# assert is injected by the host's Minitest test class.
|
|
54
|
+
T.unsafe(self).assert violations.empty?,
|
|
55
|
+
"email quality violations:\n#{violations.map { |v| " - #{v}" }.join("\n")}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig do
|
|
59
|
+
params(preview: T.untyped, email: String, locale: Symbol, guard: Guard, around_each: T.nilable(Proc))
|
|
60
|
+
.returns(T::Array[Guard::Violation])
|
|
61
|
+
end
|
|
62
|
+
def preview_violations(preview, email, locale, guard, around_each)
|
|
63
|
+
PreviewRenderer.within(preview, email, locale, around_each) { |mail| guard.violations(mail) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A non-required preview that can't render is skipped, not a silent pass.
|
|
67
|
+
sig do
|
|
68
|
+
params(preview: T.untyped, email: String, locale: Symbol, guard: Guard, required: T::Array[String])
|
|
69
|
+
.returns(T::Array[Guard::Violation])
|
|
70
|
+
end
|
|
71
|
+
def render_preview_or_skip(preview, email, locale, guard, required)
|
|
72
|
+
preview_violations(preview, email, locale, guard, ActiveMail::Quality.config.around_each)
|
|
58
73
|
rescue StandardError => e
|
|
59
|
-
|
|
74
|
+
key = PreviewRenderer.key(preview, email)
|
|
75
|
+
msg = "#{key} [#{locale}] did not render (#{e.class}: #{e.message})"
|
|
60
76
|
required.include?(key) ? T.unsafe(self).flunk("required preview #{msg}") : T.unsafe(self).skip(msg)
|
|
61
77
|
end
|
|
62
78
|
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
|
|
@@ -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
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require 'pathname'
|
|
5
6
|
|
|
6
7
|
require_relative '../quality'
|
|
8
|
+
require_relative 'output_dir'
|
|
7
9
|
|
|
8
10
|
module ActiveMail
|
|
9
11
|
module Quality
|
|
@@ -12,6 +14,7 @@ module ActiveMail
|
|
|
12
14
|
extend T::Sig
|
|
13
15
|
|
|
14
16
|
class Result < T::Struct
|
|
17
|
+
const :output_root, Pathname
|
|
15
18
|
const :discovered, Integer
|
|
16
19
|
const :rendered, Integer
|
|
17
20
|
const :render_failures, T::Hash[String, String]
|
|
@@ -19,72 +22,125 @@ module ActiveMail
|
|
|
19
22
|
const :broken_required, T::Array[String]
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
MARKER = '.activemail_previews'
|
|
26
|
+
EXTENSIONS = T.let({ html: 'html', text: 'txt' }.freeze, T::Hash[Symbol, String])
|
|
27
|
+
|
|
28
|
+
sig { returns(Pathname) }
|
|
29
|
+
attr_reader :output_root
|
|
30
|
+
|
|
31
|
+
sig { params(root: T.any(String, Pathname), config: Configuration).void }
|
|
32
|
+
def initialize(root:, config: ActiveMail::Quality.config)
|
|
25
33
|
@config = T.let(config, Configuration)
|
|
34
|
+
@output_root = T.let(OutputDir.resolve(config.output_dir, root), Pathname)
|
|
35
|
+
@render_failures = T.let({}, T::Hash[String, String])
|
|
36
|
+
@guard_failures = T.let({}, T::Hash[String, T::Array[Guard::Violation]])
|
|
37
|
+
@failed_keys = T.let(Set.new, T::Set[String])
|
|
38
|
+
@rendered = T.let(0, Integer)
|
|
26
39
|
end
|
|
27
40
|
|
|
28
41
|
sig { returns(Result) }
|
|
29
42
|
def call
|
|
30
|
-
|
|
31
|
-
FileUtils.mkdir_p(@output_root)
|
|
32
|
-
|
|
33
|
-
render_failures = {}
|
|
34
|
-
guard_failures = {}
|
|
43
|
+
clean_previous_output
|
|
35
44
|
previews = PreviewRenderer.all
|
|
36
|
-
|
|
45
|
+
PreviewRenderer.cases(@config, previews).each { |preview, email, locale| render_locale(preview, email, locale) }
|
|
37
46
|
|
|
38
47
|
Result.new(
|
|
39
|
-
discovered: previews.size, rendered: rendered, render_failures: render_failures,
|
|
40
|
-
guard_failures: guard_failures, broken_required: broken_required(previews
|
|
48
|
+
output_root: @output_root, discovered: previews.size, rendered: @rendered, render_failures: @render_failures,
|
|
49
|
+
guard_failures: @guard_failures, broken_required: broken_required(previews)
|
|
41
50
|
)
|
|
42
51
|
end
|
|
43
52
|
|
|
44
53
|
private
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
sig { void }
|
|
56
|
+
def clean_previous_output
|
|
57
|
+
ensure_owned!
|
|
58
|
+
@output_root.glob('**/*.{html,txt}').each { |path| path.delete if path.file? }
|
|
59
|
+
prune_empty_directories
|
|
60
|
+
FileUtils.mkdir_p(@output_root)
|
|
61
|
+
FileUtils.touch(@output_root.join(MARKER))
|
|
53
62
|
end
|
|
54
|
-
|
|
63
|
+
|
|
64
|
+
# The marker proves a previous run owns the directory: without it, an existing
|
|
65
|
+
# non-empty output_dir (public/, app/...) is never touched.
|
|
66
|
+
sig { void }
|
|
67
|
+
def ensure_owned!
|
|
68
|
+
return if !@output_root.exist? || @output_root.join(MARKER).exist? || @output_root.empty?
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "output_dir #{@output_root} is not empty and was not created by activemail (no #{MARKER}); use a dedicated directory"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sig { void }
|
|
74
|
+
def prune_empty_directories
|
|
75
|
+
@output_root.glob('**/*/').sort_by { |dir| -dir.to_s.size }.each { |dir| dir.rmdir if dir.empty? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A required preview that fails, OR is never discovered at all (typo / deleted), is broken.
|
|
79
|
+
sig { params(previews: T::Array[[T.untyped, String]]).returns(T::Array[String]) }
|
|
80
|
+
def broken_required(previews)
|
|
55
81
|
required = @config.required_previews
|
|
56
82
|
discovered = previews.map { |preview, email| PreviewRenderer.key(preview, email) }
|
|
57
|
-
(
|
|
83
|
+
(@failed_keys.to_a & required) | (required - discovered)
|
|
58
84
|
end
|
|
59
85
|
|
|
60
|
-
sig
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
path = render_one(preview, email, render_failures, key)
|
|
67
|
-
next false unless path
|
|
68
|
-
|
|
69
|
-
violations = @config.guard.violations(File.read(path))
|
|
70
|
-
guard_failures[key] = violations unless violations.empty?
|
|
71
|
-
true
|
|
72
|
-
end
|
|
86
|
+
sig { params(preview: T.untyped, email: String, locale: Symbol).void }
|
|
87
|
+
def render_locale(preview, email, locale)
|
|
88
|
+
key = PreviewRenderer.key(preview, email)
|
|
89
|
+
label = locale == I18n.default_locale ? key : "#{key} [#{locale}]"
|
|
90
|
+
violations = render_one(preview, email, locale, label)
|
|
91
|
+
record(key, label, violations)
|
|
73
92
|
end
|
|
74
93
|
|
|
75
|
-
sig
|
|
76
|
-
|
|
94
|
+
sig { params(key: String, label: String, violations: T.nilable(T::Array[Guard::Violation])).void }
|
|
95
|
+
def record(key, label, violations)
|
|
96
|
+
return @failed_keys << key if violations.nil?
|
|
97
|
+
|
|
98
|
+
@rendered += 1
|
|
99
|
+
return if violations.empty?
|
|
100
|
+
|
|
101
|
+
@failed_keys << key
|
|
102
|
+
@guard_failures[label] = violations
|
|
77
103
|
end
|
|
78
|
-
|
|
79
|
-
|
|
104
|
+
|
|
105
|
+
sig { params(preview: T.untyped, email: String, locale: Symbol, label: String).returns(T.nilable(T::Array[Guard::Violation])) }
|
|
106
|
+
def render_one(preview, email, locale, label)
|
|
107
|
+
PreviewRenderer.within(preview, email, locale, @config.around_each) do |mail|
|
|
108
|
+
next [] if PreviewRenderer.declined?(mail)
|
|
109
|
+
|
|
110
|
+
write(preview, email, locale, mail)
|
|
111
|
+
@config.guard.violations(mail)
|
|
112
|
+
end
|
|
80
113
|
rescue SystemCallError
|
|
81
114
|
raise # disk/permission failures are not template bugs — let them abort the run
|
|
82
115
|
rescue StandardError => e
|
|
83
|
-
|
|
84
|
-
cause = root ? "\n caused by: #{root.class}: #{root.message}" : ''
|
|
85
|
-
failures[key] = "#{e.class}: #{e.message}\n #{e.backtrace&.first(5)&.join("\n ")}#{cause}"
|
|
116
|
+
@render_failures[label] = describe(e)
|
|
86
117
|
nil
|
|
87
118
|
end
|
|
119
|
+
|
|
120
|
+
sig { params(error: StandardError).returns(String) }
|
|
121
|
+
def describe(error)
|
|
122
|
+
root = error.cause
|
|
123
|
+
cause = root ? "\n caused by: #{root.class}: #{root.message}" : ''
|
|
124
|
+
"#{error.class}: #{error.message}\n #{error.backtrace&.first(5)&.join("\n ")}#{cause}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
sig { params(preview: T.untyped, email: String, locale: Symbol, mail: T.untyped).void }
|
|
128
|
+
def write(preview, email, locale, mail)
|
|
129
|
+
dir = @output_root.join(preview.preview_name)
|
|
130
|
+
FileUtils.mkdir_p(dir)
|
|
131
|
+
# The default locale stays unsuffixed so a diff against an older run still lines up.
|
|
132
|
+
stem = locale == I18n.default_locale ? email : "#{email}.#{locale}"
|
|
133
|
+
contents = { html: PreviewRenderer.html_body(mail), text: PreviewRenderer.text_body(mail) }
|
|
134
|
+
|
|
135
|
+
@config.formats.each do |format|
|
|
136
|
+
File.write(dir.join("#{stem}.#{EXTENSIONS.fetch(format)}"), filtered(contents.fetch(format)))
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
sig { params(content: String).returns(String) }
|
|
141
|
+
def filtered(content)
|
|
142
|
+
@config.output_filters.reduce(content) { |acc, filter| filter.call(acc) }
|
|
143
|
+
end
|
|
88
144
|
end
|
|
89
145
|
end
|
|
90
146
|
end
|
|
@@ -6,8 +6,6 @@ require_relative '../quality'
|
|
|
6
6
|
module ActiveMail
|
|
7
7
|
module Quality
|
|
8
8
|
module Rspec
|
|
9
|
-
# Standalone matcher object — testable without booting RSpec. The
|
|
10
|
-
# be_a_valid_email helper below just returns it (no RSpec::Matchers.define DSL).
|
|
11
9
|
class ValidEmailMatcher
|
|
12
10
|
extend T::Sig
|
|
13
11
|
|
|
@@ -17,9 +15,9 @@ module ActiveMail
|
|
|
17
15
|
@violations = T.let([], T::Array[Guard::Violation])
|
|
18
16
|
end
|
|
19
17
|
|
|
20
|
-
sig { params(
|
|
21
|
-
def matches?(
|
|
22
|
-
@violations = @guard.violations(
|
|
18
|
+
sig { params(html_or_message: T.untyped).returns(T::Boolean) }
|
|
19
|
+
def matches?(html_or_message)
|
|
20
|
+
@violations = @guard.violations(html_or_message)
|
|
23
21
|
@violations.empty?
|
|
24
22
|
end
|
|
25
23
|
|
|
@@ -42,7 +40,7 @@ module ActiveMail
|
|
|
42
40
|
|
|
43
41
|
sig { returns(String) }
|
|
44
42
|
def formatted_violations
|
|
45
|
-
@violations.map { |v| " -
|
|
43
|
+
@violations.map { |v| " - #{v}" }.join("\n")
|
|
46
44
|
end
|
|
47
45
|
end
|
|
48
46
|
end
|
|
@@ -50,8 +48,6 @@ module ActiveMail
|
|
|
50
48
|
end
|
|
51
49
|
|
|
52
50
|
if defined?(RSpec)
|
|
53
|
-
# ValidEmailMatcher already implements the full matcher protocol, so a simple
|
|
54
|
-
# helper returning it is enough — no RSpec::Matchers.define DSL needed.
|
|
55
51
|
module RSpec
|
|
56
52
|
module Matchers
|
|
57
53
|
extend T::Sig
|