react-email-rails 0.1.2 → 0.2.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: 8f53daab3807503144d3acc647a0d82c164e7c580a01a6c515131eee0f1dbfd9
4
- data.tar.gz: 4e6529c8d47c16201f929c4c3a4486ec2d8bcf5965ac459b0c99b47bd8d88760
3
+ metadata.gz: 8de13aced1b8b88a622cdc40577ec17c80ad457cee75ea57266dc248e5355416
4
+ data.tar.gz: d42cacd94f77a32ccf75d72b5e8c19506549902c64617a723c6617e0bc613a58
5
5
  SHA512:
6
- metadata.gz: 395b4fb684b90dc42d154d2a85138ac4aad707e0789f3e5d980a5fd809049643e9255db6bdb6b255650e71288d6c4a8c2d9e81e3b01841e9649991844fe0a2f1
7
- data.tar.gz: 0360e86df8ed619d6f6ab4294bbbd2c0659bb942d0cb906c68389b17e1541515e1ba1ae5c0f8cb6a8b2ae439d4c87d4d1b649c8e609f8f62893834351c867735
6
+ metadata.gz: 1fbbbb75215e8b4b77de4d4c15e533348e2b7195196d5b9401541596e24507b3c046ca8f74998f20b76f9fbf6d9b67d8c0ec29a86af3aaa64e0b1b70bece4c85
7
+ data.tar.gz: 5e7025b1552256c5d1b2fd01a911c7d941b2fd41aa6cd94ddd646fbeb79713888dd6dc13785ff71c66cadb9362877e2abe4715855630813314e7073f509ccccc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - Add `ReactEmailRails.compose` for server-side rendering of `@react-email/editor` documents (Tiptap/ProseMirror JSON) to HTML and text, the server analog of the editor's client-side `composeReactEmail` export.
6
+ - Add the `documents` Vite plugin option for discovering document renderers, parallel to `emails`. It is off by default; the editor packages stay out of the email render path and build graph unless it is enabled.
7
+ - Report document nodes that render to nothing (a node whose extension is not an email renderer) as non-fatal warnings — on the result (`rendered.warnings`) and the `render.react-email-rails` instrumentation payload (`payload[:warnings]`) — so silently dropped content is detectable.
8
+ - Add `@react-email/editor` and `@tiptap/core` as optional peer dependencies (only required when rendering documents).
9
+ - Bump the render protocol to 2 (the renderer now accepts document requests). The Ruby gem and npm package must be upgraded together, as before.
10
+ - **Breaking:** `on_render_error` callbacks now receive a uniform `(error, **context)` shape, where `context` carries `kind:` (`"email"`/`"document"`) and either `component:` (emails) or `type:` (documents). Update `->(error, component:) { ... }` callbacks to `->(error, **context) { ... }`.
11
+
12
+ ## 0.1.3
13
+
14
+ - Remove the `verify_render_on_boot` configuration option, which only logged on failure and duplicated the render-time `ReactEmailRails::RenderError`.
15
+ - Add the `react_email_rails:verify` rake task that checks the renderer and exits non-zero on failure.
16
+
3
17
  ## 0.1.2
4
18
 
5
19
  - Support Vite 8 hook filters while keeping production email bundles standalone by default.
data/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![React Email + Rails](react-email-rails.png)
2
+
1
3
  # React Email + Rails
2
4
 
