activemail 1.2.3 → 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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -2
  3. data/README.md +105 -47
  4. data/UPGRADING.md +112 -0
  5. data/app/assets/stylesheets/activemail/_components.scss +33 -42
  6. data/app/assets/stylesheets/activemail/_dark.scss +31 -20
  7. data/app/assets/stylesheets/activemail/_grid.scss +2 -0
  8. data/app/assets/stylesheets/activemail/_settings.scss +22 -10
  9. data/app/assets/stylesheets/activemail/_utilities.scss +3 -1
  10. data/app/assets/stylesheets/activemail/activemail.scss +6 -5
  11. data/app/helpers/active_mail/styles_helper.rb +14 -15
  12. data/app/views/layouts/activemail/mailer.html.inky-erb +0 -5
  13. data/lib/activemail/asset_pipeline/base.rb +47 -0
  14. data/lib/activemail/asset_pipeline/null.rb +19 -0
  15. data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
  16. data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
  17. data/lib/activemail/asset_pipeline.rb +35 -0
  18. data/lib/activemail/components/base.rb +35 -39
  19. data/lib/activemail/components/block_grid.rb +11 -4
  20. data/lib/activemail/components/button.rb +5 -28
  21. data/lib/activemail/components/button_renderer.rb +65 -0
  22. data/lib/activemail/components/callout.rb +4 -5
  23. data/lib/activemail/components/center.rb +5 -7
  24. data/lib/activemail/components/columns.rb +26 -7
  25. data/lib/activemail/components/container.rb +11 -4
  26. data/lib/activemail/components/cta.rb +9 -33
  27. data/lib/activemail/components/h_line.rb +3 -5
  28. data/lib/activemail/components/info_box.rb +6 -12
  29. data/lib/activemail/components/menu.rb +3 -4
  30. data/lib/activemail/components/menu_item.rb +10 -3
  31. data/lib/activemail/components/row.rb +3 -4
  32. data/lib/activemail/components/spacer.rb +16 -12
  33. data/lib/activemail/components/table_component.rb +23 -0
  34. data/lib/activemail/components/wrapper.rb +10 -4
  35. data/lib/activemail/configuration.rb +43 -57
  36. data/lib/activemail/errors.rb +15 -0
  37. data/lib/activemail/inliner/base.rb +2 -10
  38. data/lib/activemail/inliner/null.rb +0 -5
  39. data/lib/activemail/inliner/roadie.rb +1 -1
  40. data/lib/activemail/inliner.rb +33 -0
  41. data/lib/activemail/parse_error_reporter.rb +5 -4
  42. data/lib/activemail/pipeline.rb +26 -0
  43. data/lib/activemail/quality/builtin_rules.rb +154 -0
  44. data/lib/activemail/quality/configuration.rb +83 -3
  45. data/lib/activemail/quality/guard.rb +45 -87
  46. data/lib/activemail/quality/minitest.rb +41 -25
  47. data/lib/activemail/quality/output_dir.rb +52 -0
  48. data/lib/activemail/quality/preview_renderer.rb +46 -33
  49. data/lib/activemail/quality/render_all.rb +96 -40
  50. data/lib/activemail/quality/rspec.rb +4 -8
  51. data/lib/activemail/quality/rule.rb +47 -0
  52. data/lib/activemail/rails/engine.rb +13 -13
  53. data/lib/activemail/rails/rendering.rb +51 -0
  54. data/lib/activemail/rails/template_handler.rb +3 -1
  55. data/lib/activemail/tokens/button_style.rb +1 -2
  56. data/lib/activemail/tokens/defaults.rb +77 -0
  57. data/lib/activemail/tokens/scss_serializer.rb +4 -4
  58. data/lib/activemail/tokens.rb +19 -58
  59. data/lib/activemail/version.rb +1 -1
  60. data/lib/activemail.rb +89 -61
  61. data/lib/generators/activemail/templates/component.rb.tt +2 -3
  62. data/lib/generators/activemail/templates/initializer.rb +0 -3
  63. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +0 -2
  64. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +0 -2
  65. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +0 -2
  66. data/lib/tasks/activemail.rake +4 -5
  67. metadata +17 -4
  68. data/lib/activemail/components/inky.rb +0 -18
  69. data/lib/activemail/inliner/interceptor.rb +0 -51
  70. data/lib/activemail/rails/compiled_stylesheet.rb +0 -62
@@ -22,7 +22,7 @@ module ActiveMail
22
22
  require 'roadie'
