activemail 2.0.0 → 2.1.1

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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +44 -17
  4. data/UPGRADING.md +66 -0
  5. data/app/assets/stylesheets/activemail/_dark.scss +17 -8
  6. data/app/assets/stylesheets/activemail/_grid.scss +38 -14
  7. data/app/assets/stylesheets/activemail/_settings.scss +1 -0
  8. data/app/views/layouts/activemail/_document_head.html.erb +18 -0
  9. data/app/views/layouts/activemail/mailer.html.inky-erb +1 -17
  10. data/lib/activemail/components/button_renderer.rb +8 -4
  11. data/lib/activemail/components/center.rb +8 -1
  12. data/lib/activemail/components/columns.rb +32 -8
  13. data/lib/activemail/components/container.rb +2 -2
  14. data/lib/activemail/inliner/premailer/document.rb +100 -0
  15. data/lib/activemail/inliner/premailer.rb +3 -2
  16. data/lib/activemail/quality/builtin_message_rules.rb +58 -0
  17. data/lib/activemail/quality/builtin_rules.rb +41 -30
  18. data/lib/activemail/quality/configuration.rb +31 -16
  19. data/lib/activemail/quality/guard.rb +34 -14
  20. data/lib/activemail/quality/minitest.rb +24 -11
  21. data/lib/activemail/quality/output_filter.rb +24 -0
  22. data/lib/activemail/quality/render_all.rb +4 -4
  23. data/lib/activemail/quality/rspec.rb +11 -6
  24. data/lib/activemail/quality/rule_exemptions.rb +49 -0
  25. data/lib/activemail/tokens/defaults.rb +2 -1
  26. data/lib/activemail/version.rb +1 -1
  27. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +3 -17
  28. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +3 -9
  29. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +3 -8
  30. 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
- require 'premailer'
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
- ::Premailer.new(html, with_html_string: true, warn_level: ::Premailer::Warnings::NONE).to_inline_css
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 + message_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
- end
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(mail: T.untyped).returns(T.nilable(String)) }
131
- def translation_missing_text_message(mail)
132
- text = "#{mail.subject}\n#{PreviewRenderer.text_body(mail)}"
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
- sig { params(mail: T.untyped).returns(T.nilable(String)) }
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(mail: T.untyped).returns(T.nilable(String)) }
142
- def text_part_present_message(mail)
143
- return if mail.multipart? && !PreviewRenderer.text_body(mail).strip.empty?
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
- sig { params(mail: T.untyped).returns(T.nilable(String)) }
149
- def text_part_plain_message(mail)
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
- @required_previews.dup.freeze
44
+ @preview_keys.fetch(:required).dup.freeze
42
45
  end
43
46
 
44
- sig { params(value: T.untyped).returns(T::Array[String]) }
47
+ sig { params(value: T.untyped).void }
45
48
  def required_previews=(value)
46
- raise TypeError, "#{value.inspect} (#{value.class}) does not respond to 'to_a'" unless value.respond_to?(:to_a)
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
- @required_previews = value.to_a.map(&:to_s)
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| output_filter(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
- @required_previews = T.let([], T::Array[String])
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, 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)
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 [] if PreviewRenderer.declined?(mail)
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 do |(preview, email, locale), i|
34
- # Index prefix: distinct keys can normalize to the same method name.
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)
39
- end
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
- PreviewRenderer.within(preview, email, locale, around_each) { |mail| guard.violations(mail) }
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
- next [] if PreviewRenderer.declined?(mail)
109
-
110
- write(preview, email, locale, mail)
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 { params(guard: ActiveMail::Quality::Guard).returns(ActiveMail::Quality::Rspec::ValidEmailMatcher) }
56
- def be_a_valid_email(guard: ActiveMail::Quality.guard)
57
- ActiveMail::Quality::Rspec::ValidEmailMatcher.new(guard: guard)
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