react-email-rails 0.3.0 → 0.4.1

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: 52da00b9618d5b217b66af2598edf2a25fa9d0850447bfc95d8637c778837583
4
- data.tar.gz: 84bcdbd09672b293b85d65bbde9779c0d0d2c2c282dd83e72cc2412d38150c6b
3
+ metadata.gz: 05b64507c82feabb25781eaec8487c5e7282f65060abbac88719bfb2bc79d499
4
+ data.tar.gz: fc1f7fe4d40195537b5218818aa0fb8b72be78834238223dc377c52f7843af15
5
5
  SHA512:
6
- metadata.gz: b9ce2a8643e90776bee1f37c9758fd9d853465d37b73184e0ebdeb20a87e33d9d686354b1a8256cb3bb4887221d25b7c14c9b6733d905c04e86f3fa2d1e06cc7
7
- data.tar.gz: 1257c29d6639ed5a1559d23d64316bea2c6ccf075b365e816dc0bfd41cb996003e466dbabe97bc50aa9b736e2a45e81e083cd908791f91d8d94f13b239e65f67
6
+ metadata.gz: 61afe5bf3fc51e550ca9608bd91deeeb9794cd4a90bac53a7bf0fb48f2f12f5984f50dadbbd245c05e34d8dc4ab7c478f2ff8420aa94e9f57c98e39fb04bab01
7
+ data.tar.gz: 992fd9253d2a2cf31f9ecd37f7bd960c52efddaf74541aefa5ed124ebef4941d9404c00d7f417a6cc2d36f998d3391d8f1bf3ec3d865ec232c43c5a2df2ed4c1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ - `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.
6
+
7
+ ## 0.4.0
8
+
9
+ - `ReactEmailRails.parse` now accepts `markdown:` as an alternative to `html:`. Markdown is converted to HTML and runs through the same extension-driven parse path, producing the same document `Hash` — handy for agent- or tool-generated content. Pass exactly one of `html:`/`markdown:`.
10
+ - Add `marked` as an optional peer dependency, required only when calling `parse` with `markdown:`. HTML parsing and compose-only rendering do not require it.
11
+
3
12
  ## 0.3.0
4
13
 
5
14
  - Add `ReactEmailRails.parse` to convert semantic HTML into a canonical `@react-email/editor` document using a renderer's extensions.
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
- ![React Email + Rails](react-email-rails.png)
1
+ ![react-email-rails](react-email-rails.png)
2
2
 
3
- # React Email + Rails
3
+ # react-email-rails
4
4
 
5
- Build and send emails using React and Rails a seamless integration between [React Email](https://react.email) and [Action Mailer](https://guides.rubyonrails.org/action_mailer_basics.html).
5
+ Build and send emails using React and Rails with [React Email](https://react.email) and [Action Mailer](https://guides.rubyonrails.org/action_mailer_basics.html).
6
6
 
7
7
  ## Contents
8
8
 
@@ -12,7 +12,6 @@ Build and send emails using React and Rails — a seamless integration between [
12
12
  - [Requirements](#requirements)
13
13
  - [Quick Start](#quick-start)
14
14
  - [Usage](#usage)
15
- - [Editor](#editor)
16
15
  - [Configuration](#configuration)
17
16
  - [Deployment](#deployment)
18
17
  - [Development](#development)
@@ -22,15 +21,15 @@ Build and send emails using React and Rails — a seamless integration between [
22
21
 
23
22
  ## Why
24
23
 
25
- Building HTML emails is painfully archaic. [React Email](https://react.email) is a collection of unstyled components for building emails with React, Tailwind, and TypeScript. This gem brings that power directly into your Rails app. Write emails as React components, send them through Action Mailer, and recipients get automatically generated HTML and text emails.
24
+ Building HTML emails is painfully archaic. [React Email](https://react.email) brings React, Tailwind, and TypeScript to email templates. This gem wires it into Action Mailer so React components deliver as generated HTML and text emails.
26
25
 
27
26
  ## How
28
27
 
29
28
  **In development,** the gem renders components live through Vite's dev pipeline, so your emails get the same module resolution and transforms as the rest of your frontend.
30
29
 
31
- **In production,** Rails builds a server-side email bundle during `assets:precompile`. The bundled rake task runs an isolated email-only Vite build, using `reactEmailRails()` in your app's Vite config for discovery and options.
30
+ **In production,** Rails builds a server-side renderer bundle during `assets:precompile` using `reactEmailRails()` in your Vite config for discovery and options.
32
31
 
33
- React Email Rails automatically renders both HTML and plain-text versions from the same component. Delivery, headers, previews, queues, and callbacks all stay normal Action Mailer. If rendering fails, no email is sent and `ReactEmailRails::RenderError` is raised.
32
+ react-email-rails automatically renders both HTML and plain-text versions from the same component. Delivery, headers, previews, queues, and callbacks all stay normal Action Mailer. If rendering fails, no email is sent and `ReactEmailRails::RenderError` is raised.
34
33
 
35
34
  ## Status
36
35
 
@@ -44,7 +43,6 @@ React Email Rails automatically renders both HTML and plain-text versions from t
44
43
  - Vite 7 or 8
45
44
  - React 18 or 19
46
45
  - `@react-email/render` 2.x
47
- - For [rendering editor documents](#editor) (optional): `@react-email/editor` 1.5+ and `@tiptap/core` 3.x
48
46
 
49
47
  > We recommend [rails_vite](https://github.com/skryukov/rails_vite/) for Vite with Rails.
50
48
 
@@ -68,11 +66,11 @@ bin/rails generate react_email_rails:install
68
66
 
69
67
  This creates `config/initializers/react_email_rails.rb`, installs missing JavaScript dependencies when it can detect your package manager, adds `reactEmailRails()` to `vite.config.*`, and creates `app/javascript/emails`.
70
68
 
71
- The installed setup follows the normal Rails lifecycle:
69
+ The installed setup then follows the normal Rails lifecycle:
72
70
 
73
71
  - `bin/rails generate react_email_rails:email ...` creates matching mailers and React components.
74
- - Development renders through Vite on demand.
75
- - `bin/rails assets:precompile` builds the production email bundle automatically.
72
+ - `bin/dev` renders through Vite on demand.
73
+ - `bin/rails assets:precompile` builds the production renderer bundle automatically.
76
74
  - `bin/rails react_email_rails:build` builds the bundle directly when CI or tests need it.
77
75
 
78
76
  ### Manual Install
@@ -104,9 +102,7 @@ Generate a mailer and React Email component:
104
102
  bin/rails generate react_email_rails:email Account welcome
105
103
  ```
