react-email-rails 0.4.1 → 0.5.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: 05b64507c82feabb25781eaec8487c5e7282f65060abbac88719bfb2bc79d499
4
- data.tar.gz: fc1f7fe4d40195537b5218818aa0fb8b72be78834238223dc377c52f7843af15
3
+ metadata.gz: 4bbfbb40c2044856c9061ba2a3c134d07dbd5aa9b58b6cfe3d576d25fe45b4d2
4
+ data.tar.gz: 7c272a65e71044f813c813b248a48bc69e3a043494ad9f6133ae8c69ab006b24
5
5
  SHA512:
6
- metadata.gz: 61afe5bf3fc51e550ca9608bd91deeeb9794cd4a90bac53a7bf0fb48f2f12f5984f50dadbbd245c05e34d8dc4ab7c478f2ff8420aa94e9f57c98e39fb04bab01
7
- data.tar.gz: 992fd9253d2a2cf31f9ecd37f7bd960c52efddaf74541aefa5ed124ebef4941d9404c00d7f417a6cc2d36f998d3391d8f1bf3ec3d865ec232c43c5a2df2ed4c1
6
+ metadata.gz: c5b434f0983a8c1280d622203869f535a0c98fb293bcfff3d51d39a8676d730d000b901cb9bcc6b3b478fe633904974798131482e44982140f40e12951ac1423
7
+ data.tar.gz: 21652f85cc0ee448ec90ffd7b24c4eaf05b892dbe0e83d487777ae57bbcda7a34c46e1b9becad9174838edd0d33919b11aa9efb56cbd520433803c8a3fd82385
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Add `react_email_share` for sharing props across every `react:` email a mailer renders, mirroring inertia-rails' `inertia_share`. Supports static values, lazy lambdas and blocks (evaluated in the mailer instance), `only`/`except`/`if`/`unless` filters, and subclass inheritance. Per-mail props win over shared props.
6
+ - Shared props merge shallowly by default. Pass `deep_merge: true` to `mail` to merge nested hashes, or set `config.deep_merge_shared_props = true` to make it the default.
7
+
3
8
  ## 0.4.1
4
9
 
5
10
  - `ReactEmailRails.parse` now neutralizes unsafe link/button URI schemes: hrefs whose scheme is not `http`, `https`, `mailto`, or `tel` (e.g. `javascript:`/`data:`) are blanked before they reach the document `Hash`. Scheme detection ignores the whitespace and control characters browsers strip when resolving a scheme, so case- and whitespace-obfuscated payloads are caught too.
data/README.md CHANGED
@@ -33,7 +33,7 @@ react-email-rails automatically renders both HTML and plain-text versions from t
33
33
 
34
34
  ## Status
35
35
 