23
23
  rescue LoadError
24
24
  # Unify on Inliner::Error (LoadError is not a StandardError, so the
25
- # interceptor's rescue would otherwise miss it); the LoadError is #cause.
25
+ # Pipeline's rescue would otherwise miss it); the LoadError is #cause.
26
26
  raise ActiveMail::Inliner::Error, "ActiveMail::Inliner::Roadie requires the 'roadie' gem. Add it to your Gemfile."
27
27
  end
28
28
  end
@@ -0,0 +1,33 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ require_relative 'inliner/base'
7
+ require_relative 'inliner/premailer'
8
+ require_relative 'inliner/roadie'
9
+ require_relative 'inliner/null'
10
+
11
+ module ActiveMail
12
+ module Inliner
13
+ extend T::Sig
14
+
15
+ ADAPTERS = T.let(
16
+ { premailer: Premailer, roadie: Roadie, null: Null }.freeze,
17
+ T::Hash[Symbol, T.class_of(Base)]
18
+ )
19
+
20
+ Setting = T.type_alias { T.any(Symbol, Base, T.class_of(Base)) }
21
+
22
+ sig { params(setting: Setting).returns(Base) }
23
+ def self.resolve(setting)
24
+ return setting if setting.is_a?(Base)
25
+ return setting.new if setting.is_a?(Class) && setting < Base
26
+
27
+ adapter = ADAPTERS[setting] if setting.is_a?(Symbol)
28
+ return adapter.new if adapter
29
+
30
+ raise ArgumentError, "unknown inliner #{setting.inspect}, expected one of #{ADAPTERS.keys.inspect}, an Inliner::Base subclass, or an instance"
31
+ end
32
+ end
33
+ end
@@ -7,19 +7,20 @@ require_relative 'libxml'
7
7
 
8
8
  module ActiveMail
9
9
  # Surfaces libxml2 recover-mode repairs that would otherwise silently change
10
- # the rendered email, honoring ActiveMail.configuration.on_parse_error.
10
+ # the rendered email, honoring the config's on_parse_error.
11
11
  class ParseErrorReporter
12
12
  extend T::Sig
13
13
 
14
- sig { params(known_tags: T::Array[String]).void }
15
- def initialize(known_tags)
14
+ sig { params(known_tags: T::Array[String], config: ActiveMail::Configuration).void }
15
+ def initialize(known_tags, config)
16
+ @config = config
16
17
  # dup.freeze: the caller's array stays decoupled and cannot be mutated under us.
17
18
  @known_tags = T.let(known_tags.dup.freeze, T::Array[String])
18
19
  end
19
20
 
20
21
  sig { params(errors: T::Array[Nokogiri::XML::SyntaxError]).void }
21
22
  def call(errors)
22
- mode = ::ActiveMail.configuration.on_parse_error
23
+ mode = @config.on_parse_error
23
24
  return if mode == :ignore
24
25
 
25
26
  relevant = errors.reject { |error| known_tag_error?(error) }
@@ -0,0 +1,26 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module ActiveMail
7
+ module Pipeline
8
+ extend T::Sig
9
+
10
+ sig { params(html: String, config: ActiveMail::Configuration).returns(String) }
11
+ def self.call(html, config: ActiveMail.configuration)
12
+ inline(config.resolved_inliner, config.core.transpile(html))
13
+ end
14
+
15
+ sig { params(inliner: ActiveMail::Inliner::Base, html: String).returns(String) }
16
+ def self.inline(inliner, html)
17
+ inliner.inline(html)
18
+ rescue ActiveMail::Inliner::Error
19
+ raise
20
+ rescue StandardError => e
21
+ # A silently non-inlined mail is worse than a failed one.
22
+ raise ActiveMail::Inliner::Error, "[#{inliner.class}] CSS inlining failed: #{e.message}"
23
+ end
24
+ private_class_method :inline
25
+ end
26
+ end
@@ -0,0 +1,154 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'nokogiri'
5
+ require 'sorbet-runtime'
6
+
7
+ require_relative '../libxml'
8
+ require_relative 'preview_renderer'
9
+ require_relative 'rule'
10
+
11
+ module ActiveMail
12
+ module Quality
13
+ class BuiltinRules
14
+ extend T::Sig
15
+
16
+ NAMES = T.let(
17
+ %i[
18
+ max_bytes parse_error table_role img_alt lang min_full_doc_bytes translation_missing single_root
19
+ translation_missing_text html_part_present text_part_present text_part_plain
20
+ ].freeze,
21
+ T::Array[Symbol]
22
+ )
23
+ MARKUP_IN_TEXT = %r{<(?:table|div|span|p|br|a)[\s>/]}i
24
+ MISSING_TRANSLATION = /translation[ _]missing/i
25
+ ROOTS = T.let({ html: /<html[\s>]/i, body: /<body[\s>]/i }.freeze, T::Hash[Symbol, Regexp])
26
+
27
+ sig { params(max_bytes: Integer, min_full_doc_bytes: Integer).void }
28
+ def initialize(max_bytes:, min_full_doc_bytes:)
29
+ # A non-positive threshold would silently disable (or invert) a check.
30
+ @max_bytes = T.let(::ActiveMail.positive_integer!(:max_bytes, max_bytes), Integer)
31
+ @min_full_doc_bytes = T.let(::ActiveMail.positive_integer!(:min_full_doc_bytes, min_full_doc_bytes), Integer)
32
+ end
33
+
34
+ sig { returns(T::Array[Rule]) }
35
+ def rules
36
+ structure_rules + content_rules + message_rules
37
+ end
38
+
39
+ private
40
+
41
+ sig { returns(T::Array[Rule]) }
42
+ def structure_rules
43
+ [
44
+ Rule.html(:max_bytes) { |_doc, html| size_message(html) },
45
+ # The Core surfaces libxml2 repairs on input; the validation layer must not
46
+ # silently bless malformed *output* (mismatched tags, bad entities) before send.
47
+ Rule.html(:parse_error) { |doc, _html| parse_error_message(doc) },
48
+ Rule.html(:table_role) { |doc, _html| table_role_message(doc) },
49
+ Rule.html(:img_alt) { |doc, _html| img_alt_message(doc) }
50
+ ]
51
+ end
52
+
53
+ sig { returns(T::Array[Rule]) }
54
+ def content_rules
55
+ [
56
+ Rule.html(:translation_missing) { |_doc, html| translation_missing_message(html) },
57
+ Rule.html(:single_root) { |_doc, html| single_root_messages(html) },
58
+ 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) }
70
+ ]
71
+ end
72
+
73
+ sig { params(html: String).returns(T.nilable(String)) }
74
+ def size_message(html)
75
+ return if html.bytesize <= @max_bytes
76
+
77
+ "HTML is #{html.bytesize} bytes, exceeds #{@max_bytes} (Gmail clipping)"
78
+ end
79
+
80
+ sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
81
+ def parse_error_message(doc)
82
+ errors = doc.errors.reject { |e| e.code == LIBXML_UNKNOWN_TAG_CODE }
83
+ return if errors.empty?
84
+
85
+ "malformed HTML: #{errors.first(3).map { |e| e.message.to_s.strip }.join('; ')}"
86
+ end
87
+
88
+ sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
89
+ def table_role_message(doc)
90
+ offenders = doc.css('table').count { |table| table['role'] != 'presentation' }
91
+ %(#{offenders} <table> missing role="presentation") unless offenders.zero?
92
+ end
93
+
94
+ sig { params(doc: Nokogiri::XML::Document).returns(T.nilable(String)) }
95
+ def img_alt_message(doc)
96
+ offenders = doc.css('img').count { |img| !img.key?('alt') }
97
+ "#{offenders} <img> missing an alt attribute" unless offenders.zero?
98
+ end
99
+
100
+ sig { params(html: String).returns(T.nilable(String)) }
101
+ def translation_missing_message(html)
102
+ 'rendered HTML contains a missing i18n translation' if html.match?(MISSING_TRANSLATION)
103
+ end
104
+
105
+ # Counted on the raw string: libxml2 silently merges duplicate roots.
106
+ sig { params(html: String).returns(T::Array[String]) }
107
+ def single_root_messages(html)
108
+ ROOTS.filter_map do |tag, pattern|
109
+ count = html.scan(pattern).size
110
+ "#{count} <#{tag}> roots, expected at most one" if count > 1
111
+ end
112
+ end
113
+
114
+ sig { params(html: String).returns(T.nilable(String)) }
115
+ def min_full_doc_message(html)
116
+ return unless ::ActiveMail.full_document?(html) && html.bytesize < @min_full_doc_bytes
117
+
118
+ "full document is only #{html.bytesize} bytes (under #{@min_full_doc_bytes})"
119
+ end
120
+
121
+ sig { params(doc: Nokogiri::XML::Document, html: String).returns(T.nilable(String)) }
122
+ def lang_message(doc, html)
123
+ return unless ::ActiveMail.full_document?(html)
124
+
125
+ html_tag = doc.at_css('html')
126
+ lang = html_tag && html_tag['lang']
127
+ '<html> must declare a non-blank lang attribute' if lang.nil? || lang.strip.empty?
128
+ end
129
+
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
135
+
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?
139
+ end
140
+
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
147
+
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)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -1,15 +1,20 @@
1
1
  # typed: strict
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'i18n'
4
5
  require 'sorbet-runtime'
