Skip to main content

Fundraising — MJML email builder

Fundraising — MJML email builder

Route / tool: /fundraising with Zustand fundraisingTool = email-builder. Primary component: src/features/fundraising/components/EmailBuilderMJML/EmailBuilderMJMLPage.tsx List / navigation entry: MarketingCampaigns.tsx (starts compose, clears IDs for “new campaign”). Hub: 00-FUNDRAISING-HUB.md · Email campaigns overview & changelog: 01-EMAIL-CAMPAIGNS.md

Canonical playbook: documentation/frontend/DEVELOPER-PLAYBOOK.md → Layer 2 → Email campaign builder (MJML).

---

Current flow

  • Campaign flow: Campaign Settings → Recipients → Choose Design → Build Email → Review & Send.
  • Design chooser: After recipients, authors pick From scratch, Basic layout, or Fully designed template. Basic and designed presets are driven by the MJML starter preset registry rather than only STARTER_TEMPLATES.
  • Builder chrome: The builder now uses a left Add / Styles rail, a top actions row (undo/redo, desktop/mobile icon toggle, Preview), and a top rich-text format bar for text/heading blocks. The old right-hand settings rail is gone.
  • Preview: Build can open a Mailchimp-style overlay preview with an Email Info column (To, From, Subject, Preview text). Review remains the canonical step for final summary, send test, schedule, and send-now actions.
  • Images: v1 supports upload, paste image URL, and Design in Canva in the Styles rail. Canva exports populate the image URL fields through the shared CanvaDesignButton; there is no inline Canva embed inside the email canvas.

---