36
- **react-email-rails is pre-1.0.** It's tested in CI across the supported Ruby, Rails, Node, and Vite versions, but it hasn't been battle-tested in high-volume production environments yet, and the API may still change before 1.0. Please [share feedback and report issues](https://github.com/heysupertape/react-email-rails/issues).
36
+ **react-email-rails is pre-1.0.** It was extracted from [XOXO](https://xoxo.email) which is pre-launch, so it hasn't been battle-tested in high-volume production environments yet. It's tested in CI across the supported Ruby, Rails, Node, and Vite versions, but the API may still change before 1.0. Please [share feedback and report issues](https://github.com/heysupertape/react-email-rails/issues).
37
37
 
38
38
  ## Requirements
39
39
 
@@ -160,7 +160,7 @@ That's it. It now renders and delivers like any other Action Mailer email.
160
160
 
161
161
  ## Usage
162
162
 
163
- Pass data from your mailer. Each top-level key becomes a prop on the component's default export.
163
+ Pass data from your mailer and each top-level key becomes a prop on the component's default export. Our API mirrors [inertia-rails](https://inertia-rails.dev), making the two libraries feel consistent when used together.
164
164
 
165
165
  ```ruby
166
166
  mail react: { foo: "bar" }, ...
@@ -172,9 +172,11 @@ export default function Email({ foo }: { foo: string }) {
172
172
  }
173
173
  ```
174
174
 
175
- Choose the level of inference you want:
175
+ ### Props
176
176
 
177
- ### Implicit Component, Instance Props
177
+ Choose the level of inference you want for your props:
178
+
179
+ #### Implicit Component, Instance Props
178
180
 
179
181
  ```ruby
180
182
  class AccountMailer < ApplicationMailer
@@ -191,7 +193,7 @@ Action Mailer's framework assigns (including `params` and `rendered_format`) are
191
193
 
192
194
  Without `use_react_instance_props`, `react: true` still infers the component and renders it with no props, which is handy for emails that take no props at all.
193
195
 
194
- ### Implicit Component, Explicit Props
196
+ #### Implicit Component, Explicit Props
195
197
 
196
198
  ```ruby
197
199
  mail(
@@ -204,7 +206,7 @@ mail(
204
206
  )
205
207
  ```
206
208
 
207
- ### Explicit Component, Explicit Props
209
+ #### Explicit Component, Explicit Props
208
210
 
209
211
  ```ruby
210
212
  mail(
@@ -218,7 +220,81 @@ mail(
218
220
  )
219
221
  ```
220
222
 
221
- These forms mirror [inertia-rails](https://inertia-rails.dev), making the two libraries feel consistent when used together.
223
+ ### Shared Props
224
+
225
+ Use `react_email_share` to share data with all of a mailer's emails and subclasses, and it'll be automatically merged with any inline props.
226
+
227
+ ```ruby
228
+ class MarketingMailer < ApplicationMailer
229
+ # Static value
230
+ react_email_share app_name: "Acme"
231
+
232
+ # Lambda value, evaluated lazily in the mailer instance
233
+ react_email_share unread_count: -> { current_user&.unread_count }
234
+
235
+ # Block, evaluated lazily in the mailer instance
236
+ react_email_share do
237
+ { brand: { name: "Acme", url: marketing_url } }
238
+ end
239
+ end
240
+ ```
241
+
242
+ Per-mail props win over shared props of the same name, so a mailer can always override what it inherits:
243
+
244
+ ```ruby
245
+ mail react: { app_name: "Acme Pro" }, ... # overrides the shared app_name
246
+ ```
247
+
248
+ Shared props apply to all three forms above (`react:` hash, `react: "component", props:`, and `react: true`).
249
+
250
+ #### Conditional Sharing
251
+
252
+ Pass any `before_action` filter (`only`, `except`, `if`, `unless`) to scope a share to certain actions:
253
+
254
+ ```ruby
255
+ react_email_share only: [:welcome, :reactivation] do
256
+ { promotion: current_promotion }
257
+ end
258
+
259
+ react_email_share if: :user_signed_in? do
260
+ { user: { name: current_user.name } }
261
+ end
262
+ ```
263
+
264
+ You can also share from inside an action, before calling `mail`:
265
+
266
+ ```ruby
267
+ def welcome
268
+ react_email_share notice: "Thanks for joining!"
269
+ mail react: { account: }, to: account.email, subject: "Welcome"
270
+ end
271
+ ```
272
+
273
+ #### Deep Merging
274
+
275
+ By default shared props are merged shallowly, so a per-mail prop replaces a shared one of the same name outright. Pass `deep_merge: true` to merge nested hashes instead:
276
+
277
+ ```ruby
278
+ react_email_share do
279
+ { settings: { theme: "light", locale: "en" } }
280
+ end
281
+
282
+ # Shallow (default): settings => { theme: "dark" }
283
+ mail react: { settings: { theme: "dark" } }, ...
284
+
285
+ # Deep: settings => { theme: "dark", locale: "en" }
286
+ mail react: { settings: { theme: "dark" } }, deep_merge: true, ...
287
+ ```
288
+
289
+ Set `config.deep_merge_shared_props = true` to make deep merging the default for every email. (See [Configuration](#configuration))
290
+
291
+ ### Prop Serialization
292
+
293
+ Like `render json:`, `mail react:` accepts any object that responds to `as_json`, including hashes, Active Model objects, and serializers such as [Alba](https://github.com/okuramasafumi/alba) or [ActiveModel::Serializer](https://github.com/rails-api/active_model_serializers).
294
+
295
+ ### Prop Transformation
296
+
297
+ Prop keys are camelized by default, so `account.plan_name` arrives as `account.planName`. Override `transform_props` in your [configuration](#configuration).
222
298
 
223
299
  ### Component Names
224
300
 
@@ -241,14 +317,6 @@ mail react: "users/welcome", props: { user: }, to:, subject:
241
317
 
242
318
  Or override `component_path_resolver` globally in your [configuration](#configuration).
243
319
 
244
- ### Prop Serialization
245
-
246
- Like `render json:`, `mail react:` accepts any object that responds to `as_json`, including hashes, Active Model objects, and serializers such as [Alba](https://github.com/okuramasafumi/alba) or [ActiveModel::Serializer](https://github.com/rails-api/active_model_serializers).
247
-
248
- ### Prop Transformation
249
-
250
- Prop keys are camelized by default, so `account.plan_name` arrives as `account.planName`. Override `transform_props` in your [configuration](#configuration).
251
-
252
320
  ### Layouts
253
321
 
254
322
  Action Mailer layouts aren't applied to `react:` emails. React Email treats layouts like any other component, so share structure with normal React composition instead:
@@ -314,6 +382,7 @@ If the defaults don't fit, override them in `config/initializers/react_email_rai
314
382
  | `render_timeout` | `10` seconds |
315
383
  | `render_process_max_requests` | `1_000` |
316
384
  | `on_render_error` | `nil` |
385
+ | `deep_merge_shared_props` | `false` |
317
386
 
318
387
  #### Prop Transformation
319
388
 
@@ -1,6 +1,9 @@
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
+ SHARED_FILTER_OPTIONS = [:if, :unless, :only, :except].freeze
6
+
4
7
  prepended do
5
8
  class_attribute(:react_email_use_instance_props, default: false)
6
9
  end
@@ -9,6 +12,22 @@ module ReactEmailRails::ActionMailer
9
12
  def use_react_instance_props
10
13
  self.react_email_use_instance_props = true
11
14
  end
15
+
16
+ # Share props with every `react:` email this mailer and its subclasses render.
17
+ # Mirrors inertia-rails `inertia_share`; `only`/`except`/`if`/`unless` go to `before_action`.
18
+ def react_email_share(hash = nil, **props, &block)
19
+ options = props.slice(*SHARED_FILTER_OPTIONS)
20
+ data = hash || props.except(*SHARED_FILTER_OPTIONS)
21
+
22
+ before_action(**options) do
23
+ react_email_append_shared(data, block)
24
+ end
25
+ end
26
+ end
27
+
28
+ # Share props from within an action, e.g. conditionally before calling `mail`.
29
+ def react_email_share(hash = nil, **props, &block)
30
+ react_email_append_shared(hash || props, block)
12
31
  end
13
32
 
14
33
  def mail(headers = {}, &block)
@@ -17,8 +36,13 @@ module ReactEmailRails::ActionMailer
17
36
  headers = headers.dup
18
37
  react = headers.delete(:react)
19
38
  props = headers.delete(:props) if headers.key?(:props)
39
+ deep_merge = headers.delete(:deep_merge) if headers.key?(:deep_merge)
20
40
 
21
41
  component, resolved_props = ReactEmailRails::PropsResolver.new(self).resolve(react, props)
42
+ resolved_props = ReactEmailRails::SharedProps.new(self).merge_into(
43
+ resolved_props,
44
+ deep_merge: react_email_deep_merge?(deep_merge),
45
+ )
22
46
  render_options = ReactEmailRails.configuration.resolve_render_options(self)
23
47
  rendered = ReactEmailRails.render(component:, props: resolved_props, render_options:)
24
48
 
@@ -28,4 +52,18 @@ module ReactEmailRails::ActionMailer
28
52
  yield(format) if block
29
53
  end
30
54
  end
55
+
56
+ private
57
+
58
+ def react_email_append_shared(data, block)
59
+ store = (@_react_email_shared ||= [])
60
+ store << data.dup.freeze if data.present?
61
+ store << block if block
62
+ end
63
+
64
+ def react_email_deep_merge?(override)
65
+ return ReactEmailRails.configuration.deep_merge_shared_props if override.nil?
66
+
67
+ override
68
+ end
31
69
  end
@@ -34,6 +34,7 @@ class ReactEmailRails::Configuration
34
34
  :render_options,
35
35
  :transform_props,
36
36
  :on_render_error,
37
+ :deep_merge_shared_props,
37
38
  )
38
39
 
39
40
  attr_reader(
@@ -52,6 +53,7 @@ class ReactEmailRails::Configuration
52
53
  config.render_process_max_requests = DEFAULT_RENDER_PROCESS_MAX_REQUESTS
53
54
  config.transform_props = :lower_camel
54
55
  config.on_render_error = nil
56
+ config.deep_merge_shared_props = false
55
57
  end
56
58
  end
57
59
  end
@@ -0,0 +1,44 @@
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
+ class ReactEmailRails::SharedProps
4
+ IVAR = :@_react_email_shared
5
+
6
+ def initialize(mailer)
7
+ @mailer = mailer
8
+ end
9
+
10
+ # Returns `props` untouched when nothing is shared, so non-Hash inputs (e.g.
11
+ # serializers) still flow straight through to serialization.
12
+ def merge_into(props, deep_merge:)
13
+ shared = to_h
14
+ return props if shared.empty?
15
+
16
+ base = shared.as_json
17
+ incoming = props.as_json
18
+ deep_merge ? base.deep_merge(incoming) : base.merge(incoming)
19
+ end
20
+
21
+ def to_h
22
+ entries.each_with_object({}) do |entry, props|
23
+ props.merge!(resolve(entry))
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader(:mailer)
30
+
31
+ def entries
32
+ mailer.instance_variable_get(IVAR) || []
33
+ end
34
+
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
+ def resolve(entry)
38
+ hash = entry.respond_to?(:call) ? mailer.instance_exec(&entry) : entry
39
+
40
+ (hash || {}).transform_values do |value|
41
+ value.respond_to?(:call) ? mailer.instance_exec(&value) : value
42
+ end
43
+ end
44
+ end
@@ -1,3 +1,3 @@
1
1
  module ReactEmailRails
2
- VERSION = "0.4.1"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -5,6 +5,7 @@ require("active_support/concern")
5
5
  require("active_support/notifications")
6
6
  require("active_support/core_ext/object/blank")
7
7
  require("active_support/core_ext/object/json")
8
+ require("active_support/core_ext/hash/deep_merge")
8
9
  require("active_support/inflector")
9
10
  require("rails/railtie")
10
11
 
@@ -24,6 +25,7 @@ require_relative("react_email_rails/render_modes/persistent/command_runner")
24
25
  require_relative("react_email_rails/configuration")
25
26
  require_relative("react_email_rails/tasks")
26
27
  require_relative("react_email_rails/props_resolver")
28
+ require_relative("react_email_rails/shared_props")
27
29
  require_relative("react_email_rails/railtie")
28
30
 
29
31
  module ReactEmailRails
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.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Supertape
@@ -164,6 +164,7 @@ files:
164
164
  - lib/react_email_rails/render_modes/subprocess/command_runner.rb
165
165
  - lib/react_email_rails/render_protocol.rb
166
166
  - lib/react_email_rails/rendered_email.rb
167
+ - lib/react_email_rails/shared_props.rb
167
168
  - lib/react_email_rails/tasks.rb
168
169
  - lib/react_email_rails/version.rb
169
170
  - lib/tasks/react_email_rails/build.rake