5
6
 
6
7
  require_relative 'guard'
8
+ require_relative 'output_dir'
7
9
 
8
10
  module ActiveMail
9
11
  module Quality
10
12
  class Configuration
11
13
  extend T::Sig
12
14
 
15
+ FORMATS = T.let(%i[html text].freeze, T::Array[Symbol])
16
+ DEFAULT_LOCALES = T.let(->(_preview) { [I18n.default_locale] }, Proc)
17
+
13
18
  sig { returns(Guard) }
14
19
  attr_reader :guard
15
20
 
@@ -25,9 +30,7 @@ module ActiveMail
25
30
 
26
31
  sig { params(value: String).void }
27
32
  def output_dir=(value)
28
- raise ArgumentError, 'output_dir must not be blank' if value.strip.empty?
29
-
30
- @output_dir = value
33
+ @output_dir = OutputDir.validate!(value)
31
34
  end
32
35
 
33
36
  # Preview keys ("preview_name#email") that MUST render and pass the guard;
@@ -45,11 +48,88 @@ module ActiveMail
45
48
  @required_previews = value.to_a.map(&:to_s)
46
49
  end
47
50
 
51
+ sig { returns(T.any(T::Array[Symbol], Proc)) }
52
+ attr_reader :locales
53
+
54
+ sig { params(value: T.untyped).void }
55
+ def locales=(value)
56
+ @locales = value.is_a?(Proc) ? value : normalize_locales(value)
57
+ end
58
+
59
+ sig { params(preview: T.untyped).returns(T::Array[Symbol]) }
60
+ def locales_for(preview)
61
+ value = @locales
62
+ value.is_a?(Proc) ? normalize_locales(value.call(preview)) : value
63
+ end
64
+
65
+ sig { returns(T::Array[Symbol]) }
66
+ def formats
67
+ @formats.dup.freeze
68
+ end
69
+
70
+ sig { params(value: T.untyped).void }
71
+ def formats=(value)
72
+ raise TypeError, "formats must be an Array, got #{value.class}" unless value.is_a?(Array)
73
+
74
+ formats = value.map(&:to_sym)
75
+ unknown = formats - FORMATS
76
+ raise ArgumentError, "unknown format(s): #{unknown.inspect}, expected a subset of #{FORMATS.inspect}" unless unknown.empty?
77
+ raise ArgumentError, 'formats must not be empty' if formats.empty?
78
+
79
+ @formats = formats.uniq
80
+ end
81
+
82
+ # ->(preview, email, locale, &block) wrapping each rendering; it must call block.
83
+ sig { returns(T.nilable(Proc)) }
84
+ attr_reader :around_each
85
+
86
+ sig { params(value: T.untyped).void }
87
+ def around_each=(value)
88
+ raise TypeError, "around_each must be a Proc or nil, got #{value.class}" unless value.nil? || value.is_a?(Proc)
89
+
90
+ @around_each = value
91
+ end
92
+
93
+ # Applied to written files only: the Guard always sees the unfiltered content.
94
+ sig { returns(T::Array[Proc]) }
95
+ attr_reader :output_filters
96
+
97
+ sig { params(value: T.untyped).void }
98
+ def output_filters=(value)
99
+ raise TypeError, "output_filters must be an Array, got #{value.class}" unless value.is_a?(Array)
100
+
101
+ @output_filters = value.map { |filter| output_filter(filter) }
102
+ end
103
+
48
104
  sig { void }
49
105
  def initialize
50
106
  @guard = T.let(Guard.new, Guard)
51
107
  @output_dir = T.let('tmp/activemail_previews', String)
52
108
  @required_previews = T.let([], T::Array[String])
