activemail 2.0.0 → 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 +53 -1
- data/README.md +44 -17
- data/UPGRADING.md +73 -0
- data/app/assets/stylesheets/activemail/_dark.scss +17 -8
- data/app/assets/stylesheets/activemail/_grid.scss +38 -14
- data/app/assets/stylesheets/activemail/_settings.scss +1 -0
- data/app/views/layouts/activemail/_document_head.html.erb +18 -0
- data/app/views/layouts/activemail/mailer.html.inky-erb +1 -17
- data/lib/activemail/components/button_renderer.rb +8 -4
- data/lib/activemail/components/center.rb +8 -6
- data/lib/activemail/components/columns.rb +32 -8
- data/lib/activemail/components/container.rb +2 -2
- data/lib/activemail/inliner/premailer/document.rb +100 -0
- data/lib/activemail/inliner/premailer.rb +3 -2
- data/lib/activemail/quality/builtin_message_rules.rb +58 -0
- data/lib/activemail/quality/builtin_rules.rb +41 -30
- data/lib/activemail/quality/configuration.rb +31 -16
- data/lib/activemail/quality/guard.rb +34 -14
- data/lib/activemail/quality/minitest.rb +24 -11
- data/lib/activemail/quality/output_filter.rb +24 -0
- data/lib/activemail/quality/render_all.rb +4 -4
- data/lib/activemail/quality/rspec.rb +11 -6
- data/lib/activemail/quality/rule_exemptions.rb +49 -0
- data/lib/activemail/tokens/defaults.rb +2 -1
- data/lib/activemail/version.rb +1 -1
- data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +3 -17
- data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +3 -9
- data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +3 -8
- metadata +6 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'premailer'
|
|
6
|
+
require 'sorbet-runtime'
|
|
7
|
+
|
|
8
|
+
module ActiveMail
|
|
9
|
+
module Inliner
|
|
10
|
+
class Premailer < Base
|
|
11
|
+
# Premailer re-parses every <style> block with css_parser on each email; the framework
|
|
12
|
+
# CSS never changes between two emails, so its rule sets are parsed once per fingerprint.
|
|
13
|
+
class Document < ::Premailer
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
# Mirrors the parser Premailer builds for these options.
|
|
17
|
+
PARSER_OPTIONS = T.let(
|
|
18
|
+
{ absolute_paths: true, import: true, io_exceptions: false, rule_set_exceptions: true, allow_local_network: false }.freeze,
|
|
19
|
+
T::Hash[Symbol, T.untyped]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# FIFO-bounded and thread-safe: mailers render from several threads and a dev reload
|
|
23
|
+
# compiles new CSS under a new fingerprint.
|
|
24
|
+
class RuleSetCache
|
|
25
|
+
extend T::Sig
|
|
26
|
+
|
|
27
|
+
LIMIT = 16
|
|
28
|
+
|
|
29
|
+
sig { void }
|
|
30
|
+
def initialize
|
|
31
|
+
@entries = T.let({}, T::Hash[T.untyped, T.untyped])
|
|
32
|
+
@mutex = T.let(Mutex.new, Mutex)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { params(key: T.untyped, block: T.proc.returns(T.untyped)).returns(T.untyped) }
|
|
36
|
+
def fetch(key, &block)
|
|
37
|
+
@mutex.synchronize { @entries[key] } || store(key, block.call)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { void }
|
|
41
|
+
def clear
|
|
42
|
+
@mutex.synchronize { @entries.clear }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { returns(Integer) }
|
|
46
|
+
def size
|
|
47
|
+
@mutex.synchronize { @entries.size }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
sig { params(key: T.untyped, value: T.untyped).returns(T.untyped) }
|
|
53
|
+
def store(key, value)
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
@entries[key] = value
|
|
56
|
+
@entries.shift while @entries.size > LIMIT
|
|
57
|
+
end
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class CachedParser < ::CssParser::Parser
|
|
63
|
+
extend T::Sig
|
|
64
|
+
|
|
65
|
+
sig { params(block: String, options: T::Hash[Symbol, T.untyped]).void }
|
|
66
|
+
def add_block!(block, options = {})
|
|
67
|
+
Document.rule_sets.fetch([Digest::SHA256.hexdigest(block), options]) { parse(block, options) }.each do |rule_set, media_types|
|
|
68
|
+
add_rule_set!(rule_set, media_types)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
sig { params(block: String, options: T::Hash[Symbol, T.untyped]).returns(T::Array[T.untyped]) }
|
|
75
|
+
def parse(block, options)
|
|
76
|
+
parser = ::CssParser::Parser.new(PARSER_OPTIONS)
|
|
77
|
+
parser.add_block!(block, options)
|
|
78
|
+
rule_sets = T.let([], T::Array[T.untyped])
|
|
79
|
+
parser.each_rule_set { |rule_set, media_types| rule_sets << [rule_set, media_types] }
|
|
80
|
+
rule_sets
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
sig { returns(RuleSetCache) }
|
|
85
|
+
def self.rule_sets
|
|
86
|
+
@rule_sets ||= T.let(RuleSetCache.new, T.nilable(RuleSetCache))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
protected
|
|
90
|
+
|
|
91
|
+
# Premailer exposes no hook for its parser: swap it before the <style> blocks are read.
|
|
92
|
+
sig { void }
|
|
93
|
+
def load_css_from_html!
|
|
94
|
+
T.unsafe(self).instance_variable_set(:@css_parser, CachedParser.new(PARSER_OPTIONS))
|
|
95
|
+
super
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -12,10 +12,11 @@ module ActiveMail
|
|
|
12
12
|
def inline(html)
|
|
13
13
|
# premailer is a hard runtime dependency (the default inliner), so require
|
|
14
14
|
# can't fail — unlike the optional Roadie adapter, which guards LoadError.
|
|
15
|
-
|
|
15
|
+
# Loaded on first use: premailer is slow to load and a :roadie or :null host never needs it.
|
|
16
|
+
require_relative 'premailer/document'
|
|
16
17
|
# warn_level NONE: premailer's un-inlinable-CSS warnings are noise at delivery
|
|
17
18
|
# time; the quality layer is where coverage gaps should surface.
|
|
18
|
-
|
|
19
|
+
Document.new(html, with_html_string: true, warn_level: ::Premailer::Warnings::NONE).to_inline_css
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require_relative 'preview_renderer'
|
|
7
|
+
require_relative 'rule'
|
|
8
|
+
|
|
9
|
+
module ActiveMail
|
|
10
|
+
module Quality
|
|
11
|
+
# The built-in rules that read the Mail message (subject, text part) rather than the HTML.
|
|
12
|
+
class BuiltinMessageRules
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
sig { returns(T::Array[Rule]) }
|
|
16
|
+
def rules
|
|
17
|
+
[
|
|
18
|
+
Rule.message(:translation_missing_text) { |mail| translation_missing_text_message(mail) },
|
|
19
|
+
Rule.message(:html_part_present) { |mail| html_part_present_message(mail) },
|
|
20
|
+
Rule.message(:text_part_present) { |mail| text_part_present_message(mail) },
|
|
21
|
+
Rule.message(:text_part_plain) { |mail| text_part_plain_message(mail) },
|
|
22
|
+
Rule.message(:text_entities) { |mail| text_entities_message(mail) }
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
29
|
+
def text_entities_message(mail)
|
|
30
|
+
found = PreviewRenderer.text_body(mail).scan(BuiltinRules::HTML_ENTITY).uniq
|
|
31
|
+
"text/plain part contains HTML entities: #{found.first(3).join(', ')}" unless found.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
35
|
+
def translation_missing_text_message(mail)
|
|
36
|
+
text = "#{mail.subject}\n#{PreviewRenderer.text_body(mail)}"
|
|
37
|
+
'subject or text part contains a missing i18n translation' if text.match?(BuiltinRules::MISSING_TRANSLATION)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
41
|
+
def html_part_present_message(mail)
|
|
42
|
+
'message has no HTML part' if PreviewRenderer.html_body(mail).empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
46
|
+
def text_part_present_message(mail)
|
|
47
|
+
return if mail.multipart? && !PreviewRenderer.text_body(mail).strip.empty?
|
|
48
|
+
|
|
49
|
+
'message must be multipart with a non-empty text/plain part'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { params(mail: T.untyped).returns(T.nilable(String)) }
|
|
53
|
+
def text_part_plain_message(mail)
|
|
54
|
+
'text/plain part contains HTML markup' if PreviewRenderer.text_body(mail).match?(BuiltinRules::MARKUP_IN_TEXT)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -5,6 +5,7 @@ require 'nokogiri'
|
|
|
5
5
|
require 'sorbet-runtime'
|
|
6
6
|
|
|
7
7
|
require_relative '../libxml'
|
|
8
|
+
require_relative 'builtin_message_rules'
|
|
8
9
|
require_relative 'preview_renderer'
|
|
9
10
|
require_relative 'rule'
|
|
10
11
|
|
|
@@ -17,11 +18,18 @@ module ActiveMail
|
|
|
17
18
|
%i[
|
|
18
19
|
max_bytes parse_error table_role img_alt lang min_full_doc_bytes translation_missing single_root
|
|
19
20
|
translation_missing_text html_part_present text_part_present text_part_plain
|
|
21
|
+
title anchor_href placeholder unsupported_color text_entities
|
|
20
22
|
].freeze,
|
|
21
23
|
T::Array[Symbol]
|
|
22
24
|
)
|
|
23
25
|
MARKUP_IN_TEXT = %r{<(?:table|div|span|p|br|a)[\s>/]}i
|
|
24
26
|
MISSING_TRANSLATION = /translation[ _]missing/i
|
|
27
|
+
# An unresolved `{{variable}}` of a user-editable template; public so a host can refuse it before sending.
|
|
28
|
+
PLACEHOLDER = /\{\{[^{}]+\}\}/
|
|
29
|
+
# Clients drop or mangle these CSS color forms in a style attribute.
|
|
30
|
+
UNSUPPORTED_COLOR = /rgba?\([^)]*%[^)]*\)|hsla?\(|color-mix\(|\bvar\(/i
|
|
31
|
+
HTML_ENTITY = /&(?:[a-z][a-z0-9]*|#\d+|#x\h+);/i
|
|
32
|
+
STYLE_AND_SCRIPT = %r{<(style|script)\b[\s\S]*?</\1\s*>}i
|
|
25
33
|
ROOTS = T.let({ html: /<html[\s>]/i, body: /<body[\s>]/i }.freeze, T::Hash[Symbol, Regexp])
|
|
26
34
|
|
|
27
35
|
sig { params(max_bytes: Integer, min_full_doc_bytes: Integer).void }
|
|
@@ -33,7 +41,7 @@ module ActiveMail
|
|
|
33
41
|
|
|
34
42
|
sig { returns(T::Array[Rule]) }
|
|
35
43
|
def rules
|
|
36
|
-
structure_rules + content_rules +
|
|
44
|
+
structure_rules + content_rules + BuiltinMessageRules.new.rules
|
|
37
45
|
end
|
|
38
46
|
|
|
39
47
|
private
|
|
@@ -46,7 +54,9 @@ module ActiveMail
|
|
|
46
54
|
# silently bless malformed *output* (mismatched tags, bad entities) before send.
|
|
47
55
|
Rule.html(:parse_error) { |doc, _html| parse_error_message(doc) },
|
|
48
56
|
Rule.html(:table_role) { |doc, _html| table_role_message(doc) },
|
|
49
|
-
Rule.html(:img_alt) { |doc, _html| img_alt_message(doc) }
|
|
57
|
+
Rule.html(:img_alt) { |doc, _html| img_alt_message(doc) },
|
|
58
|
+
Rule.html(:anchor_href) { |doc, _html| anchor_href_message(doc) },
|
|
59
|
+
Rule.html(:unsupported_color) { |doc, html| unsupported_color_message(doc, html) }
|
|
50
60
|
]
|
|
51
61
|
end
|
|
52
62
|
|
|
@@ -56,17 +66,9 @@ module ActiveMail
|
|
|
56
66
|
Rule.html(:translation_missing) { |_doc, html| translation_missing_message(html) },
|
|
57
67
|
Rule.html(:single_root) { |_doc, html| single_root_messages(html) },
|
|
58
68
|
Rule.html(:min_full_doc_bytes) { |_doc, html| min_full_doc_message(html) },
|
|
59
|
-
Rule.html(:lang) { |doc, html| lang_message(doc, html) }
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
sig { returns(T::Array[Rule]) }
|
|
64
|
-
def message_rules
|
|
65
|
-
[
|
|
66
|
-
Rule.message(:translation_missing_text) { |mail| translation_missing_text_message(mail) },
|
|
67
|
-
Rule.message(:html_part_present) { |mail| html_part_present_message(mail) },
|
|
68
|
-
Rule.message(:text_part_present) { |mail| text_part_present_message(mail) },
|
|
69
|
-
Rule.message(:text_part_plain) { |mail| text_part_plain_message(mail) }
|
|
69
|
+
Rule.html(:lang) { |doc, html| lang_message(doc, html) },
|
|
70
|
+
Rule.html(:title) { |doc, html| title_message(doc, html) },
|
|
71
|
+
Rule.html(:placeholder) { |_doc, html| placeholder_message(html) }
|
|
70
72
|
]
|
|
71
73
|
end
|
|
72
74
|
|
|
@@ -97,6 +99,23 @@ module ActiveMail
|
|
|
97
99
|
"#{offenders} <img> missing an alt attribute" unless offenders.zero?
|
|
98
100
|
end
|
|
99
101
|
|
|
102
|
+
sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
|
|
103
|
+
def anchor_href_message(doc)
|
|
104
|
+
offenders = doc.css('a').count { |anchor| anchor['href'].to_s.strip.empty? }
|
|
105
|
+
"#{offenders} <a> without a href" unless offenders.zero?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The raw-string pre-check keeps the per-node scan off the common, clean path.
|
|
109
|
+
sig { params(doc: Nokogiri::XML::Document, html: String).returns(T.nilable(String)) }
|
|
110
|
+
def unsupported_color_message(doc, html)
|
|
111
|
+
return unless html.match?(UNSUPPORTED_COLOR)
|
|
112
|
+
|
|
113
|
+
offenders = doc.xpath('//*[@style]').filter_map { |node| node['style'].to_s[UNSUPPORTED_COLOR] }
|
|
114
|
+
return if offenders.empty?
|
|
115
|
+
|
|
116
|
+
"#{offenders.size} inline style(s) use a color clients ignore (rgb(%), hsl, color-mix, var()): #{offenders.uniq.first(3).join(', ')}"
|
|
117
|
+
end
|
|
118
|
+
|
|
100
119
|
sig { params(html: String).returns(T.nilable(String)) }
|
|
101
120
|
def translation_missing_message(html)
|
|
102
121
|
'rendered HTML contains a missing i18n translation' if html.match?(MISSING_TRANSLATION)
|
|
@@ -127,27 +146,19 @@ module ActiveMail
|
|
|
127
146
|
'<html> must declare a non-blank lang attribute' if lang.nil? || lang.strip.empty?
|
|
128
147
|
end
|
|
129
148
|
|
|
130
|
-
sig { params(
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
'subject or text part contains a missing i18n translation' if text.match?(MISSING_TRANSLATION)
|
|
134
|
-
end
|
|
149
|
+
sig { params(doc: Nokogiri::XML::Document, html: String).returns(T.nilable(String)) }
|
|
150
|
+
def title_message(doc, html)
|
|
151
|
+
return unless ::ActiveMail.full_document?(html)
|
|
135
152
|
|
|
136
|
-
|
|
137
|
-
def html_part_present_message(mail)
|
|
138
|
-
'message has no HTML part' if PreviewRenderer.html_body(mail).empty?
|
|
153
|
+
'<head> must contain a non-blank <title>' if doc.at_css('title')&.text.to_s.strip.empty?
|
|
139
154
|
end
|
|
140
155
|
|
|
141
|
-
sig { params(
|
|
142
|
-
def
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
'message must be multipart with a non-empty text/plain part'
|
|
146
|
-
end
|
|
156
|
+
sig { params(html: String).returns(T.nilable(String)) }
|
|
157
|
+
def placeholder_message(html)
|
|
158
|
+
return unless html.include?('{{')
|
|
147
159
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
'text/plain part contains HTML markup' if PreviewRenderer.text_body(mail).match?(MARKUP_IN_TEXT)
|
|
160
|
+
found = html.gsub(STYLE_AND_SCRIPT, '').scan(PLACEHOLDER).uniq
|
|
161
|
+
"unresolved placeholder(s): #{found.first(3).join(', ')}" unless found.empty?
|
|
151
162
|
end
|
|
152
163
|
end
|
|
153
164
|
end
|
|
@@ -6,11 +6,14 @@ require 'sorbet-runtime'
|
|
|
6
6
|
|
|
7
7
|
require_relative 'guard'
|
|
8
8
|
require_relative 'output_dir'
|
|
9
|
+
require_relative 'output_filter'
|
|
10
|
+
require_relative 'rule_exemptions'
|
|
9
11
|
|
|
10
12
|
module ActiveMail
|
|
11
13
|
module Quality
|
|
12
14
|
class Configuration
|
|
13
15
|
extend T::Sig
|
|
16
|
+
include RuleExemptions
|
|
14
17
|
|
|
15
18
|
FORMATS = T.let(%i[html text].freeze, T::Array[Symbol])
|
|
16
19
|
DEFAULT_LOCALES = T.let(->(_preview) { [I18n.default_locale] }, Proc)
|
|
@@ -38,14 +41,29 @@ module ActiveMail
|
|
|
38
41
|
# reported.
|
|
39
42
|
sig { returns(T::Array[String]) }
|
|
40
43
|
def required_previews
|
|
41
|
-
@
|
|
44
|
+
@preview_keys.fetch(:required).dup.freeze
|
|
42
45
|
end
|
|
43
46
|
|
|
44
|
-
sig { params(value: T.untyped).
|
|
47
|
+
sig { params(value: T.untyped).void }
|
|
45
48
|
def required_previews=(value)
|
|
46
|
-
|
|
49
|
+
@preview_keys[:required] = preview_keys(value)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Preview keys that may legitimately decline to send (NullMail); any other
|
|
53
|
+
# preview returning one is a violation.
|
|
54
|
+
sig { returns(T::Array[String]) }
|
|
55
|
+
def allow_declined
|
|
56
|
+
@preview_keys.fetch(:allow_declined).dup.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { params(value: T.untyped).void }
|
|
60
|
+
def allow_declined=(value)
|
|
61
|
+
@preview_keys[:allow_declined] = preview_keys(value)
|
|
62
|
+
end
|
|
47
63
|
|
|
48
|
-
|
|
64
|
+
sig { params(key: String).returns(T::Boolean) }
|
|
65
|
+
def allow_declined?(key)
|
|
66
|
+
@preview_keys.fetch(:allow_declined).include?(key)
|
|
49
67
|
end
|
|
50
68
|
|
|
51
69
|
sig { returns(T.any(T::Array[Symbol], Proc)) }
|
|
@@ -98,14 +116,14 @@ module ActiveMail
|
|
|
98
116
|
def output_filters=(value)
|
|
99
117
|
raise TypeError, "output_filters must be an Array, got #{value.class}" unless value.is_a?(Array)
|
|
100
118
|
|
|
101
|
-
@output_filters = value.map { |filter|
|
|
119
|
+
@output_filters = value.map { |filter| OutputFilter.coerce(filter) }
|
|
102
120
|
end
|
|
103
121
|
|
|
104
122
|
sig { void }
|
|
105
123
|
def initialize
|
|
106
124
|
@guard = T.let(Guard.new, Guard)
|
|
107
125
|
@output_dir = T.let('tmp/activemail_previews', String)
|
|
108
|
-
@
|
|
126
|
+
@preview_keys = T.let({ required: [], allow_declined: [] }, T::Hash[Symbol, T::Array[String]])
|
|
109
127
|
@locales = T.let(DEFAULT_LOCALES, T.any(T::Array[Symbol], Proc))
|
|
110
128
|
@formats = T.let(FORMATS, T::Array[Symbol])
|
|
111
129
|
@around_each = T.let(nil, T.nilable(Proc))
|
|
@@ -114,6 +132,13 @@ module ActiveMail
|
|
|
114
132
|
|
|
115
133
|
private
|
|
116
134
|
|
|
135
|
+
sig { params(value: T.untyped).returns(T::Array[String]) }
|
|
136
|
+
def preview_keys(value)
|
|
137
|
+
raise TypeError, "#{value.inspect} (#{value.class}) does not respond to 'to_a'" unless value.respond_to?(:to_a)
|
|
138
|
+
|
|
139
|
+
value.to_a.map(&:to_s)
|
|
140
|
+
end
|
|
141
|
+
|
|
117
142
|
sig { params(value: T.untyped).returns(T::Array[Symbol]) }
|
|
118
143
|
def normalize_locales(value)
|
|
119
144
|
raise TypeError, "locales must be an Array, got #{value.class}" unless value.is_a?(Array)
|
|
@@ -121,16 +146,6 @@ module ActiveMail
|
|
|
121
146
|
|
|
122
147
|
value.map(&:to_sym)
|
|
123
148
|
end
|
|
124
|
-
|
|
125
|
-
sig { params(filter: T.untyped).returns(Proc) }
|
|
126
|
-
def output_filter(filter)
|
|
127
|
-
return filter if filter.is_a?(Proc)
|
|
128
|
-
|
|
129
|
-
pattern, replacement = filter if filter.is_a?(Array) && filter.size == 2
|
|
130
|
-
raise TypeError, "output filter must be a Proc or [Regexp, String], got #{filter.inspect}" unless pattern.is_a?(Regexp) && replacement.is_a?(String)
|
|
131
|
-
|
|
132
|
-
->(content) { content.gsub(pattern, replacement) }
|
|
133
|
-
end
|
|
134
149
|
end
|
|
135
150
|
end
|
|
136
151
|
end
|
|
@@ -30,33 +30,51 @@ module ActiveMail
|
|
|
30
30
|
DEFAULT_MAX_BYTES = 102_400
|
|
31
31
|
# A full HTML document smaller than this carries no real layout and is suspect.
|
|
32
32
|
DEFAULT_MIN_FULL_DOC_BYTES = 1_024
|
|
33
|
+
NO_SKIP = T.let([].freeze, T::Array[Symbol])
|
|
33
34
|
|
|
34
35
|
sig { params(max_bytes: Integer, min_full_doc_bytes: Integer, disable: T::Array[Symbol], rules: T::Array[Rule]).void }
|
|
35
36
|
def initialize(max_bytes: DEFAULT_MAX_BYTES, min_full_doc_bytes: DEFAULT_MIN_FULL_DOC_BYTES, disable: [], rules: [])
|
|
36
37
|
all = assemble_rules(BuiltinRules.new(max_bytes: max_bytes, min_full_doc_bytes: min_full_doc_bytes), disable, rules)
|
|
37
38
|
@html_rules = T.let(all.select { |rule| rule.level == :html }, T::Array[Rule])
|
|
38
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])
|
|
39
42
|
end
|
|
40
43
|
|
|
41
|
-
# A String only gets the HTML rules; a message gets both levels
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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)
|
|
45
50
|
|
|
46
51
|
mail = PreviewRenderer.mail(html_or_message)
|
|
47
|
-
return
|
|
52
|
+
return declined_violations(allow_declined) if PreviewRenderer.declined?(mail)
|
|
48
53
|
|
|
49
54
|
html = PreviewRenderer.html_body(mail)
|
|
50
|
-
(html.empty? ? [] : html_violations(html)) + apply(@message_rules, [mail])
|
|
55
|
+
(html.empty? ? [] : html_violations(html, skip)) + apply(@message_rules, [mail], skip)
|
|
51
56
|
end
|
|
52
57
|
|
|
53
|
-
sig { params(html_or_message: T.untyped).returns(T::Boolean) }
|
|
54
|
-
def valid?(html_or_message)
|
|
55
|
-
violations(html_or_message).empty?
|
|
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?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig { params(skip: T::Array[Symbol]).void }
|
|
64
|
+
def check_skippable!(skip)
|
|
65
|
+
return if skip.empty?
|
|
66
|
+
|
|
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?
|
|
56
69
|
end
|
|
57
70
|
|
|
58
71
|
private
|
|
59
72
|
|
|
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)')]
|
|
76
|
+
end
|
|
77
|
+
|
|
60
78
|
sig { params(builtin: BuiltinRules, disable: T::Array[Symbol], custom: T::Array[Rule]).returns(T::Array[Rule]) }
|
|
61
79
|
def assemble_rules(builtin, disable, custom)
|
|
62
80
|
unknown = disable - DISABLEABLE
|
|
@@ -73,14 +91,16 @@ module ActiveMail
|
|
|
73
91
|
rules
|
|
74
92
|
end
|
|
75
93
|
|
|
76
|
-
sig { params(html: String).returns(T::Array[Violation]) }
|
|
77
|
-
def html_violations(html)
|
|
78
|
-
apply(@html_rules, [Nokogiri::HTML(html), html])
|
|
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)
|
|
79
97
|
end
|
|
80
98
|
|
|
81
|
-
sig { params(rules: T::Array[Rule], args: T::Array[T.untyped]).returns(T::Array[Violation]) }
|
|
82
|
-
def apply(rules, args)
|
|
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)
|
|
83
101
|
rules.flat_map do |rule|
|
|
102
|
+
next [] if skip.include?(rule.name)
|
|
103
|
+
|
|
84
104
|
rule.messages(args).map { |message| Violation.new(rule: rule.name, message: message) }
|
|
85
105
|
end
|
|
86
106
|
end
|
|
@@ -8,9 +8,9 @@ module ActiveMail
|
|
|
8
8
|
module Minitest
|
|
9
9
|
extend T::Sig
|
|
10
10
|
|
|
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))
|
|
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))
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
sig do
|
|
@@ -27,16 +27,23 @@ module ActiveMail
|
|
|
27
27
|
sig { params(guard: Guard).void }
|
|
28
28
|
def assert_quality_for_all_previews(guard: ActiveMail::Quality.guard)
|
|
29
29
|
config = ActiveMail::Quality.config
|
|
30
|
+
config.check_exemptions!(guard)
|
|
30
31
|
cases = PreviewRenderer.cases(config)
|
|
31
32
|
# A silent no-test run would look like everything passed.
|
|
32
33
|
Kernel.warn('[activemail] assert_quality_for_all_previews: no previews discovered.') if cases.empty?
|
|
33
|
-
cases.each_with_index
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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)
|
|
40
47
|
end
|
|
41
48
|
end
|
|
42
49
|
end
|
|
@@ -60,7 +67,13 @@ module ActiveMail
|
|
|
60
67
|
.returns(T::Array[Guard::Violation])
|
|
61
68
|
end
|
|
62
69
|
def preview_violations(preview, email, locale, guard, around_each)
|
|
63
|
-
|
|
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
|
|
64
77
|
end
|
|
65
78
|
|
|
66
79
|
# A non-required preview that can't render is skipped, not a silent pass.
|
|
@@ -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
|
|
@@ -40,6 +40,7 @@ module ActiveMail
|
|
|
40
40
|
|
|
41
41
|
sig { returns(Result) }
|
|
42
42
|
def call
|
|
43
|
+
@config.check_exemptions!(@config.guard)
|
|
43
44
|
clean_previous_output
|
|
44
45
|
previews = PreviewRenderer.all
|
|
45
46
|
PreviewRenderer.cases(@config, previews).each { |preview, email, locale| render_locale(preview, email, locale) }
|
|
@@ -105,10 +106,9 @@ module ActiveMail
|
|
|
105
106
|
sig { params(preview: T.untyped, email: String, locale: Symbol, label: String).returns(T.nilable(T::Array[Guard::Violation])) }
|
|
106
107
|
def render_one(preview, email, locale, label)
|
|
107
108
|
PreviewRenderer.within(preview, email, locale, @config.around_each) do |mail|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
@config.guard.violations(mail)
|
|
109
|
+
write(preview, email, locale, mail) unless PreviewRenderer.declined?(mail)
|
|
110
|
+
key = PreviewRenderer.key(preview, email)
|
|
111
|
+
@config.guard.violations(mail, allow_declined: @config.allow_declined?(key), skip: @config.exempt_rules_for(key))
|
|
112
112
|
end
|
|
113
113
|
rescue SystemCallError
|
|
114
114
|
raise # disk/permission failures are not template bugs — let them abort the run
|
|
@@ -9,15 +9,17 @@ module ActiveMail
|
|
|
9
9
|
class ValidEmailMatcher
|
|
10
10
|
extend T::Sig
|
|
11
11
|
|
|
12
|
-
sig { params(guard: Guard).void }
|
|
13
|
-
def initialize(guard: ActiveMail::Quality.guard)
|
|
12
|
+
sig { params(guard: Guard, allow_declined: T::Boolean, skip: T::Array[Symbol]).void }
|
|
13
|
+
def initialize(guard: ActiveMail::Quality.guard, allow_declined: false, skip: Guard::NO_SKIP)
|
|
14
14
|
@guard = T.let(guard, Guard)
|
|
15
|
+
@allow_declined = T.let(allow_declined, T::Boolean)
|
|
16
|
+
@skip = T.let(skip, T::Array[Symbol])
|
|
15
17
|
@violations = T.let([], T::Array[Guard::Violation])
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
sig { params(html_or_message: T.untyped).returns(T::Boolean) }
|
|
19
21
|
def matches?(html_or_message)
|
|
20
|
-
@violations = @guard.violations(html_or_message)
|
|
22
|
+
@violations = @guard.violations(html_or_message, allow_declined: @allow_declined, skip: @skip)
|
|
21
23
|
@violations.empty?
|
|
22
24
|
end
|
|
23
25
|
|
|
@@ -52,9 +54,12 @@ if defined?(RSpec)
|
|
|
52
54
|
module Matchers
|
|
53
55
|
extend T::Sig
|
|
54
56
|
|
|
55
|
-
sig
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
sig do
|
|
58
|
+
params(guard: ActiveMail::Quality::Guard, allow_declined: T::Boolean, skip: T::Array[Symbol])
|
|
59
|
+
.returns(ActiveMail::Quality::Rspec::ValidEmailMatcher)
|
|
60
|
+
end
|
|
61
|
+
def be_a_valid_email(guard: ActiveMail::Quality.guard, allow_declined: false, skip: ActiveMail::Quality::Guard::NO_SKIP)
|
|
62
|
+
ActiveMail::Quality::Rspec::ValidEmailMatcher.new(guard: guard, allow_declined: allow_declined, skip: skip)
|
|
58
63
|
end
|
|
59
64
|
end
|
|
60
65
|
end
|