react-email-rails 0.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 (34) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CONTRIBUTING.md +68 -0
  4. data/LICENSE.md +21 -0
  5. data/README.md +492 -0
  6. data/SECURITY.md +5 -0
  7. data/lib/generators/react_email_rails/USAGE +19 -0
  8. data/lib/generators/react_email_rails/email_generator.rb +224 -0
  9. data/lib/generators/react_email_rails/install_generator.rb +211 -0
  10. data/lib/generators/react_email_rails/templates/USAGE +33 -0
  11. data/lib/generators/react_email_rails/templates/email/application_mailer.rb.tt +5 -0
  12. data/lib/generators/react_email_rails/templates/email/component.tsx +14 -0
  13. data/lib/generators/react_email_rails/templates/email/mailer.rb.tt +17 -0
  14. data/lib/generators/react_email_rails/templates/email/mailer_preview.rb.tt +14 -0
  15. data/lib/generators/react_email_rails/templates/email/mailer_test.rb.tt +28 -0
  16. data/lib/generators/react_email_rails/templates/initializer.rb +3 -0
  17. data/lib/generators/react_email_rails/templates/vite.config.ts +6 -0
  18. data/lib/react-email-rails.rb +1 -0
  19. data/lib/react_email_rails/action_mailer.rb +31 -0
  20. data/lib/react_email_rails/configuration.rb +145 -0
  21. data/lib/react_email_rails/props_resolver.rb +48 -0
  22. data/lib/react_email_rails/railtie.rb +16 -0
  23. data/lib/react_email_rails/render_error.rb +1 -0
  24. data/lib/react_email_rails/render_modes/persistent/command_runner.rb +44 -0
  25. data/lib/react_email_rails/render_modes/persistent/server.rb +204 -0
  26. data/lib/react_email_rails/render_modes/persistent.rb +28 -0
  27. data/lib/react_email_rails/render_modes/subprocess/command_runner.rb +56 -0
  28. data/lib/react_email_rails/render_modes/subprocess.rb +99 -0
  29. data/lib/react_email_rails/render_modes.rb +1 -0
  30. data/lib/react_email_rails/render_protocol.rb +21 -0
  31. data/lib/react_email_rails/rendered_email.rb +3 -0
  32. data/lib/react_email_rails/version.rb +3 -0
  33. data/lib/react_email_rails.rb +58 -0
  34. metadata +194 -0
