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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +38 -212
- data/lib/generators/react_email_rails/email_generator.rb +31 -25
- data/lib/generators/react_email_rails/install_generator.rb +2 -8
- data/lib/generators/react_email_rails/vite_config_files.rb +14 -0
- data/lib/react_email_rails/configuration.rb +6 -12
- data/lib/react_email_rails/props_resolver.rb +1 -2
- data/lib/react_email_rails/render_modes/persistent/command_runner.rb +7 -8
- data/lib/react_email_rails/render_modes/persistent/server.rb +27 -26
- data/lib/react_email_rails/render_modes/persistent.rb +9 -11
- data/lib/react_email_rails/render_modes/subprocess/command_runner.rb +1 -1
- data/lib/react_email_rails/render_modes/subprocess.rb +12 -7
- data/lib/react_email_rails/render_protocol.rb +7 -0
- data/lib/react_email_rails/rendered_email.rb +1 -2
- data/lib/react_email_rails/version.rb +1 -1
- data/lib/react_email_rails.rb +29 -24
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05b64507c82feabb25781eaec8487c5e7282f65060abbac88719bfb2bc79d499
|
|
4
|
+
data.tar.gz: fc1f7fe4d40195537b5218818aa0fb8b72be78834238223dc377c52f7843af15
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-

|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# react-email-rails
|
|
4
4
|
|
|
5
|
-
Build and send emails using React and Rails
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
-
|
|
75
|
-
- `bin/rails assets:precompile` builds the production
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
525
|
-
- It
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
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
|
|
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`.
|
|
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
|
|
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
|
|
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`.
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
135
|
-
"
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
33
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
22
|
+
ReactEmailRails.configuration.render_process_max_requests
|
|
25
23
|
end
|
|
26
24
|
end
|
|
27
25
|
|
|
@@ -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
|
-
|
|
5
|
+
ReactEmailRails::RenderProtocol.healthy_result?(result)
|
|
6
6
|
rescue StandardError
|
|
7
7
|
false
|
|
8
8
|
end
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
44
|
-
|
|
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`
|
|
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
|
data/lib/react_email_rails.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
71
|
-
|
|
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
|
-
|
|
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.
|
|
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
|