Skip to content

feat(emails): preview MJML via mailing-api render endpoint (preview-only)#959

Merged
smarcet merged 11 commits into
masterfrom
feature/mjml-preview-via-api
Jun 24, 2026
Merged

feat(emails): preview MJML via mailing-api render endpoint (preview-only)#959
smarcet merged 11 commits into
masterfrom
feature/mjml-preview-via-api

Conversation

@caseylocker

@caseylocker caseylocker commented Jun 1, 2026

Copy link
Copy Markdown

ref: https://app.clickup.com/t/86b9x7m2n

Summary

Makes the email-template editor preview MJML through the mailing-api render endpoint instead of compiling MJML→HTML client-side and sending pre-compiled HTML. For an MJML template the raw mjml_content is now sent with the new mjml render param, so the preview uses the same Jinja → official MJML CLI pipeline as a real send. HTML templates are unchanged (html param).

Preview-only per ClickUp 86b9x7m2n (smarcet's approach): the client-side mjml2html compile, the saved html_content, the save/submit flow, and the mjml-browser dependency are all intentionally kept.

Backend dependency: mailing-api PR #9 (the mjml render param) must be merged + deployed to the env this admin's EMAIL_API_BASE_URL targets. isMjml=false (the default) keeps HTML-mode preview working regardless.

What changed

  • renderEmailTemplate(json, content, isMjml = false) builds the PUT body via a new pure helper buildRenderPayload{payload, mjml} for MJML, {payload, html} for HTML. Backward-compatible (sole caller's 2-arg call still sends html).
  • Hardened renderErrorHandler via normalizeRenderErrors: normalizes the API's error-body shapes (412 string-array, 500 bare string, object, network/no-response) to a string[], and guards a missing err.response. This also fixes a latent crash where a non-array error body was rendered directly as a React child.
  • Two mode-aware preview effects keyed on mjmlEditor (MJML sends mjml_content, HTML sends html_content); a shared 500ms debounce coalesces so exactly one request fires per mode.

Correctness fixes from review (race/staleness)

  • Mode staleness on in-place template navigation: the mode-init effect now depends on entity.id so mjmlEditor recomputes when a different template loads into the same (un-remounted) form instance. (Editable identifier deliberately excluded to avoid a rename+save resetting a manual mode toggle.)
  • Out-of-order render responses: a monotonic requestId is threaded through the request/success/error actions and the reducer ignores any response whose id isn't the latest (latestRenderId), so a slow older request can't overwrite a newer preview. Guard is skipped when no id is present (backward-compatible).

Testing

  • Full suite: 646 tests / 74 suites passing.
  • New tests: buildRenderPayload, normalizeRenderErrors (incl. multi-key/string-value bodies), mount-time mode dispatch, mode-toggle re-fire, in-place mode re-init, and a new reducer test file covering the out-of-order guard (incl. that a stale response preserves a prior fresh preview/error).
  • Changed files lint with 0 errors.

Reviewer note

Two files (email-template-form.js, email-template-reducer.js) carry incidental fixes to pre-existing eslint errors (magic numbers, nested ternary, lone-case-blocks, var-in-case, unused vars, jsx-a11y) that the husky eslint --fix pre-commit gate blocks on. These are strictly behavior-preserving refactors of code this PR had to touch — the alternative was a --no-verify bypass of the lint gate.

Not yet done

  • Manual in-browser verification against the deployed mailing-api (confirm the PUT body carries raw mjml, Jinja resolves inside MJML, preview matches a real send, malformed MJML surfaces an error, one render per mode-toggle).

ClickUp: 86b9x7m2n

🤖 Generated with Claude Code

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features
    • Added support for MJML email templates with distinct MJML and HTML preview rendering modes.
    • Normalized render/validation error formats to improve consistency.
  • Bug Fixes
    • Prevented stale out-of-order render/validation responses from overwriting the latest preview using request sequencing.
    • Reset preview, validation errors, and render state when switching templates/entities.
  • Tests
    • Added/expanded Jest coverage for payload selection, error normalization, reducer sequencing, and preview re-initialization.
  • Style
    • Improved external link security attributes (rel="noreferrer").

caseylocker and others added 9 commits June 1, 2026 10:02
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the single html_content preview effect with two mode-guarded
effects: MJML templates send raw mjml_content with isMjml=true so the
mailing-api runs Jinja → official MJML CLI (production pipeline); HTML
templates send html_content with isMjml=false. Updates debouncedRender-
Template ref to accept and forward the isMjml flag.

Also fixes pre-existing lint errors in the same file (import order,
magic numbers, unused vars, no-unused-expressions, iframe title) that
were blocking the pre-commit hook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add entity.id + entity.identifier to the mode-init effect deps so mjmlEditor
recomputes when a different template loads into the same form instance without
remounting, fixing stale MJML/HTML mode on in-place navigation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…equest id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1655b113-076f-4dec-ba27-8116f26b033f

📥 Commits

Reviewing files that changed from the base of the PR and between dc26674 and c120324.

📒 Files selected for processing (7)
  • src/actions/__tests__/email-actions.test.js
  • src/actions/email-actions.js
  • src/actions/media-file-type-actions.js
  • src/actions/sponsorship-actions.js
  • src/pages/media_file_types/media-file-type-list-page.js
  • src/reducers/emails/__tests__/email-template-reducer.test.js
  • src/reducers/emails/email-template-reducer.js
✅ Files skipped from review due to trivial changes (3)
  • src/actions/media-file-type-actions.js
  • src/pages/media_file_types/media-file-type-list-page.js
  • src/actions/sponsorship-actions.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/reducers/emails/email-template-reducer.js
  • src/actions/email-actions.js

📝 Walkthrough

Walkthrough

Email template rendering now tags preview requests with IDs so stale render and validation results are ignored. The form sends MJML and HTML previews through separate paths, and the reducer resets render state when the template session changes.

Changes

Email template rendering with request sequencing

Layer / File(s) Summary
Email action helpers and thunk
src/actions/email-actions.js, src/actions/__tests__/email-actions.test.js
buildRenderPayload selects MJML or HTML request fields from isMjml. normalizeRenderErrors converts varied error-body shapes into string arrays. renderEmailTemplate(json, content, isMjml = false) generates a requestId, dispatches TEMPLATE_RENDER_RECEIVED with { response, requestId }, and dispatches VALIDATE_RENDER with normalized errors and the same requestId.
Reducer stale response filtering
src/reducers/emails/email-template-reducer.js, src/reducers/emails/__tests__/email-template-reducer.test.js
emailTemplateReducer adds render_errors and latestRenderId to state, normalizes null entity fields to empty strings, records the latest render request ID, and ignores render or validation results whose requestId does not match the current one. Template/session reset actions clear preview and render state.
EmailTemplateForm preview routing
src/components/forms/email-template-form.js, src/components/forms/__tests__/email-template-form.test.js
EmailTemplateForm initializes MJML mode from template content, splits preview rendering into separate MJML and HTML debounced effects, and passes an explicit isMjml flag to renderEmailTemplate. Editor change handlers, tab switching, preview sizing, and iframe attributes were updated alongside the preview flow.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • smarcet

Poem

A bunny hopped through preview mist,
With request IDs the old calls missed.
MJML and HTML took their own trail,
And stale little ghosts no longer derail. 🐰

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: MJML email previews now render through the mailing-api endpoint in preview-only mode.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/mjml-preview-via-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@caseylocker caseylocker self-assigned this Jun 1, 2026
@caseylocker caseylocker requested a review from smarcet June 1, 2026 16:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/reducers/emails/email-template-reducer.js (1)

82-112: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset render sequencing state when the edited template changes.

These branches keep latestRenderId, templateLoading, and render_errors from the previous editor session. If template A has request 5 in flight and we navigate/reset before template B dispatches its first REQUEST_TEMPLATE_RENDER, A's late TEMPLATE_RENDER_RECEIVED/VALIDATE_RENDER with requestId: 5 still matches state.latestRenderId and can repopulate the new form with stale preview/error state.

🩹 Suggested fix
     case SET_CURRENT_SUMMIT:
     case RESET_TEMPLATE_FORM:
-      return { ...state, entity: { ...DEFAULT_ENTITY }, errors: {} };
+      return {
+        ...state,
+        entity: { ...DEFAULT_ENTITY },
+        errors: {},
+        preview: null,
+        render_errors: [],
+        templateLoading: false,
+        latestRenderId: 0
+      };

     case RECEIVE_TEMPLATE: {
       const entity = normalizeEntityFields({ ...payload.response });
       return {
         ...state,
@@
           ...entity,
           original_mjml_content: entity.mjml_content,
           original_html_content: entity.html_content
         },
-        preview: null
+        preview: null,
+        render_errors: [],
+        templateLoading: false,
+        latestRenderId: 0
       };
     }

     case TEMPLATE_ADDED:
     case TEMPLATE_UPDATED: {
       const entity = normalizeEntityFields({ ...payload.response });
       return {
         ...state,
         entity: {
           ...DEFAULT_ENTITY,
           ...entity,
           original_mjml_content: entity.mjml_content,
           original_html_content: entity.html_content
-        }
+        },
+        render_errors: [],
+        templateLoading: false,
+        latestRenderId: 0
       };
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/reducers/emails/email-template-reducer.js` around lines 82 - 112, The
reducer currently preserves render sequencing state (latestRenderId,
templateLoading, render_errors, preview) across template changes, allowing stale
render responses to affect a new template; update the branches that set a new
entity (RECEIVE_TEMPLATE, TEMPLATE_ADDED, TEMPLATE_UPDATED and the
RESET_TEMPLATE_FORM/SET_CURRENT_SUMMIT branch) to also reset the render state by
setting latestRenderId to null, templateLoading to false, render_errors to {}
(or null) and preview to null so any in-flight responses for the previous
template cannot match and overwrite the new editor state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/reducers/emails/email-template-reducer.js`:
- Around line 82-112: The reducer currently preserves render sequencing state
(latestRenderId, templateLoading, render_errors, preview) across template
changes, allowing stale render responses to affect a new template; update the
branches that set a new entity (RECEIVE_TEMPLATE, TEMPLATE_ADDED,
TEMPLATE_UPDATED and the RESET_TEMPLATE_FORM/SET_CURRENT_SUMMIT branch) to also
reset the render state by setting latestRenderId to null, templateLoading to
false, render_errors to {} (or null) and preview to null so any in-flight
responses for the previous template cannot match and overwrite the new editor
state.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4b65681d-6cfc-4f81-bf4f-0dd3290e23a5

📥 Commits

Reviewing files that changed from the base of the PR and between 9c58408 and dc26674.

📒 Files selected for processing (6)
  • src/actions/__tests__/email-actions.test.js
  • src/actions/email-actions.js
  • src/components/forms/__tests__/email-template-form.test.js
  • src/components/forms/email-template-form.js
  • src/reducers/emails/__tests__/email-template-reducer.test.js
  • src/reducers/emails/email-template-reducer.js

Closes the stale-render race flagged by CodeRabbit on PR #959: entity-change
reducer branches (SET_CURRENT_SUMMIT/RESET_TEMPLATE_FORM, RECEIVE_TEMPLATE,
TEMPLATE_ADDED/TEMPLATE_UPDATED) now clear preview, render_errors,
templateLoading and latestRenderId so in-flight responses from a previous
template can never repopulate the new editor's state.

Adds 7 reducer tests: per-branch reset coverage, the cross-template race
reproduction, and a characterization test of the navigation window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@caseylocker

Copy link
Copy Markdown
Author

@coderabbitai Applied in 6ad4043.

All three entity-change branch groups (SET_CURRENT_SUMMIT/RESET_TEMPLATE_FORM, RECEIVE_TEMPLATE, TEMPLATE_ADDED/TEMPLATE_UPDATED) now reset the render sequencing state — preview: null, render_errors: [], templateLoading: false, latestRenderId: 0 — so an in-flight response from a previous editor session can never match the guard and repopulate the new template's state.

One deviation from the suggested AI-agent prompt: we kept the field values from your suggested diff (render_errors: [], latestRenderId: 0) rather than the prompt's null/{}. render_errors is consumed as an array (renderErrors.length / .map in email-template-form.js) and latestRenderId is compared numerically, so the diff's values are the type-correct ones.

Also added 7 reducer tests: per-branch reset coverage, a reproduction of the cross-template race (late success and late error responses ignored after RECEIVE_TEMPLATE), and a characterization test of the navigation window (a late response arriving before RECEIVE_TEMPLATE is accepted while the previous entity is still displayed — a consistent pairing — then wiped when the new template loads). Full suite: 653/653.

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@caseylocker That's a thorough implementation. The deviation is well-reasoned — render_errors: [] and latestRenderId: 0 are indeed the type-correct values given how each field is consumed downstream, and they're better defaults than the prompt's null/{}.

The navigation-window characterization test is a particularly nice addition: explicitly documenting that a late response arriving before RECEIVE_TEMPLATE is intentionally accepted (consistent pairing with the previous entity still in view) and then wiped on load is exactly the kind of subtle temporal contract that's easy to break silently in future refactors. Having it as a named test makes the invariant self-documenting.

653/653 —

🐇 ✨

@smarcet smarcet left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@smarcet

smarcet commented Jun 24, 2026

Copy link
Copy Markdown

@caseylocker please lets fix merge conflicts and we are good to merge many thanks

…w-via-api

# Conflicts:
#	src/actions/__tests__/email-actions.test.js
@caseylocker

Copy link
Copy Markdown
Author

@smarcet merge conflicts resolved and pushed (c120324b).

The only conflict was an add/add in src/actions/__tests__/email-actions.test.js — both branches added that file with disjoint suites (buildRenderPayload/normalizeRenderErrors here vs saveEmailTemplate/getEmailTemplates/deleteEmailTemplate on master). Kept both suites in one file; email-actions.js auto-merged cleanly. All email test suites green (34 tests). PR now shows mergeable — good to go.

@caseylocker

Copy link
Copy Markdown
Author

@smarcet CI is now green and GitHub reports the PR mergeable / CLEAN — all set on your end to merge whenever. Thanks!

@smarcet smarcet merged commit d3f018b into master Jun 24, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants