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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +60 -0
  3. data/LICENSE.txt +23 -0
  4. data/README.md +375 -0
  5. data/app/assets/stylesheets/active_mail/_active_mail_tokens.scss.erb +3 -0
  6. data/app/assets/stylesheets/active_mail/_components.scss +58 -0
  7. data/app/assets/stylesheets/active_mail/_dark.scss +123 -0
  8. data/app/assets/stylesheets/active_mail/_grid.scss +49 -0
  9. data/app/assets/stylesheets/active_mail/_settings.scss +25 -0
  10. data/app/assets/stylesheets/active_mail/_utilities.scss +47 -0
  11. data/app/assets/stylesheets/active_mail/active_mail.scss +44 -0
  12. data/app/helpers/active_mail/styles_helper.rb +29 -0
  13. data/app/views/layouts/active_mail/_footer.html.inky-erb +10 -0
  14. data/app/views/layouts/active_mail/_head.html.inky-erb +6 -0
  15. data/app/views/layouts/active_mail/mailer.html.inky-erb +35 -0
  16. data/lib/active_mail/components/base.rb +119 -0
  17. data/lib/active_mail/components/block_grid.rb +19 -0
  18. data/lib/active_mail/components/button.rb +39 -0
  19. data/lib/active_mail/components/callout.rb +23 -0
  20. data/lib/active_mail/components/center.rb +24 -0
  21. data/lib/active_mail/components/columns.rb +72 -0
  22. data/lib/active_mail/components/container.rb +25 -0
  23. data/lib/active_mail/components/cta.rb +36 -0
  24. data/lib/active_mail/components/h_line.rb +19 -0
  25. data/lib/active_mail/components/info_box.rb +32 -0
  26. data/lib/active_mail/components/inky.rb +18 -0
  27. data/lib/active_mail/components/menu.rb +22 -0
  28. data/lib/active_mail/components/menu_item.rb +21 -0
  29. data/lib/active_mail/components/row.rb +18 -0
  30. data/lib/active_mail/components/spacer.rb +45 -0
  31. data/lib/active_mail/components/wrapper.rb +21 -0
  32. data/lib/active_mail/configuration.rb +229 -0
  33. data/lib/active_mail/inliner/base.rb +29 -0
  34. data/lib/active_mail/inliner/interceptor.rb +51 -0
  35. data/lib/active_mail/inliner/null.rb +23 -0
  36. data/lib/active_mail/inliner/premailer.rb +22 -0
  37. data/lib/active_mail/inliner/roadie.rb +30 -0
  38. data/lib/active_mail/libxml.rb +8 -0
  39. data/lib/active_mail/parse_error_reporter.rb +47 -0
  40. data/lib/active_mail/quality/configuration.rb +56 -0
  41. data/lib/active_mail/quality/guard.rb +131 -0
  42. data/lib/active_mail/quality/minitest.rb +64 -0
  43. data/lib/active_mail/quality/preview_renderer.rb +70 -0
  44. data/lib/active_mail/quality/render_all.rb +90 -0
  45. data/lib/active_mail/quality/rspec.rb +65 -0
  46. data/lib/active_mail/quality.rb +38 -0
  47. data/lib/active_mail/rails/compiled_stylesheet.rb +62 -0
  48. data/lib/active_mail/rails/engine.rb +36 -0
  49. data/lib/active_mail/rails/template_handler.rb +62 -0
  50. data/lib/active_mail/tokens.rb +139 -0
  51. data/lib/active_mail/version.rb +6 -0
  52. data/lib/active_mail.rb +161 -0
  53. data/lib/activemail.rb +5 -0
  54. data/lib/generators/active_mail/component_generator.rb +31 -0
  55. data/lib/generators/active_mail/install_generator.rb +59 -0
  56. data/lib/generators/active_mail/styles_generator.rb +30 -0
  57. data/lib/generators/active_mail/templates/component.rb.tt +13 -0
  58. data/lib/generators/active_mail/templates/initializer.rb +32 -0
  59. data/lib/generators/active_mail/templates/mailer_layout.html.inky-erb +30 -0
  60. data/lib/generators/active_mail/templates/mailer_layout.html.inky-haml +17 -0
  61. data/lib/generators/active_mail/templates/mailer_layout.html.inky-slim +16 -0
  62. data/lib/generators/active_mail/views_generator.rb +16 -0
  63. data/lib/tasks/active_mail.rake +36 -0
  64. 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