activemail 1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/LICENSE.txt +23 -0
- data/README.md +375 -0
- data/app/assets/stylesheets/active_mail/_active_mail_tokens.scss.erb +3 -0
- data/app/assets/stylesheets/active_mail/_components.scss +58 -0
- data/app/assets/stylesheets/active_mail/_dark.scss +123 -0
- data/app/assets/stylesheets/active_mail/_grid.scss +49 -0
- data/app/assets/stylesheets/active_mail/_settings.scss +25 -0
- data/app/assets/stylesheets/active_mail/_utilities.scss +47 -0
- data/app/assets/stylesheets/active_mail/active_mail.scss +44 -0
- data/app/helpers/active_mail/styles_helper.rb +29 -0
- data/app/views/layouts/active_mail/_footer.html.inky-erb +10 -0
- data/app/views/layouts/active_mail/_head.html.inky-erb +6 -0
- data/app/views/layouts/active_mail/mailer.html.inky-erb +35 -0
- data/lib/active_mail/components/base.rb +119 -0
- data/lib/active_mail/components/block_grid.rb +19 -0
- data/lib/active_mail/components/button.rb +39 -0
- data/lib/active_mail/components/callout.rb +23 -0
- data/lib/active_mail/components/center.rb +24 -0
- data/lib/active_mail/components/columns.rb +72 -0
- data/lib/active_mail/components/container.rb +25 -0
- data/lib/active_mail/components/cta.rb +36 -0
- data/lib/active_mail/components/h_line.rb +19 -0
- data/lib/active_mail/components/info_box.rb +32 -0
- data/lib/active_mail/components/inky.rb +18 -0
- data/lib/active_mail/components/menu.rb +22 -0
- data/lib/active_mail/components/menu_item.rb +21 -0
- data/lib/active_mail/components/row.rb +18 -0
- data/lib/active_mail/components/spacer.rb +45 -0
- data/lib/active_mail/components/wrapper.rb +21 -0
- data/lib/active_mail/configuration.rb +229 -0
- data/lib/active_mail/inliner/base.rb +29 -0
- data/lib/active_mail/inliner/interceptor.rb +51 -0
- data/lib/active_mail/inliner/null.rb +23 -0
- data/lib/active_mail/inliner/premailer.rb +22 -0
- data/lib/active_mail/inliner/roadie.rb +30 -0
- data/lib/active_mail/libxml.rb +8 -0
- data/lib/active_mail/parse_error_reporter.rb +47 -0
- data/lib/active_mail/quality/configuration.rb +56 -0
- data/lib/active_mail/quality/guard.rb +131 -0
- data/lib/active_mail/quality/minitest.rb +64 -0
- data/lib/active_mail/quality/preview_renderer.rb +70 -0
- data/lib/active_mail/quality/render_all.rb +90 -0
- data/lib/active_mail/quality/rspec.rb +65 -0
- data/lib/active_mail/quality.rb +38 -0
- data/lib/active_mail/rails/compiled_stylesheet.rb +62 -0
- data/lib/active_mail/rails/engine.rb +36 -0
- data/lib/active_mail/rails/template_handler.rb +62 -0
- data/lib/active_mail/tokens.rb +139 -0
- data/lib/active_mail/version.rb +6 -0
- data/lib/active_mail.rb +161 -0
- data/lib/activemail.rb +5 -0
- data/lib/generators/active_mail/component_generator.rb +31 -0
- data/lib/generators/active_mail/install_generator.rb +59 -0
- data/lib/generators/active_mail/styles_generator.rb +30 -0
- data/lib/generators/active_mail/templates/component.rb.tt +13 -0
- data/lib/generators/active_mail/templates/initializer.rb +32 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-erb +30 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-haml +17 -0
- data/lib/generators/active_mail/templates/mailer_layout.html.inky-slim +16 -0
- data/lib/generators/active_mail/views_generator.rb +16 -0
- data/lib/tasks/active_mail.rake +36 -0
- metadata +151 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'nokogiri'
|
|
5
|
+
require 'sorbet-runtime'
|
|
6
|
+
|
|
7
|
+
require_relative '../libxml'
|
|
8
|
+
|
|
9
|
+
module ActiveMail
|
|
10
|
+
module Quality
|
|
11
|
+
class Guard
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
# One failed invariant. :rule is a stable symbol for programmatic matching;
|
|
15
|
+
# :message is human-facing.
|
|
16
|
+
class Violation < T::Struct
|
|
17
|
+
const :rule, Symbol
|
|
18
|
+
const :message, String
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
DISABLEABLE = T.let(%i[max_bytes parse_error table_role img_alt lang min_full_doc_bytes].freeze, T::Array[Symbol])
|
|
22
|
+
# Gmail clips messages past ~102KB.
|
|
23
|
+
DEFAULT_MAX_BYTES = 102_400
|
|
24
|
+
# A full HTML document smaller than this carries no real layout and is suspect.
|
|
25
|
+
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])
|
|
37
|
+
end
|
|
38
|
+
|
|
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
|
|
63
|
+
|
|
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
|
|
69
|
+
end
|
|
70
|
+
|
|
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)")
|
|
77
|
+
end
|
|
78
|
+
|
|
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?
|
|
87
|
+
|
|
88
|
+
violations << Violation.new(rule: :parse_error, message: "malformed HTML: #{errors.first(3).map { |e| e.message.to_s.strip }.join('; ')}")
|
|
89
|
+
end
|
|
90
|
+
|
|
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?
|
|
97
|
+
|
|
98
|
+
violations << Violation.new(rule: :table_role, message: %(#{offenders} <table> missing role="presentation"))
|
|
99
|
+
end
|
|
100
|
+
|
|
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")
|
|
109
|
+
end
|
|
110
|
+
|
|
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)
|
|
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
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../quality'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Quality
|
|
8
|
+
module Minitest
|
|
9
|
+
extend T::Sig
|
|
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")}"
|
|
17
|
+
end
|
|
18
|
+
|
|
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)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module ClassMethods
|
|
25
|
+
extend T::Sig
|
|
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
|
+
sig { params(guard: Guard).void }
|
|
30
|
+
def assert_quality_for_all_previews(guard: ActiveMail::Quality.guard)
|
|
31
|
+
required = ActiveMail::Quality.config.required_previews
|
|
32
|
+
previews = PreviewRenderer.all
|
|
33
|
+
# 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
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { params(base: T.untyped).void }
|
|
47
|
+
def self.included(base)
|
|
48
|
+
base.extend(ClassMethods)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
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
|
+
rescue StandardError => e
|
|
59
|
+
msg = "#{key} did not render (#{e.class}: #{e.message})"
|
|
60
|
+
required.include?(key) ? T.unsafe(self).flunk("required preview #{msg}") : T.unsafe(self).skip(msg)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'pathname'
|
|
6
|
+
require 'sorbet-runtime'
|
|
7
|
+
|
|
8
|
+
module ActiveMail
|
|
9
|
+
module Quality
|
|
10
|
+
module PreviewRenderer
|
|
11
|
+
class << self
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
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
|
+
sig { params(preview: T.untyped, email: String).returns(String) }
|
|
23
|
+
def key(preview, email)
|
|
24
|
+
"#{preview.preview_name}##{email}"
|
|
25
|
+
end
|
|
26
|
+
|
|
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
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# [] when ActionMailer previews aren't loaded (host has none / previews disabled).
|
|
37
|
+
sig { returns(T::Array[[T.untyped, String]]) }
|
|
38
|
+
def all
|
|
39
|
+
return [] unless defined?(ActionMailer::Preview)
|
|
40
|
+
|
|
41
|
+
ActionMailer::Preview.all.flat_map do |preview|
|
|
42
|
+
preview.emails.map { |email| [preview, email] }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
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
|
|
53
|
+
|
|
54
|
+
body.respond_to?(:decoded) ? body.decoded : body.to_s
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
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?
|
|
64
|
+
|
|
65
|
+
mail.body if mail.mime_type.nil? || mail.mime_type == 'text/html'
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
require_relative '../quality'
|
|
7
|
+
|
|
8
|
+
module ActiveMail
|
|
9
|
+
module Quality
|
|
10
|
+
# Render + guard every host preview; lives here (not the Rakefile) to stay unit-testable.
|
|
11
|
+
class RenderAll
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
class Result < T::Struct
|
|
15
|
+
const :discovered, Integer
|
|
16
|
+
const :rendered, Integer
|
|
17
|
+
const :render_failures, T::Hash[String, String]
|
|
18
|
+
const :guard_failures, T::Hash[String, T::Array[Guard::Violation]]
|
|
19
|
+
const :broken_required, T::Array[String]
|
|
20
|
+
end
|
|
21
|
+
|
|
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
|
+
@config = T.let(config, Configuration)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { returns(Result) }
|
|
29
|
+
def call
|
|
30
|
+
FileUtils.rm_rf(@output_root)
|
|
31
|
+
FileUtils.mkdir_p(@output_root)
|
|
32
|
+
|
|
33
|
+
render_failures = {}
|
|
34
|
+
guard_failures = {}
|
|
35
|
+
previews = PreviewRenderer.all
|
|
36
|
+
rendered = render_all(previews, render_failures, guard_failures)
|
|
37
|
+
|
|
38
|
+
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)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
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])
|
|
53
|
+
end
|
|
54
|
+
def broken_required(previews, render_failures, guard_failures)
|
|
55
|
+
required = @config.required_previews
|
|
56
|
+
discovered = previews.map { |preview, email| PreviewRenderer.key(preview, email) }
|
|
57
|
+
((render_failures.keys | guard_failures.keys) & required) | (required - discovered)
|
|
58
|
+
end
|
|
59
|
+
|
|
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
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
sig do
|
|
76
|
+
params(preview: T.untyped, email: String, failures: T::Hash[String, String], key: String).returns(T.nilable(Pathname))
|
|
77
|
+
end
|
|
78
|
+
def render_one(preview, email, failures, key)
|
|
79
|
+
PreviewRenderer.render_to_disk(preview, email, @output_root)
|
|
80
|
+
rescue SystemCallError
|
|
81
|
+
raise # disk/permission failures are not template bugs — let them abort the run
|
|
82
|
+
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}"
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../quality'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Quality
|
|
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
|
+
class ValidEmailMatcher
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
sig { params(guard: Guard).void }
|
|
15
|
+
def initialize(guard: ActiveMail::Quality.guard)
|
|
16
|
+
@guard = T.let(guard, Guard)
|
|
17
|
+
@violations = T.let([], T::Array[Guard::Violation])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(html: String).returns(T::Boolean) }
|
|
21
|
+
def matches?(html)
|
|
22
|
+
@violations = @guard.violations(html)
|
|
23
|
+
@violations.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { returns(String) }
|
|
27
|
+
def failure_message
|
|
28
|
+
"expected email HTML to be valid, but found violations:\n#{formatted_violations}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { returns(String) }
|
|
32
|
+
def failure_message_when_negated
|
|
33
|
+
'expected email HTML to have quality violations, but it was valid'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { returns(String) }
|
|
37
|
+
def description
|
|
38
|
+
'be a valid email'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
sig { returns(String) }
|
|
44
|
+
def formatted_violations
|
|
45
|
+
@violations.map { |v| " - [#{v.rule}] #{v.message}" }.join("\n")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
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
|
+
module RSpec
|
|
56
|
+
module Matchers
|
|
57
|
+
extend T::Sig
|
|
58
|
+
|
|
59
|
+
sig { params(guard: ActiveMail::Quality::Guard).returns(ActiveMail::Quality::Rspec::ValidEmailMatcher) }
|
|
60
|
+
def be_a_valid_email(guard: ActiveMail::Quality.guard)
|
|
61
|
+
ActiveMail::Quality::Rspec::ValidEmailMatcher.new(guard: guard)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require_relative 'quality/guard'
|
|
7
|
+
require_relative 'quality/configuration'
|
|
8
|
+
require_relative 'quality/preview_renderer'
|
|
9
|
+
|
|
10
|
+
module ActiveMail
|
|
11
|
+
# Opt-in email-quality layer. Host apps require this explicitly from their test
|
|
12
|
+
# suite; `require 'active_mail'` must NOT pull it in.
|
|
13
|
+
module Quality
|
|
14
|
+
extend T::Sig
|
|
15
|
+
|
|
16
|
+
sig { returns(Configuration) }
|
|
17
|
+
def self.config
|
|
18
|
+
@config ||= T.let(Configuration.new, T.nilable(Configuration))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
sig { params(config: T.untyped).returns(Configuration) }
|
|
22
|
+
def self.config=(config)
|
|
23
|
+
raise TypeError, 'Not an ActiveMail::Quality::Configuration' unless config.is_a?(Configuration)
|
|
24
|
+
|
|
25
|
+
@config = config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { params(block: T.proc.params(config: Configuration).void).void }
|
|
29
|
+
def self.configure(&block)
|
|
30
|
+
block.call(config)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig { returns(Guard) }
|
|
34
|
+
def self.guard
|
|
35
|
+
config.guard
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
# Reads a compiled CSS asset's bytes (Sprockets or Propshaft, by duck-type) — the
|
|
8
|
+
# bytes, not a digest URL, are what the Premailer adapter needs to inline.
|
|
9
|
+
module CompiledStylesheet
|
|
10
|
+
class << self
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
# '' (not nil) on a miss: callers embed the result verbatim, and a missing
|
|
14
|
+
# asset must degrade to an empty <style>, never interpolate "nil" into HTML.
|
|
15
|
+
sig { params(logical_path: String).returns(String) }
|
|
16
|
+
def read(logical_path)
|
|
17
|
+
sprockets_source(logical_path) || propshaft_source(logical_path) || ''
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Sprockets: config.assets.compile (dev) resolves logical paths in-memory;
|
|
23
|
+
# precompiled (prod) resolves through the manifest. #find_asset covers both.
|
|
24
|
+
sig { params(logical_path: String).returns(T.nilable(String)) }
|
|
25
|
+
def sprockets_source(logical_path)
|
|
26
|
+
assets = rails_assets_environment
|
|
27
|
+
return unless assets.respond_to?(:find_asset)
|
|
28
|
+
|
|
29
|
+
asset = assets.find_asset(logical_path)
|
|
30
|
+
asset&.source&.to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Propshaft: the load_path resolves a logical path to a compiled file on disk.
|
|
34
|
+
sig { params(logical_path: String).returns(T.nilable(String)) }
|
|
35
|
+
def propshaft_source(logical_path)
|
|
36
|
+
assets = rails_assets
|
|
37
|
+
load_path = assets.respond_to?(:load_path) ? assets.load_path : nil
|
|
38
|
+
return unless load_path.respond_to?(:find)
|
|
39
|
+
|
|
40
|
+
asset = load_path.find(logical_path)
|
|
41
|
+
asset&.path && File.exist?(asset.path) ? File.read(asset.path) : nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# config.assets.environment is the Sprockets::Environment; absent under Propshaft.
|
|
45
|
+
sig { returns(T.untyped) }
|
|
46
|
+
def rails_assets_environment
|
|
47
|
+
assets = rails_assets
|
|
48
|
+
assets.respond_to?(:environment) ? assets.environment : nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { returns(T.untyped) }
|
|
52
|
+
def rails_assets
|
|
53
|
+
return unless Object.const_defined?(:Rails)
|
|
54
|
+
|
|
55
|
+
rails = Object.const_get(:Rails)
|
|
56
|
+
app = rails.respond_to?(:application) ? rails.application : nil
|
|
57
|
+
config = app&.config
|
|
58
|
+
config.respond_to?(:assets) ? config.assets : nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'rails/engine'
|
|
5
|
+
require 'active_mail/rails/compiled_stylesheet'
|
|
6
|
+
|
|
7
|
+
module ActiveMail
|
|
8
|
+
module Rails
|
|
9
|
+
class Engine < ::Rails::Engine
|
|
10
|
+
config.annotations.register_extensions('active_mail') { |annotation| /<!--\s*(#{annotation}):?\s*(.*) -->/ } if config.respond_to?(:annotations)
|
|
11
|
+
|
|
12
|
+
# Sprockets only compiles whitelisted assets; the framework entry must be
|
|
13
|
+
# reachable as `stylesheet_link_tag "active_mail/active_mail"` from a host.
|
|
14
|
+
initializer 'active_mail.assets' do |app|
|
|
15
|
+
# Propshaft exposes config.assets but no #precompile (Sprockets-only).
|
|
16
|
+
assets = app.config.respond_to?(:assets) ? app.config.assets : nil
|
|
17
|
+
assets.precompile += %w[active_mail/active_mail.css] if assets.respond_to?(:precompile)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
initializer 'active_mail.action_mailer' do
|
|
21
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
22
|
+
require 'active_mail/inliner/interceptor'
|
|
23
|
+
# The interceptor honors config.register_inline_interceptor (and inliner =
|
|
24
|
+
# :null) at delivery time — a boot-time check would precede host config.
|
|
25
|
+
register_interceptor ActiveMail::Inliner::Interceptor
|
|
26
|
+
# active_mail_inline_styles must be available to mailer layouts/views.
|
|
27
|
+
helper ActiveMail::StylesHelper
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
rake_tasks do
|
|
32
|
+
load File.expand_path('../../tasks/active_mail.rake', __dir__)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ActiveMail
|
|
7
|
+
module Rails
|
|
8
|
+
class TemplateHandler
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(compose_with: T.nilable(T.any(String, Symbol))).void }
|
|
12
|
+
def initialize(compose_with = nil)
|
|
13
|
+
# ActionView handlers share no interface (Procs, objects, classes).
|
|
14
|
+
@engine_handler = T.let(nil, T.untyped)
|
|
15
|
+
return unless compose_with
|
|
16
|
+
|
|
17
|
+
# Without this guard a typo would silently fall back to the configured
|
|
18
|
+
# template_engine in #engine_handler.
|
|
19
|
+
@engine_handler = ActionView::Template.registered_template_handler(compose_with) ||
|
|
20
|
+
raise(ArgumentError, "No template handler found for #{compose_with}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { returns(T.untyped) }
|
|
24
|
+
def engine_handler
|
|
25
|
+
return @engine_handler if @engine_handler
|
|
26
|
+
|
|
27
|
+
type = ::ActiveMail.configuration.template_engine
|
|
28
|
+
ActionView::Template.registered_template_handler(type) ||
|
|
29
|
+
raise("No template handler found for #{type}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { params(template: T.untyped, source: T.nilable(String)).returns(String) }
|
|
33
|
+
def call(template, source = nil)
|
|
34
|
+
compiled_source =
|
|
35
|
+
if source
|
|
36
|
+
engine_handler.call(template, source)
|
|
37
|
+
else
|
|
38
|
+
engine_handler.call(template)
|
|
39
|
+
end
|
|
40
|
+
"ActiveMail::Core.new.release_the_kraken(begin; #{compiled_source};end)"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
module Composer
|
|
44
|
+
extend T::Sig
|
|
45
|
+
|
|
46
|
+
sig { params(ext: T.untyped, args: T.untyped).returns(T.untyped) }
|
|
47
|
+
def register_template_handler(ext, *args)
|
|
48
|
+
super
|
|
49
|
+
super(:"inky-#{ext}", ActiveMail::Rails::TemplateHandler.new(ext))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
ActiveSupport.on_load(:action_view) do
|
|
57
|
+
ActionView::Template.template_handler_extensions.each do |ext|
|
|
58
|
+
ActionView::Template.register_template_handler :"inky-#{ext}", ActiveMail::Rails::TemplateHandler.new(ext)
|
|
59
|
+
end
|
|
60
|
+
ActionView::Template.register_template_handler :inky, ActiveMail::Rails::TemplateHandler.new
|
|
61
|
+
ActionView::Template.singleton_class.send :prepend, ActiveMail::Rails::TemplateHandler::Composer
|
|
62
|
+
end
|