react-email-rails 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58d96423ee9be99d650a3ad7d164cbb8aca228ccbcc2b36d1a9be2bac2e06172
4
- data.tar.gz: 370303cb64b83fd747009f59291ddf5f4598bd5670651ed89ec23edcf4b1072e
3
+ metadata.gz: 957e5d57adf900478fa7736755f400bd424dbfc4aa47bee2e6ef82822fa9d048
4
+ data.tar.gz: 7bdd67d8de3ce6051c1849338337e74734fb51514d18eee0931ba172c2edac93
5
5
  SHA512:
6
- metadata.gz: ccee408e14a4f0b831ae58d19ce6891687b273a9f017e6b97184bdbeef7a79107a4e7ba32b4e5db551c4160e2a85b62509c9c85e1ee53bca394b77857af4e2c9
7
- data.tar.gz: 262e70dd3acaf9b6bd0e8d1854649bb4a475634c4b0569c69dd2498f3a8bd9093bc33e89e35fe2c6785315247838122d081e4a26206c73d8d51da97698aa31a3
6
+ metadata.gz: 7e44fad8be9e76043f9811a5cadffc9ffe4a6da0dd4b649b283a6f5af599d600d4c69750a2dc454224923abab985204f07238978def3fd2ca3af7506c73444d6
7
+ data.tar.gz: 86617b862cfaf09b4a20a21918b042732648d8a8383dcf319cbe4ba77e5f982d9dd98d0abdc643d8ef902dfcc46485f0428547c0aed2a2eccae7e508a87ee5c2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - Add development live-reloading for Action Mailer previews. A development-only preview interceptor injects Vite's `@vite/client` into previews, and the `reactEmailRails()` plugin triggers a full reload when an email component changes, so the open preview refreshes on save. Configure the dev-server URL with `live_reload_url`, or set it to a falsy value to disable.
6
+
7
+ ## 0.6.1
8
+
9
+ - Fix `react:` rendering — and the `mailer`/`message` props — being skipped for actions that opt in through a class-level `default react: true` rather than a per-`mail` `react:` option. `mail` now resolves `react` (in any form: `true`, a component string, or a prop hash) from the mailer's `default`, a per-action `react: false` opts back out, and the internal `react`/`props`/`deep_merge` options never leak onto the message as email headers.
10
+
3
11
  ## 0.6.0
4
12
 
5
13
  - Every `react:` email now receives `mailer` and `message` props, mirroring Action Mailer's `mailer`/`message` ERB view helpers. Rendering now happens after Action Mailer assigns headers, so `message` includes subject, addressing, and default `from`/`reply_to` values. Per-mail and shared props win on conflict, serializers whose `as_json` returns a Hash receive the context, and collection props keep their original shape. The npm package exports matching `Mailer`/`Message` TypeScript types.
data/README.md CHANGED
@@ -30,8 +30,8 @@ The supported Ruby, Rails, Node, React, and Vite versions are tested in CI. Plea
30
30
 