106
104
 
107
- The generator follows Rails' mailer generator shape: `NAME [method method]`. It creates `app/mailers/account_mailer.rb`, matching components under your configured React Email directory, plus a mailer preview and test.
108
-
109
- The generator reads `emails.path` and `emails.extension` from `reactEmailRails()` when available. Pass flags to override them:
105
+ The generator follows Rails' mailer generator shape: `NAME [method method]`. It creates the mailer, matching React components, a mailer preview, and a test. It reads `emails.path` and `emails.extension` from `reactEmailRails()` when available. Pass flags to override them:
110
106
 
111
107
  ```sh
112
108
  bin/rails generate react_email_rails:email Account welcome --emails-path=app/emails --extension=jsx
@@ -160,11 +156,11 @@ export default function Welcome({ account }: WelcomeProps) {
160
156
 
161
157
  > [@react-email/components](https://react.email/docs/components/html) provides primitives like `<Button>`, `<Heading>`, `<Tailwind>`, and more.
162
158
 
163
- That's it it now renders and delivers like any other Action Mailer email.
159
+ That's it. It now renders and delivers like any other Action Mailer email.
164
160
 
165
161
  ## Usage
166
162
 
167
- Pass data from your mailers and each top-level key becomes a prop on the React component's default export.
163
+ Pass data from your mailer. Each top-level key becomes a prop on the component's default export.
168
164
 
169
165
  ```ruby
170
166
  mail react: { foo: "bar" }, ...
@@ -233,9 +229,9 @@ Component names are inferred from the mailer and action:
233
229
  | `AccountMailer#welcome` | `account_mailer/welcome` |
234
230
  | `Users::InviteMailer#new_invite` | `users/invite_mailer/new_invite` |
235
231
 
236
- Rails derives `account_mailer` from `AccountMailer` via its `mailer_name`. The default Vite plugin resolves those names under `app/javascript/emails`, so `account_mailer/welcome` maps to `app/javascript/emails/account_mailer/welcome.tsx` or `.jsx`.
232
+ Rails derives `account_mailer` from `AccountMailer` via `mailer_name`. By default, `account_mailer/welcome` resolves to `app/javascript/emails/account_mailer/welcome.tsx` or `.jsx`.
237
233
 
238
- Files and directories starting with `_` are ignored as renderable email entries by default. Use them for shared components such as `_components/email_layout.tsx`; they can still be imported by email components.
234
+ Files and directories starting with `_` are ignored as renderable email entries by default. Use them for shared components such as `_components/email_layout.tsx`. They can still be imported by email components.
239
235
 
240
236
  Override the inferred name per mail:
241
237
 