109
+ @locales = T.let(DEFAULT_LOCALES, T.any(T::Array[Symbol], Proc))
110
+ @formats = T.let(FORMATS, T::Array[Symbol])
111
+ @around_each = T.let(nil, T.nilable(Proc))
112
+ @output_filters = T.let([], T::Array[Proc])
113
+ end
114
+
115
+ private
116
+
117
+ sig { params(value: T.untyped).returns(T::Array[Symbol]) }
118
+ def normalize_locales(value)
119
+ raise TypeError, "locales must be an Array, got #{value.class}" unless value.is_a?(Array)
120
+ raise ArgumentError, 'locales must not be empty' if value.empty?
121
+
122
+ value.map(&:to_sym)
123
+ 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) }
53
133
  end
54
134
  end
55
135
  end
@@ -4,127 +4,85 @@
4
4
  require 'nokogiri'
5
5
  require 'sorbet-runtime'
6
6
 
7
- require_relative '../libxml'
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 = T.let(%i[max_bytes parse_error table_role img_alt lang min_full_doc_bytes].freeze, T::Array[Symbol])
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
- # disable: any subset of DISABLEABLE to skip those checks.
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
- # A non-positive threshold would silently disable (or invert) a check.
31
- @max_bytes = T.let(positive_threshold!(:max_bytes, max_bytes), Integer)
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
- sig { params(html: String).returns(T::Array[Violation]) }
40
- def violations(html)
41
- violations = []
42
- check_size(html, violations)
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
- sig { params(name: Symbol, value: Integer).returns(Integer) }
65
- def positive_threshold!(name, value)
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
- value
49
+ html = PreviewRenderer.html_body(mail)
50
+ (html.empty? ? [] : html_violations(html)) + apply(@message_rules, [mail])
69
51
  end
70
52
 
71
- sig { params(html: String, violations: T::Array[Violation]).void }
72
- def check_size(html, violations)
73
- return unless enabled?(:max_bytes)
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
- # The Core surfaces libxml2 repairs on input; the validation layer must not
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
- errors = doc.errors.reject { |e| e.code == LIBXML_UNKNOWN_TAG_CODE }
86
- return if errors.empty?
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
- violations << Violation.new(rule: :parse_error, message: "malformed HTML: #{errors.first(3).map { |e| e.message.to_s.strip }.join('; ')}")
65
+ unique_names!(builtin.rules.reject { |rule| disable.include?(rule.name) } + custom)
89
66
  end
90
67
 
91
- sig { params(doc: Nokogiri::XML::Document, violations: T::Array[Violation]).void }
92
- def check_table_roles(doc, violations)
93
- return unless enabled?(:table_role)
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
- violations << Violation.new(rule: :table_role, message: %(#{offenders} <table> missing role="presentation"))
73
+ rules
99
74
  end
100
75
 
101
- sig { params(doc: Nokogiri::XML::Document, violations: T::Array[Violation]).void }
102
- def check_img_alts(doc, violations)
103
- return unless enabled?(:img_alt)
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(html: String, doc: Nokogiri::XML::Document, violations: T::Array[Violation]).void }
112
- def check_full_document(html, doc, violations)
113
- if enabled?(:min_full_doc_bytes) && html.bytesize < @min_full_doc_bytes
114
- violations << Violation.new(rule: :min_full_doc_bytes, message: "full document is only #{html.bytesize} bytes (under #{@min_full_doc_bytes})")
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(html: String, guard: Guard).void }
12
- def assert_email_quality(html, guard: ActiveMail::Quality.guard)
13
- violations = guard.violations(html)
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 { params(preview: T.untyped, email: String, guard: Guard).void }
20
- def assert_preview_quality(preview, email, guard: ActiveMail::Quality.guard)
21
- assert_email_quality(PreviewRenderer.render(preview, email), guard: guard)
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
- required = ActiveMail::Quality.config.required_previews
32
- previews = PreviewRenderer.all
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 previews.empty?
35
- previews.each_with_index do |(preview, email), i|
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
- T.unsafe(self).define_method("test_#{i}_#{key.gsub(/\W/, '_')}_email_quality") do
39
- html = T.unsafe(self).send(:render_preview_or_skip, preview, email, key, required)
40
- T.unsafe(self).assert_email_quality(html, guard: guard)
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
- # Returns rendered HTML; a non-required preview that fails is skipped (not a
54
- # silent pass), a required one fails. flunk/skip are injected by Minitest; both raise.
55
- sig { params(preview: T.untyped, email: String, key: String, required: T::Array[String]).returns(String) }
56
- def render_preview_or_skip(preview, email, key, required)
57
- PreviewRenderer.render(preview, email)
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
- msg = "#{key} did not render (#{e.class}: #{e.message})"
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