react-email-rails 0.1.3 → 0.3.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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +180 -4
- data/SECURITY.md +4 -0
- data/lib/react_email_rails/render_modes/persistent/server.rb +2 -0
- data/lib/react_email_rails/render_modes/subprocess.rb +40 -21
- data/lib/react_email_rails/render_protocol.rb +1 -1
- data/lib/react_email_rails/rendered_email.rb +7 -1
- data/lib/react_email_rails/version.rb +1 -1
- data/lib/react_email_rails.rb +59 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 52da00b9618d5b217b66af2598edf2a25fa9d0850447bfc95d8637c778837583
|
|
4
|
+
data.tar.gz: 84bcdbd09672b293b85d65bbde9779c0d0d2c2c282dd83e72cc2412d38150c6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9ce2a8643e90776bee1f37c9758fd9d853465d37b73184e0ebdeb20a87e33d9d686354b1a8256cb3bb4887221d25b7c14c9b6733d905c04e86f3fa2d1e06cc7
|
|
7
|
+
data.tar.gz: 1257c29d6639ed5a1559d23d64316bea2c6ccf075b365e816dc0bfd41cb996003e466dbabe97bc50aa9b736e2a45e81e083cd908791f91d8d94f13b239e65f67
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Add `ReactEmailRails.parse` to convert semantic HTML into a canonical `@react-email/editor` document using a renderer's extensions.
|
|
6
|
+
- Add `@tiptap/html` and `happy-dom` as optional peer dependencies, required only when calling `parse`; compose-only document rendering does not require them.
|
|
7
|
+
- Bump the render protocol to 3 (the renderer now accepts parse requests). The Ruby gem and npm package must be upgraded together, as before.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
- Add `ReactEmailRails.compose` for server-side rendering of `@react-email/editor` documents to HTML and text.
|
|
12
|
+
- Add the `documents` Vite plugin option for discovering document renderers, parallel to `emails`.
|
|
13
|
+
- Report document nodes that render to nothing as non-fatal warnings on the result and instrumentation payload.
|
|
14
|
+
- Add `@react-email/editor` and `@tiptap/core` as optional peer dependencies (only required when rendering documents).
|
|
15
|
+
- Bump the render protocol to 2 (the renderer now accepts document requests). The Ruby gem and npm package must be upgraded together, as before.
|
|
16
|
+
- **Breaking:** `on_render_error` callbacks now receive `(error, **context)` with `kind:` plus `component:` for emails or `type:` for documents.
|
|
17
|
+
|
|
3
18
|
## 0.1.3
|
|
4
19
|
|
|
5
20
|
- 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
|
+

|
|
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,174 @@ 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
|
+
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:
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
// vite.config.ts
|
|
324
|
+
|
|
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.
|
|
465
|
+
|
|
294
466
|
## Configuration
|
|
295
467
|
|
|
296
468
|
Configuration is handled primarily on the Rails side, though there are some Vite options to be aware of.
|
|
@@ -374,19 +546,19 @@ end
|
|
|
374
546
|
|
|
375
547
|
#### Error Reporting
|
|
376
548
|
|
|
377
|
-
Use `on_render_error` to report failures before the exception is re-raised:
|
|
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:
|
|
378
550
|
|
|
379
551
|
```ruby
|
|
380
552
|
ReactEmailRails.configure do |config|
|
|
381
|
-
config.on_render_error = ->(error,
|
|
382
|
-
Rails.error.report(error, context:
|
|
553
|
+
config.on_render_error = ->(error, **context) {
|
|
554
|
+
Rails.error.report(error, context:)
|
|
383
555
|
}
|
|
384
556
|
end
|
|
385
557
|
```
|
|
386
558
|
|
|
387
559
|
#### Instrumentation
|
|
388
560
|
|
|
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
|
|
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)):
|
|
390
562
|
|
|
391
563
|
```ruby
|
|
392
564
|
ActiveSupport::Notifications.subscribe("render.react-email-rails") do |event|
|
|
@@ -407,6 +579,10 @@ In development and production, the isolated renderer loads the `reactEmailRails(
|
|
|
407
579
|
| `emails.path` | `"app/javascript/emails"` | Directory containing email components |
|
|
408
580
|
| `emails.extension` | `[".tsx", ".jsx"]` | Component extension, or an array of extensions |
|
|
409
581
|
| `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` |
|
|
410
586
|
| `standalone` | `true` | Inline production email bundle dependencies |
|
|
411
587
|
| `vite` | `{}` | Extra email-only Vite config for compilation and resolution |
|
|
412
588
|
|
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,8 @@ 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")
|
|
84
|
+
body[:document] = response["document"] if response.key?("document")
|
|
83
85
|
end,
|
|
84
86
|
))
|
|
85
87
|
rescue JSON::ParserError => e
|
|
@@ -8,10 +8,14 @@ class ReactEmailRails::RenderModes::Subprocess
|
|
|
8
8
|
end
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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).
|
|
15
|
+
def initialize(payload:, label:, response: :email)
|
|
16
|
+
@payload = payload
|
|
17
|
+
@label = label
|
|
18
|
+
@response = response
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
def render
|
|
@@ -20,15 +24,15 @@ class ReactEmailRails::RenderModes::Subprocess
|
|
|
20
24
|
|
|
21
25
|
private
|
|
22
26
|
|
|
23
|
-
attr_reader(:
|
|
27
|
+
attr_reader(:payload, :label, :response)
|
|
24
28
|
|
|
25
29
|
def run
|
|
26
30
|
result = capture(payload_json)
|
|
27
31
|
raise(render_error(error_message(result.stderr, result.status))) unless result.status.success?
|
|
28
32
|
|
|
29
33
|
body = JSON.parse(result.stdout)
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
validate_metadata!(body)
|
|
35
|
+
build_result(body)
|
|
32
36
|
rescue JSON::ParserError => e
|
|
33
37
|
raise(render_error("render process returned invalid JSON: #{e.message}"))
|
|
34
38
|
rescue KeyError => e
|
|
@@ -52,17 +56,6 @@ class ReactEmailRails::RenderModes::Subprocess
|
|
|
52
56
|
ReactEmailRails.configuration.render_timeout
|
|
53
57
|
end
|
|
54
58
|
|
|
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
59
|
def payload_json
|
|
67
60
|
@payload_json ||= JSON.generate(payload)
|
|
68
61
|
end
|
|
@@ -85,15 +78,41 @@ class ReactEmailRails::RenderModes::Subprocess
|
|
|
85
78
|
raise(render_error("email bundle not found at #{bundle_path.inspect}; run react-email-rails-build before rendering React emails"))
|
|
86
79
|
end
|
|
87
80
|
|
|
88
|
-
def
|
|
89
|
-
|
|
81
|
+
def validate_metadata!(body)
|
|
82
|
+
return if ReactEmailRails::RenderProtocol.compatible_metadata?(body)
|
|
83
|
+
|
|
84
|
+
raise(render_error(ReactEmailRails::RenderProtocol.mismatch_message(body)))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_result(body)
|
|
88
|
+
response == :document ? build_document(body) : build_rendered_email(body)
|
|
89
|
+
end
|
|
90
90
|
|
|
91
|
+
def build_rendered_email(body)
|
|
91
92
|
raise(KeyError.new(key: "html")) unless body.key?("html")
|
|
92
93
|
raise(render_error("render process returned an invalid response: html must be a string")) unless body["html"].is_a?(String)
|
|
93
94
|
raise(render_error("render process returned an invalid response: text must be a string")) if body.key?("text") && !body["text"].is_a?(String)
|
|
95
|
+
|
|
96
|
+
ReactEmailRails::RenderedEmail.new(html: body.fetch("html"), text: body["text"].to_s, warnings: warnings_from(body))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_document(body)
|
|
100
|
+
raise(KeyError.new(key: "document")) unless body.key?("document")
|
|
101
|
+
|
|
102
|
+
document = body.fetch("document")
|
|
103
|
+
raise(render_error("parse process returned an invalid response: document must be an object")) unless document.is_a?(Hash)
|
|
104
|
+
|
|
105
|
+
document
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def warnings_from(body)
|
|
109
|
+
warnings = body["warnings"]
|
|
110
|
+
return [] unless warnings.is_a?(Array)
|
|
111
|
+
|
|
112
|
+
warnings.filter_map { |warning| warning.transform_keys(&:to_sym) if warning.is_a?(Hash) }
|
|
94
113
|
end
|
|
95
114
|
|
|
96
115
|
def render_error(message)
|
|
97
|
-
ReactEmailRails::RenderError.new("React Email render failed for #{
|
|
116
|
+
ReactEmailRails::RenderError.new("React Email render failed for #{label}: #{message}")
|
|
98
117
|
end
|
|
99
118
|
end
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
module ReactEmailRails
|
|
2
|
-
|
|
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
|
data/lib/react_email_rails.rb
CHANGED
|
@@ -37,13 +37,50 @@ module ReactEmailRails
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def render(component:, props:, render_options: configuration.resolve_render_options)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
64
|
+
end
|
|
65
|
+
rescue ReactEmailRails::RenderError => e
|
|
66
|
+
configuration.on_render_error&.call(e, kind: "document", type:)
|
|
67
|
+
raise
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Parse HTML into an editor document Hash using the renderer's extensions.
|
|
71
|
+
def parse(type:, html:, context: {})
|
|
72
|
+
payload = {
|
|
73
|
+
kind: "parse",
|
|
74
|
+
type:,
|
|
75
|
+
html: html.to_s,
|
|
76
|
+
context: serialized_props(context),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
instrument(kind: "parse", type:) do
|
|
80
|
+
configuration.resolved_render_mode.new(payload:, label: type, response: :document).render
|
|
44
81
|
end
|
|
45
82
|
rescue ReactEmailRails::RenderError => e
|
|
46
|
-
configuration.on_render_error&.call(e,
|
|
83
|
+
configuration.on_render_error&.call(e, kind: "parse", type:)
|
|
47
84
|
raise
|
|
48
85
|
end
|
|
49
86
|
|
|
@@ -55,5 +92,22 @@ module ReactEmailRails
|
|
|
55
92
|
rescue StandardError
|
|
56
93
|
false
|
|
57
94
|
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def serialized_props(value)
|
|
99
|
+
configuration.send(:serialize_props, value)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def instrument(**metadata)
|
|
103
|
+
ActiveSupport::Notifications.instrument("render.react-email-rails", **metadata) do |payload|
|
|
104
|
+
yield.tap do |result|
|
|
105
|
+
next unless result.respond_to?(:html)
|
|
106
|
+
|
|
107
|
+
payload[:html_bytes] = result.html.bytesize
|
|
108
|
+
payload[:warnings] = result.warnings if result.warnings.present?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
58
112
|
end
|
|
59
113
|
end
|