3
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).
@@ -10,6 +12,7 @@ Build and send emails using React and Rails — a seamless integration between [
10
12
  - [Requirements](#requirements)
11
13
  - [Quick Start](#quick-start)
12
14
  - [Usage](#usage)
15
+ - [Editor](#editor)
13
16
  - [Configuration](#configuration)
14
17
  - [Deployment](#deployment)
15
18
  - [Development](#development)
@@ -19,7 +22,7 @@ Build and send emails using React and Rails — a seamless integration between [
19
22
 
20
23
  ## Why
21
24
 
22
- 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 and send them through Action Mailer.
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.
23
26
 
24
27
  ## How
25
28
 
@@ -27,7 +30,7 @@ Building HTML emails is painfully archaic. [React Email](https://react.email) is
27
30
 
28
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.
29
32
 
30
- Delivery, headers, multipart parts, previews, queues, and callbacks all stay normal Action Mailer. If rendering fails, no email is sent and `ReactEmailRails::RenderError` is raised.
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.
31
34
 
32
35
  ## Status
33
36
 
@@ -41,6 +44,7 @@ Delivery, headers, multipart parts, previews, queues, and callbacks all stay nor
41
44
  - Vite 7 or 8
42
45
  - React 18 or 19
43
46
  - `@react-email/render` 2.x
47
+ - For [rendering editor documents](#editor) (optional): `@react-email/editor` 1.5+ and `@tiptap/core` 3.x
44
48
 
45
49
  > We recommend [rails_vite](https://github.com/skryukov/rails_vite/) for Vite with Rails.
46
50
 
@@ -291,6 +295,138 @@ export default function Welcome() {
291
295
 
292
296
  See [Component Names](#component-names) for how shared `_` files are handled.
293
297
 
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
+ Enable the `documents` option in your Vite config:
315
+
316
+ ```ts
317
+ // vite.config.ts
318
+
319
+ import { defineConfig } from "vite"
320
+ import { reactEmailRails } from "react-email-rails"
321
+
322
+ export default defineConfig({
323
+ plugins: [reactEmailRails({ documents: true })],
324
+ })
325
+ ```
326
+
327
+ `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).
328
+
329
+ ### Document Renderers
330
+
331
+ 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`).
332
+
333
+ ```ts
334
+ // app/javascript/documents/broadcast.ts
335
+
336
+ import { StarterKit } from "@react-email/editor/extensions"
337
+ import { EmailTheming } from "@react-email/editor/plugins"
338
+
339
+ // Required: the Tiptap extensions the document was authored with.
340
+ export function buildExtensions() {
341
+ return [StarterKit, EmailTheming]
342
+ }
343
+ ```
344
+
345
+ A renderer can export two optional hooks:
346
+
347
+ | Export | Required | Description |
348
+ |--------|----------|-------------|
349
+ | `buildExtensions(context)` | Yes | Returns the Tiptap extension list for the document. |
350
+ | `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. |
351
+ | `getPreview(context)` | No | Returns inbox preview text when the `compose` call doesn't pass one. |
352
+
353
+ `context` is the optional data you pass to `compose` (see below). Use it to vary extensions, transforms, or preview text per render.
354
+
355
+ ```ts
356
+ // app/javascript/documents/broadcast.ts
357
+
358
+ import { StarterKit } from "@react-email/editor/extensions"
359
+ import { EmailTheming } from "@react-email/editor/plugins"
360
+
361
+ export function buildExtensions(context) {
362
+ return [StarterKit, EmailTheming]
363
+ }
364
+
365
+ // Inject a branded header after the persisted theme node, wherever it sits.
366
+ export function transformDocument(document, context) {
367
+ const header = {
368
+ type: "heading",
369
+ attrs: { level: 1 },
370
+ content: [{ type: "text", text: context.brandName }],
371
+ }
372
+ // Find globalContent and insert after it, rather than assuming a position, so
373
+ // the theme node is preserved.
374
+ const themeIndex = document.content.findIndex((node) => node.type === "globalContent")
375
+ const at = themeIndex + 1
376
+ return {
377
+ ...document,
378
+ content: [...document.content.slice(0, at), header, ...document.content.slice(at)],
379
+ }
380
+ }
381
+
382
+ export function getPreview(context) {
383
+ return context.previewText
384
+ }
385
+ ```
386
+
387
+ > **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.
388
+
389
+ > **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.
390
+
391
+ ### Composing a Document
392
+
393
+ Call `ReactEmailRails.compose` with the renderer `type`, the stored document, and optional `context`/`preview`:
394
+
395
+ ```ruby
396
+ broadcast = Broadcast.find(params[:id])
397
+
398
+ rendered = ReactEmailRails.compose(
399
+ type: "broadcast",
400
+ document: broadcast.body, # Tiptap JSON, e.g. a jsonb column
401
+ context: { brand_name: "Acme", preview_text: broadcast.subject },
402
+ preview: broadcast.subject, # optional; falls back to getPreview(context)
403
+ )
404
+
405
+ rendered.html # => "<!DOCTYPE html>..."
406
+ rendered.text # => "ACME\n\n..."
407
+ ```
408
+
409
+ 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.
410
+
411
+ **Keys:** `context` is key-transformed exactly like component props (so `brand_name` arrives as `brandName`, per [`transform_props`](#prop-transformation)). The **`document` is passed through verbatim** — its keys (`type`, `attrs`, `content`, `marks`, node names, `globalContent`) are structural and are never transformed.
412
+
413
+ `render_options` does not apply to documents; `composeReactEmail` controls its own rendering.
414
+
415
+ ### Debugging Dropped Content
416
+
417
+ 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.
418
+
419
+ `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:
420
+
421
+ ```ruby
422
+ ActiveSupport::Notifications.subscribe("render.react-email-rails") do |event|
423
+ warnings = event.payload[:warnings]
424
+ raise "dropped #{warnings.sum { _1[:count] }} node(s): #{warnings.map { _1[:type] }.join(", ")}" if warnings
425
+ end
426
+ ```
427
+
428
+ 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.
429
+
294
430
  ## Configuration
295
431
 
296
432
  Configuration is handled primarily on the Rails side, though there are some Vite options to be aware of.
@@ -308,7 +444,6 @@ If the defaults don't fit, override them in `config/initializers/react_email_rai
308
444
  | `render_timeout` | `10` seconds |
309
445
  | `render_process_max_requests` | `1_000` |
310
446
  | `on_render_error` | `nil` |
311
- | `verify_render_on_boot` | `false` |
312
447
 
313
448
  #### Prop Transformation
314
449
 
@@ -375,24 +510,22 @@ end
375
510
 
376
511
  #### Error Reporting
377
512
 
378
- Use `on_render_error` to report failures before the exception is re-raised:
513
+ Use `on_render_error` to report failures before the exception is re-raised. The callback receives the error and a `context` of `kind:` (`"email"` or `"document"`) plus the identifier — `component:` for emails, `type:` for documents. Accept `**context` so one handler covers both render kinds:
379
514
 
380
515
  ```ruby
381
516
  ReactEmailRails.configure do |config|
382
- config.on_render_error = ->(error, component:) {
383
- Rails.error.report(error, context: { component: })
517
+ config.on_render_error = ->(error, **context) {
518
+ Rails.error.report(error, context:)
384
519
  }
385
520
  end
386
521
  ```
387
522
 
388
523
  #### Instrumentation
389
524
 
390
- 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 the `component` name and, on success, the rendered HTML size in `html_bytes`:
525
+ 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"` or `"document"`), the `component` name (email) or `type` (document), and, on success, the rendered HTML size in `html_bytes`. Document renders that drop content also include `warnings` (see [Debugging Dropped Content](#debugging-dropped-content)):
391
526
 
392
527
  ```ruby
393
528
  ActiveSupport::Notifications.subscribe("render.react-email-rails") do |event|
394
- next if event.payload[:exception]
395
-
396
529
  Rails.logger.info("[react-email-rails] Rendered #{event.payload[:component]} (Duration: #{event.duration.round}ms | Size: #{event.payload[:html_bytes]} bytes)")
397
530
  end
398
531
  ```
@@ -410,6 +543,10 @@ In development and production, the isolated renderer loads the `reactEmailRails(
410
543
  | `emails.path` | `"app/javascript/emails"` | Directory containing email components |
411
544
  | `emails.extension` | `[".tsx", ".jsx"]` | Component extension, or an array of extensions |
412
545
  | `emails.ignore` | `["**/_*", "**/_*/**"]` | Glob patterns ignored under `emails.path` |
546
+ | `documents` | `false` (off) | Enable [editor document rendering](#editor). `true`, a path string, or `{ path, extension, ignore }` |
547
+ | `documents.path` | `"app/javascript/documents"` | Directory containing document renderers |
548
+ | `documents.extension` | `[".ts", ".tsx"]` | Renderer extension, or an array of extensions |
549
+ | `documents.ignore` | `["**/_*", "**/_*/**"]` | Glob patterns ignored under `documents.path` |
413
550
  | `standalone` | `true` | Inline production email bundle dependencies |
414
551
  | `vite` | `{}` | Extra email-only Vite config for compilation and resolution |
415
552
 
@@ -495,18 +632,23 @@ The Ruby gem and npm package must stay on the same version. The renderer include
495
632
 
496
633
  The build command preserves `emails.path`, `emails.extension`, `emails.ignore`, `standalone`, and email-only `vite` options.
497
634
 
498
- ### Runtime Dependencies
635
+ ### Renderer Verification
499
636
 
500
- For runtime dependency tradeoffs, see [Standalone Builds](#standalone-builds).
637
+ To confirm the renderer is ready before relying on it, run:
501
638
 
502
- ### Boot Verification
639
+ ```sh
640
+ bin/rails react_email_rails:verify
641
+ ```
642
+
643
+ 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.
503
644
 
504
- Boot verification is disabled by default. If you want the app to check the renderer during boot, scope it to the same processes that build or ship the bundle:
645
+ 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:
505
646
 
506
647
  ```ruby
507
- ReactEmailRails.configure do |config|
508
- config.render_mode = :persistent if Rails.env.production? && Sidekiq.server?
509
- config.verify_render_on_boot = -> { Rails.env.production? && Sidekiq.server? }
648
+ Rails.application.config.after_initialize do
649
+ if Rails.env.production? && Sidekiq.server? && !ReactEmailRails.healthy?
650
+ Rails.logger.error("[react-email-rails] renderer verification failed")
651
+ end
510
652
  end
511
653
  ```
512
654
 
data/SECURITY.md CHANGED
@@ -3,3 +3,7 @@
3
3
  Please report security issues privately by emailing hi@supertape.com.
4
4
 
5
5
  Do not open a public GitHub issue for suspected vulnerabilities. Include the affected version, a minimal reproduction when possible, and any relevant deployment details.
6
+
7
+ ## Rendering editor documents
8
+
9
+ `ReactEmailRails.compose` renders an `@react-email/editor` document (Tiptap/ProseMirror JSON) server-side. Treat that document as untrusted input: it is typically authored in a visual editor and stored, then rendered later. The renderer only dispatches to the extensions you register for that document type, and React Email escapes rendered output, but URL and asset sanitization inside your custom extensions (links, image sources, button hrefs) remains your application's responsibility. Validate or sanitize those values where you build extensions or transform the document.
@@ -27,14 +27,11 @@ class ReactEmailRails::Configuration
27
27
  end
28
28
  end
29
29
 
30
- DEFAULT_VERIFY_RENDER_ON_BOOT = false
31
-
32
30
  attr_accessor(
33
31
  :component_path_resolver,
34
32
  :render_options,
35
33
  :transform_props,
36
34
  :on_render_error,
37
- :verify_render_on_boot,
38
35
  )
39
36
  attr_reader(
40
37
  :render_mode,
@@ -52,15 +49,10 @@ class ReactEmailRails::Configuration
52
49
  config.render_process_max_requests = DEFAULT_RENDER_PROCESS_MAX_REQUESTS
53
50
  config.transform_props = :lower_camel
54
51
  config.on_render_error = nil
55
- config.verify_render_on_boot = DEFAULT_VERIFY_RENDER_ON_BOOT
56
52
  end
57
53
  end
58
54
  end
59
55
 
60
- def verify_render_on_boot?
61
- verify_render_on_boot.respond_to?(:call) ? !!verify_render_on_boot.call : !!verify_render_on_boot
62
- end
63
-
64
56
  def render_mode=(value)
65
57
  if (value.is_a?(Symbol) || value.is_a?(String)) && !RENDER_MODES.key?(value.to_sym)
66
58
  raise(ArgumentError, "Unknown react-email-rails render mode: #{value.inspect}")
@@ -6,13 +6,4 @@ class ReactEmailRails::Railtie < Rails::Railtie
6
6
  end
7
7
 
8
8
  rake_tasks { load(File.expand_path("../tasks/react_email_rails/build.rake", __dir__)) }
9
-
10
- config.after_initialize do
11
- if ReactEmailRails.configuration.verify_render_on_boot? && !ReactEmailRails.healthy?
12
- Rails.logger.error(
13
- "[react-email-rails] render verification failed for command: " \
14
- "#{ReactEmailRails.configuration.send(:resolved_render_command).inspect}",
15
- )
16
- end
17
- end
18
9
  end
@@ -80,6 +80,7 @@ class ReactEmailRails::RenderModes::Persistent::Server
80
80
  }.tap do |body|
81
81
  body[:html] = response["html"] if response.key?("html")
82
82
  body[:text] = response["text"] if response.key?("text")
83
+ body[:warnings] = response["warnings"] if response.key?("warnings")
83
84
  end,
84
85
  ))
85
86
  rescue JSON::ParserError => e
@@ -8,10 +8,11 @@ class ReactEmailRails::RenderModes::Subprocess
8
8
  end
9
9
  end
10
10
 
11
- def initialize(component:, props:, render_options: {})
12
- @component = component
13
- @props = props
14
- @render_options = render_options
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
+ def initialize(payload:, label:)
14
+ @payload = payload
15
+ @label = label
15
16
  end
16
17
 
17
18
  def render
@@ -20,7 +21,7 @@ class ReactEmailRails::RenderModes::Subprocess
20
21
 
21
22
  private
22
23
 
23
- attr_reader(:component, :props, :render_options)
24
+ attr_reader(:payload, :label)
24
25
 
25
26
  def run
26
27
  result = capture(payload_json)
@@ -28,7 +29,7 @@ class ReactEmailRails::RenderModes::Subprocess
28
29
 
29
30
  body = JSON.parse(result.stdout)
30
31
  validate_response!(body)
31
- ReactEmailRails::RenderedEmail.new(html: body.fetch("html"), text: body["text"].to_s)
32
+ ReactEmailRails::RenderedEmail.new(html: body.fetch("html"), text: body["text"].to_s, warnings: warnings_from(body))
32
33
  rescue JSON::ParserError => e
33
34
  raise(render_error("render process returned invalid JSON: #{e.message}"))
34
35
  rescue KeyError => e
@@ -52,17 +53,6 @@ class ReactEmailRails::RenderModes::Subprocess
52
53
  ReactEmailRails.configuration.render_timeout
53
54
  end
54
55
 
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
56
  def payload_json
67
57
  @payload_json ||= JSON.generate(payload)
68
58
  end
@@ -93,7 +83,14 @@ class ReactEmailRails::RenderModes::Subprocess
93
83
  raise(render_error("render process returned an invalid response: text must be a string")) if body.key?("text") && !body["text"].is_a?(String)
94
84
  end
95
85
 
86
+ def warnings_from(body)
87
+ warnings = body["warnings"]
88
+ return [] unless warnings.is_a?(Array)
89
+
90
+ warnings.filter_map { |warning| warning.transform_keys(&:to_sym) if warning.is_a?(Hash) }
91
+ end
92
+
96
93
  def render_error(message)
97
- ReactEmailRails::RenderError.new("React Email render failed for #{component}: #{message}")
94
+ ReactEmailRails::RenderError.new("React Email render failed for #{label}: #{message}")
98
95
  end
99
96
  end
@@ -1,5 +1,5 @@
1
1
  module ReactEmailRails
2
- RENDER_PROTOCOL_VERSION = 1
2
+ RENDER_PROTOCOL_VERSION = 2
3
3
 
4
4
  module RenderProtocol
5
5
  extend(self)
@@ -1,3 +1,9 @@
1
1
  module ReactEmailRails
2
- RenderedEmail = Data.define(:html, :text)
2
+ # `warnings` carries non-fatal renderer warnings (e.g. document nodes dropped
3
+ # because no extension rendered them); empty for component renders.
4
+ RenderedEmail = Data.define(:html, :text, :warnings) do
5
+ def initialize(html:, text:, warnings: [])
6
+ super
7
+ end
8
+ end
3
9
  end
@@ -14,6 +14,13 @@ module ReactEmailRails::Tasks
14
14
  FileUtils.rm_rf(Rails.root.join(File.dirname(ReactEmailRails::Configuration::BUNDLE_PATH)))
15
15
  end
16
16
 
17
+ def verify
18
+ return if ReactEmailRails.healthy?
19
+
20
+ command = ReactEmailRails.configuration.send(:resolved_render_command)
21
+ raise("react-email-rails renderer verification failed for command: #{command.inspect}")
22
+ end
23
+
17
24
  private
18
25
 
19
26
  def build_command
@@ -1,3 +1,3 @@
1
1
  module ReactEmailRails
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -37,13 +37,33 @@ module ReactEmailRails
37
37
  end
38
38
 
39
39
  def render(component:, props:, render_options: configuration.resolve_render_options)
40
- ActiveSupport::Notifications.instrument("render.react-email-rails", component:) do |payload|
41
- configuration.resolved_render_mode.new(component:, props:, render_options:).render.tap do |rendered|
42
- payload[:html_bytes] = rendered.html.bytesize
43
- end
40
+ payload = { component:, props: serialized_props(props) }
41
+ payload[:renderOptions] = render_options if render_options.present?
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
49
+ end
50
+
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.
53
+ def compose(type:, document:, context: {}, preview: nil)
54
+ payload = {
55
+ kind: "document",
56
+ type:,
57
+ document: document.as_json,
58
+ context: serialized_props(context),
59
+ preview:,
60
+ }
61
+
62
+ instrument(kind: "document", type:) do
63
+ configuration.resolved_render_mode.new(payload:, label: type).render
44
64
  end
45
65
  rescue ReactEmailRails::RenderError => e
46
- configuration.on_render_error&.call(e, component:)
66
+ configuration.on_render_error&.call(e, kind: "document", type:)
47
67
  raise
48
68
  end
49
69
 
@@ -55,5 +75,20 @@ module ReactEmailRails
55
75
  rescue StandardError
56
76
  false
57
77
  end
78
+
79
+ private
80
+
81
+ def serialized_props(value)
82
+ configuration.send(:serialize_props, value)
83
+ end
84
+
85
+ def instrument(**metadata)
86
+ ActiveSupport::Notifications.instrument("render.react-email-rails", **metadata) do |payload|
87
+ yield.tap do |rendered|
88
+ payload[:html_bytes] = rendered.html.bytesize
89
+ payload[:warnings] = rendered.warnings if rendered.warnings.present?
90
+ end
91
+ end
92
+ end
58
93
  end
59
94
  end
@@ -4,6 +4,9 @@ namespace(:react_email_rails) do
4
4
 
5
5
  desc("Remove the React Email Rails production bundle")
6
6
  task(clobber: :environment) { ReactEmailRails::Tasks.clobber }
7
+
8
+ desc("Verify the React Email Rails renderer is healthy (exits non-zero on failure)")
9
+ task(verify: :environment) { ReactEmailRails::Tasks.verify }
7
10
  end
8
11
 
9
12
  unless ENV["SKIP_REACT_EMAIL_RAILS_BUILD"]
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.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Supertape