@@ -0,0 +1,224 @@
1
+ require("rails/generators/named_base")
2
+ require("json")
3
+ require("open3")
4
+ require("timeout")
5
+
6
+ module ReactEmailRails; end
7
+ module ReactEmailRails::Generators; end
8
+
9
+ class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
10
+ source_root(File.expand_path("templates/email", __dir__))
11
+
12
+ argument(:actions, type: :array, default: [], banner: "method method")
13
+
14
+ class_option(
15
+ :skip_preview,
16
+ type: :boolean,
17
+ default: false,
18
+ desc: "Skip creating the mailer preview",
19
+ )
20
+ class_option(
21
+ :skip_test,
22
+ type: :boolean,
23
+ default: false,
24
+ desc: "Skip creating the mailer test",
25
+ )
26
+ class_option(
27
+ :emails_path,
28
+ type: :string,
29
+ desc: "Directory containing React Email components",
30
+ )
31
+ class_option(
32
+ :extension,
33
+ type: :string,
34
+ desc: "React Email component extension, such as tsx or jsx",
35
+ )
36
+
37
+ check_class_collision(suffix: "Mailer")
38
+
39
+ def create_mailer_file
40
+ template("mailer.rb", File.join("app/mailers", class_path, "#{file_name}_mailer.rb"))
41
+
42
+ in_root do
43
+ if behavior == :invoke && !File.exist?(application_mailer_file_name)
44
+ template("application_mailer.rb", application_mailer_file_name)
45
+ end
46
+ end
47
+ end
48
+
49
+ def copy_email_components
50
+ empty_directory(component_base_path)
51
+
52
+ actions.each do |action|
53
+ @action = action
54
+ @component_name = action.camelize
55
+ @path = File.join(component_base_path, "#{action}#{component_extension}")
56
+ template("component.tsx", @path)
57
+ end
58
+ end
59
+
60
+ def create_test_file
61
+ return if options[:skip_test]
62
+
63
+ template("mailer_test.rb", File.join("test/mailers", class_path, "#{file_name}_mailer_test.rb"))
64
+ end
65
+
66
+ def create_preview_file
67
+ return if options[:skip_preview]
68
+
69
+ template("mailer_preview.rb", File.join("test/mailers/previews", class_path, "#{file_name}_mailer_preview.rb"))
70
+ end
71
+
72
+ private
73
+
74
+ def file_name
75
+ @_file_name ||= super.sub(/_mailer\z/i, "")
76
+ end
77
+
78
+ def mailer_file_path
79
+ "#{file_path}_mailer"
80
+ end
81
+
82
+ def component_base_path
83
+ File.join(emails_path, "#{file_path}_mailer")
84
+ end
85
+
86
+ def emails_path
87
+ @emails_path ||= normalize_emails_path(
88
+ options[:emails_path].presence ||
89
+ vite_plugin_metadata.dig("emails", "path") ||
90
+ emails_path_from_vite_config ||
91
+ "app/javascript/emails",
92
+ )
93
+ end
94
+
95
+ def component_extension
96
+ @component_extension ||= normalize_extension(
97
+ options[:extension].presence ||
98
+ vite_plugin_metadata.dig("emails", "extensions")&.first ||
99
+ extension_from_vite_config ||
100
+ extension_from_existing_email_components ||
101
+ extension_from_existing_app_components ||
102
+ extension_from_typescript_signal ||
103
+ ".tsx",
104
+ )
105
+ end
106
+
107
+ def application_mailer_file_name
108
+ @_application_mailer_file_name ||= if mountable_engine?
109
+ "app/mailers/#{namespaced_path}/application_mailer.rb"
110
+ else
111
+ "app/mailers/application_mailer.rb"
112
+ end
113
+ end
114
+
115
+ def vite_plugin_metadata
116
+ @vite_plugin_metadata ||= begin
117
+ command = vite_config_command
118
+ if command
119
+ stdout, _stderr, status = Timeout.timeout(10) do
120
+ Open3.capture3(command, chdir: destination_root)
121
+ end
122
+
123
+ status.success? ? JSON.parse(stdout) : {}
124
+ else
125
+ {}
126
+ end
127
+ rescue JSON::ParserError, Timeout::Error
128
+ {}
129
+ end
130
+ end
131
+
132
+ def vite_config_command
133
+ [
134
+ "node_modules/.bin/react-email-rails-config",
135
+ "node_modules/.bin/react-email-rails-config.cmd",
136
+ ].find { |path| File.exist?(File.join(destination_root, path)) }
137
+ end
138
+
139
+ def emails_path_from_vite_config
140
+ source = vite_config_source
141
+ return unless source
142
+
143
+ source[
144
+ /reactEmailRails\s*\(\s*\{.*?emails:\s*["']([^"']+)["']/m,
145
+ 1,
146
+ ] || source[
147
+ /reactEmailRails\s*\(\s*\{.*?emails:\s*\{.*?path:\s*["']([^"']+)["']/m,
148
+ 1,
149
+ ]
150
+ end
151
+
152
+ def extension_from_vite_config
153
+ source = vite_config_source
154
+ return unless source
155
+
156
+ source[
157
+ /reactEmailRails\s*\(\s*\{.*?emails:\s*\{.*?extension:\s*["']([^"']+)["']/m,
158
+ 1,
159
+ ] || source[
160
+ /reactEmailRails\s*\(\s*\{.*?emails:\s*\{.*?extension:\s*\[\s*["']([^"']+)["']/m,
161
+ 1,
162
+ ]
163
+ end
164
+
165
+ def vite_config_source
166
+ @vite_config_source ||= begin
167
+ path = [
168
+ "vite.config.ts",
169
+ "vite.config.mts",
170
+ "vite.config.js",
171
+ "vite.config.mjs",
172
+ "vite.config.cts",
173
+ "vite.config.cjs",
174
+ ].find { |candidate| File.exist?(File.join(destination_root, candidate)) }
175
+
176
+ File.read(File.join(destination_root, path)) if path
177
+ end
178
+ end
179
+
180
+ def extension_from_existing_email_components
181
+ dominant_extension(Dir[File.join(destination_root, emails_path, "**/*.{tsx,jsx}")])
182
+ end
183
+
184
+ def extension_from_existing_app_components
185
+ dominant_extension(Dir[File.join(destination_root, "app/javascript/**/*.{tsx,jsx}")])
186
+ end
187
+
188
+ def dominant_extension(paths)
189
+ paths
190
+ .map { |path| File.extname(path) }
191
+ .select { |extension| [".tsx", ".jsx"].include?(extension) }
192
+ .tally
193
+ .max_by { |_extension, count| count }
194
+ &.first
195
+ end
196
+
197
+ def extension_from_typescript_signal
198
+ return ".tsx" if File.exist?(File.join(destination_root, "tsconfig.json"))
199
+
200
+ package_path = File.join(destination_root, "package.json")
201
+ return unless File.exist?(package_path)
202
+
203
+ package = JSON.parse(File.read(package_path))
204
+ dependencies = package.fetch("dependencies", {}).merge(package.fetch("devDependencies", {}))
205
+ ".tsx" if dependencies.key?("typescript")
206
+ rescue JSON::ParserError
207
+ nil
208
+ end
209
+
210
+ def normalize_emails_path(path)
211
+ path.to_s.delete_prefix("/").delete_suffix("/")
212
+ end
213
+
214
+ def normalize_extension(extension)
215
+ extension = extension.to_s
216
+ extension = ".#{extension}" unless extension.start_with?(".")
217
+
218
+ unless extension.match?(/\A\.[a-zA-Z0-9_.-]+\z/)
219
+ raise(Thor::Error, "Invalid React Email component extension: #{extension.inspect}")
220
+ end
221
+
222
+ extension
223
+ end
224
+ end
@@ -0,0 +1,211 @@
1
+ require("json")
2
+
3
+ module ReactEmailRails; end
4
+ module ReactEmailRails::Generators; end
5
+
6
+ class ReactEmailRails::Generators::InstallGenerator < Rails::Generators::Base
7
+ JAVASCRIPT_PACKAGES = [
8
+ "react-email-rails",
9
+ "@react-email/render",
10
+ "@react-email/components",
11
+ "react",
12
+ "react-dom",
13
+ ].freeze
14
+
15
+ PACKAGE_MANAGER_LOCKFILES = {
16
+ "pnpm-lock.yaml" => "pnpm",
17
+ "yarn.lock" => "yarn",
18
+ "bun.lock" => "bun",
19
+ "bun.lockb" => "bun",
20
+ "package-lock.json" => "npm",
21
+ }.freeze
22
+
23
+ SUPPORTED_PACKAGE_MANAGERS = ["bun", "npm", "pnpm", "yarn"].freeze
24
+ VITE_CONFIG_FILES = [
25
+ "vite.config.ts",
26
+ "vite.config.mts",
27
+ "vite.config.js",
28
+ "vite.config.mjs",
29
+ "vite.config.cts",
30
+ "vite.config.cjs",
31
+ ].freeze
32
+
33
+ VITE_IMPORT = 'import { reactEmailRails } from "react-email-rails"'
34
+
35
+ source_root(File.expand_path("templates", __dir__))
36
+
37
+ class_option(
38
+ :package_manager,
39
+ type: :string,
40
+ desc: "JavaScript package manager to use: npm, pnpm, yarn, or bun",
41
+ )
42
+ class_option(
43
+ :skip_package_install,
44
+ type: :boolean,
45
+ default: false,
46
+ desc: "Skip installing JavaScript dependencies",
47
+ )
48
+ class_option(
49
+ :skip_vite,
50
+ type: :boolean,
51
+ default: false,
52
+ desc: "Skip updating vite.config.*",
53
+ )
54
+ def copy_initializer
55
+ template("initializer.rb", "config/initializers/react_email_rails.rb")
56
+ end
57
+
58
+ def install_javascript_dependencies
59
+ return if options[:skip_package_install]
60
+
61
+ package = package_json
62
+ unless package
63
+ say_status(:skip, "JavaScript dependencies; package.json was not found", :yellow)
64
+ return
65
+ end
66
+
67
+ missing = missing_javascript_packages(package)
68
+ if missing.empty?
69
+ say_status(:identical, "JavaScript dependencies", :green)
70
+ return
71
+ end
72
+
73
+ manager = package_manager(package)
74
+ unless manager
75
+ say_status(
76
+ :skip,
77
+ "JavaScript dependencies; could not detect npm, pnpm, yarn, or bun",
78
+ :yellow,
79
+ )
80
+ return
81
+ end
82
+
83
+ run(javascript_install_command(manager, missing))
84
+ end
85
+
86
+ def configure_vite
87
+ return if options[:skip_vite]
88
+
89
+ if (config = vite_config_path)
90
+ update_vite_config(config)
91
+ else
92
+ template("vite.config.ts", "vite.config.ts")
93
+ end
94
+ end
95
+
96
+ def create_email_directory
97
+ empty_directory("app/javascript/emails")
98
+ end
99
+
100
+ private
101
+
102
+ def destination_path(path)
103
+ File.join(destination_root, path)
104
+ end
105
+
106
+ def package_json_path
107
+ destination_path("package.json")
108
+ end
109
+
110
+ def package_json
111
+ return unless File.exist?(package_json_path)
112
+
113
+ JSON.parse(File.read(package_json_path))
114
+ rescue JSON::ParserError => e
115
+ say_status(:skip, "JavaScript dependencies; package.json is invalid: #{e.message}", :yellow)
116
+ nil
117
+ end
118
+
119
+ def missing_javascript_packages(package)
120
+ dependencies = package.fetch("dependencies", {}).merge(package.fetch("devDependencies", {}))
121
+
122
+ JAVASCRIPT_PACKAGES.reject { |name| dependencies.key?(name) }
123
+ end
124
+
125
+ def package_manager(package)
126
+ manager = options[:package_manager].presence || package_manager_from_package_json(package) || package_manager_from_lockfile
127
+ return unless manager
128
+
129
+ manager = manager.to_s
130
+ raise(Thor::Error, "Unsupported package manager: #{manager}") if SUPPORTED_PACKAGE_MANAGERS.exclude?(manager)
131
+
132
+ manager
133
+ end
134
+
135
+ def package_manager_from_package_json(package)
136
+ package.fetch("packageManager", "").to_s.split("@").first.presence
137
+ end
138
+
139
+ def package_manager_from_lockfile
140
+ lockfile = PACKAGE_MANAGER_LOCKFILES.keys.find { |path| File.exist?(destination_path(path)) }
141
+ PACKAGE_MANAGER_LOCKFILES[lockfile]
142
+ end
143
+
144
+ def javascript_install_command(manager, packages)
145
+ case manager
146
+ when "npm"
147
+ "npm install #{packages.join(" ")}"
148
+ when "pnpm"
149
+ "pnpm add #{packages.join(" ")}"
150
+ when "yarn"
151
+ "yarn add #{packages.join(" ")}"
152
+ when "bun"
153
+ "bun add #{packages.join(" ")}"
154
+ end
155
+ end
156
+
157
+ def vite_config_path
158
+ VITE_CONFIG_FILES.find { |path| File.exist?(destination_path(path)) }
159
+ end
160
+
161
+ def update_vite_config(path)
162
+ full_path = destination_path(path)
163
+ source = File.read(full_path)
164
+
165
+ if configured_for_react_email?(source)
166
+ say_status(:identical, path, :green)
167
+ return
168
+ end
169
+
170
+ updated = insert_react_email_plugin(source)
171
+ unless updated
172
+ say_status(:skip, "#{path}; add reactEmailRails() to the Vite plugins array", :yellow)
173
+ return
174
+ end
175
+
176
+ File.write(full_path, ensure_react_email_import(updated))
177
+ say_status(:insert, path, :green)
178
+ end
179
+
180
+ def configured_for_react_email?(source)
181
+ source.match?(/reactEmailRails\s*\(/)
182
+ end
183
+
184
+ def insert_react_email_plugin(source)
185
+ return source if configured_for_react_email?(source)
186
+
187
+ if source.match?(/plugins:\s*\[\s*\]/)
188
+ source.sub(/plugins:\s*\[\s*\]/, "plugins: [reactEmailRails()]")
189
+ elsif source.match?(/plugins:\s*\[/)
190
+ source.sub(/plugins:\s*\[/) { |match| "#{match}reactEmailRails(), " }
191
+ elsif source.match?(/defineConfig\(\s*\{/)
192
+ source.sub(/defineConfig\(\s*\{/) { |match| "#{match}\n plugins: [reactEmailRails()]," }
193
+ elsif source.match?(/export\s+default\s+\{/)
194
+ source.sub(/export\s+default\s+\{/) { |match| "#{match}\n plugins: [reactEmailRails()]," }
195
+ end
196
+ end
197
+
198
+ def ensure_react_email_import(source)
199
+ return source if source.match?(/from\s+["']react-email-rails["']/)
200
+
201
+ lines = source.lines
202
+ last_import_index = (lines.length - 1).downto(0).find { |index| lines[index].match?(/\Aimport\b/) }
203
+
204
+ if last_import_index
205
+ lines.insert(last_import_index + 1, "#{VITE_IMPORT}\n")
206
+ lines.join
207
+ else
208
+ "#{VITE_IMPORT}\n\n#{source}"
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,33 @@
1
+ Description:
2
+ Generate an Action Mailer class backed by React Email components.
3
+
4
+ Example:
5
+ bin/rails generate react_email_rails:email Account created invited
6
+
7
+ This creates:
8
+ app/mailers/account_mailer.rb
9
+ app/javascript/emails/account_mailer/created.tsx
10
+ app/javascript/emails/account_mailer/invited.tsx
11
+ test/mailers/account_mailer_test.rb
12
+ test/mailers/previews/account_mailer_preview.rb
13
+
14
+ The generator follows Rails' mailer generator shape:
15
+
16
+ NAME [method method]
17
+
18
+ Options:
19
+ --emails-path=app/emails
20
+ Override the React Email component directory. By default this is
21
+ detected from reactEmailRails({ emails: ... }) in vite.config.* and
22
+ falls back to app/javascript/emails.
23
+
24
+ --extension=tsx|jsx|email.tsx
25
+ Override the generated component extension. By default this is detected
26
+ from the Vite plugin config, existing components, TypeScript signals,
27
+ and finally falls back to tsx.
28
+
29
+ --skip-test
30
+ Do not create a mailer test.
31
+
32
+ --skip-preview
33
+ Do not create a mailer preview.
@@ -0,0 +1,5 @@
1
+ <% module_namespacing do -%>
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ end
5
+ <% end -%>
@@ -0,0 +1,14 @@
1
+ import { Body, Container, Heading, Html, Text } from "@react-email/components"
2
+
3
+ export default function <%= @component_name %>() {
4
+ return (
5
+ <Html>
6
+ <Body>
7
+ <Container>
8
+ <Heading><%= class_name %>Mailer#<%= @action %></Heading>
9
+ <Text>Hi, find me in <%= @path %></Text>
10
+ </Container>
11
+ </Body>
12
+ </Html>
13
+ )
14
+ }
@@ -0,0 +1,17 @@
1
+ <% module_namespacing do -%>
2
+ class <%= class_name %>Mailer < ApplicationMailer
3
+ <% actions.each_with_index do |action, index| -%>
4
+ <% if index != 0 -%>
5
+
6
+ <% end -%>
7
+ # Subject can be set in your I18n file at config/locales/en.yml
8
+ # with the following lookup:
9
+ #
10
+ # en.<%= mailer_file_path.tr("/", ".") %>.<%= action %>.subject
11
+ #
12
+ def <%= action %>
13
+ mail to: "to@example.org", react: true
14
+ end
15
+ <% end -%>
16
+ end
17
+ <% end -%>
@@ -0,0 +1,14 @@
1
+ <% module_namespacing do -%>
2
+ # Preview all emails at http://localhost:3000/rails/mailers/<%= mailer_file_path %>
3
+ class <%= class_name %>MailerPreview < ActionMailer::Preview
4
+ <% actions.each_with_index do |action, index| -%>
5
+ <% if index != 0 -%>
6
+
7
+ <% end -%>
8
+ # Preview this email at http://localhost:3000/rails/mailers/<%= mailer_file_path %>/<%= action %>
9
+ def <%= action %>
10
+ <%= class_name %>Mailer.<%= action %>
11
+ end
12
+ <% end -%>
13
+ end
14
+ <% end -%>
@@ -0,0 +1,28 @@
1
+ require "test_helper"
2
+
3
+ <% module_namespacing do -%>
4
+ class <%= class_name %>MailerTest < ActionMailer::TestCase
5
+ <% actions.each_with_index do |action, index| -%>
6
+ <% if index != 0 -%>
7
+
8
+ <% end -%>
9
+ test "<%= action %>" do
10
+ rendered = ReactEmailRails::RenderedEmail.new(html: "<p>Hi</p>", text: "Hi")
11
+
12
+ ReactEmailRails.stub(:render, rendered) do
13
+ mail = <%= class_name %>Mailer.<%= action %>
14
+
15
+ assert_equal <%= action.to_s.humanize.inspect %>, mail.subject
16
+ assert_equal ["to@example.org"], mail.to
17
+ assert_equal ["from@example.com"], mail.from
18
+ assert_match "Hi", mail.body.encoded
19
+ end
20
+ end
21
+ <% end -%>
22
+ <% if actions.blank? -%>
23
+ # test "the truth" do
24
+ # assert true
25
+ # end
26
+ <% end -%>
27
+ end
28
+ <% end -%>
@@ -0,0 +1,3 @@
1
+ ReactEmailRails.configure do |config|
2
+ # See configuration options at: https://github.com/heysupertape/react-email-rails#configuration
3
+ end
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from "vite"
2
+ import { reactEmailRails } from "react-email-rails"
3
+
4
+ export default defineConfig({
5
+ plugins: [reactEmailRails()],
6
+ })
@@ -0,0 +1 @@
1
+ require_relative("react_email_rails")
@@ -0,0 +1,31 @@
1
+ module ReactEmailRails::ActionMailer
2
+ extend(ActiveSupport::Concern)
3
+
4
+ prepended do
5
+ class_attribute(:react_email_use_instance_props, default: false)
6
+ end
7
+
8
+ class_methods do
9
+ def use_react_instance_props
10
+ self.react_email_use_instance_props = true
11
+ end
12
+ end
13
+
14
+ def mail(headers = {}, &block)
15
+ return super unless headers.is_a?(Hash) && headers.key?(:react)
16
+
17
+ headers = headers.dup
18
+ react = headers.delete(:react)
19
+ props = headers.delete(:props) if headers.key?(:props)
20
+
21
+ component, resolved_props = ReactEmailRails::PropsResolver.new(self).resolve(react, props)
22
+ render_options = ReactEmailRails.configuration.resolve_render_options(self)
23
+ rendered = ReactEmailRails.render(component:, props: resolved_props, render_options:)
24
+
25
+ super(headers) do |format|
26
+ format.html { rendered.html }
27
+ format.text { rendered.text } if rendered.text.present?
28
+ yield(format) if block
29
+ end
30
+ end
31
+ end