@@ -247,15 +243,15 @@ Or override `component_path_resolver` globally in your [configuration](#configur
247
243
 
248
244
  ### Prop Serialization
249
245
 
250
- Just like `render json:` in controllers, you can pass any object that responds to `as_json` to `mail react:`. Plain hashes, Active Model objects, and serialization libraries like [Alba](https://github.com/okuramasafumi/alba) or [ActiveModel::Serializer](https://github.com/rails-api/active_model_serializers) are supported.
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).
251
247
 
252
248
  ### Prop Transformation
253
249
 
254
- By default, prop keys are camelized on the way to React, so `account.plan_name` arrives as `account.planName` in your component. This makes them more idiomatic for the frontend, but you can override the `transform_props` behavior in your [configuration](#configuration).
250
+ Prop keys are camelized by default, so `account.plan_name` arrives as `account.planName`. Override `transform_props` in your [configuration](#configuration).
255
251
 
256
252
  ### Layouts
257
253
 
258
- Action Mailer layouts are not applied to `react:` emails. React Email treats layouts like any other component, so share structure with normal React composition instead:
254
+ 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:
259
255
 
260
256
  ```tsx
261
257
  // app/javascript/emails/_components/email_layout.tsx
@@ -295,173 +291,11 @@ export default function Welcome() {
295
291
 
296
292
  See [Component Names](#component-names) for how shared `_` files are handled.
297
293
 
298
- ## Editor
299
-
300
- Alongside named components, the gem can render a [@react-email/editor](https://react.email/docs/editor) document — the Tiptap/ProseMirror JSON a visual editor produces — to HTML and text on the server.
301
-
302
- React Email exposes [composeReactEmail](https://react.email/docs/editor/api-reference/compose-react-email) for this, but only from the browser, with a live editor instance from the export panel. `ReactEmailRails.compose` is the server analog: it rebuilds what `composeReactEmail` needs headlessly (no DOM, no live editor) from the stored document and the extensions you declare, then calls the same function. So `compose` is to the editor what `render` is to a component.
303
-
304
- This is opt-in. The editor packages are optional peer dependencies and stay out of the email render path until you enable it.
305
-
306
- ### Setup
307
-
308
- Install the editor packages:
309
-
310
- ```sh
311
- npm i @react-email/editor @tiptap/core
312
- ```
313
-
314
- To also parse HTML into documents with [`parse`](#parsing-html-into-a-document), add `@tiptap/html` and its server DOM, `happy-dom`:
315
-
316
- ```sh
317
- npm i @tiptap/html happy-dom
318
- ```
319
-
320
- Enable the `documents` option in your Vite config:
294
+ ### Editor
321
295
 
322
- ```ts
323
- // vite.config.ts
296
+ If you're also using [@react-email/editor](https://react.email/docs/editor) to let users compose emails inside your app, `ReactEmailRails.compose` can render those stored documents on the server.
324
297
 
325
- import { defineConfig } from "vite"
326
- import { reactEmailRails } from "react-email-rails"
327
-
328
- export default defineConfig({
329
- plugins: [reactEmailRails({ documents: true })],
330
- })
331
- ```
332
-
333
- `documents: true` enables it with defaults (`app/javascript/documents`, `.ts`/`.tsx` extensions). Like `emails`, it also accepts a directory string or `{ path, extension, ignore }`. See [Plugin Options](#plugin-options).
334
-
335
- ### Document Renderers
336
-
337
- A document doesn't carry the editor configuration it was authored with, so a headless renderer has to be told which extensions a given document needs. Each file under the documents directory is a **document renderer** — the editor-side analog of an email component — and its name is resolved from the directory layout just like [component names](#component-names) (so `broadcast` maps to `app/javascript/documents/broadcast.ts`).
338
-
339
- ```ts
340
- // app/javascript/documents/broadcast.ts
341
-
342
- import { StarterKit } from "@react-email/editor/extensions"
343
- import { EmailTheming } from "@react-email/editor/plugins"
344
-
345
- // Required: the Tiptap extensions the document was authored with.
346
- export function buildExtensions() {
347
- return [StarterKit, EmailTheming]
348
- }
349
- ```
350
-
351
- A renderer can export two optional hooks:
352
-
353
- | Export | Required | Description |
354
- |--------|----------|-------------|
355
- | `buildExtensions(context)` | Yes | Returns the Tiptap extension list for the document. |
356
- | `transformDocument(document, context)` | No | Returns a rewritten document before rendering — for example, to inject header/footer nodes that aren't persisted in the stored document. |
357
- | `getPreview(context)` | No | Returns inbox preview text when the `compose` call doesn't pass one. |
358
-
359
- `context` is the optional data you pass to `compose` (see below). Use it to vary extensions, transforms, or preview text per render.
360
-
361
- ```ts
362
- // app/javascript/documents/broadcast.ts
363
-
364
- import { StarterKit } from "@react-email/editor/extensions"
365
- import { EmailTheming } from "@react-email/editor/plugins"
366
-
367
- export function buildExtensions(context) {
368
- return [StarterKit, EmailTheming]
369
- }
370
-
371
- // Inject a branded header after the persisted theme node, wherever it sits.
372
- export function transformDocument(document, context) {
373
- const header = {
374
- type: "heading",
375
- attrs: { level: 1 },
376
- content: [{ type: "text", text: context.brandName }],
377
- }
378
- // Find globalContent and insert after it, rather than assuming a position, so
379
- // the theme node is preserved.
380
- const themeIndex = document.content.findIndex((node) => node.type === "globalContent")
381
- const at = themeIndex + 1
382
- return {
383
- ...document,
384
- content: [...document.content.slice(0, at), header, ...document.content.slice(at)],
385
- }
386
- }
387
-
388
- export function getPreview(context) {
389
- return context.previewText
390
- }
391
- ```
392
-
393
- > **Match the extensions to the document.** `composeReactEmail` renders any node whose extension isn't registered as `null`, so a document that uses a node your `buildExtensions` omits will silently drop that content. Return the same extension list the document was authored with.
394
-
395
- > **Keep the theme node.** The editor persists its theme in a `globalContent` node and `EmailTheming` reads it back when rendering. If you reshape the document in `transformDocument`, preserve that node.
396
-
397
- ### Composing a Document
398
-
399
- Call `ReactEmailRails.compose` with the renderer `type`, the stored document, and optional `context`/`preview`:
400
-
401
- ```ruby
402
- broadcast = Broadcast.find(params[:id])
403
-
404
- rendered = ReactEmailRails.compose(
405
- type: "broadcast",
406
- document: broadcast.body, # a Hash in Tiptap's shape, e.g. from a jsonb column
407
- context: { brand_name: "Acme", preview_text: broadcast.subject },
408
- preview: broadcast.subject, # optional; falls back to getPreview(context)
409
- )
410
-
411
- rendered.html # => "<!DOCTYPE html>..."
412
- rendered.text # => "ACME\n\n..."
413
- ```
414
-
415
- It returns the same `RenderedEmail` (`html`/`text`) as `render`, runs through the same [render modes](#render-modes), and raises `ReactEmailRails::RenderError` on failure. Documents don't go through Action Mailer — broadcasts and the like usually have their own delivery path — so deliver `rendered.html`/`rendered.text` however your app sends mail.
416
-
417
- **The document is a `Hash`.** You pass and store it as a plain Ruby `Hash` with string keys — what a jsonb column hands back, and what [`parse`](#parsing-html-into-a-document) returns. "Tiptap JSON" names the document's *shape* (and the format it's serialized to over the wire to the renderer), not the Ruby type; `compose` accepts any object that responds to `as_json`, but a `Hash` is the norm.
418
-
419
- **Keys:** the document's keys (`type`, `attrs`, `content`, `marks`, node names, `globalContent`) are structural and **passed through verbatim** — never transformed. Only `context` is key-transformed, camelized exactly like component props (so `brand_name` arrives as `brandName`, per [`transform_props`](#prop-transformation)).
420
-
421
- `render_options` does not apply to documents; `composeReactEmail` controls its own rendering.
422
-
423
- ### Parsing HTML into a Document
424
-
425
- `ReactEmailRails.parse` converts semantic HTML into the same document `Hash` shape the editor stores, using the selected renderer's extensions. This needs the `@tiptap/html` and `happy-dom` packages (see [Setup](#setup)).
426
-
427
- ```ruby
428
- document = ReactEmailRails.parse(
429
- type: "broadcast", # the same renderer type compose uses
430
- html: params[:body_html], # the HTML the caller sent
431
- context: { brand_name: "Acme" }, # optional; reaches buildExtensions, like compose
432
- )
433
-
434
- broadcast.update!(body: document) # persist the Hash (e.g. to a jsonb column)
435
- ```
436
-
437
- Later, render the stored document like any other:
438
-
439
- ```ruby
440
- rendered = ReactEmailRails.compose(type: "broadcast", document: broadcast.body)
441
- ```
442
-
443
- `parse` returns a plain Ruby `Hash` with string keys, normalized through the renderer's schema. It uses the same [render modes](#render-modes) as `compose` and raises `ReactEmailRails::RenderError` on failure. `context` is key-transformed like component props; the HTML is sent verbatim.
444
-
445
- What this means in practice:
446
-
447
- - HTML maps to a node only when an extension defines how to parse it. Unknown elements, inline styles, and classes may be dropped or flattened.
448
- - Editor-only constructs such as custom email nodes and the persisted `globalContent` theme node do not round-trip from plain HTML.
449
- - If you already have the document `Hash`, pass it to `compose` directly.
450
-
451
- ### Debugging Dropped Content
452
-
453
- The most common integration bug is an extension/document mismatch. A node whose type is **missing from `buildExtensions` entirely** raises `ReactEmailRails::RenderError` (it can't be parsed) — loud and safe. The quiet case is a node that *is* in the schema but whose extension does not render to email (a plain Tiptap node rather than an email one): `composeReactEmail` renders it as nothing, with no error.
454
-
455
- `compose` reports those dropped node types so the silent case isn't silent. They appear both on the result as `rendered.warnings` and on the [`render.react-email-rails`](#instrumentation) instrumentation event as `payload[:warnings]` — an array of `{ type:, count: }`. The editor's own non-rendering nodes (the `globalContent` theme node and similar) are excluded, so a non-empty `warnings` means real content was lost. Subscribe to the event to alert on it — or refuse to send:
456
-
457
- ```ruby
458
- ActiveSupport::Notifications.subscribe("render.react-email-rails") do |event|
459
- warnings = event.payload[:warnings]
460
- raise "dropped #{warnings.sum { _1[:count] }} node(s): #{warnings.map { _1[:type] }.join(", ")}" if warnings
461
- end
462
- ```
463
-
464
- If content is missing, confirm `buildExtensions` returns the **same** extensions the document was authored with — the editor's `StarterKit` plus every custom node and plugin in use — and, if you reshape the document in `transformDocument`, that you preserved the `globalContent` theme node. Treat a mismatch as data or version skew: pinning a document's renderer `type` to the extension set it was created with, and versioning that set, avoids drift.
298
+ See [Editor rendering](docs/editor.md) for setup and usage.
465
299
 
466
300
  ## Configuration
467
301
 
@@ -499,17 +333,13 @@ ReactEmailRails.configure do |config|
499
333
  end
500
334
  ```
501
335
 
502
- `transform_props` only controls prop key names; props are always serialized with `as_json`.
336
+ `transform_props` only controls prop key names. Props are always serialized with `as_json`.
503
337
 
504
338
  #### Render Modes
505
339
 
506
- `:subprocess` starts a fresh Node process for each render. It's simple, always uses the latest bundle, and keeps failures isolated, but pays Node startup and bundle load on every render.
507
-
508
- `:persistent` reuses one long-lived Node process per worker. It's faster because it avoids per-render startup, but uses more memory and can serve a stale component until recycled.
509
-
510
- For background email delivery, the default `:subprocess` mode is usually enough. Switch to `:persistent` when Node startup appears in traces or batch jobs render many emails from the same bundle (see [Instrumentation](#instrumentation)).
340
+ `:subprocess` starts a fresh Node process for each render. It's simple, isolated, and always uses the latest bundle, but pays Node startup and bundle load each time.
511
341
 
512
- The render mode also shapes the development experience: `:subprocess` boots a fresh Vite dev server per render and always reflects your latest edits, while `:persistent` reuses the server and may serve a stale component until the process is recycled.
342
+ `:persistent` reuses one long-lived Node process per worker. It's faster for render-heavy workers, but uses more memory and can serve a stale component until recycled. The default `:subprocess` mode is usually enough. Switch when Node startup shows up in traces or batch jobs render many emails from the same bundle.
513
343
 
514
344
  Enable persistent mode for render-heavy worker processes:
515
345
 
@@ -521,13 +351,13 @@ end
521
351
 
522
352
  Persistent mode keeps one Node child per process:
523
353
 
524
- - Renders are sent as newline-delimited JSON and processed one at a time, so a single child never renders concurrently. Scale throughput by adding worker processes.
525
- - It is fork-safe: under clustered Puma or forking job runners, each worker spawns its own child.
354
+ - Renders are newline-delimited JSON and processed one at a time. Scale throughput with more worker processes.
355
+ - It's fork-safe: under clustered Puma or forking job runners, each worker spawns its own child.
526
356
  - The child is recycled after `render_process_max_requests` renders to bound memory growth. Set it to `nil` to disable recycling.
527
357
 
528
358
  #### Render Options
529
359
 
530
- `render_options` is passed to [@react-email/render](https://react.email/docs/utilities/render). `html` options apply to HTML rendering and `text` options apply to plain-text rendering. Keys are camelized before they cross into JavaScript.
360
+ `render_options` is passed to [@react-email/render](https://react.email/docs/utilities/render). Use `html` and `text` keys for each output. Option keys are camelized before they cross into JavaScript.
531
361
 
532
362
  ```ruby
533
363
  ReactEmailRails.configure do |config|
@@ -546,7 +376,7 @@ end
546
376
 
547
377
  #### Error Reporting
548
378
 
549
- Use `on_render_error` to report failures before the exception is re-raised. The callback receives the error and a `context` of `kind:` (`"email"`, `"document"`, or `"parse"`) plus the identifier — `component:` for emails, `type:` for documents and parse requests. Accept `**context` so one handler covers every render kind:
379
+ Use `on_render_error` to report failures before the exception is re-raised. The callback receives the error plus `kind:` and `component:`:
550
380
 
551
381
  ```ruby
552
382
  ReactEmailRails.configure do |config|
@@ -558,7 +388,7 @@ end
558
388
 
559
389
  #### Instrumentation
560
390
 
561
- Every render emits an [ActiveSupport::Notifications](https://guides.rubyonrails.org/active_support_instrumentation.html) event named `render.react-email-rails`, so you can log render timing or forward it to your APM. The payload carries a `kind` (`"email"`, `"document"`, or `"parse"`), the `component` name (email) or `type` (document and parse), and, on a successful render, the rendered HTML size in `html_bytes` (omitted for `parse`, which returns a document rather than HTML). Document renders that drop content also include `warnings` (see [Debugging Dropped Content](#debugging-dropped-content)):
391
+ Every render emits `render.react-email-rails` through [ActiveSupport::Notifications](https://guides.rubyonrails.org/active_support_instrumentation.html). The payload includes `kind`, `component`, and successful HTML size in `html_bytes`:
562
392
 
563
393
  ```ruby
564
394
  ActiveSupport::Notifications.subscribe("render.react-email-rails") do |event|
@@ -568,9 +398,9 @@ end
568
398
 
569
399
  ### Vite Configuration
570
400
 
571
- Most apps only need the `reactEmailRails()` plugin from [Quick Start](#quick-start). The options below change where components are discovered, how the bundle handles dependencies, and which email-only Vite transforms run in the isolated renderer.
401
+ Most apps only need the `reactEmailRails()` plugin from [Quick Start](#quick-start). The options below change component discovery, bundle dependency handling, and isolated renderer transforms.
572
402
 
573
- In development and production, the isolated renderer loads the `reactEmailRails()` plugin, JSX support, and component-facing Vite config such as `resolve`, `define`, `css`, `json`, `assetsInclude`, `esbuild`, and `oxc` but none of your other app plugins. Forwarded config is only for compiling and resolving email components; server, preview, dependency optimization, and build output settings stay owned by React Email Rails.
403
+ In development and production, the isolated renderer loads `reactEmailRails()`, JSX support, and component-facing Vite config such as `resolve`, `define`, `css`, `json`, `assetsInclude`, `esbuild`, and `oxc`. It doesn't load your other app plugins. Server, preview, dependency optimization, and build output settings stay owned by react-email-rails.
574
404
 
575
405
  #### Plugin Options
576
406
 
@@ -579,11 +409,7 @@ In development and production, the isolated renderer loads the `reactEmailRails(
579
409
  | `emails.path` | `"app/javascript/emails"` | Directory containing email components |
580
410
  | `emails.extension` | `[".tsx", ".jsx"]` | Component extension, or an array of extensions |
581
411
  | `emails.ignore` | `["**/_*", "**/_*/**"]` | Glob patterns ignored under `emails.path` |
582
- | `documents` | `false` (off) | Enable [editor document rendering](#editor). `true`, a path string, or `{ path, extension, ignore }` |
583
- | `documents.path` | `"app/javascript/documents"` | Directory containing document renderers |
584
- | `documents.extension` | `[".ts", ".tsx"]` | Renderer extension, or an array of extensions |
585
- | `documents.ignore` | `["**/_*", "**/_*/**"]` | Glob patterns ignored under `documents.path` |
586
- | `standalone` | `true` | Inline production email bundle dependencies |
412
+ | `standalone` | `true` | Inline production renderer bundle dependencies |
587
413
  | `vite` | `{}` | Extra email-only Vite config for compilation and resolution |
588
414
 
589
415
  Use a custom directory:
@@ -608,7 +434,7 @@ Component names come from the Vite directory layout (see [Component Names](#comp
608
434
 
609
435
  #### Advanced: Email-Only Vite Plugins
610
436
 
611
- Most apps do not need extra email plugins. If email components need a transform that is not part of Vite's default pipeline, add that transform to the email renderer:
437
+ Most apps don't need extra email plugins. If email components need a transform that isn't part of Vite's default pipeline, add that transform to the isolated renderer:
612
438
 
613
439
  ```ts
614
440
  import mdx from "@mdx-js/rollup"
@@ -626,11 +452,11 @@ export default defineConfig({
626
452
  })
627
453
  ```
628
454
 
629
- These `vite` options are used by `react-email-rails-dev` and `react-email-rails-build`. They are intentionally scoped to React Email. Only `assetsInclude`, `css`, `define`, `esbuild`, `json`, `oxc`, `plugins`, and `resolve` are accepted here; output settings such as `build.outDir` and `build.rollupOptions` are ignored so the Ruby renderer can always find the generated bundle.
455
+ These `vite` options are used by `react-email-rails-dev` and `react-email-rails-build`. Only `assetsInclude`, `css`, `define`, `esbuild`, `json`, `oxc`, `plugins`, and `resolve` are accepted. Output settings such as `build.outDir` and `build.rollupOptions` are ignored so Ruby can always find the bundle.
630
456
 
631
457
  #### Standalone Builds
632
458
 
633
- By default the production email bundle inlines React, `@react-email/render`, and other Node dependencies. That makes the bundle larger, but it works well for Rails deploys that build assets in one stage and run without `node_modules` in the final runtime image. Development previews keep dependencies external for Vite's module runner, even when `standalone` is enabled.
459
+ By default the production renderer bundle inlines React, `@react-email/render`, and other Node dependencies. This works well for Rails deploys that build assets in one stage and run without `node_modules` in the final image. Development previews keep dependencies external, even when `standalone` is enabled.
634
460
 
635
461
  Set `standalone: false` when your runtime already ships `node_modules` and you prefer a smaller SSR-style bundle:
636
462
 
@@ -650,7 +476,7 @@ For production deploys, run the normal Rails asset task:
650
476
  bin/rails assets:precompile
651
477
  ```
652
478
 
653
- The `react_email_rails:build` task is hooked into `assets:precompile` automatically. It loads your Vite config to find `reactEmailRails()` and its options, then writes `tmp/react-email-rails/emails.js` with the isolated React Email pipeline.
479
+ The `react_email_rails:build` task is hooked into `assets:precompile` automatically. It loads `reactEmailRails()` options from your Vite config, then writes `tmp/react-email-rails/emails.js` with the email component registry. If [Editor document rendering](docs/editor.md) is enabled, the bundle also includes document renderers.
654
480
 
655
481
  You can run it directly when needed:
656
482
 
@@ -660,9 +486,9 @@ bin/rails react_email_rails:build
660
486
 
661
487
  Production rendering runs that bundle with Node. Set `SKIP_REACT_EMAIL_RAILS_BUILD=1` to skip the automatic asset hook. Directly running `bin/rails react_email_rails:build` always attempts the build.
662
488
 
663
- The npm package, Vite, React, and `@react-email/render` must be available when Rails runs `assets:precompile`. This is the same stage where Rails apps normally install JavaScript dependencies and build frontend assets.
489
+ The npm package, Vite, React, and `@react-email/render` must be available when Rails runs `assets:precompile`. If you enable [Editor document rendering](docs/editor.md), its peer dependencies must be available too.
664
490
 
665
- The bundle is required, not an optimization. If it's missing, renders raise `ReactEmailRails::RenderError` and no mail is sent.
491
+ The bundle is required, not an optimization. If it's missing, renders raise `ReactEmailRails::RenderError`. Action Mailer deliveries aren't sent.
666
492
 
667
493
  The Ruby gem and npm package must stay on the same version. The renderer includes a small protocol/version handshake, so mismatched installs fail with an actionable `ReactEmailRails::RenderError` instead of silently returning malformed output.
668
494
 
@@ -676,7 +502,7 @@ To confirm the renderer is ready before relying on it, run:
676
502
  bin/rails react_email_rails:verify
677
503
  ```
678
504
 
679
- It checks that the render command runs and that the npm package version matches the gem, then exits non-zero with an actionable message on failure. Wire it into your CI or release step to catch a missing bundle or version drift before a deploy ships — a renderer failure won't otherwise surface until the first email is sent.
505
+ It checks that the render command runs and that the npm package version matches the gem, then exits non-zero with an actionable message on failure. Wire it into CI or release steps to catch missing bundles or version drift before the first render.
680
506
 
681
507
  For programmatic checks (for example, a health endpoint), `ReactEmailRails.healthy?` returns a boolean. If you specifically want a check at boot, call it from your own initializer and scope it to the processes that send mail so others don't pay the cost:
682
508
 
@@ -2,11 +2,14 @@ require("rails/generators/named_base")
2
2
  require("json")
3
3
  require("open3")
4
4
  require("timeout")
5
+ require_relative("vite_config_files")
5
6
 
6
7
  module ReactEmailRails; end
7
8
  module ReactEmailRails::Generators; end
8
9
 
9
10
  class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
11
+ CONFIG_BIN = "node_modules/.bin/react-email-rails-config"
12
+
10
13
  source_root(File.expand_path("templates/email", __dir__))
11
14
 
12
15
  argument(:actions, type: :array, default: [], banner: "method method")
@@ -80,7 +83,7 @@ class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
80
83
  end
81
84
 
82
85
  def component_base_path
83
- File.join(emails_path, "#{file_path}_mailer")
86
+ File.join(emails_path, mailer_file_path)
84
87
  end
85
88
 
86
89
  def emails_path
@@ -131,47 +134,50 @@ class ReactEmailRails::Generators::EmailGenerator < Rails::Generators::NamedBase
131
134
 
132
135
  def vite_config_command
133
136
  [
134
- "node_modules/.bin/react-email-rails-config",
135
- "node_modules/.bin/react-email-rails-config.cmd",
137
+ CONFIG_BIN,
138
+ "#{CONFIG_BIN}.cmd",
136
139
  ].find { |path| File.exist?(File.join(destination_root, path)) }
137
140
  end
138
141
 
142
+ # The reactEmailRails({ ... }) plugin call, up to the option key being scanned for.
143
+ PLUGIN_OPENING = /reactEmailRails\s*\(\s*\{.*?/m
144
+
139
145
  def emails_path_from_vite_config
140
146
  source = vite_config_source
141
147
  return unless source
142
148
 
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
- ]
149
+ first_capture(
150
+ source,
151
+ /#{PLUGIN_OPENING}emails:\s*["']([^"']+)["']/m,
152
+ /#{PLUGIN_OPENING}emails:\s*\{.*?path:\s*["']([^"']+)["']/m,
153
+ )
150
154
  end
151
155
 
152
156
  def extension_from_vite_config
153
157
  source = vite_config_source
154
158
  return unless source
155
159
 
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
- ]
160
+ first_capture(
161
+ source,
162
+ /#{PLUGIN_OPENING}emails:\s*\{.*?extension:\s*["']([^"']+)["']/m,
163
+ /#{PLUGIN_OPENING}emails:\s*\{.*?extension:\s*\[\s*["']([^"']+)["']/m,
164
+ )
165
+ end
166
+
167
+ # First capture-group match across an ordered list of patterns, or nil.
168
+ def first_capture(source, *patterns)
169
+ patterns.each do |pattern|
170
+ match = source[pattern, 1]
171
+ return match if match
172
+ end
173
+ nil
163
174
  end
164
175
 
165
176
  def vite_config_source
166
177
  @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)) }
178
+ path = ReactEmailRails::Generators::VITE_CONFIG_FILES.find do |candidate|
179
+ File.exist?(File.join(destination_root, candidate))
180
+ end
175
181
 
176
182
  File.read(File.join(destination_root, path)) if path
177
183
  end
@@ -1,4 +1,5 @@
1
1
  require("json")
2
+ require_relative("vite_config_files")
2
3
 
3
4
  module ReactEmailRails; end
4
5
  module ReactEmailRails::Generators; end
@@ -21,14 +22,7 @@ class ReactEmailRails::Generators::InstallGenerator < Rails::Generators::Base
21
22
  }.freeze
22
23
 
23
24
  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
25
+ VITE_CONFIG_FILES = ReactEmailRails::Generators::VITE_CONFIG_FILES
32
26
 
33
27
  VITE_IMPORT = 'import { reactEmailRails } from "react-email-rails"'
34
28
 
@@ -0,0 +1,14 @@
1
+ module ReactEmailRails; end
2
+ module ReactEmailRails::Generators; end
3
+
4
+ module ReactEmailRails::Generators
5
+ # Candidate Vite config filenames in precedence order; shared by both generators.
6
+ VITE_CONFIG_FILES = [
7
+ "vite.config.ts",
8
+ "vite.config.mts",
9
+ "vite.config.js",
10
+ "vite.config.mjs",
11
+ "vite.config.cts",
12
+ "vite.config.cjs",
13
+ ].freeze
14
+ end
@@ -1,6 +1,8 @@
1
1
  class ReactEmailRails::Configuration
2
+ # Must match OUT_DIR/BUNDLE_FILE in vite/src/index.ts (check_version_sync.rb asserts it).
2
3
  BUNDLE_PATH = "tmp/react-email-rails/emails.js"
3
4
  BUILD_BIN = "node_modules/.bin/react-email-rails-build"
5
+ CONFIG_BIN = "node_modules/.bin/react-email-rails-config"
4
6
  DEV_RENDER_BIN = "node_modules/.bin/react-email-rails-dev"
5
7
 
6
8
  DEFAULT_RENDER_TIMEOUT = 10
@@ -33,6 +35,7 @@ class ReactEmailRails::Configuration
33
35
  :transform_props,
34
36
  :on_render_error,
35
37
  )
38
+
36
39
  attr_reader(
37
40
  :render_mode,
38
41
  :render_timeout,
@@ -83,6 +86,8 @@ class ReactEmailRails::Configuration
83
86
  end
84
87
  end
85
88
 
89
+ # A callable render_options is instance_exec'd against `context` (the mailer) when given,
90
+ # so it can use per-mail helpers; otherwise it's called or returned as-is.
86
91
  def resolve_render_options(context = nil)
87
92
  value =
88
93
  if render_options.respond_to?(:call) && context
@@ -93,7 +98,7 @@ class ReactEmailRails::Configuration
93
98
  render_options
94
99
  end
95
100
 
96
- deep_camelize_keys(value.as_json)
101
+ deep_transform_keys(value.as_json, KEY_TRANSFORMS.fetch(:lower_camel))
97
102
  end
98
103
 
99
104
  private
@@ -124,15 +129,4 @@ class ReactEmailRails::Configuration
124
129
  value
125
130
  end
126
131
  end
127
-
128
- def deep_camelize_keys(value)
129
- case value
130
- when Array
131
- value.map { |item| deep_camelize_keys(item) }
132
- when Hash
133
- value.transform_keys { |key| key.to_s.camelize(:lower) }.transform_values { |item| deep_camelize_keys(item) }
134
- else
135
- value
136
- end
137
- end
138
132
  end
@@ -34,8 +34,7 @@ class ReactEmailRails::PropsResolver
34
34
  end
35
35
 
36
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.
37
+ # `react: true` infers the component; instance vars become props only when the mailer opts in.
39
38
  return {} unless mailer.class.react_email_use_instance_props
40
39
 
41
40
  mailer.instance_variables.each_with_object({}) do |ivar, props|
@@ -1,4 +1,7 @@
1
1
  class ReactEmailRails::RenderModes::Persistent::CommandRunner
2
+ # Eager (not lazy) so concurrent first renders can't each create separate Mutexes.
3
+ @mutex = Mutex.new
4
+
2
5
  class << self
3
6
  def capture(command, input:, timeout:, max_requests: nil)
4
7
  server_for(command).capture(input:, timeout:, max_requests:)
@@ -6,13 +9,13 @@ class ReactEmailRails::RenderModes::Persistent::CommandRunner
6
9
 
7
10
  def healthy?(command, timeout:)
8
11
  result = server_for(command).health_check(timeout:)
9
- result.status.success? && ReactEmailRails::RenderProtocol.compatible_response?(JSON.parse(result.stdout))
12
+ ReactEmailRails::RenderProtocol.healthy_result?(result)
10
13
  rescue StandardError
11
14
  false
12
15
  end
13
16
 
14
17
  def stop_all
15
- @mutex&.synchronize do
18
+ @mutex.synchronize do
16
19
  @servers&.each_value(&:stop)
17
20
  @servers&.clear
18
21
  end
@@ -21,18 +24,14 @@ class ReactEmailRails::RenderModes::Persistent::CommandRunner
21
24
  private
22
25
 
23
26
  def server_for(command)
24
- @mutex ||= Mutex.new
25
-
26
27
  @mutex.synchronize do
27
28
  reset_after_fork
28
29
  @servers[command.map(&:to_s)] ||= ReactEmailRails::RenderModes::Persistent::Server.new(command)
29
30
  end
30
31
  end
31
32
 
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.
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.
36
35
  def reset_after_fork
37
36
  return if @owner_pid == Process.pid && @servers
38
37
 
@@ -1,6 +1,7 @@
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.
4
5
  Status = Data.define(:success) do
5
6
  def success? = success
6
7
  end
@@ -15,29 +16,13 @@ class ReactEmailRails::RenderModes::Persistent::Server
15
16
  end
16
17
 
17
18
  def capture(input:, timeout:, max_requests:)
18
- @mutex.synchronize do
19
+ with_retry_on_broken_pipe do
19
20
  capture_once(input:, timeout:).tap { recycle_if_needed(max_requests) }
20
21
  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
22
  end
31
23
 
32
24
  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
25
+ with_retry_on_broken_pipe { health_check_once(timeout:) }
41
26
  end
42
27
 
43
28
  def stop
@@ -49,30 +34,46 @@ class ReactEmailRails::RenderModes::Persistent::Server
49
34
  rescue Errno::ESRCH, Errno::EPERM
50
35
  nil
51
36
  ensure
52
- [@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
53
37
  @stderr_reader&.kill
54
- @stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
55
- @stdout_buffer.clear
38
+ release_io
56
39
  end
57
40
 
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.
41
+ # Release this process's copy of an inherited child's pipes without signalling the parent-owned process.
60
42
  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
43
+ release_io
64
44
  rescue IOError
65
45
  nil
66
46
  end
67
47
 
68
48
  private
69
49
 
50
+ # Shared by stop (which also kills the process) and abandon (which must not).
51
+ def release_io
52
+ [@stdin, @stdout, @stderr].compact.each { |io| io.close unless io.closed? }
53
+ @stdin = @stdout = @stderr = @wait_thread = @stderr_reader = nil
54
+ @stdout_buffer.clear
55
+ end
56
+
57
+ # Run under the mutex; on a broken pipe, stop and retry once (the child respawns).
58
+ def with_retry_on_broken_pipe(&block)
59
+ @mutex.synchronize(&block)
60
+ rescue Errno::EPIPE, IOError
61
+ stop
62
+ begin
63
+ @mutex.synchronize(&block)
64
+ rescue Errno::EPIPE, IOError
65
+ failure("render process exited before responding")
66
+ end
67
+ end
68
+
70
69
  attr_reader(:command)
71
70
 
72
71
  def capture_once(input:, timeout:)
73
72
  response = request(input, timeout:)
74
73
  return failure(response["error"].to_s.presence || "render process failed") unless response["ok"]
75
74
 
75
+ # Re-serialize into the same Result.stdout contract the subprocess produces, so
76
+ # Subprocess#run parses and validates every render uniformly.
76
77
  success(JSON.generate(
77
78
  {
78
79
  protocolVersion: response["protocolVersion"],
@@ -8,20 +8,18 @@ class ReactEmailRails::RenderModes::Persistent < ReactEmailRails::RenderModes::S
8
8
  private
9
9
 
10
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}"))
11
+ with_capture_rescues do
12
+ CommandRunner.capture(
13
+ command,
14
+ input:,
15
+ timeout: render_timeout,
16
+ max_requests: render_process_max_requests,
17
+ )
18
+ end
21
19
  end
22
20
 
23
21
  def render_process_max_requests
24
- ReactEmailRails.configuration.send(:render_process_max_requests)
22
+ ReactEmailRails.configuration.render_process_max_requests
25
23
  end
26
24
  end
27
25
 
@@ -50,7 +50,7 @@ class ReactEmailRails::RenderModes::Subprocess::CommandRunner
50
50
 
51
51
  def terminate_process(signal, pid)
52
52
  Process.kill(signal, -pid)
53
- rescue Errno::ESRCH
53
+ rescue Errno::ESRCH, Errno::EPERM
54
54
  nil
55
55
  end
56
56
  end
@@ -2,16 +2,14 @@ class ReactEmailRails::RenderModes::Subprocess
2
2
  class << self
3
3
  def healthy?(command:, timeout:)
4
4
  result = CommandRunner.capture([*command, "--health"], timeout:)
5
- result.status.success? && ReactEmailRails::RenderProtocol.compatible_response?(JSON.parse(result.stdout))
5
+ ReactEmailRails::RenderProtocol.healthy_result?(result)
6
6
  rescue StandardError
7
7
  false
8
8
  end
9
9
  end
10
10
 
11
- # Payload-agnostic transport: the caller builds and serializes the payload.
12
- # `label` identifies the render in error messages (component name or document type).
13
- # `response` selects how the renderer's reply is interpreted: `:email` builds a
14
- # RenderedEmail (render/compose), `:document` returns the parsed document (parse).
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).
15
13
  def initialize(payload:, label:, response: :email)
16
14
  @payload = payload
17
15
  @label = label
@@ -40,8 +38,15 @@ class ReactEmailRails::RenderModes::Subprocess
40
38
  end
41
39
 
42
40
  def capture(input)
43
- validate_command!
44
- CommandRunner.capture(command, input:, timeout: render_timeout)
41
+ with_capture_rescues do
42
+ validate_command!
43
+ CommandRunner.capture(command, input:, timeout: render_timeout)
44
+ end
45
+ end
46
+
47
+ # Shared by Persistent#capture so the transport-error messages live in one place.
48
+ def with_capture_rescues
49
+ yield
45
50
  rescue Timeout::Error
46
51
  raise(render_error("render process timed out after #{render_timeout}s"))
47
52
  rescue Errno::ENOENT
@@ -1,9 +1,16 @@
1
+ require("json")
2
+
1
3
  module ReactEmailRails
2
4
  RENDER_PROTOCOL_VERSION = 3
3
5
 
4
6
  module RenderProtocol
5
7
  extend(self)
6
8
 
9
+ # Callers keep their own rescue to also cover failures obtaining the result.
10
+ def healthy_result?(result)
11
+ result.status.success? && compatible_response?(JSON.parse(result.stdout))
12
+ end
13
+
7
14
  def compatible_response?(body)
8
15
  body["ok"] == true && compatible_metadata?(body)
9
16
  end
@@ -1,6 +1,5 @@
1
1
  module ReactEmailRails
2
- # `warnings` carries non-fatal renderer warnings (e.g. document nodes dropped
3
- # because no extension rendered them); empty for component renders.
2
+ # `warnings` are non-fatal (document nodes nothing rendered); empty for component renders.
4
3
  RenderedEmail = Data.define(:html, :text, :warnings) do
5
4
  def initialize(html:, text:, warnings: [])
6
5
  super
@@ -1,3 +1,3 @@
1
1
  module ReactEmailRails
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.1"
3
3
  end
@@ -40,16 +40,10 @@ module ReactEmailRails
40
40
  payload = { component:, props: serialized_props(props) }
41
41
  payload[:renderOptions] = render_options if render_options.present?
42
42
 
43
- instrument(kind: "email", component:) do
44
- configuration.resolved_render_mode.new(payload:, label: component).render
45
- end
46
- rescue ReactEmailRails::RenderError => e
47
- configuration.on_render_error&.call(e, kind: "email", component:)
48
- raise
43
+ perform(payload:, label: component, kind: "email", component:)
49
44
  end
50
45
 
51
- # Render an @react-email/editor document (Tiptap JSON) to HTML+text. The document
52
- # is sent verbatim (its keys are structural); only context is key-transformed, like props.
46
+ # The document is sent verbatim (keys are structural); only context is key-transformed, like props.
53
47
  def compose(type:, document:, context: {}, preview: nil)
54
48
  payload = {
55
49
  kind: "document",
@@ -59,29 +53,19 @@ module ReactEmailRails
59
53
  preview:,
60
54
  }
61
55
 
62
- instrument(kind: "document", type:) do
63
- configuration.resolved_render_mode.new(payload:, label: type).render
64
- end
65
- rescue ReactEmailRails::RenderError => e
66
- configuration.on_render_error&.call(e, kind: "document", type:)
67
- raise
56
+ perform(payload:, label: type, kind: "document", type:)
68
57
  end
69
58
 
70
- # Parse HTML into an editor document Hash using the renderer's extensions.
71
- def parse(type:, html:, context: {})
59
+ # Parse semantic HTML or Markdown into an editor document Hash using the renderer's
60
+ # extensions. Pass exactly one of `html:` or `markdown:`.
61
+ def parse(type:, html: nil, markdown: nil, context: {})
72
62
  payload = {
73
63
  kind: "parse",
74
64
  type:,
75
- html: html.to_s,
76
65
  context: serialized_props(context),
77
- }
66
+ }.merge(parse_source(html:, markdown:))
78
67
 
79
- instrument(kind: "parse", type:) do
80
- configuration.resolved_render_mode.new(payload:, label: type, response: :document).render
81
- end
82
- rescue ReactEmailRails::RenderError => e
83
- configuration.on_render_error&.call(e, kind: "parse", type:)
84
- raise
68
+ perform(payload:, label: type, response: :document, kind: "parse", type:)
85
69
  end
86
70
 
87
71
  def healthy?
@@ -95,6 +79,27 @@ module ReactEmailRails
95
79
 
96
80
  private
97
81
 
82
+ def perform(payload:, label:, response: :email, **metadata)
83
+ instrument(**metadata) do
84
+ configuration.resolved_render_mode.new(payload:, label:, response:).render
85
+ end
86
+ rescue ReactEmailRails::RenderError => e
87
+ configuration.on_render_error&.call(e, **metadata)
88
+ raise
89
+ end
90
+
91
+ # Markdown is converted renderer-side, not here; both inputs are sent as-is.
92
+ def parse_source(html:, markdown:)
93
+ if !html.nil? && !markdown.nil?
94
+ raise(ArgumentError, "ReactEmailRails.parse accepts only one of html: or markdown:")
95
+ end
96
+
97
+ return { html: html.to_s } unless html.nil?
98
+ return { markdown: markdown.to_s } unless markdown.nil?
99
+
100
+ raise(ArgumentError, "ReactEmailRails.parse requires html: or markdown:")
101
+ end
102
+
98
103
  def serialized_props(value)
99
104
  configuration.send(:serialize_props, value)
100
105
  end
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.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Supertape
@@ -148,6 +148,7 @@ files:
148
148
  - lib/generators/react_email_rails/templates/email/mailer_test.rb.tt
149
149
  - lib/generators/react_email_rails/templates/initializer.rb
150
150
  - lib/generators/react_email_rails/templates/vite.config.ts
151
+ - lib/generators/react_email_rails/vite_config_files.rb
151
152
  - lib/react-email-rails.rb
152
153
  - lib/react_email_rails.rb
153
154
  - lib/react_email_rails/action_mailer.rb