activemail 1.2.3 → 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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -2
  3. data/README.md +141 -56
  4. data/UPGRADING.md +185 -0
  5. data/app/assets/stylesheets/activemail/_components.scss +33 -42
  6. data/app/assets/stylesheets/activemail/_dark.scss +44 -24
  7. data/app/assets/stylesheets/activemail/_grid.scss +40 -14
  8. data/app/assets/stylesheets/activemail/_settings.scss +23 -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/_document_head.html.erb +18 -0
  13. data/app/views/layouts/activemail/mailer.html.inky-erb +1 -22
  14. data/lib/activemail/asset_pipeline/base.rb +47 -0
  15. data/lib/activemail/asset_pipeline/null.rb +19 -0
  16. data/lib/activemail/asset_pipeline/propshaft.rb +19 -0
  17. data/lib/activemail/asset_pipeline/sprockets.rb +39 -0
  18. data/lib/activemail/asset_pipeline.rb +35 -0
  19. data/lib/activemail/components/base.rb +35 -39
  20. data/lib/activemail/components/block_grid.rb +11 -4
  21. data/lib/activemail/components/button.rb +5 -28
  22. data/lib/activemail/components/button_renderer.rb +69 -0
  23. data/lib/activemail/components/callout.rb +4 -5
  24. data/lib/activemail/components/center.rb +10 -10
  25. data/lib/activemail/components/columns.rb +58 -15
  26. data/lib/activemail/components/container.rb +13 -6
  27. data/lib/activemail/components/cta.rb +9 -33
  28. data/lib/activemail/components/h_line.rb +3 -5
  29. data/lib/activemail/components/info_box.rb +6 -12
  30. data/lib/activemail/components/menu.rb +3 -4
  31. data/lib/activemail/components/menu_item.rb +10 -3
  32. data/lib/activemail/components/row.rb +3 -4
  33. data/lib/activemail/components/spacer.rb +16 -12
  34. data/lib/activemail/components/table_component.rb +23 -0
  35. data/lib/activemail/components/wrapper.rb +10 -4
  36. data/lib/activemail/configuration.rb +43 -57
  37. data/lib/activemail/errors.rb +15 -0
  38. data/lib/activemail/inliner/base.rb +2 -10
  39. data/lib/activemail/inliner/null.rb +0 -5
  40. data/lib/activemail/inliner/premailer/document.rb +100 -0
  41. data/lib/activemail/inliner/premailer.rb +3 -2
  42. data/lib/activemail/inliner/roadie.rb +1 -1
  43. data/lib/activemail/inliner.rb +33 -0
  44. data/lib/activemail/parse_error_reporter.rb +5 -4
  45. data/lib/activemail/pipeline.rb +26 -0
  46. data/lib/activemail/quality/builtin_message_rules.rb +58 -0
  47. data/lib/activemail/quality/builtin_rules.rb +165 -0
  48. data/lib/activemail/quality/configuration.rb +103 -8
  49. data/lib/activemail/quality/guard.rb +61 -83
  50. data/lib/activemail/quality/minitest.rb +56 -27
  51. data/lib/activemail/quality/output_dir.rb +52 -0
  52. data/lib/activemail/quality/output_filter.rb +24 -0
  53. data/lib/activemail/quality/preview_renderer.rb +46 -33
  54. data/lib/activemail/quality/render_all.rb +95 -39
  55. data/lib/activemail/quality/rspec.rb +14 -13
  56. data/lib/activemail/quality/rule.rb +47 -0
  57. data/lib/activemail/quality/rule_exemptions.rb +49 -0
  58. data/lib/activemail/rails/engine.rb +13 -13
  59. data/lib/activemail/rails/rendering.rb +51 -0
  60. data/lib/activemail/rails/template_handler.rb +3 -1
  61. data/lib/activemail/tokens/button_style.rb +1 -2
  62. data/lib/activemail/tokens/defaults.rb +78 -0
  63. data/lib/activemail/tokens/scss_serializer.rb +4 -4
  64. data/lib/activemail/tokens.rb +19 -58
  65. data/lib/activemail/version.rb +1 -1
  66. data/lib/activemail.rb +89 -61
  67. data/lib/generators/activemail/templates/component.rb.tt +2 -3
  68. data/lib/generators/activemail/templates/initializer.rb +0 -3
  69. data/lib/generators/activemail/templates/mailer_layout.html.inky-erb +3 -19
  70. data/lib/generators/activemail/templates/mailer_layout.html.inky-haml +3 -11
  71. data/lib/generators/activemail/templates/mailer_layout.html.inky-slim +3 -10
  72. data/lib/tasks/activemail.rake +4 -5
  73. metadata +22 -4
  74. data/lib/activemail/components/inky.rb +0 -18
  75. data/lib/activemail/inliner/interceptor.rb +0 -51
  76. data/lib/activemail/rails/compiled_stylesheet.rb +0 -62
@@ -4,127 +4,105 @@
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
-
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])
33
+ NO_SKIP = T.let([].freeze, T::Array[Symbol])
34
+
35
+ sig { params(max_bytes: Integer, min_full_doc_bytes: Integer, disable: T::Array[Symbol], rules: T::Array[Rule]).void }
36
+ def initialize(max_bytes: DEFAULT_MAX_BYTES, min_full_doc_bytes: DEFAULT_MIN_FULL_DOC_BYTES, disable: [], rules: [])
37
+ all = assemble_rules(BuiltinRules.new(max_bytes: max_bytes, min_full_doc_bytes: min_full_doc_bytes), disable, rules)
38
+ @html_rules = T.let(all.select { |rule| rule.level == :html }, T::Array[Rule])
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])
37
42
  end