31
31
  - [Requirements](#requirements)
32
32
  - [Installation](#installation)
33
- - [Your First Email](#your-first-email)
34
- - [How Rendering Works](#how-rendering-works)
33
+ - [Quick Start](#quick-start)
34
+ - [Rendering](#rendering)
35
35
  - [Usage](#usage)
36
36
  - [Configuration](#configuration)
37
37
  - [Deployment](#deployment)
@@ -100,7 +100,7 @@ export default defineConfig({
100
100
  })
101
101
  ```
102
102
 
103
- ## Your First Email
103
+ ## Quick Start
104
104
 
105
105
  Generate a mailer and React Email component:
106
106
 
@@ -169,7 +169,7 @@ AccountMailer.with(account: current_account).welcome.deliver_later
169
169
 
170
170
  React Email also provides primitives like [`<Button>`, `<Heading>`, `<Tailwind>`, and more](https://react.email/docs/components/html).
171
171
 
172
- ## How Rendering Works
172
+ ## Rendering
173
173
 
174
174
  In development, react-email-rails renders components through Vite's dev pipeline. Your email components get the same module resolution and transforms as the rest of your frontend.
175
175
 
@@ -177,6 +177,18 @@ In production, `assets:precompile` builds a server-side renderer bundle from you
177
177
 
178
178
  Every `react:` email renders HTML and plain text from the same component. If rendering fails, the email is not sent and `ReactEmailRails::RenderError` is raised.
179
179
 
180
+ ### Live-Reloading Previews
181
+
182
+ In development, Action Mailer previews automatically reload themselves when you edit an email component. react-email-rails registers a [preview interceptor](https://api.rubyonrails.org/classes/ActionMailer/Base.html#class-ActionMailer::Base-label-Previewing+emails) that injects `@vite/client` into the preview, and the `reactEmailRails()` plugin broadcasts a full reload over Vite's websocket whenever a file under your emails directory changes.
183
+
184
+ The `live_reload_url` defaults to Vite's `http://localhost:5173`, but you can point elsewhere if needed, or set it to a falsy value to disable live reload. (See [Configuration](#configuration))
185
+
186
+ ```ruby
187
+ ReactEmailRails.configure do |config|
188
+ config.live_reload_url = "http://localhost:3036" # or nil or false to disable
189
+ end
190
+ ```
191
+
180
192
  ## Usage
181
193
 
182
194
  ### Passing Props
@@ -240,6 +252,14 @@ end
240
252
 
241
253
  Action Mailer's framework assigns, including `params` and `rendered_format`, are excluded from instance props.
242
254
 
255
+ To make React the default for every action, set `default react: true` on the mailer (or `ApplicationMailer`). Each `mail` call then renders the inferred component without repeating `react: true`, and a single action can opt back out with `react: false`:
256
+
257
+ ```ruby
258
+ class ApplicationMailer < ActionMailer::Base
259
+ default react: true
260
+ end
261
+ ```
262
+
243
263
  ### Explicit Components
244
264
 
245
265
  Pass a component name when the mailer action and component path do not line up:
@@ -484,6 +504,7 @@ end
484
504
  | `render_process_max_requests` | `1_000` |
485
505
  | `on_render_error` | `nil` |
486
506
  | `deep_merge_shared_props` | `false` |
507
+ | `live_reload_url` | `http://localhost:5173` |
487
508
 
488
509
  ### Prop Transformation
489
510
 
@@ -139,7 +139,6 @@ class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
139
139
  ].find { |path| File.exist?(File.join(destination_root, path)) }
140
140
  end
141
141
 
142
- # The reactEmailRails({ ... }) plugin call, up to the option key being scanned for.
143
142
  PLUGIN_OPENING = /reactEmailRails\s*\(\s*\{.*?/m
144
143
 
145
144
  def emails_path_from_vite_config
@@ -2,7 +2,6 @@ module ReactEmailRails; end
2
2
  module ReactEmailRails::Generators; end
3
3
 
4
4
  module ReactEmailRails::Generators
5
- # Candidate Vite config filenames in precedence order; shared by both generators.
6
5
  VITE_CONFIG_FILES = [
7
6
  "vite.config.ts",
8
7
  "vite.config.mts",
@@ -1,7 +1,6 @@
1
1
  module ReactEmailRails::ActionMailer
2
2
  extend(ActiveSupport::Concern)
3
3
 
4
- # `react_email_share` kwargs reserved for `before_action` filtering, not prop data.
5
4
  SHARED_FILTER_OPTIONS = [:if, :unless, :only, :except].freeze
6
5
 
7
6
  prepended do
@@ -28,12 +27,15 @@ module ReactEmailRails::ActionMailer
28
27
  end
29
28
 
30
29
  def mail(headers = {}, &block)
31
- return super unless headers.is_a?(Hash) && headers.key?(:react)
30
+ return super unless headers.is_a?(Hash)
31
+ return super if headers.empty? && @_mail_was_called
32
32
 
33
- headers = headers.dup
34
- react = headers.delete(:react)
35
- props = headers.delete(:props) if headers.key?(:props)
36
- deep_merge = headers.delete(:deep_merge) if headers.key?(:deep_merge)
33
+ react = headers.key?(:react) ? headers[:react] : compute_default(self.class.default[:react])
34
+ return super unless react
35
+
36
+ props = headers[:props]
37
+ deep_merge = headers[:deep_merge]
38
+ headers = headers.except(:react, :props, :deep_merge)
37
39
 
38
40
  component, resolved_props = ReactEmailRails::PropsResolver.new(self).resolve(react, props)
39
41
  resolved_props = ReactEmailRails::SharedProps.new(self).merge_into(
@@ -53,6 +55,10 @@ module ReactEmailRails::ActionMailer
53
55
 
54
56
  private
55
57
 
58
+ def assign_headers_to_message(message, headers)
59
+ super(message, headers.except(:react, :props, :deep_merge))
60
+ end
61
+
56
62
  def react_email_render(component, props)
57
63
  props = ReactEmailRails::MailerContext.new(self).merge_into(props)
58
64
  render_options = ReactEmailRails.configuration.resolve_render_options(self)
@@ -1,5 +1,4 @@
1
1
  class ReactEmailRails::Configuration
2
- # Must match OUT_DIR/BUNDLE_FILE in vite/src/index.ts (check_version_sync.rb asserts it).
3
2
  BUNDLE_PATH = "tmp/react-email-rails/emails.js"
4
3
  BUILD_BIN = "node_modules/.bin/react-email-rails-build"
5
4
  CONFIG_BIN = "node_modules/.bin/react-email-rails-config"
@@ -7,6 +6,7 @@ class ReactEmailRails::Configuration
7
6
 
8
7
  DEFAULT_RENDER_TIMEOUT = 10
9
8
  DEFAULT_RENDER_PROCESS_MAX_REQUESTS = 1_000
9
+ DEFAULT_LIVE_RELOAD_URL = "http://localhost:5173"
10
10
 
11
11
  RENDER_MODES = {
12
12
  subprocess: ReactEmailRails::RenderModes::Subprocess,
@@ -35,6 +35,7 @@ class ReactEmailRails::Configuration
35
35
  :transform_props,
36
36
  :on_render_error,
37
37
  :deep_merge_shared_props,
38
+ :live_reload_url,
38
39
  )
39
40
 
40
41
  attr_reader(
@@ -54,10 +55,17 @@ class ReactEmailRails::Configuration
54
55
  config.transform_props = :lower_camel
55
56
  config.on_render_error = nil
56
57
  config.deep_merge_shared_props = false
58
+ config.live_reload_url = DEFAULT_LIVE_RELOAD_URL
57
59
  end
58
60
  end
59
61
  end
60
62
 
63
+ def resolve_live_reload_url
64
+ return if live_reload_url.blank?
65
+
66
+ live_reload_url.to_s.chomp("/")
67
+ end
68
+
61
69
  def render_mode=(value)
62
70
  if (value.is_a?(Symbol) || value.is_a?(String)) && !RENDER_MODES.key?(value.to_sym)
63
71
  raise(ArgumentError, "Unknown react-email-rails render mode: #{value.inspect}")
@@ -88,8 +96,6 @@ class ReactEmailRails::Configuration
88
96
  end
89
97
  end
90
98
 
91
- # A callable render_options is instance_exec'd against `context` (the mailer) when given,
92
- # so it can use per-mail helpers; otherwise it's called or returned as-is.
93
99
  def resolve_render_options(context = nil)
94
100
  value =
95
101
  if render_options.respond_to?(:call) && context
@@ -0,0 +1,33 @@
1
+ class ReactEmailRails::PreviewLiveReload
2
+ SCRIPT_TEMPLATE = %(<script type="module" src="%s/@vite/client"></script>)
3
+ BODY_CLOSE = %r{</body>}i
4
+
5
+ class << self
6
+ def previewing_email(message)
7
+ url = ReactEmailRails.configuration.resolve_live_reload_url
8
+ return unless url
9
+
10
+ part = html_part(message)
11
+ return unless part
12
+
13
+ part.body = inject(part.body.decoded, url)
14
+ end
15
+
16
+ private
17
+
18
+ def html_part(message)
19
+ return message.html_part if message.html_part
20
+ return message if message.mime_type == "text/html"
21
+
22
+ nil
23
+ end
24
+
25
+ def inject(html, url)
26
+ snippet = format(SCRIPT_TEMPLATE, url)
27
+ return html if html.include?(snippet)
28
+ return "#{html}#{snippet}" unless BODY_CLOSE.match?(html)
29
+
30
+ html.sub(BODY_CLOSE) { |tag| "#{snippet}#{tag}" }
31
+ end
32
+ end
33
+ end
@@ -1,6 +1,5 @@
1
1
  class ReactEmailRails::PropsResolver
2
2
  INTERNAL_ASSIGN_PREFIX = "_"
3
- # Some Action Mailer framework assigns do not use the internal `_` prefix.
4
3
  RESERVED_ASSIGNS = ["params", "rendered_format"].freeze
5
4
 
6
5
  def initialize(mailer)
@@ -34,7 +33,6 @@ class ReactEmailRails::PropsResolver
34
33
  end
35
34
 
36
35
  def assign_props
37
- # `react: true` infers the component; instance vars become props only when the mailer opts in.
38
36
  return {} unless mailer.class.react_email_use_instance_props
39
37
 
40
38
  mailer.instance_variables.each_with_object({}) do |ivar, props|
@@ -2,6 +2,7 @@ class ReactEmailRails::Railtie < Rails::Railtie
2
2
  initializer("react-email-rails.action_mailer") do
3
3
  ActiveSupport.on_load(:action_mailer) do
4
4
  prepend(ReactEmailRails::ActionMailer)
5
+ register_preview_interceptor(ReactEmailRails::PreviewLiveReload) if Rails.env.development?
5
6
  end
6
7
  end
7
8
 
@@ -1,5 +1,4 @@
1
1
  class ReactEmailRails::RenderModes::Persistent::CommandRunner
2
- # Eager (not lazy) so concurrent first renders can't each create separate Mutexes.
3
2
  @mutex = Mutex.new
4
3
 
5
4
  class << self
@@ -30,8 +29,6 @@ class ReactEmailRails::RenderModes::Persistent::CommandRunner
30
29
  end
31
30
  end
32
31
 
33
- # A forked child inherits the parent's Servers and their open pipes; sharing those pipes
34
- # interleaves requests across processes, so drop them without killing the parent's process.
35
32
  def reset_after_fork
36
33
  return if @owner_pid == Process.pid && @servers
37
34
 
@@ -1,7 +1,6 @@
1
1
  class ReactEmailRails::RenderModes::Persistent::Server
2
2
  STDERR_LIMIT = 8 * 1024
3
3
 
4
- # Minimal Process::Status stand-in; only #success? is ever read.
5
4
  Status = Data.define(:success) do
6
5
  def success? = success
7
6
  end
@@ -38,7 +37,6 @@ class ReactEmailRails::RenderModes::Persistent::Server
38
37
  release_io
39
38
  end
40
39
 
41
- # Release this process's copy of an inherited child's pipes without signalling the parent-owned process.
42
40
  def abandon
43
41
  release_io
44
42
  rescue IOError
@@ -47,14 +45,12 @@ class ReactEmailRails::RenderModes::Persistent::Server
47
45
 
48
46
  private
49
47
 
50
- # Shared by stop (which also kills the process) and abandon (which must not).
51
48
  def release_io
52
49
  [@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
53
50
  @stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
54
51
  @stdout_buffer.clear
55
52
  end
56
53
 
57
- # Run under the mutex; on a broken pipe, stop and retry once (the child respawns).
58
54
  def with_retry_on_broken_pipe(&block)
59
55
  @mutex.synchronize(&block)
60
56
  rescue Errno::EPIPE, IOError
@@ -72,8 +68,6 @@ class ReactEmailRails::RenderModes::Persistent::Server
72
68
  response = request(input, timeout:)
73
69
  return failure(response["error"].to_s.presence || "render process failed") unless response["ok"]
74
70
 
75
- # Re-serialize into the same Result.stdout contract the subprocess produces, so
76
- # Subprocess#run parses and validates every render uniformly.
77
71
  success(JSON.generate(
78
72
  {
79
73
  protocolVersion: response["protocolVersion"],
@@ -8,8 +8,6 @@ class ReactEmailRails::RenderModes::Subprocess
8
8
  end
9
9
  end
10
10
 
11
- # `label` names the render in error messages. `response` reads the reply as `:email`
12
- # (RenderedEmail, for render/compose) or `:document` (parsed document, for parse).
13
11
  def initialize(payload:, label:, response: :email)
14
12
  @payload = payload
15
13
  @label = label
@@ -44,7 +42,6 @@ class ReactEmailRails::RenderModes::Subprocess
44
42
  end
45
43
  end
46
44
 
47
- # Shared by Persistent#capture so the transport-error messages live in one place.
48
45
  def with_capture_rescues
49
46
  yield
50
47
  rescue Timeout::Error
@@ -6,7 +6,6 @@ module ReactEmailRails
6
6
  module RenderProtocol
7
7
  extend(self)
8
8
 
9
- # Callers keep their own rescue to also cover failures obtaining the result.
10
9
  def healthy_result?(result)
11
10
  result.status.success? && compatible_response?(JSON.parse(result.stdout))
12
11
  end
@@ -1,5 +1,4 @@
1
1
  module ReactEmailRails
2
- # `warnings` are non-fatal (document nodes nothing rendered); empty for component renders.
3
2
  RenderedEmail = Data.define(:html, :text, :warnings) do
4
3
  def initialize(html:, text:, warnings: [])
5
4
  super
@@ -1,5 +1,3 @@
1
- # Collects props registered with `react_email_share` and merges them beneath the
2
- # per-mail props, which win on conflict. Mirrors inertia-rails shared data.
3
1
  class ReactEmailRails::SharedProps
4
2
  IVAR = :@_react_email_shared
5
3
 
@@ -7,8 +5,6 @@ class ReactEmailRails::SharedProps
7
5
  @mailer = mailer
8
6
  end
9
7
 
10
- # Returns `props` untouched when nothing is shared, so non-Hash inputs (e.g.
11
- # serializers) still flow straight through to serialization.
12
8
  def merge_into(props, deep_merge:)
13
9
  shared = to_h
14
10
  return props if shared.empty?
@@ -32,8 +28,6 @@ class ReactEmailRails::SharedProps
32
28
  mailer.instance_variable_get(IVAR) || []
33
29
  end
34
30
 
35
- # An entry is either a static Hash or a block evaluated at render time. Callable
36
- # values inside a Hash are evaluated too, so `unread_count: -> { ... }` works.
37
31
  def resolve(entry)
38
32
  hash = entry.respond_to?(:call) ? mailer.instance_exec(&entry) : entry
39
33
 
@@ -1,3 +1,3 @@
1
1
  module ReactEmailRails
2
- VERSION = "0.6.0"
2
+ VERSION = "0.7.0"
3
3
  end
@@ -27,6 +27,7 @@ require_relative("react_email_rails/tasks")
27
27
  require_relative("react_email_rails/props_resolver")
28
28
  require_relative("react_email_rails/shared_props")
29
29
  require_relative("react_email_rails/mailer_context")
30
+ require_relative("react_email_rails/preview_live_reload")
30
31
  require_relative("react_email_rails/railtie")
31
32
 
32
33
  module ReactEmailRails
@@ -46,7 +47,6 @@ module ReactEmailRails
46
47
  perform(payload:, label: component, kind: "email", component:)
47
48
  end
48
49
 
49
- # The document is sent verbatim (keys are structural); only context is key-transformed, like props.
50
50
  def compose(type:, document:, context: {}, preview: nil)
51
51
  payload = {
52
52
  kind: "document",
@@ -59,7 +59,6 @@ module ReactEmailRails
59
59
  perform(payload:, label: type, kind: "document", type:)
60
60
  end
61
61
 
62
- # Returns an editor document Hash, built via the renderer's extensions. Pass exactly one of `html:`/`markdown:`.
63
62
  def parse(type:, html: nil, markdown: nil, context: {})
64
63
  payload = {
65
64
  kind: "parse",
@@ -90,7 +89,6 @@ module ReactEmailRails
90
89
  raise
91
90
  end
92
91
 
93
- # Markdown is converted renderer-side, not here; both inputs are sent as-is.
94
92
  def parse_source(html:, markdown:)
95
93
  if !html.nil? && !markdown.nil?
96
94
  raise(ArgumentError, "ReactEmailRails.parse accepts only one of html: or markdown:")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: react-email-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Supertape
@@ -154,6 +154,7 @@ files:
154
154
  - lib/react_email_rails/action_mailer.rb
155
155
  - lib/react_email_rails/configuration.rb
156
156
  - lib/react_email_rails/mailer_context.rb
157
+ - lib/react_email_rails/preview_live_reload.rb
157
158
  - lib/react_email_rails/props_resolver.rb
158
159
  - lib/react_email_rails/railtie.rb
159
160
  - lib/react_email_rails/render_error.rb