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,145 @@
1
+ class ReactEmailRails::Configuration
2
+ BUNDLE_PATH = "tmp/react-email-rails/emails.js"
3
+ DEV_RENDER_BIN = "node_modules/.bin/react-email-rails-dev"
4
+
5
+ DEFAULT_RENDER_TIMEOUT = 10
6
+ DEFAULT_RENDER_PROCESS_MAX_REQUESTS = 1_000
7
+
8
+ RENDER_MODES = {
9
+ subprocess: ReactEmailRails::RenderModes::Subprocess,
10
+ persistent: ReactEmailRails::RenderModes::Persistent,
11
+ }.freeze
12
+
13
+ KEY_TRANSFORMS = {
14
+ camel: ->(key) { key.to_s.camelize },
15
+ lower_camel: ->(key) { key.to_s.camelize(:lower) },
16
+ dash: ->(key) { key.to_s.underscore.dasherize },
17
+ snake: ->(key) { key.to_s.underscore },
18
+ none: ->(key) { key },
19
+ }.freeze
20
+
21
+ DEFAULT_RENDER_COMMAND = lambda do
22
+ if Rails.env.development?
23
+ [Rails.root.join(DEV_RENDER_BIN).to_s]
24
+ else
25
+ ["node", Rails.root.join(BUNDLE_PATH).to_s]
26
+ end
27
+ end
28
+
29
+ DEFAULT_VERIFY_RENDER_ON_BOOT = false
30
+
31
+ attr_accessor(
32
+ :component_path_resolver,
33
+ :render_options,
34
+ :transform_props,
35
+ :on_render_error,
36
+ :verify_render_on_boot,
37
+ )
38
+ attr_reader(
39
+ :render_mode,
40
+ :render_timeout,
41
+ :render_process_max_requests,
42
+ )
43
+
44
+ class << self
45
+ def default
46
+ new.tap do |config|
47
+ config.component_path_resolver = ->(mailer:, action:) { "#{mailer}/#{action}" }
48
+ config.render_mode = :subprocess
49
+ config.render_options = {}
50
+ config.render_timeout = DEFAULT_RENDER_TIMEOUT
51
+ config.render_process_max_requests = DEFAULT_RENDER_PROCESS_MAX_REQUESTS
52
+ config.transform_props = :lower_camel
53
+ config.on_render_error = nil
54
+ config.verify_render_on_boot = DEFAULT_VERIFY_RENDER_ON_BOOT
55
+ end
56
+ end
57
+ end
58
+
59
+ def verify_render_on_boot?
60
+ verify_render_on_boot.respond_to?(:call) ? !!verify_render_on_boot.call : !!verify_render_on_boot
61
+ end
62
+
63
+ def render_mode=(value)
64
+ if (value.is_a?(Symbol) || value.is_a?(String)) && !RENDER_MODES.key?(value.to_sym)
65
+ raise(ArgumentError, "Unknown react-email-rails render mode: #{value.inspect}")
66
+ end
67
+
68
+ @render_mode = value
69
+ end
70
+
71
+ def render_timeout=(value)
72
+ raise(ArgumentError, "react-email-rails render_timeout must be positive") unless value.is_a?(Numeric) && value.positive?
73
+
74
+ @render_timeout = value
75
+ end
76
+
77
+ def render_process_max_requests=(value)
78
+ unless value.nil? || (value.is_a?(Integer) && value.positive?)
79
+ raise(ArgumentError, "react-email-rails render_process_max_requests must be a positive integer or nil")
80
+ end
81
+
82
+ @render_process_max_requests = value
83
+ end
84
+
85
+ def resolved_render_mode
86
+ return render_mode unless render_mode.is_a?(Symbol) || render_mode.is_a?(String)
87
+
88
+ RENDER_MODES.fetch(render_mode.to_sym) do
89
+ raise(ArgumentError, "Unknown react-email-rails render mode: #{render_mode.inspect}")
90
+ end
91
+ end
92
+
93
+ def resolve_render_options(context = nil)
94
+ value =
95
+ if render_options.respond_to?(:call) && context
96
+ context.instance_exec(&render_options)
97
+ elsif render_options.respond_to?(:call)
98
+ render_options.call
99
+ else
100
+ render_options
101
+ end
102
+
103
+ deep_camelize_keys(value.as_json)
104
+ end
105
+
106
+ private
107
+
108
+ def resolved_render_command
109
+ DEFAULT_RENDER_COMMAND.call
110
+ end
111
+
112
+ def serialize_props(props)
113
+ deep_transform_keys(props.as_json, key_transform)
114
+ end
115
+
116
+ def key_transform
117
+ transform = transform_props.respond_to?(:to_sym) ? transform_props.to_sym : transform_props
118
+
119
+ KEY_TRANSFORMS.fetch(transform) do
120
+ raise(ArgumentError, "Unknown react-email-rails prop transform: #{transform_props.inspect}")
121
+ end
122
+ end
123
+
124
+ def deep_transform_keys(value, transform)
125
+ case value
126
+ when Array
127
+ value.map { |item| deep_transform_keys(item, transform) }
128
+ when Hash
129
+ value.transform_keys { |key| transform.call(key) }.transform_values { |item| deep_transform_keys(item, transform) }
130
+ else
131
+ value
132
+ end
133
+ end
134
+
135
+ def deep_camelize_keys(value)
136
+ case value
137
+ when Array
138
+ value.map { |item| deep_camelize_keys(item) }
139
+ when Hash
140
+ value.transform_keys { |key| key.to_s.camelize(:lower) }.transform_values { |item| deep_camelize_keys(item) }
141
+ else
142
+ value
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,48 @@
1
+ class ReactEmailRails::PropsResolver
2
+ INTERNAL_ASSIGN_PREFIX = "_"
3
+ # Some Action Mailer framework assigns do not use the internal `_` prefix.
4
+ RESERVED_ASSIGNS = ["params", "rendered_format"].freeze
5
+
6
+ def initialize(mailer)
7
+ @mailer = mailer
8
+ end
9
+
10
+ def resolve(react, props)
11
+ case react
12
+ when String
13
+ [react, props || {}]
14
+ when Hash
15
+ raise(ArgumentError, "Parameter `props` is not allowed when passing a Hash to `react`") if props
16
+
17
+ [inferred_component, react]
18
+ when true
19
+ [inferred_component, assign_props]
20
+ else
21
+ raise(ArgumentError, "`react` must be a String, Hash, or true")
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader(:mailer)
28
+
29
+ def inferred_component
30
+ ReactEmailRails.configuration.component_path_resolver.call(
31
+ mailer: mailer.class.mailer_name,
32
+ action: mailer.action_name,
33
+ )
34
+ end
35
+
36
+ def assign_props
37
+ # `react: true` infers the component name; instance vars become props only when the
38
+ # mailer opts in. Without it, the component renders with no props.
39
+ return {} unless mailer.class.react_email_use_instance_props
40
+
41
+ mailer.instance_variables.each_with_object({}) do |ivar, props|
42
+ name = ivar.to_s.delete_prefix("@")
43
+ next if name.start_with?(INTERNAL_ASSIGN_PREFIX) || RESERVED_ASSIGNS.include?(name)
44
+
45
+ props[name] = mailer.instance_variable_get(ivar)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,16 @@
1
+ class ReactEmailRails::Railtie < Rails::Railtie
2
+ initializer("react-email-rails.action_mailer") do
3
+ ActiveSupport.on_load(:action_mailer) do
4
+ prepend(ReactEmailRails::ActionMailer)
5
+ end
6
+ end
7
+
8
+ config.after_initialize do
9
+ if ReactEmailRails.configuration.verify_render_on_boot? && !ReactEmailRails.healthy?
10
+ Rails.logger.error(
11
+ "[react-email-rails] render verification failed for command: " \
12
+ "#{ReactEmailRails.configuration.send(:resolved_render_command).inspect}",
13
+ )
14
+ end
15
+ end
16
+ end
@@ -0,0 +1 @@
1
+ class ReactEmailRails::RenderError < StandardError; end
@@ -0,0 +1,44 @@
1
+ class ReactEmailRails::RenderModes::Persistent::CommandRunner
2
+ class << self
3
+ def capture(command, input:, timeout:, max_requests: nil)
4
+ server_for(command).capture(input:, timeout:, max_requests:)
5
+ end
6
+
7
+ def healthy?(command, timeout:)
8
+ result = server_for(command).health_check(timeout:)
9
+ result.status.success? && ReactEmailRails::RenderProtocol.compatible_response?(JSON.parse(result.stdout))
10
+ rescue StandardError
11
+ false
12
+ end
13
+
14
+ def stop_all
15
+ @mutex&.synchronize do
16
+ @servers&.each_value(&:stop)
17
+ @servers&.clear
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def server_for(command)
24
+ @mutex ||= Mutex.new
25
+
26
+ @mutex.synchronize do
27
+ reset_after_fork
28
+ @servers[command.map(&:to_s)] ||= ReactEmailRails::RenderModes::Persistent::Server.new(command)
29
+ end
30
+ end
31
+
32
+ # A forked child inherits the parent's Server objects and the open pipes to
33
+ # the parent's render processes. Sharing those pipes interleaves requests
34
+ # and responses across processes, so drop them (without killing the
35
+ # parent-owned process) and let this process spawn its own on demand.
36
+ def reset_after_fork
37
+ return if @owner_pid == Process.pid && @servers
38
+
39
+ @servers&.each_value(&:abandon)
40
+ @servers = {}
41
+ @owner_pid = Process.pid
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,204 @@
1
+ class ReactEmailRails::RenderModes::Persistent::Server
2
+ STDERR_LIMIT = 8 * 1024
3
+
4
+ Status = Data.define(:success) do
5
+ def success? = success
6
+ end
7
+
8
+ def initialize(command)
9
+ @command = command
10
+ @mutex = Mutex.new
11
+ @stderr_buffer = +""
12
+ @stderr_mutex = Mutex.new
13
+ @stdout_buffer = +""
14
+ @requests = 0
15
+ end
16
+
17
+ def capture(input:, timeout:, max_requests:)
18
+ @mutex.synchronize do
19
+ capture_once(input:, timeout:).tap { recycle_if_needed(max_requests) }
20
+ end
21
+ rescue Errno::EPIPE, IOError
22
+ stop
23
+ begin
24
+ @mutex.synchronize do
25
+ capture_once(input:, timeout:).tap { recycle_if_needed(max_requests) }
26
+ end
27
+ rescue Errno::EPIPE, IOError
28
+ failure("render process exited before responding")
29
+ end
30
+ end
31
+
32
+ def health_check(timeout:)
33
+ @mutex.synchronize { health_check_once(timeout:) }
34
+ rescue Errno::EPIPE, IOError
35
+ stop
36
+ begin
37
+ @mutex.synchronize { health_check_once(timeout:) }
38
+ rescue Errno::EPIPE, IOError
39
+ failure("render process exited before responding")
40
+ end
41
+ end
42
+
43
+ def stop
44
+ if @wait_thread&.alive?
45
+ terminate_process("TERM", @wait_thread.pid)
46
+ @wait_thread.join(1)
47
+ terminate_process("KILL", @wait_thread.pid) if @wait_thread.alive?
48
+ end
49
+ rescue Errno::ESRCH, Errno::EPERM
50
+ nil
51
+ ensure
52
+ [@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
53
+ @stderr_reader&.kill
54
+ @stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
55
+ @stdout_buffer.clear
56
+ end
57
+
58
+ # Release this process's copy of an inherited child's pipes without signalling
59
+ # the process itself, which is still owned by the parent that started it.
60
+ def abandon
61
+ [@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
62
+ @stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
63
+ @stdout_buffer.clear
64
+ rescue IOError
65
+ nil
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader(:command)
71
+
72
+ def capture_once(input:, timeout:)
73
+ response = request(input, timeout:)
74
+ return failure(response["error"].to_s.presence || "render process failed") unless response["ok"]
75
+
76
+ success(JSON.generate(
77
+ {
78
+ protocolVersion: response["protocolVersion"],
79
+ packageVersion: response["packageVersion"],
80
+ }.tap do |body|
81
+ body[:html] = response["html"] if response.key?("html")
82
+ body[:text] = response["text"] if response.key?("text")
83
+ end,
84
+ ))
85
+ rescue JSON::ParserError => e
86
+ failure("render process returned invalid JSON: #{e.message}")
87
+ end
88
+
89
+ def health_check_once(timeout:)
90
+ response = request(JSON.generate(health: true), timeout:)
91
+ response["ok"] ? success(JSON.generate(response)) : failure(response["error"].to_s.presence || "render process failed")
92
+ rescue JSON::ParserError => e
93
+ failure("render process returned invalid JSON: #{e.message}")
94
+ end
95
+
96
+ def start
97
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(*command, "--persistent", pgroup: true)
98
+ @stderr_buffer = +""
99
+ @stdout_buffer = +""
100
+ @requests = 0
101
+ @stderr_reader = Thread.new { drain_stderr }
102
+ @stderr_reader.report_on_exception = false
103
+ end
104
+
105
+ def running?
106
+ @wait_thread&.alive?
107
+ end
108
+
109
+ def request(input, timeout:)
110
+ start unless running?
111
+
112
+ @stdin.write("#{input}\n")
113
+ @stdin.flush
114
+
115
+ line = read_response_line(timeout)
116
+ return { "ok" => false, "error" => "render process exited before responding" } unless line
117
+
118
+ JSON.parse(line)
119
+ end
120
+
121
+ def read_response_line(timeout)
122
+ deadline = monotonic_time + timeout
123
+ line = +""
124
+
125
+ loop do
126
+ if (buffered_line = consume_buffered_response_line)
127
+ line << buffered_line
128
+ return line
129
+ end
130
+
131
+ line << @stdout_buffer
132
+ @stdout_buffer.clear
133
+
134
+ remaining = deadline - monotonic_time
135
+ if remaining <= 0 || IO.select([@stdout], nil, nil, remaining).nil?
136
+ stop
137
+ raise(Timeout::Error)
138
+ end
139
+
140
+ begin
141
+ @stdout_buffer << @stdout.read_nonblock(16 * 1024)
142
+ rescue IO::WaitReadable
143
+ next
144
+ end
145
+ end
146
+ rescue EOFError
147
+ line.presence
148
+ end
149
+
150
+ def consume_buffered_response_line
151
+ separator = @stdout_buffer.index("\n")
152
+ return unless separator
153
+
154
+ @stdout_buffer.slice!(0, separator + 1)
155
+ end
156
+
157
+ def monotonic_time
158
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
159
+ end
160
+
161
+ def terminate_process(signal, pid)
162
+ Process.kill(signal, -pid)
163
+ rescue Errno::ESRCH, Errno::EPERM
164
+ nil
165
+ end
166
+
167
+ def drain_stderr
168
+ @stderr.each do |chunk|
169
+ @stderr_mutex.synchronize do
170
+ @stderr_buffer << chunk
171
+ @stderr_buffer = @stderr_buffer.byteslice(-STDERR_LIMIT, STDERR_LIMIT) if @stderr_buffer.bytesize > STDERR_LIMIT
172
+ end
173
+ end
174
+ rescue IOError
175
+ nil
176
+ end
177
+
178
+ def success(stdout)
179
+ ReactEmailRails::RenderModes::Subprocess::CommandRunner::Result.new(
180
+ stdout:,
181
+ stderr: "",
182
+ status: Status.new(true),
183
+ )
184
+ end
185
+
186
+ def failure(message)
187
+ ReactEmailRails::RenderModes::Subprocess::CommandRunner::Result.new(
188
+ stdout: "",
189
+ stderr: [message, stderr_buffer].reject(&:blank?).join("\n"),
190
+ status: Status.new(false),
191
+ )
192
+ end
193
+
194
+ def stderr_buffer
195
+ @stderr_mutex.synchronize { @stderr_buffer.dup }
196
+ end
197
+
198
+ def recycle_if_needed(max_requests)
199
+ return unless max_requests&.positive?
200
+
201
+ @requests += 1
202
+ stop if @requests >= max_requests
203
+ end
204
+ end
@@ -0,0 +1,28 @@
1
+ class ReactEmailRails::RenderModes::Persistent < ReactEmailRails::RenderModes::Subprocess
2
+ class << self
3
+ def healthy?(command:, timeout:)
4
+ CommandRunner.healthy?(command, timeout:)
5
+ end
6
+ end
7
+
8
+ private
9
+
10
+ def capture(input)
11
+ CommandRunner.capture(
12
+ command,
13
+ input:,
14
+ timeout: render_timeout,
15
+ max_requests: render_process_max_requests,
16
+ )
17
+ rescue Timeout::Error
18
+ raise(render_error("render process timed out after #{render_timeout}s"))
19
+ rescue Errno::ENOENT
20
+ raise(render_error("render command not found: #{command.inspect}"))
21
+ end
22
+
23
+ def render_process_max_requests
24
+ ReactEmailRails.configuration.send(:render_process_max_requests)
25
+ end
26
+ end
27
+
28
+ at_exit { ReactEmailRails::RenderModes::Persistent::CommandRunner.stop_all }
@@ -0,0 +1,56 @@
1
+ class ReactEmailRails::RenderModes::Subprocess::CommandRunner
2
+ Result = Data.define(:stdout, :stderr, :status)
3
+
4
+ class << self
5
+ def capture(command, input: nil, timeout:)
6
+ new(command:, input:, timeout:).capture
7
+ end
8
+ end
9
+
10
+ def initialize(command:, input:, timeout:)
11
+ @command = command
12
+ @input = input
13
+ @timeout = timeout
14
+ end
15
+
16
+ def capture
17
+ Open3.popen3(*command, pgroup: true) do |stdin, stdout, stderr, wait_thread|
18
+ out_reader = read_async(stdout)
19
+ err_reader = read_async(stderr)
20
+
21
+ write_input(stdin)
22
+
23
+ if wait_thread.join(timeout).nil?
24
+ terminate_process("KILL", wait_thread.pid)
25
+ wait_thread.join
26
+ out_reader.kill
27
+ err_reader.kill
28
+ raise(Timeout::Error)
29
+ end
30
+
31
+ Result.new(stdout: out_reader.value, stderr: err_reader.value, status: wait_thread.value)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader(:command, :input, :timeout)
38
+
39
+ def read_async(io)
40
+ Thread.new { io.read }.tap { |thread| thread.report_on_exception = false }
41
+ end
42
+
43
+ def write_input(stdin)
44
+ stdin.write(input) if input
45
+ rescue Errno::EPIPE
46
+ nil
47
+ ensure
48
+ stdin.close
49
+ end
50
+
51
+ def terminate_process(signal, pid)
52
+ Process.kill(signal, -pid)
53
+ rescue Errno::ESRCH
54
+ nil
55
+ end
56
+ end
@@ -0,0 +1,99 @@
1
+ class ReactEmailRails::RenderModes::Subprocess
2
+ class << self
3
+ def healthy?(command:, timeout:)
4
+ result = CommandRunner.capture([*command, "--health"], timeout:)
5
+ result.status.success? && ReactEmailRails::RenderProtocol.compatible_response?(JSON.parse(result.stdout))
6
+ rescue StandardError
7
+ false
8
+ end
9
+ end
10
+
11
+ def initialize(component:, props:, render_options: {})
12
+ @component = component
13
+ @props = props
14
+ @render_options = render_options
15
+ end
16
+
17
+ def render
18
+ run
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader(:component, :props, :render_options)
24
+
25
+ def run
26
+ result = capture(payload_json)
27
+ raise(render_error(error_message(result.stderr, result.status))) unless result.status.success?
28
+
29
+ body = JSON.parse(result.stdout)
30
+ validate_response!(body)
31
+ ReactEmailRails::RenderedEmail.new(html: body.fetch("html"), text: body["text"].to_s)
32
+ rescue JSON::ParserError => e
33
+ raise(render_error("render process returned invalid JSON: #{e.message}"))
34
+ rescue KeyError => e
35
+ raise(render_error("render process returned an invalid response: missing #{e.key.inspect}"))
36
+ end
37
+
38
+ def capture(input)
39
+ validate_command!
40
+ CommandRunner.capture(command, input:, timeout: render_timeout)
41
+ rescue Timeout::Error
42
+ raise(render_error("render process timed out after #{render_timeout}s"))
43
+ rescue Errno::ENOENT
44
+ raise(render_error("render command not found: #{command.inspect}"))
45
+ end
46
+
47
+ def command
48
+ @command ||= ReactEmailRails.configuration.send(:resolved_render_command)
49
+ end
50
+
51
+ def render_timeout
52
+ ReactEmailRails.configuration.render_timeout
53
+ end
54
+
55
+ def payload
56
+ @payload ||= begin
57
+ payload = {
58
+ component:,
59
+ props: ReactEmailRails.configuration.send(:serialize_props, props),
60
+ }
61
+ payload[:renderOptions] = render_options if render_options.present?
62
+ payload
63
+ end
64
+ end
65
+
66
+ def payload_json
67
+ @payload_json ||= JSON.generate(payload)
68
+ end
69
+
70
+ def error_message(stderr, status)
71
+ stderr.to_s.strip.presence || "render process exited with #{status}"
72
+ end
73
+
74
+ def validate_command!
75
+ command_path = command.first.to_s
76
+ bundle_path = command[1].to_s
77
+
78
+ if command_path.end_with?(ReactEmailRails::Configuration::DEV_RENDER_BIN) && !File.exist?(command_path)
79
+ raise(render_error("development renderer not found at #{command_path.inspect}; install JavaScript dependencies with npm, pnpm, yarn, or bun"))
80
+ end
81
+
82
+ return unless command_path == "node" && bundle_path.end_with?(ReactEmailRails::Configuration::BUNDLE_PATH)
83
+ return if File.file?(bundle_path)
84
+
85
+ raise(render_error("email bundle not found at #{bundle_path.inspect}; run vite build before rendering React emails"))
86
+ end
87
+
88
+ def validate_response!(body)
89
+ raise(render_error(ReactEmailRails::RenderProtocol.mismatch_message(body))) unless ReactEmailRails::RenderProtocol.compatible_metadata?(body)
90
+
91
+ raise(KeyError.new(key: "html")) unless body.key?("html")
92
+ raise(render_error("render process returned an invalid response: html must be a string")) unless body["html"].is_a?(String)
93
+ raise(render_error("render process returned an invalid response: text must be a string")) if body.key?("text") && !body["text"].is_a?(String)
94
+ end
95
+
96
+ def render_error(message)
97
+ ReactEmailRails::RenderError.new("React Email render failed for #{component}: #{message}")
98
+ end
99
+ end
@@ -0,0 +1 @@
1
+ module ReactEmailRails::RenderModes; end
@@ -0,0 +1,21 @@
1
+ module ReactEmailRails
2
+ RENDER_PROTOCOL_VERSION = 1
3
+
4
+ module RenderProtocol
5
+ extend(self)
6
+
7
+ def compatible_response?(body)
8
+ body["ok"] == true && compatible_metadata?(body)
9
+ end
10
+
11
+ def compatible_metadata?(body)
12
+ body["protocolVersion"] == RENDER_PROTOCOL_VERSION &&
13
+ body["packageVersion"] == VERSION
14
+ end
15
+
16
+ def mismatch_message(body)
17
+ "renderer version mismatch: expected react-email-rails #{VERSION} protocol #{RENDER_PROTOCOL_VERSION}, " \
18
+ "got package #{body["packageVersion"].inspect} protocol #{body["protocolVersion"].inspect}"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ module ReactEmailRails
2
+ RenderedEmail = Data.define(:html, :text)
3
+ end
@@ -0,0 +1,3 @@
1
+ module ReactEmailRails
2
+ VERSION = "0.1.0"
3
+ end