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,139 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sorbet-runtime'
5
+
6
+ module ActiveMail
7
+ # Design-tokens registry: the single Ruby source of truth, bridged to SCSS by #to_scss.
8
+ class Tokens
9
+ extend T::Sig
10
+
11
+ TokenMap = T.type_alias { T::Hash[Symbol, String] }
12
+
13
+ DEFAULT_COLORS = T.let(
14
+ {
15
+ primary: '#2a9d8f',
16
+ secondary: '#264653',
17
+ text: '#1a1a1a',
18
+ background: '#ffffff',
19
+ muted: '#6b7280',
20
+ border: '#e5e7eb',
21
+ button_text: '#ffffff'
22
+ }.freeze,
23
+ TokenMap
24
+ )
25
+
26
+ SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
27
+
28
+ DEFAULT_FONTS = T.let(
29
+ {
30
+ body: SYSTEM_FONT_STACK,
31
+ heading: SYSTEM_FONT_STACK
32
+ }.freeze,
33
+ TokenMap
34
+ )
35
+
36
+ DEFAULT_SPACINGS = T.let(
37
+ {
38
+ xs: '4px',
39
+ sm: '8px',
40
+ md: '16px',
41
+ lg: '24px',
42
+ xl: '40px'
43
+ }.freeze,
44
+ TokenMap
45
+ )
46
+
47
+ sig { void }
48
+ def initialize
49
+ @colors = T.let(DEFAULT_COLORS.dup, TokenMap)
50
+ @fonts = T.let(DEFAULT_FONTS.dup, TokenMap)
51
+ @spacings = T.let(DEFAULT_SPACINGS.dup, TokenMap)
52
+ end
53
+
54
+ # value given → set, omitted → get. Open registry: any key is accepted (define
55
+ # custom tokens for custom components); color!/font!/spacing! fail loud on read.
56
+ sig { params(name: T.any(String, Symbol), value: T.nilable(String)).returns(T.nilable(String)) }
57
+ def color(name, value = nil)
58
+ access(@colors, name, value)
59
+ end
60
+
61
+ sig { params(name: T.any(String, Symbol), value: T.nilable(String)).returns(T.nilable(String)) }
62
+ def font(name, value = nil)
63
+ access(@fonts, name, value)
64
+ end
65
+
66
+ sig { params(name: T.any(String, Symbol), value: T.nilable(String)).returns(T.nilable(String)) }
67
+ def spacing(name, value = nil)
68
+ access(@spacings, name, value)
69
+ end
70
+
71
+ # Strict reads for code that must have the token (e.g. a component's inline
72
+ # color): raise rather than interpolating nil into the CSS.
73
+ sig { params(name: T.any(String, Symbol)).returns(String) }
74
+ def color!(name)
75
+ fetch!(@colors, :color, name)
76
+ end
77
+
78
+ sig { params(name: T.any(String, Symbol)).returns(String) }
79
+ def font!(name)
80
+ fetch!(@fonts, :font, name)
81
+ end
82
+
83
+ sig { params(name: T.any(String, Symbol)).returns(String) }
84
+ def spacing!(name)
85
+ fetch!(@spacings, :spacing, name)
86
+ end
87
+
88
+ # Frozen dup: mutating the returned hash must not bypass the DSL setters.
89
+ sig { returns(TokenMap) }
90
+ def colors
91
+ @colors.dup.freeze
92
+ end
93
+
94
+ sig { returns(TokenMap) }
95
+ def fonts
96
+ @fonts.dup.freeze
97
+ end
98
+
99
+ sig { returns(TokenMap) }
100
+ def spacings
101
+ @spacings.dup.freeze
102
+ end
103
+
104
+ # SCSS bridge: !default lets power-users pre-declare overrides upstream.
105
+ # Values are emitted verbatim (trusted, app-controlled input) — not escaped.
106
+ sig { returns(String) }
107
+ def to_scss
108
+ lines = scss_lines('color', @colors) + scss_lines('font', @fonts) + scss_lines('spacing', @spacings)
109
+ "#{lines.join("\n")}\n"
110
+ end
111
+
112
+ private
113
+
114
+ sig { params(store: TokenMap, kind: Symbol, name: T.any(String, Symbol)).returns(String) }
115
+ def fetch!(store, kind, name)
116
+ store.fetch(name.to_sym) { raise KeyError, "unknown #{kind} token #{name.inspect}" }
117
+ end
118
+
119
+ sig { params(store: TokenMap, name: T.any(String, Symbol), value: T.nilable(String)).returns(T.nilable(String)) }
120
+ def access(store, name, value)
121
+ key = name.to_sym
122
+ return store[key] if value.nil?
123
+ # Emitted verbatim into SCSS by #to_scss — reject blanks that would yield broken CSS.
124
+ raise ArgumentError, "token #{key} value must not be blank" if value.strip.empty?
125
+
126
+ store[key] = value
127
+ end
128
+
129
+ sig { params(group: String, store: TokenMap).returns(T::Array[String]) }
130
+ def scss_lines(group, store)
131
+ store.map { |name, value| "$am-#{group}-#{scss_name(name)}: #{value} !default;" }
132
+ end
133
+
134
+ sig { params(name: Symbol).returns(String) }
135
+ def scss_name(name)
136
+ name.to_s.tr('_', '-')
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,6 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveMail
5
+ VERSION = '1.0.0'
6
+ end
@@ -0,0 +1,161 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'nokogiri'
5
+ require 'sorbet-runtime'
6
+
7
+ require_relative 'active_mail/version'
8
+ require_relative 'active_mail/components/base'
9
+ require_relative 'active_mail/components/button'
10
+ require_relative 'active_mail/components/row'
11
+ require_relative 'active_mail/components/columns'
12
+ require_relative 'active_mail/components/container'
13
+ require_relative 'active_mail/components/inky'
14
+ require_relative 'active_mail/components/block_grid'
15
+ require_relative 'active_mail/components/menu'
16
+ require_relative 'active_mail/components/menu_item'
17
+ require_relative 'active_mail/components/center'
18
+ require_relative 'active_mail/components/callout'
19
+ require_relative 'active_mail/components/spacer'
20
+ require_relative 'active_mail/components/h_line'
21
+ require_relative 'active_mail/components/wrapper'
22
+ require_relative 'active_mail/components/cta'
23
+ require_relative 'active_mail/components/info_box'
24
+ require_relative 'active_mail/configuration'
25
+ require_relative 'active_mail/inliner/interceptor'
26
+ require_relative 'active_mail/parse_error_reporter'
27
+
28
+ module ActiveMail
29
+ class ParseError < StandardError; end
30
+
31
+ class Core
32
+ extend T::Sig
33
+
34
+ # Nokogiri cannot parse a bare <th> outside a <tr>; components that emit
35
+ # <th> use this placeholder, swapped back at the end.
36
+ INTERIM_TH_TAG = 'active-mail-interim-th'
37
+ INTERIM_TH_TAG_REGEX = T.let(%r{(?<=<|</)#{Regexp.escape(INTERIM_TH_TAG)}}, Regexp)
38
+
39
+ DEFAULT_COMPONENTS = T.let(
40
+ {
41
+ 'button' => ActiveMail::Components::Button,
42
+ 'row' => ActiveMail::Components::Row,
43
+ 'columns' => ActiveMail::Components::Columns,
44
+ 'container' => ActiveMail::Components::Container,
45
+ 'inky' => ActiveMail::Components::Inky,
46
+ 'block-grid' => ActiveMail::Components::BlockGrid,
47
+ 'menu' => ActiveMail::Components::Menu,
48
+ 'item' => ActiveMail::Components::MenuItem,
49
+ 'center' => ActiveMail::Components::Center,
50
+ 'callout' => ActiveMail::Components::Callout,
51
+ 'spacer' => ActiveMail::Components::Spacer,
52
+ 'h-line' => ActiveMail::Components::HLine,
53
+ 'wrapper' => ActiveMail::Components::Wrapper
54
+ }.freeze,
55
+ ActiveMail::ComponentMap
56
+ )
57
+
58
+ sig { returns(Integer) }
59
+ attr_reader :column_count, :container_width
60
+
61
+ sig { returns(T::Hash[String, ActiveMail::Components::Base]) }
62
+ attr_reader :component_instances
63
+
64
+ sig { params(options: T::Hash[Symbol, T.untyped]).void }
65
+ def initialize(options = {})
66
+ config = ::ActiveMail.configuration
67
+ @component_instances = T.let(build_components(config, options[:components]), T::Hash[String, ActiveMail::Components::Base])
68
+ @column_count = T.let(ActiveMail.assert_positive_dimension!(:column_count, options[:column_count] || config.column_count), Integer)
69
+ @container_width = T.let(ActiveMail.assert_positive_dimension!(:container_width, options[:container_width] || config.container_width), Integer)
70
+ end
71
+
72
+ # Object, not String: ActionView::OutputBuffer is no longer a String since Rails 7.1.
73
+ sig { params(html_string: Object).returns(String) }
74
+ def release_the_kraken(html_string)
75
+ raws, str = extract_raws(normalize_input(html_string))
76
+ parse_cmd = ::ActiveMail.full_document?(str) ? :parse : :fragment
77
+ html = Nokogiri::HTML.public_send(parse_cmd, str)
78
+ ParseErrorReporter.new(component_instances.keys).call(html.errors)
79
+ transform_doc(html)
80
+ string = html.to_html
81
+ string = string.gsub(INTERIM_TH_TAG_REGEX, 'th')
82
+ # Needle is a literal U+00A0 (Nokogiri decodes the nbsp entity to one); re-encode
83
+ # it to the entity for email clients that mishandle raw NBSP bytes.
84
+ string = string.gsub(' ', '&nbsp;')
85
+ re_inject_raws(string, raws)
86
+ end
87
+
88
+ sig { params(elem: Nokogiri::XML::Node).returns(Nokogiri::XML::Node) }
89
+ def transform_doc(elem)
90
+ if elem.respond_to?(:children)
91
+ elem.children.each { |child| transform_doc(child) }
92
+ markup = component_factory(elem)
93
+ elem.replace(markup) if markup
94
+ end
95
+ elem
96
+ end
97
+
98
+ sig { params(node: Nokogiri::XML::Node).returns(T.nilable(String)) }
99
+ def component_factory(node)
100
+ component = component_instances[node.name]
101
+ return unless component
102
+
103
+ # Nokogiri::NodeSet has no #join; map to String first.
104
+ inner = node.children.map(&:to_s).join # rubocop:disable Style/MapJoin
105
+ component.transform(node, inner)
106
+ end
107
+
108
+ sig { params(string: String).returns([T::Array[String], String]) }
109
+ def extract_raws(string)
110
+ raws = []
111
+ i = 0
112
+ # Only the tags + content, across lines: surrounding whitespace is left in
113
+ # place (true pass-through; eating adjacent newlines corrupts <pre>/inline text).
114
+ regex = %r{< *raw *>([\s\S]*?)</ *raw *>}i
115
+ str = string
116
+ while (raw = str.match(regex))
117
+ raws[i] = T.must(raw[1])
118
+ str = str.sub(regex, "###RAW#{i}###")
119
+ i += 1
120
+ end
121
+ [raws, str]
122
+ end
123
+
124
+ sig { params(string: String, raws: T::Array[String]).returns(String) }
125
+ def re_inject_raws(string, raws)
126
+ str = string
127
+ raws.each_with_index do |val, i|
128
+ # Block form: the 2-arg String#sub would expand \0/\1/\& in val.
129
+ str = str.sub("###RAW#{i}###") { val }
130
+ end
131
+ str = str.html_safe if str.respond_to?(:html_safe)
132
+ str
133
+ end
134
+
135
+ private
136
+
137
+ # Internal transpilation details, not public surface (column_count/container_width stay public).
138
+ private :component_instances, :transform_doc, :component_factory, :extract_raws, :re_inject_raws
139
+
140
+ sig { params(config: ActiveMail::Configuration, overrides: T.untyped).returns(T::Hash[String, ActiveMail::Components::Base]) }
141
+ def build_components(config, overrides)
142
+ # Lookup is by node name (String); a Symbol key would never match.
143
+ overrides = (overrides || {}).transform_keys(&:to_s)
144
+ overrides.each { |tag, klass| ActiveMail::Components.validate_component!(tag, klass) }
145
+ DEFAULT_COMPONENTS.merge(config.components).merge(overrides).transform_values { |klass| klass.new(self) }
146
+ end
147
+
148
+ sig { params(html_string: Object).returns(String) }
149
+ def normalize_input(html_string)
150
+ html_string = html_string.to_s
151
+ html_string = html_string.dup.force_encoding(Encoding::UTF_8) if html_string.encoding == Encoding::BINARY
152
+ html_string = ::ActiveMail.scrub_invalid_bytes(html_string) unless html_string.valid_encoding?
153
+ html_string.gsub(/doctype/i, 'DOCTYPE')
154
+ end
155
+ end
156
+ end
157
+
158
+ if defined?(Rails::Engine)
159
+ require 'active_mail/rails/engine'
160
+ require 'active_mail/rails/template_handler'
161
+ end
data/lib/activemail.rb ADDED
@@ -0,0 +1,5 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ # Bundler auto-requires the gem name ('activemail'); the library lives in 'active_mail'.
5
+ require 'active_mail'
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/named_base'
5
+
6
+ module ActiveMail
7
+ module Generators
8
+ class ComponentGenerator < ::Rails::Generators::NamedBase
9
+ desc 'Scaffold an ActiveMail component class (rails g active_mail:component Cta)'
10
+ source_root File.join(File.dirname(__FILE__), 'templates')
11
+
12
+ def create_component
13
+ template 'component.rb.tt', File.join('app', 'mailers', 'components', "#{file_name}.rb")
14
+ end
15
+
16
+ def show_register_snippet
17
+ say "\nRegister the component in config/initializers/active_mail.rb:", :green
18
+ say %( config.register_component "#{tag_name}", Components::#{class_name})
19
+ say '(top-level Components:: — rename the module if it collides in your app)', :yellow
20
+ say "\nThen use <#{tag_name}>…</#{tag_name}> in your ActiveMail views.\n"
21
+ end
22
+
23
+ private
24
+
25
+ # Tag = kebab-cased class name, matching the gem's component naming.
26
+ def tag_name
27
+ file_name.tr('_', '-')
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module ActiveMail
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ desc 'Install ActiveMail: initializer and a mailer layout'
9
+ source_root File.join(File.dirname(__FILE__), 'templates')
10
+ argument :layout_name, type: :string, default: 'mailer', banner: 'layout_name'
11
+
12
+ class_option :haml, desc: 'Generate the layout in Haml', type: :boolean
13
+ class_option :slim, desc: 'Generate the layout in Slim', type: :boolean
14
+
15
+ def create_initializer
16
+ template 'initializer.rb', File.join('config', 'initializers', 'active_mail.rb')
17
+ end
18
+
19
+ # A plain mailer.html.erb would win over the generated inky layout; keep it.
20
+ def preserve_original_mailer_layout
21
+ return unless layout_name == 'mailer' && extension == 'erb'
22
+
23
+ original = File.join(layouts_base_dir, 'mailer.html.erb')
24
+ back_up_layout(original) if File.exist?(File.join(destination_root, original))
25
+ end
26
+
27
+ def create_mailer_layout
28
+ template "mailer_layout.html.inky-#{extension}",
29
+ File.join(layouts_base_dir, "#{layout_name.underscore}.html.inky-#{extension}")
30
+ end
31
+
32
+ def show_readme
33
+ say "\nActiveMail installed.", :green
34
+ say ' • config/initializers/active_mail.rb — configure tokens, inliner, components.'
35
+ say " • app/views/layouts/#{layout_name.underscore}.html.inky-#{extension} — your mailer layout."
36
+ say "\nPoint your mailers at the layout, e.g. `layout \"#{layout_name.underscore}\"`, and"
37
+ say "name views *.html.inky-#{extension} to enable ActiveMail markup."
38
+ say "\nCustomize styling via Ruby tokens in the initializer (config.tokens.color/font/spacing),"
39
+ say 'or run `rails g active_mail:styles` to eject and edit the SCSS partials.'
40
+ end
41
+
42
+ private
43
+
44
+ def back_up_layout(original)
45
+ backup = File.join(layouts_base_dir, "old_mailer_#{Time.now.strftime('%Y%m%d%H%M%S%L')}.html.erb")
46
+ File.rename(File.join(destination_root, original), File.join(destination_root, backup))
47
+ say "Renamed existing #{original} → #{backup} (it would shadow the new ActiveMail layout).", :yellow
48
+ end
49
+
50
+ def layouts_base_dir
51
+ File.join('app', 'views', 'layouts')
52
+ end
53
+
54
+ def extension
55
+ %w[haml slim].find { |ext| options.send(ext) } || 'erb'
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module ActiveMail
6
+ module Generators
7
+ class StylesGenerator < ::Rails::Generators::Base
8
+ desc 'Copy ActiveMail framework SCSS partials into the host app for customization'
9
+ source_root File.expand_path('../../../app/assets/stylesheets/active_mail', __dir__)
10
+
11
+ TARGET_DIR = File.join('app', 'assets', 'stylesheets', 'active_mail')
12
+
13
+ # The .scss.erb token bridge is deliberately not ejected: it needs ERB
14
+ # preprocessing, and tokens come from Ruby config (rake active_mail:tokens:export).
15
+ def copy_styles
16
+ Dir.children(self.class.source_root).each do |name|
17
+ next if name.end_with?('.erb')
18
+
19
+ copy_file name, File.join(TARGET_DIR, name)
20
+ end
21
+ end
22
+
23
+ def show_readme
24
+ say "\nEjected the ActiveMail SCSS partials to #{TARGET_DIR}.", :green
25
+ say 'Token values come from Ruby (config.tokens.color/font/spacing). For a static'
26
+ say 'SCSS partial of those values, run `rake active_mail:tokens:export`.'
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ module Components
5
+ # Register in config/initializers/active_mail.rb:
6
+ # config.register_component "<%= tag_name %>", Components::<%= class_name %>
7
+ class <%= class_name %> < ActiveMail::Components::Base
8
+ def transform(node, inner)
9
+ classes = combine_classes(node, "<%= tag_name %>")
10
+ %(<table class="#{classes}" #{TABLE_RESET}><tbody><tr><td>#{inner}</td></tr></tbody></table>)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ActiveMail configuration. Works zero-config; uncomment to customize.
4
+ ActiveMail.configure do |config|
5
+ # Template engine ActiveMail composes with for `.inky` views (`.inky-erb`,
6
+ # `.inky-haml`, … are always available regardless of this default).
7
+ # config.template_engine = :erb
8
+
9
+ # Layout geometry.
10
+ # config.column_count = 12
11
+ # config.container_width = 600
12
+
13
+ # CSS inliner: :premailer (default), :roadie, :null, or a custom
14
+ # ActiveMail::Inliner::Base subclass/instance.
15
+ # config.inliner = :premailer
16
+
17
+ # Set false if another inliner (e.g. premailer-rails) already runs on mailers.
18
+ # config.register_inline_interceptor = true
19
+
20
+ # How malformed markup is handled: :warn (default), :ignore, or :raise.
21
+ # config.on_parse_error = :warn
22
+
23
+ # Design tokens — the single source of truth, bridged to SCSS via $am-*.
24
+ # config.tokens.color :primary, "#2a9d8f"
25
+ # config.tokens.color :secondary, "#264653"
26
+ # config.tokens.font :heading, "Georgia, serif"
27
+ # config.tokens.spacing :lg, "32px"
28
+
29
+ # Register components (built-ins like ActiveMail::Components::Cta, or your own
30
+ # Components::* from `rails g active_mail:component`).
31
+ # config.register_component "cta", ActiveMail::Components::Cta
32
+ end
@@ -0,0 +1,30 @@
1
+ <!DOCTYPE html>
2
+ <html lang="<%%= I18n.locale %>" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="x-apple-disable-message-reformatting">
7
+ <meta name="color-scheme" content="light dark">
8
+ <meta name="supported-color-schemes" content="light dark">
9
+ <!--[if mso]>
10
+ <noscript>
11
+ <xml>
12
+ <o:OfficeDocumentSettings>
13
+ <o:PixelsPerInch>96</o:PixelsPerInch>
14
+ </o:OfficeDocumentSettings>
15
+ </xml>
16
+ </noscript>
17
+ <![endif]-->
18
+ <%%= active_mail_inline_styles %>
19
+ <%%# Fallback for when active_mail_inline_styles cannot read the compiled asset. %>
20
+ <%%= stylesheet_link_tag "active_mail/active_mail", media: "all" %>
21
+ </head>
22
+
23
+ <body style="margin:0;padding:0;word-spacing:normal;">
24
+ <div role="article" aria-roledescription="email" lang="<%%= I18n.locale %>" style="width:100%;">
25
+ <container>
26
+ <%%= yield %>
27
+ </container>
28
+ </div>
29
+ </body>
30
+ </html>
@@ -0,0 +1,17 @@
1
+ !!!
2
+ %html{lang: I18n.locale, xmlns: "http://www.w3.org/1999/xhtml", "xmlns:v" => "urn:schemas-microsoft-com:vml", "xmlns:o" => "urn:schemas-microsoft-com:office:office"}
3
+ %head
4
+ %meta{charset: "utf-8"}
5
+ %meta{name: "viewport", content: "width=device-width, initial-scale=1"}
6
+ %meta{name: "x-apple-disable-message-reformatting"}
7
+ %meta{name: "color-scheme", content: "light dark"}
8
+ %meta{name: "supported-color-schemes", content: "light dark"}
9
+ :plain
10
+ <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
11
+ = active_mail_inline_styles
12
+ -# Fallback for when active_mail_inline_styles cannot read the compiled asset.
13
+ = stylesheet_link_tag "active_mail/active_mail", media: "all"
14
+ %body{style: "margin:0;padding:0;word-spacing:normal;"}
15
+ %div{role: "article", "aria-roledescription" => "email", lang: I18n.locale, style: "width:100%;"}
16
+ %container
17
+ = yield
@@ -0,0 +1,16 @@
1
+ doctype html
2
+ html lang=I18n.locale xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"
3
+ head
4
+ meta charset="utf-8"
5
+ meta name="viewport" content="width=device-width, initial-scale=1"
6
+ meta name="x-apple-disable-message-reformatting"
7
+ meta name="color-scheme" content="light dark"
8
+ meta name="supported-color-schemes" content="light dark"
9
+ | <!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
10
+ = active_mail_inline_styles
11
+ / Fallback for when active_mail_inline_styles cannot read the compiled asset.
12
+ = stylesheet_link_tag "active_mail/active_mail", media: "all"
13
+ body style="margin:0;padding:0;word-spacing:normal;"
14
+ div role="article" aria-roledescription="email" lang=I18n.locale style="width:100%;"
15
+ container
16
+ = yield
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module ActiveMail
6
+ module Generators
7
+ class ViewsGenerator < ::Rails::Generators::Base
8
+ desc 'Copy ActiveMail default layout views into the host app for customization'
9
+ source_root File.expand_path('../../../app/views/layouts/active_mail', __dir__)
10
+
11
+ def copy_views
12
+ directory '.', File.join('app', 'views', 'layouts', 'active_mail')
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_mail'
4
+ require 'fileutils'
5
+
6
+ namespace :active_mail do
7
+ namespace :tokens do
8
+ desc 'Export design tokens to a static SCSS partial (for Propshaft apps that cannot preprocess .scss.erb)'
9
+ # :environment so the host initializer's config.tokens overrides are loaded.
10
+ task :export, [:path] => :environment do |_task, args|
11
+ path = args[:path] || 'app/assets/stylesheets/active_mail/_active_mail_tokens.scss'
12
+ FileUtils.mkdir_p(File.dirname(path))
13
+ File.write(path, ActiveMail.scss_variables)
14
+ puts "Wrote #{ActiveMail.tokens.colors.size + ActiveMail.tokens.fonts.size + ActiveMail.tokens.spacings.size} tokens to #{path}"
15
+ end
16
+ end
17
+
18
+ namespace :emails do
19
+ desc 'Render every host mailer preview to disk and run the quality guard on each'
20
+ task render_all: :environment do
21
+ require 'active_mail/quality/render_all'
22
+
23
+ config = ActiveMail::Quality.config
24
+ output_root = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.join(config.output_dir) : Pathname(config.output_dir)
25
+ result = ActiveMail::Quality::RenderAll.new(output_root: output_root, config: config).call
26
+ puts "Rendered #{result.rendered} email(s) into #{output_root}"
27
+ # A green run on zero previews would silently verify nothing — make it visible.
28
+ warn '[activemail] WARNING: no mailer previews were discovered — nothing was checked.' if result.discovered.zero?
29
+ result.render_failures.each { |key, error| puts " render failed: #{key}: #{error}" }
30
+ result.guard_failures.each do |key, violations|
31
+ puts " guard failed: #{key}", violations.map { |v| " - [#{v.rule}] #{v.message}" }.join("\n")
32
+ end
33
+ abort "\n#{result.broken_required.size} required preview(s) failed: #{result.broken_required.join(', ')}" if result.broken_required.any?
34
+ end
35
+ end
36
+ end