Developer notes (March-April 2026)

  • Campaign persistence contract (Apr 27, 2026): MJML campaigns now persist two content shapes. marketing_campaigns.builder_content stores the structured builder document JSON used to reopen/edit drafts and scheduled campaigns. marketing_campaigns.content may store the same JSON for drafts/immediate sends, but scheduled campaigns store rendered HTML there so process-scheduled-campaigns can send without rebuilding MJML in Deno. Edit flows must prefer builder_content and fall back to content for legacy rows.
  • Pre-send auto-save id handoff: useAutoSaveDraft.flushNow() returns the persisted campaign id. Send Now and Schedule must use that id after a forced flush; otherwise a newly-created draft row and a second sending/scheduled row can be created from the same click.
  • Household dedupe persistence: The Recipients-step household dedupe checkbox is persisted as marketing_campaigns.deduplicate_by_household. Immediate sends and scheduled sends both use the same primary-contact-preferred household algorithm for all-donors.
  • Mailchimp alignment refresh (Apr 8, 2026): The campaign builder now uses a 5-step flow (Campaign Settings → Recipients → Choose Design → Build Email → Review & Send) instead of the older 3-step settings/build/review flow. Build regained Preview as an overlay-only action for Mailchimp parity, but Review remains the canonical step for final send/schedule/test orchestration. EmailBuilderMJMLPage.tsx, EmailBuilder.tsx, EmailPreviewDialog.tsx, starterPresets.ts, EMAILBUILDER-MJML-INLINE-EDITING.md, DEVELOPER-PLAYBOOK.md.
  • Step 2 control cleanup (Mar 26, 2026, superseded in part): The earlier “no Preview on build” rule is no longer authoritative. Keep the intent: Review still owns the high-risk send actions, while Build may open a non-destructive preview overlay.
  • Drag cursor alignment hardening (Mar 26, 2026): Sortable block transforms are clamped to vertical movement in EmailCanvas to eliminate rightward drift during reorder. EmailBuilder drag overlay now applies a pointer-aware modifier so the overlay tracks cursor position more closely across palette drags and canvas reorders.
  • Segment recipients loading + counts (Mar 25, 2026): For All Donors / Volunteers / Prospects, the settings-step recipient summary shows a loading state (not error styling) while the corresponding useDonors / useVolunteers / useProspects query is in flight—same pattern as mailing-list member loading. Displayed counts use `isValidCampaignRecipientEmail` and shared `dedupeDonorsByHousehold` so they match `handleSend`. MarketingCampaigns prefetches donors for the selected entity; useDonors keeps previous data on entity change to limit count flashes.
  • Canvas “refresh” / document reset (effect dependencies): EmailBuilderMJMLPage no longer re-applies fetchCampaignByIdsetDocument whenever the entities list refetches (dependency was clobbering in-progress edits). Campaign JSON loads once per campaignId with cancellation on id change; settings.entityId can still patch when mapping/entities hydrate via a separate effect. SessionStorage template/campaign handoff runs once per page mount (ref guard + useAppStore.getState() for campaignId / selectedEntity) so selectedEntity changes do not clear keys then call resetDocument() for new campaigns. setCampaignIdForDraft syncs in its own [campaignId] effect.
  • Columns canvas UX: Nested blocks inside column droppables use NestedCanvasBlockselectBlock(id, columnIndex) + stopPropagation so the parent columns block does not steal selection; column flex uses minWidth: 0 / alignItems: stretch for filled columns. Outbound HTML/MJML: column table cells and MJML mj-column (css-class="email-mj-column-wrap") apply word-breaking; InlineTextEditor adds overflow-wrap / word-break for long strings. Canvas <img> previews use draggable={false} to avoid fighting @dnd-kit.
  • Recipient context retry (mapping timeout hardening): Recipient context loading (mailing lists + unsubscribes) now retries when entity mapping initialization is delayed, instead of silently bailing after the first timeout. The settings step surfaces a non-blocking status hint while it retries.
  • Event block parity (canvas vs review/send): Event cards now use a shared event fetch context for Review/Send/Schedule HTML generation so selected event details render in outbound email output (not only in the canvas renderer).
  • Event-card custom image upload context: Event-card image uploads now require a resolved org UUID path and no longer fall back to general/.... This avoids storage policy UUID-cast errors (22P02) and surfaces a clear UI error if org context is missing.
  • Styles rail overflow: Long event names in select controls still need truncation / min-w-0 guards inside the left Styles panel so block settings never push the panel off-screen.
  • Placeholder normalization: Placeholder/filler detection for text/heading blocks now uses normalized HTML/plain-text checks across canvas and generated email output to prevent filler text from reappearing inconsistently.
  • False stale refresh guard: useStaleStateRecovery now only runs the "back online" recovery path if a real offline event was observed first. This prevents occasional refresh/step rollback interruptions while moving from Campaign Settings to Build Email in long-lived sessions.
  • Builder-session stale recovery pause: App-level stale-state recovery is explicitly disabled while fundraisingTool === 'email-builder' so global cache/session wake-up refreshes cannot interrupt step navigation while editing an in-progress campaign draft.
  • Canvas runtime guard for style refs: BlockRenderer.tsx now uses a shared local padding helper and consistent style object references in TextBlockRenderer and VideoBlockRenderer. This fixes Safari/runtime ReferenceError crashes (safeStyles / styles) that could drop authors into the "Whoops! We need a refresh" fallback while sending or navigating the builder.
  • Mailing lists + unsubscribes after cold load: When settings.entityId is set before the entity slug ↔ UUID mapping cache finishes initializing, fetchFundraisingLists / fetchUnsubscribedEmails could run too early and return empty. The builder now `await waitForMappingInitialized()` in the effect that loads fundraising lists and unsubscribed emails, with a cancel flag on unmount / entity change. Inactive lists appear in the dropdown as disabled; send/schedule is blocked if an inactive list is selected.
  • Mailing list “Send Now (N)” vs no recipients: The review-step count must match what handleSend can actually load. Do not use marketing_lists.member_count alone for the button label—that number can disagree with fetchFundraisingListMembers when RLS returns fewer rows (e.g. fund user vs parent-scoped list). The builder loads members in an effect, counts only sendable emails (same placeholder/invalid filter as send), sets count to 0 until the fetch finishes, disables Send Now while loading, and shows a specific toast if the fetch fails or the filtered list is empty.
  • New campaign navigation: campaignId is persisted in the global store. Starting a new campaign from MarketingCampaigns.tsx must clear it (setCampaignId(null)) and remove emailBuilderCampaignId from sessionStorage, or EmailBuilderMJMLPage will load the previous campaign from the API.
  • List scope: fetchCampaigns uses getActualOrgId(entityId) so the campaigns tab respects parent org vs fund selection (no cross-fund bleed in the list).
  • Browser drafts: Unsaved MJML content for *new* campaigns is stored per fund scope (resolveDraftEntityScope); changing Organization on step 1 refreshes the “Unsaved draft” banner for that scope.
  • Custom email themes: Saved in localStorage per org (email-builder-custom-themes--{scope}), keyed off the campaign Organization field in the builder step.
  • SMS: Text campaigns use component-local editingCampaignId. “New message” and applying a template from the Templates tab clear it so you don’t overwrite the wrong draft. Persisted store campaignId is email-only and is cleared when switching fundraising tools (see playbook).
  • Button block — canvas vs sent email: The Link URL on a Button block is always included in generated MJML/HTML for sends. On the Build canvas, BlockRenderer.tsx renders a real <a href> (new tab, rel="noopener noreferrer") when the URL is non-empty and not the lone placeholder #, so authors can smoke-test links without sending. Review and other iframe previews (EmailBuilderMJMLPage PreviewWithProcessedTags, PreviewModal, MarketingCampaigns campaign view, TemplateManager) use sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" so target="_blank" button links work in strict browsers (e.g. Safari).
  • Build-step drag + media settings hardening: Palette drag overlay is now rendered via createPortal(..., document.body) so cursor alignment is stable even when the page wrapper uses fadeInBlurStyle transform animations. Also ensure MediaBlockSettings.tsx imports useAppStore whenever image/video settings read selectedEntity; missing hook imports can throw ReferenceError and trigger PageErrorFallback auto-refresh (is not defined heuristic).
  • Portal container safety (React #200 guard): When rendering drag overlays with createPortal, never assume document.body is always valid at render time. EmailBuilder.tsx now resolves/stores a verified HTMLElement container in an effect and falls back to inline overlay rendering if unavailable, preventing Minified React error #200 (createPortal target not a DOM element) during step transitions.
  • Step transition refresh-loop guard: Global stale-bundle recovery (main.tsx) and page-level fallback auto-refresh now only trigger on clear chunk/module-load failures (Failed to fetch dynamically imported module, Loading chunk, Loading CSS chunk, Importing a module script failed, ChunkLoadError). Generic feature runtime errors should surface in ErrorBoundary UI instead of forcing an automatic reload.

Related documentation


Synced from IFMmvp-Frontend documentation: pages/fundraising/12-EMAIL-BUILDER-MJML.md

Ready to Get Started?

See how Alignmint can simplify your nonprofit's operations. Schedule a free demo with our team and we'll walk you through everything.

Questions? Email us at steven@getalignmint.org

Ready to get started?Start Plus Trial