38
43
 
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
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)
51
50
 
52
- sig { params(html: String).returns(T::Boolean) }
53
- def valid?(html)
54
- violations(html).empty?
55
- end
56
-
57
- private
51
+ mail = PreviewRenderer.mail(html_or_message)
52
+ return declined_violations(allow_declined) if PreviewRenderer.declined?(mail)
58
53
 
59
- sig { params(rule: Symbol).returns(T::Boolean) }
60
- def enabled?(rule)
61
- !@disabled.include?(rule)
54
+ html = PreviewRenderer.html_body(mail)
55
+ (html.empty? ? [] : html_violations(html, skip)) + apply(@message_rules, [mail], skip)
62
56
  end
63
57
 
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?
67
-
68
- value
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?
69
61
  end
70
62
 
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
63
+ sig { params(skip: T::Array[Symbol]).void }
64
+ def check_skippable!(skip)
65
+ return if skip.empty?
75
66
 
76
- violations << Violation.new(rule: :max_bytes, message: "HTML is #{html.bytesize} bytes, exceeds #{@max_bytes} (Gmail clipping)")
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?
77
69
  end
78
70
 
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)
84
-
85
- errors = doc.errors.reject { |e| e.code == LIBXML_UNKNOWN_TAG_CODE }
86
- return if errors.empty?
71
+ private
87
72
 
88
- violations << Violation.new(rule: :parse_error, message: "malformed HTML: #{errors.first(3).map { |e| e.message.to_s.strip }.join('; ')}")
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)')]
89
76
  end
90
77
 
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?
78
+ sig { params(builtin: BuiltinRules, disable: T::Array[Symbol], custom: T::Array[Rule]).returns(T::Array[Rule]) }
79
+ def assemble_rules(builtin, disable, custom)
80
+ unknown = disable - DISABLEABLE
81
+ raise ArgumentError, "unknown rule(s): #{unknown.inspect}, expected a subset of #{DISABLEABLE.inspect}" unless unknown.empty?
97
82
 
98
- violations << Violation.new(rule: :table_role, message: %(#{offenders} <table> missing role="presentation"))
83
+ unique_names!(builtin.rules.reject { |rule| disable.include?(rule.name) } + custom)
99
84
  end
100
85
 
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?
86
+ sig { params(rules: T::Array[Rule]).returns(T::Array[Rule]) }
87
+ def unique_names!(rules)
88
+ duplicates = rules.map(&:name).tally.select { |_name, count| count > 1 }.keys
89
+ raise ArgumentError, "duplicate rule name(s): #{duplicates.inspect}" unless duplicates.empty?
107
90
 
108
- violations << Violation.new(rule: :img_alt, message: "#{offenders} <img> missing an alt attribute")
91
+ rules
109
92
  end
110
93
 
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})")
115
- end
116
- check_lang(doc, violations)
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)
117
97
  end
118
98
 
119
- sig { params(doc: Nokogiri::XML::Document, violations: T::Array[Violation]).void }
120
- def check_lang(doc, violations)
121
- return unless enabled?(:lang)
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)
101
+ rules.flat_map do |rule|
102
+ next [] if skip.include?(rule.name)
122
103
 
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')
104
+ rule.messages(args).map { |message| Violation.new(rule: rule.name, message: message) }
105
+ end
128
106
  end
129
107
  end
130
108
  end
@@ -8,37 +8,42 @@ 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, 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))
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
+ config.check_exemptions!(guard)
31
+ cases = PreviewRenderer.cases(config)
33
32
  # 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)
37
- # 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)
41
- end
33
+ Kernel.warn('[activemail] assert_quality_for_all_previews: no previews discovered.') if cases.empty?
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)
42
47
  end
43
48
  end
44
49
  end
@@ -50,13 +55,37 @@ module ActiveMail
50
55
 
51
56
  private
52
57
 
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)
58
+ sig { params(violations: T::Array[Guard::Violation]).void }
59
+ def assert_no_quality_violations(violations)
60
+ # assert is injected by the host's Minitest test class.
61
+ T.unsafe(self).assert violations.empty?,
62
+ "email quality violations:\n#{violations.map { |v| " - #{v}" }.join("\n")}"
63
+ end
64
+
65
+ sig do
66
+ params(preview: T.untyped, email: String, locale: Symbol, guard: Guard, around_each: T.nilable(Proc))
67
+ .returns(T::Array[Guard::Violation])
68
+ end
69
+ def preview_violations(preview, email, locale, guard, around_each)
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
77
+ end
78
+
79
+ # A non-required preview that can't render is skipped, not a silent pass.
80
+ sig do
81
+ params(preview: T.untyped, email: String, locale: Symbol, guard: Guard, required: T::Array[String])
82
+ .returns(T::Array[Guard::Violation])
83
+ end
84
+ def render_preview_or_skip(preview, email, locale, guard, required)
85
+ preview_violations(preview, email, locale, guard, ActiveMail::Quality.config.around_each)
58
86
  rescue StandardError => e
59
- msg = "#{key} did not render (#{e.class}: #{e.message})"
87
+ key = PreviewRenderer.key(preview, email)
88
+ msg = "#{key} [#{locale}] did not render (#{e.class}: #{e.message})"
60
89
  required.include?(key) ? T.unsafe(self).flunk("required preview #{msg}") : T.unsafe(self).skip(msg)
61
90
  end
62
91
  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
@@ -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
@@ -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