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_contentstores the structured builder document JSON used to reopen/edit drafts and scheduled campaigns.marketing_campaigns.contentmay store the same JSON for drafts/immediate sends, but scheduled campaigns store rendered HTML there soprocess-scheduled-campaignscan send without rebuilding MJML in Deno. Edit flows must preferbuilder_contentand fall back tocontentfor 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 forall-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
EmailCanvasto eliminate rightward drift during reorder.EmailBuilderdrag 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/useProspectsquery is in flight—same pattern as mailing-list member loading. Displayed counts use `isValidCampaignRecipientEmail` and shared `dedupeDonorsByHousehold` so they match `handleSend`.MarketingCampaignsprefetches donors for the selected entity;useDonorskeeps previous data on entity change to limit count flashes. - Canvas “refresh” / document reset (effect dependencies):
EmailBuilderMJMLPageno longer re-appliesfetchCampaignById→setDocumentwhenever theentitieslist refetches (dependency was clobbering in-progress edits). Campaign JSON loads once percampaignIdwith cancellation on id change;settings.entityIdcan still patch when mapping/entities hydrate via a separate effect. SessionStorage template/campaign handoff runs once per page mount (ref guard +useAppStore.getState()forcampaignId/selectedEntity) soselectedEntitychanges do not clear keys then callresetDocument()for new campaigns.setCampaignIdForDraftsyncs in its own[campaignId]effect. - Columns canvas UX: Nested blocks inside column droppables use
NestedCanvasBlock—selectBlock(id, columnIndex)+stopPropagationso the parent columns block does not steal selection; column flex usesminWidth: 0/alignItems: stretchfor filled columns. Outbound HTML/MJML: column table cells and MJMLmj-column(css-class="email-mj-column-wrap") apply word-breaking;InlineTextEditoraddsoverflow-wrap/word-breakfor long strings. Canvas<img>previews usedraggable={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-0guards 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:
useStaleStateRecoverynow only runs the "back online" recovery path if a realofflineevent 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.tsxnow uses a shared local padding helper and consistent style object references inTextBlockRendererandVideoBlockRenderer. This fixes Safari/runtimeReferenceErrorcrashes (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.entityIdis set before the entity slug ↔ UUID mapping cache finishes initializing,fetchFundraisingLists/fetchUnsubscribedEmailscould 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
handleSendcan actually load. Do not usemarketing_lists.member_countalone for the button label—that number can disagree withfetchFundraisingListMemberswhen 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:
campaignIdis persisted in the global store. Starting a new campaign fromMarketingCampaigns.tsxmust clear it (setCampaignId(null)) and removeemailBuilderCampaignIdfromsessionStorage, orEmailBuilderMJMLPagewill load the previous campaign from the API. - List scope:
fetchCampaignsusesgetActualOrgId(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
localStorageper 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 storecampaignIdis 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.tsxrenders 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 (EmailBuilderMJMLPagePreviewWithProcessedTags,PreviewModal,MarketingCampaignscampaign view,TemplateManager) usesandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"sotarget="_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 usesfadeInBlurStyletransform animations. Also ensureMediaBlockSettings.tsximportsuseAppStorewhenever image/video settings readselectedEntity; missing hook imports can throwReferenceErrorand triggerPageErrorFallbackauto-refresh (is not definedheuristic). - Portal container safety (React #200 guard): When rendering drag overlays with
createPortal, never assumedocument.bodyis always valid at render time.EmailBuilder.tsxnow resolves/stores a verifiedHTMLElementcontainer in an effect and falls back to inline overlay rendering if unavailable, preventingMinified React error #200(createPortaltarget 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
- 01-EMAIL-CAMPAIGNS.md — Email list, Resend, templates, historical builder notes
- 11-TEXT-CAMPAIGNS.md — Text campaigns (separate draft ID model)
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