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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -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 +38 -7
  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
@@ -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
@@ -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 'fileutils'
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
- sig { params(preview: T.untyped, email: String, output_root: T.any(String, Pathname)).returns(Pathname) }
28
- def render_to_disk(preview, email, output_root)
29
- dir = Pathname(output_root).join(preview.preview_name)
30
- FileUtils.mkdir_p(dir)
31
- path = dir.join("#{email}.html")
32
- File.write(path, render(preview, email))
33
- path
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
- # Returns '' for a plain-text-only message so the Guard surfaces missing
47
- # HTML instead of validating plain text as if it were HTML.
48
- sig { params(message: T.untyped).returns(String) }
49
- def html_body(message)
50
- mail = message.respond_to?(:message) ? message.message : message
51
- body = mail.html_part&.body || single_part_html_body(mail)
52
- return '' unless body
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
- body.respond_to?(:decoded) ? body.decoded : body.to_s
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
- private
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
- # A single-part body counts as HTML only when its content-type says so
60
- # (or is unset); a declared text/plain body is not HTML.
61
- sig { params(mail: T.untyped).returns(T.untyped) }
62
- def single_part_html_body(mail)
63
- return if mail.multipart?
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
- mail.body if mail.mime_type.nil? || mail.mime_type == 'text/html'
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
- sig { params(output_root: T.any(String, Pathname), config: Configuration).void }
23
- def initialize(output_root:, config: ActiveMail::Quality.config)
24
- @output_root = T.let(Pathname(output_root), Pathname)
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
- FileUtils.rm_rf(@output_root)
31
- FileUtils.mkdir_p(@output_root)
32
-
33
- render_failures = {}
34
- guard_failures = {}
43
+ clean_previous_output
35
44
  previews = PreviewRenderer.all
36
- rendered = render_all(previews, render_failures, guard_failures)
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, render_failures, guard_failures)
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
- # A required preview that fails, OR is never discovered at all (typo / deleted), is broken.
47
- sig do
48
- params(
49
- previews: T::Array[[T.untyped, String]],
50
- render_failures: T::Hash[String, String],
51
- guard_failures: T::Hash[String, T::Array[Guard::Violation]]
52
- ).returns(T::Array[String])
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
- def broken_required(previews, render_failures, guard_failures)
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
- ((render_failures.keys | guard_failures.keys) & required) | (required - discovered)
83
+ (@failed_keys.to_a & required) | (required - discovered)
58
84
  end
59
85
 
60
- sig do
61
- params(previews: T::Array[[T.untyped, String]], render_failures: T::Hash[String, String], guard_failures: T::Hash[String, T::Array[Guard::Violation]]).returns(Integer)
62
- end
63
- def render_all(previews, render_failures, guard_failures)
64
- previews.count do |preview, email|
65
- key = PreviewRenderer.key(preview, email)
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 do
76
- params(preview: T.untyped, email: String, failures: T::Hash[String, String], key: String).returns(T.nilable(Pathname))
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
- def render_one(preview, email, failures, key)
79
- PreviewRenderer.render_to_disk(preview, email, @output_root)
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
- root = e.cause
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(html: String).returns(T::Boolean) }
21
- def matches?(html)
22
- @violations = @guard.violations(html)
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| " - [#{v.rule}] #{v.message}" }.join("\n")
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