react-email-rails 0.1.3 → 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: d5d324a8d629ad252aa11946478dd3b610c78920675bb027aedabf8c96c36239
4
- data.tar.gz: eaa690f2caecb96ef322bac6e587bb1dbf2bfe51e9c601a8b1b9dceb4d43d7a8
3
+ metadata.gz: 8de13aced1b8b88a622cdc40577ec17c80ad457cee75ea57266dc248e5355416
4
+ data.tar.gz: d42cacd94f77a32ccf75d72b5e8c19506549902c64617a723c6617e0bc613a58
5
5
  SHA512:
6
- metadata.gz: ca696714d6b95038f96fc579c8040fb9eaf6e98b213ddf42ba7ab92fdb49e5ab8d1dc3bb6b8a095d8fc91573314df6f4ae06136c4af41f3ec9cf66ea2bd22c85
7
- data.tar.gz: 77c513dfda5a19d5296396433d4adb79e198cffcda518311e4fe5f780efb19f532baba3743dd0b0d08928528eece7d74047280f0d3bde34a89a565ab06ddd975
6
+ metadata.gz: 1fbbbb75215e8b4b77de4d4c15e533348e2b7195196d5b9401541596e24507b3c046ca8f74998f20b76f9fbf6d9b67d8c0ec29a86af3aaa64e0b1b70bece4c85
7
+ data.tar.gz: 5e7025b1552256c5d1b2fd01a911c7d941b2fd41aa6cd94ddd646fbeb79713888dd6dc13785ff71c66cadb9362877e2abe4715855630813314e7073f509ccccc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
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
+
3
12
  ## 0.1.3
4
13
 
5
14
  - Remove the `verify_render_on_boot` configuration option, which only logged on failure and duplicated the render-time `ReactEmailRails::RenderError`.
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)
@@ -41,6 +44,7 @@ React Email Rails automatically renders both HTML and plain-text versions from t
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.
@@ -374,19 +510,19 @@ end
374
510
 
375
511
  #### Error Reporting
376
512
 
377
- 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:
378
514
 
379
515
  ```ruby
380
516
  ReactEmailRails.configure do |config|
381
- config.on_render_error = ->(error, component:) {
382
- Rails.error.report(error, context: { component: })
517
+ config.on_render_error = ->(error, **context) {
518
+ Rails.error.report(error, context:)
383
519
  }
384
520
  end
385
521
  ```
386
522
 
387
523
  #### Instrumentation
388
524
 
389
- 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)):
390
526
 
391
527
  ```ruby
392
528
  ActiveSupport::Notifications.subscribe("render.react-email-rails") do |event|
@@ -407,6 +543,10 @@ In development and production, the isolated renderer loads the `reactEmailRails(
407
543
  | `emails.path` | `"app/javascript/emails"` | Directory containing email components |
408
544
  | `emails.extension` | `[".tsx", ".jsx"]` | Component extension, or an array of extensions |
409
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` |
410
550
  | `standalone` | `true` | Inline production email bundle dependencies |
411
551
  | `vite` | `{}` | Extra email-only Vite config for compilation and resolution |
412
552
 
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.
@@ -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
@@ -1,3 +1,3 @@
1
1
  module ReactEmailRails
2
- VERSION = "0.1.3"
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
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.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Supertape