Joy Notification Email V2 · Architecture Reference

Email Block Editor — Architecture Reference

Một cây block JSON → một renderTree → MJML → HTML email. Cùng một render path chạy cho cả preview lẫn gửi thật. Tài liệu này giải phẫu các quyết định kiến trúc của package email-editor và lý do đằng sau chúng.

Tổng quan & mental model

Đọc trang này theo thứ tự. Mỗi mục giả định mục trước. Mục cuối (cách tư duy) là phần đúc rút quan trọng nhất.

Mental model một câu

Người dùng kéo-thả block → một cây JSON do hệ thống sở hữu → một hàm renderTree duy nhất → MJML → HTML email. Cùng một cây, cùng một render path, chạy cho cả preview lẫn gửi thật.

Đây là bất biến trung tâm. Mọi quyết định kiến trúc còn lại trong tài liệu này tồn tại để bảo vệ câu trên khỏi rạn nứt.

Sơ đồ tổng

flowchart TB
  UI["ui: Canvas / Tree / Inspector"]
  Store["state: EditorStore (dispatch + undo)"]
  Doc[("EmailDoc — block-tree JSON (source of truth)")]
  RT["render-mjml: renderTree — preview & send"]
  AST["MJML JSON-AST"]
  HTML["HTML email"]
  UI -- "1. dispatch(action)" --> Store
  Store -- "2. treeOps (immutable)" --> Doc
  Doc -. "3. renderEditor → canvas (React)" .-> UI
  Doc == "4. renderTree" ==> RT
  RT -- "5. block.toMjml" --> AST
  AST -- "6. mjml2html" --> HTML
  MT["merge-tags: preview=sample / send=keep {{tag}} raw"] -. "inject" .-> RT
  PA["persistence: load / save / migrate"] -. " " .- Doc
    

Vòng EDIT (bước 1–3): mọi thay đổi qua dispatch → cây mới → canvas vẽ lại. Vòng RENDER (bước 4–6): cùng cây đó → MJML → HTML, dùng chung cho preview lẫn send.

1Bài toán & mental model

V1 đau ở đâu (vì sao cần V2)

Email thông báo loyalty (points earned, tier achieved, birthday reward…) ở V1 là một form cứng: merchant điền vài ô (subject, heading, nội dung HTML thô qua editor, link button…), cộng một map visibleBlocks bật/tắt vài vùng cố định. Hai vấn đề cốt tử:

1. Không linh hoạt. Bố cục cố định — merchant không thể thêm block, đổi thứ tự, chèn ảnh hero, hay dựng 2 cột. Muốn khác đi phải sửa code template.
2. Preview ≠ email gửi đi. V1 dựng preview bằng một đường, gửi mail bằng một đường khác → hai đường trôi (drift) khỏi nhau theo thời gian. Cái merchant thấy lúc soạn không chắc giống cái khách nhận. Đây là class lỗi kinh điển của email tooling.
↔ Backend Tương tự khi tồn tại hai code path tính cùng một con số (một path cho API response, một cho báo cáo) — sớm muộn chúng lệch nhau. Lời giải luôn là gộp về một đường.

V2 giải gì

Thay form bằng một visual block editor 3 pane: kéo-thả block (Heading, Text, Image, Button, Loyalty card, Product, Coupon…) vào canvas, chỉnh thuộc tính ở panel bên phải, xem preview giống hệt email thật. Cùng một "tài liệu" đó render ra HTML email an toàn cho mọi client (Gmail/Outlook) qua MJML.

Mental model: 4 danh từ + 1 động từ

Cả feature gói gọn trong một chuỗi:

EmailDoc (cây block JSON)  --renderTree-->  MJML JSON-AST  --mjml2html-->  HTML email
  • EmailDoctài liệu email: một cây block, cộng subject/preheader theo locale, theme mặc định. Đây là thứ hệ thống sở hữu và lưu trữ (xem mục 4).
    interface EmailDoc {
      schemaVersion: 1 | 2;
      notificationType?: string;      // 'tier-achieved' | 'points-earned' | ...
      theme?: string;                 // theme mặc định doc-level
      subjectByLocale: LocalizedText; // { en: '...', vi: '...' }
      preheaderByLocale?: LocalizedText;
      root: BlockNode[];              // cây block
    }
  • BlockNode — một block trong cây, tự mô tả: type, props (style/cấu hình dùng chung mọi locale), text (nội dung theo locale), children (chỉ container).
    interface BlockNode {
      id: string;        // per-instance: scoped binding + sẵn sàng collab
      type: string;      // key tra trong BlockRegistry
      version?: number;  // version schema từng block → migrate được
      props: Record<string, unknown>;
      text?: LocalizedText;
      children?: BlockNode[];
    }
  • renderTreeđộng từ trung tâm: đi từ cây → MJML → HTML. Chỉ một hàm này, dùng cho cả preview lẫn gửi (mục 5).
  • MJML — ngôn ngữ trung gian: viết mj-section / mj-column / mj-button (tư duy như component), mjml2html() biên dịch ra HTML table-based + inline CSS — thứ duy nhất Outlook/Gmail render ổn. Hệ thống không tự sinh HTML email bằng tay.

Hai vòng lặp, một cây ở giữa

Có đúng hai vòng quay quanh cùng một EmailDoc (xem sơ đồ tổng):

  • Vòng EDIT (trình duyệt): UI gọi dispatch(action) → cây mới (immutable) → canvas vẽ lại bằng block.renderEditor (React). Xem mục 8.
  • Vòng RENDER (preview + send): renderTree(doc)block.toMjml → MJML → HTML.

Mỗi block vì thế có hai mặt: renderEditor (vẽ canvas, React) và toMjml (sinh email, không React). Việc tách hai mặt này là một quyết định lớn — mục 6.

2Kiến trúc phân lớp & deps một chiều

Vì sao không nhét hết vào một file?

Một email editor "làm nhanh" thường là một component React khổng lồ: state, drag-drop, sinh HTML, gọi API lưu — tất cả trộn lẫn. Nó chạy, nhưng không đổi được: muốn thay engine render, hay tách phần gửi mail ra Node, là phải mổ xẻ cả khối. Package email-editor đi hướng ngược lại — chẻ thành các layer trong src/, mỗi layer một trách nhiệm:

Layer (src/…)Trách nhiệm một câu
contracts.tsBản hợp đồng — mọi type dùng chung. Không có runtime dep.
model/treeOps — phép toán cây immutable (insert/remove/move/update/validate).
state/EditorStore — một cửa dispatch(action) + undo/redo.
blocks/Định nghĩa 17 block: mỗi block = fields + renderEditor + toMjml.
render-mjml/renderTree — cây → MJML → HTML. Một đường cho preview + send.
merge-tags/Resolver biến {{tag}} thành sample (preview) hoặc giữ raw (send).
inspector/Tự sinh panel thuộc tính từ fields của block.
text/Rich-text (Tiptap) cho block Text, theo locale.
dnd/Drag-drop context (dnd-kit) + collision.
ui/Vỏ editor 3-pane (EmailEditor) + canvas, tree, palette, preview.
persistence/load/save + migrate doc-version + migrate V1→V2.
theme-resolve.ts, active-target.tsNeutral leaf — tiện ích không phụ thuộc layer nào.
↔ Backend Đây chính là tinh thần package-by-layer / hexagonal. model ≈ domain core thuần; render-mjml ≈ một adapter (cổng ra MJML); persistence ≈ repository; ui ≈ controller/view.

Bất biến: deps một chiều CỐT LÕI

Đây là luật xương sống. Mũi tên phụ thuộc chỉ chảy về phía model/contracts, không bao giờ ngược:

flowchart RL
  contracts["contracts.ts (types-only)"]
  model["model"]
  state["state"]
  blocks["blocks"]
  render["render-mjml"]
  persist["persistence"]
  inspector["inspector"]
  ui["ui"]
  model --> contracts
  state --> model
  blocks --> model
  render --> model
  persist --> model
  inspector --> contracts
  ui --> state
  ui --> blocks
  state --> contracts
  blocks --> contracts
  render --> contracts
    
  • model không import gì từ các layer khác. Nó là đáy.
  • state / blocks / render-mjml / persistence được import model + contracts, không bao giờ ui.
  • uirender-mjml: UI không import layer MJML (vì sẽ kéo lib mjml Node vào bundle trình duyệt). UI nhận HTML preview qua một callback renderPreview mà host wire.
  • uiblocks (cho nội tại block): UI vẽ block qua BlockRegistry + renderEditor, không import module block cụ thể.

Vì sao một chiều lại quý

  • Đổi được mà không vỡ.model không biết UI tồn tại, đổi UI framework không đụng model. Vì model/render tách bạch, swap render engine (MJML → react-email) không đụng model. Đây là "hai ranh giới quý nhất".
  • Test được tầng dưới một mình. treeOps là hàm thuần — test không cần render hay DOM.
  • Suy luận được. Đọc model là biết chắc nó không gọi ngược lên ui — không có vòng ẩn để truy.

"Sự vắng mặt mới là điều đáng nói nhất"

Giá trị của kiến trúc này nằm ở những import không tồn tại: model không import ui → core sạch; render-mjml không import blocks/*.ts (chỉ import *.tomjml.ts) → bundle send không kéo React (mục 6); contracts.ts không có runtime dep → chạy y hệt trên browser lẫn server.

↔ Backend Khi review một service, thứ làm yên tâm không phải "có gì trong domain core" mà là "không có import nào của framework/HTTP/JDBC rò vào domain core". Vắng mặt đúng chỗ = thiết kế tốt.

Neutral leaf — mẹo phá coupling

Có những tiện ích nhiều layer cùng cần: bảng màu theme (theme-resolve.ts) và "ô text đang focus" để chèn {{tag}} (active-target.ts). Đặt chúng làm leaf trung tính — không phụ thuộc layer nào, nên ai cũng import được mà không kéo theo coupling.

// theme-resolve.ts — peer của contracts, 0 layer dep → blocks + inspector + ui đều import được
export function resolveThemeColor(theme: string, role: string): string { … }

3contracts.ts — bản hợp đồng

Định nghĩa interface TRƯỚC, build mọi layer AGAINST nó

src/contracts.ts là file đầu tiên được viết và là file không layer nào được tự sửa — đổi nó = đổi hợp đồng. Mọi layer khác build dựa trên các type trong đây.

↔ Backend Đây là interface-first / contract-first. Giống định nghĩa một Java interface (hay một .proto, một OpenAPI spec) rồi nhiều phần code song song hai phía. Hợp đồng đứng yên thì hai bên không cần biết chi tiết của nhau.

Vì sao contract phải "types-only, zero runtime deps"

contracts.ts chỉ chứa type/interface, không có code chạy. Lý do: cùng một file hợp đồng dùng được ở cả trình duyệt (editor) lẫn Node (gửi mail) vì nó không kéo theo lib nào. Đây là điều kiện để cú tách React-free (mục 6) khả thi.

Các interface chính

ModelBlockNode, EmailDoc (mục 1) và TreeOps:

interface TreeOps {
  findById(doc, id): BlockNode | null;
  insert(doc, parentId, index, node): EmailDoc;  // trả về doc MỚI (immutable)
  remove(doc, id): EmailDoc;
  move(doc, id, toParentId, toIndex): EmailDoc;
  update(doc, id, patch): EmailDoc;
  cloneWithNewIds(node): BlockNode;              // duplicate/paste, id mới toàn bộ
  validate(doc, registry): ContractViolation[];
}

Blocks (registry) — interface đắt giá nhất, BlockDefinition:

interface BlockDefinition {
  type: string;
  category: 'layout' | 'content' | 'dynamic';
  isContainer?: boolean;
  allowedChildren?: string[];   // luật lồng nhau
  allowedParents?: string[];
  defaultProps: Record<string, unknown>;
  fields: FieldSchema[];                                  // → inspector tự sinh panel
  renderEditor: (args: BlockRenderArgs) => EditorElement; // canvas (React)
  toMjml: (node, ctx: RenderContext) => MjmlNode | MjmlNode[]; // email (KHÔNG React)
}

Một block khai cả 3 mặt: form (fields → inspector tự sinh panel), canvas (renderEditor), email (toMjml). "Thêm block mới" = thêm một file, không sửa engine. Các nhóm còn lại: Render (MjmlNode, RenderContext, RenderResult, EmailRenderer), State (EditorAction, EditorStore), Merge-tags, Persistence.

Phần "Invariants" ở đầu file — đây là LUẬT

* Invariants:
*  - One-way deps: model ← state/blocks/render/...; UI never leaks into model/render.
*  - model + render are the two most valuable boundaries (swap render-engine / UI
*    must not break model).
*  - The block-tree JSON is the source of truth we own (anti-lock-in).
*  - ONE renderTree drives both preview and send (only difference = merge-tag mode).

Vì sao "khoá" contract lại quan trọng

Các layer được phát triển độc lập, có thể song song. Nếu mỗi layer được sửa type dùng chung thì hai layer sẽ định nghĩa lệch nhau → vỡ tích hợp. Khoá contracts.ts (chỉ một nơi sửa) biến nó thành điểm đồng bộ duy nhất: layer A và layer B chưa từng "nói chuyện", nhưng vì cùng tuân một hợp đồng nên ghép vào là khớp. Đây là lý do contract-first cho phép làm việc song song mà không cần đồng bộ liên tục (quay lại ở mục 10).

Một chi tiết tinh tế: EditorElement = unknown

/** Returned JSX is typed `unknown` so contracts stay React-free. */
export type EditorElement = unknown;

renderEditor trả JSX (React), nhưng contract cố tình type nó là unknown thay vì React.ReactNode — để contracts.ts không phải import React → giữ được lời hứa "dùng được ở server". Một dòng nhỏ, nhưng là cái bản lề giữ cho cú tách React-free (mục 6) thành hiện thực.

4Cây block JSON là source of truth

Lưu CÂY, không lưu HTML

Khi merchant soạn email, thứ được cất vào DB không phải HTML output, mà là cây block JSON (EmailDoc). HTML chỉ được sinh ra về sau, mỗi lần preview hoặc gửi. Bất biến: "The block-tree JSON is the source of truth we own (anti-lock-in)."

Vì sao không lưu HTML

HTML là lossy — nó là kết quả render, đã mất ý định gốc. Từ một mớ table/tr/td đã inline CSS, ta không biết "đây vốn là một Button màu accent theo theme" — và không reverse được về block để sửa. Đổi theme/locale/render engine? Với HTML thì chịu; với cây thì chỉ là render lại. Lưu cây = giữ semantics (ý nghĩa), không chỉ giữ bytes (hình hài).

↔ Backend Hai liên hệ mạnh: (1) Event sourcing — lưu sự kiện/ý định (cây block), derive trạng thái hiển thị (HTML) khi cần. (2) Lưu source chứ không lưu binary — HTML là "binary đã compile"; cây JSON là "source". Build lại binary bất cứ lúc nào từ source, không thể decompile binary về source sạch.

"Anti-lock-in"

Bài học từ các SDK email nhúng: SDK = "iframe + JSON in/out", nhưng JSON đó proprietary — giữ được bytes nhưng không hiểu semantics, và rời SDK là mất hết. Bằng cách tự sở hữu schema (BlockNode/EmailDoc do chính package định nghĩa), hệ thống giữ semantics. MJML chỉ là một target trung gian swappable.

Block "tự mô tả"

  • typehợp đồng (de)serialize: chỉ cần string 'button' là tra được trong BlockRegistry ra cả renderEditor + toMjml. Cây không cần "biết" cách vẽ — nó chỉ mang type, layer khác tra nghĩa (tách dữ liệu khỏi hành vi).
  • id per-instance: scoped binding + sẵn sàng collab/realtime.
  • version từng block: migrate một loại block mà không đụng loại khác.
  • text theo locale, props dùng chung: nội dung dịch theo ngôn ngữ, màu/căn lề dùng chung. resolveText(t) chọn text theo locale, fallback về fallbackLocale.

Hai kỷ luật giữ cho cây "bất tử"

1. Schema ADDITIVE-ONLY. Không bao giờ xoá/đổi-nghĩa field đang có — chỉ thêm field optional. Muốn đổi nghĩa = bump version + một migration thuần (vN)→(vN+1) + một golden fixture.

↔ Backend Y hệt luật tiến hoá schema của Protobuf/Avro (chỉ thêm field optional, không tái dùng số field cũ) hay luật migration cộng dồn của DB. Lý do giống nhau: dữ liệu cũ phải còn đọc được.

2. Tolerant loader. Gặp type lạ hay field lạ → placeholder/passthrough, không bao giờ crash hay drop:

// render-mjml/render-tree.ts
const def = registry.get(node.type);
if (!def) return [{tagName: 'mj-raw', content: `<!-- unknown block "${node.type}" -->`}];

Một block type chưa biết → nhả ra một comment HTML vô hại, phần còn lại của email vẫn render. Không "một block lỗi làm hỏng cả email".

↔ Backend Postel's law ("nghiêm khi gửi, khoan dung khi nhận"). Một loader khoan dung là cái khiến rollout/rollback an toàn — code cũ và mới đọc được doc của nhau.

Phép toán cây: immutable, thuần

Mọi thay đổi cây đi qua treeOps, và mọi op trả về một EmailDoc mới (dùng immer). move có bảo vệ no-cycle (cấm kéo một node vào trong chính con cháu nó) và no-op detection (kéo về đúng chỗ cũ → trả về cùng reference, không tạo entry undo rác):

if (src.parentId === toParentId && src.index === toIndex) return doc; // không đổi → cùng ref
if (subtree.has(toParentId)) return doc;                              // sẽ tạo vòng → từ chối

Immutable làm cho undo gần như miễn phí (chỉ cần giữ snapshot cũ) và cho phép so sánh "có đổi không" bằng === (reference equality) — cầu nối sang mục 8.

5Một renderTree: preview = send

Bất biến quan trọng nhất CỐT LÕI

"ONE renderTree drives both preview and send — the only difference is merge-tag mode." Đây là lời hứa cốt lõi để diệt tận gốc class lỗi "preview khác email gửi đi" của V1. Chỉ tồn tại một hàm sinh email — renderTreerender-mjml/render-tree.ts. Preview gọi nó. Send gọi nó. Cùng một code, cùng một output.

↔ Backend Drift xảy ra khi có hai code path làm cùng việc: sửa ở hai thời điểm, bởi người khác nhau, lệch dần. Lời giải bền vững là một nguồn tính, hai nơi dùng. renderTree chính là "một nguồn tính" đó cho HTML email.

Preview và send khác nhau ở đâu?

Khác đúng một thứ: cách xử lý merge-tag {{tag}}, gói trong ctx.mode + ctx.resolveMergeTags.

mode: 'preview'mode: 'send'
{{points_balance}} thành"1,250" (giá trị sample)giữ nguyên {{points_balance}}
Ai thay giá trị thật— (chỉ để xem)backend Liquid, sau render

Send giữ {{tag}} raw để backend thay giá trị thật về sau — package không bao giờ chạm dữ liệu khách thật, nó chỉ phát template có {{tag}} (mục 9).

Bên trong renderTree

export async function renderTree(doc, ctx, registry): Promise<RenderResult> {
  const boundCtx = {
    ...ctx,
    docTheme: doc.theme ?? ctx.docTheme,
    renderChildren: (node) => (node.children ?? []).flatMap(c => renderNode(c, boundCtx, registry)),
  };
  const tree = buildMjmlTree(doc, boundCtx, registry);          // cây block → MJML JSON-AST
  const {html, errors} = await mjml2html(toMjmlJson(tree), {validationLevel: 'strict'});
  // ... gom warnings + đo size (cảnh báo >90KB Gmail clip)
  return {html, sizeBytes, warnings};
}

Ba chặng: cây block → MJML JSON-AST (gọi block.toMjml từng node) → mjml2htmlHTML + cảnh báo. asyncmjml2html v5 trả Promise (không có đường sync) — chi tiết nhỏ nhưng lan ra contract: EmailRenderer.renderTree cũng async.

"Isomorphic": cùng code, hai môi trường

renderTree static-import lib mjml (bản Node). Nhưng preview chạy trong trình duyệt. Lời giải: build trình duyệt alias mjmlmjml-browser (cùng version, cùng API async). Cùng một file renderTree, ở browser thì cái mjml nó import là mjml-browser, ở Node thì là mjml thật. Một nguồn logic, hai binding môi trường.

UI lấy preview qua callback, KHÔNG import render layer

Một hệ quả của deps một chiều (mục 2): UI không import render-mjml. Thay vào đó UI nhận một callback renderPreview do host wire:

const renderPreview = (doc, {locale, device}) =>
  renderTree(doc, { locale, fallbackLocale:'en', mode:'preview',
    resolveText, renderChildren, docTheme: doc.theme,
    resolveMergeTags: raw => resolver.resolve(raw, 'preview'),
  }).then(r => r.html);
<EmailEditor ... renderPreview={renderPreview} />

UI biết "có một hàm cho tôi HTML" mà không biết hàm đó là MJML — dependency injection thuần. Đổi engine không đụng UI.

6Tách React-free cho đường send

Vấn đề: gửi mail chạy ở Node, nhưng block lại "dính" React

Mỗi block khai cả hai mặt (mục 3): renderEditor (vẽ canvas, dùng React, block Text còn kéo Tiptap/ProseMirror) và toMjml (sinh email, không cần React). Đường gửi mail chạy trong Node, chỉ cần toMjml. Nếu nó import "block" ngây thơ — import {buttonBlock} from './button' — thì button.ts lại import React, block Text kéo cả ProseMirror. Kết quả: bundle gửi mail ở Node phình ra ôm cả React + Tiptap — thứ nó không bao giờ dùng.

Lời giải: tách toMjml ra một file riêng KHÔNG React

Mỗi block chẻ làm hai file: button.ts (BlockDefinition đầy đủ với renderEditor React) và button.tomjml.ts (chỉ toMjml + style thuần, không import React).

// button.tomjml.ts — React-free. Chỉ type + helper thuần.
import type {BlockNode, MjmlNode, RenderContext} from '../contracts';
export function buttonToMjml(node: BlockNode, ctx: RenderContext): MjmlNode {
  const t = blockTheme(node.props, ctx.docTheme);
  const v = buttonVisual(node.props, t);                 // style dùng chung
  return { tagName: 'mj-button', attributes: attrs({ href: ctx.resolveMergeTags(...), ... }),
           content: escapeHtml(ctx.resolveMergeTags(ctx.resolveText(node.text))) };
}
// button.ts — React Ở ĐÂY, và import toMjml từ file kia.
import React from 'react';
import {buttonToMjml, buttonVisual} from './button.tomjml';
export const buttonBlock: BlockDefinition = {
  type: 'button', fields: [...],
  renderEditor({node, ...}) { return React.createElement('div', ...); },
  toMjml: buttonToMjml,           // ← tái dùng cùng một hàm
};

buttonVisual (tính màu/viền/width theo variant) sống ở file .tomjmlđược cả hai mặt dùng chung → canvas và email không thể lệch về style. Một nguồn style, hai nơi vẽ — tinh thần "một nguồn sự thật" ở mức vi mô.

Hai registry: editor vs server

flowchart TB
  subgraph editor [Browser - canvas]
    A["createDefaultRegistry"] --> B["button.ts + 16 blocks (pulls React / Tiptap)"]
  end
  subgraph server [Node - send mail]
    C["createServerRegistry"] --> D["button.tomjml + 16 *.tomjml (react = 0 in bundle)"]
  end
  editor == "mirror each other, MJML byte-identical" ==> server
    
  • createDefaultRegistry() — gom *.ts đầy đủ → có renderEditor. Dùng cho canvas.
  • createServerRegistry() — chỉ import *.tomjml → chỉ có toMjml. Dùng cho send.

Server registry mirror đúng map type→toMjml của editor registry (kể cả wrap hide-on-mobile) → preview và send cho ra MJML byte-identical. Tách runtime không làm hai bên lệch — vẫn giữ invariant "preview = send" (mục 5).

Cổng vào riêng cho server

// render-mjml/server.ts
export function createServerRenderer(): EmailRenderer {
  return createEmailRenderer(createServerRegistry());   // React-free registry + renderTree
}

Cú tách được kiểm chứng bằng công cụ: esbuild metafile chứng minh 0 react / react-dom / @tiptap / prosemirror / liquidjs trong graph của entry server — không phải tin lời. Server entry còn cố tình không re-export merge-tag resolver (bản preview kéo liquidjs) để giữ liquidjs khỏi bundle Node send.

Bài học tư duy: tách concern theo RUNTIME

Người ta hay tách concern theo chức năng (UI / logic / data). Ở đây có thêm một trục: theo môi trường chạy. "Cái gì chạy ở trình duyệt" vs "cái gì chạy ở Node" là một đường cắt — và nó quyết định dependency nào được phép có mặt mỗi phía. Khi một thứ "logic-thuần lẽ ra nhẹ" lại kéo theo lib nặng của môi trường khác, đó là dấu hiệu cần một đường cắt như *.tomjml.

7Tích hợp host & flag-gate an toàn

Bối cảnh: package host-agnostic

Package email-editor không biết gì về hệ thống host — nó nhận EmailDoc, nhả HTML, để lại các chỗ cắm (PersistenceAdapter, renderPreview, merge-tag resolver) cho host wire. Mục này tập trung vào điểm tích hợp nguy hiểm nhất: đường gửi mail thật.

Vấn đề rollout: hàng nghìn shop đang chạy V1

Không thể "bật V2 cho tất cả" trong một lần deploy. Email là thứ khách hàng nhận thật — một bug render là một email hỏng gửi đi, không rút lại được. Cần: (1) bật V2 cho một nhóm shop kiểm soát được, mở dần; (2) shop chưa bật phải nhận email byte-identical như trước; (3) V2 render lỗi → không được mất mail, phải tự lui về V1.

Lời giải: HARD GATE bằng flag

// trong handler gửi mail của host (getEmailData)
let useV2 = shop.useEmailEditorV2 && sendMailData.emailDocV2?.root?.length;
if (useV2) {
  try {
    const locale = shop.primaryLanguage || 'en';
    const ctx = {
      locale, fallbackLocale: 'en',
      resolveText: tt => tt?.[locale] ?? tt?.en ?? Object.values(tt||{})[0] ?? '',
      resolveMergeTags: raw => raw,          // identity → giữ {{tag}} raw cho Liquid
      mode: 'send',
      docTheme: sendMailData.emailDocV2.theme,
      showBranding: !shop.removeBrandingCheckbox,
    };
    const {html} = await createServerRenderer().renderTree(sendMailData.emailDocV2, ctx);
    content = await prepareBodyHtml({templateContent: html, variable});  // Liquid pass (giống V1)
    subject = await prepareContentHtml(sendMailData.emailDocV2.subjectByLocale?.[locale] ?? ..., variable);
  } catch (e) {
    console.error('[EmailV2] render failed, falling back to V1', e);
    useV2 = false;                            // ← FAIL-SAFE
  }
}
if (!useV2) {
  /* ... nhánh V1 byte-identical (visual template / customHtml) ... */
}

Ba điều kiện, ba quyết định

1. Gate "cứng": cần CẢ flag CẢ doc. Phải đồng thời: shop bật flag thực sự có một emailDocV2 có block. Thiếu một → rơi xuống V1. Đây là feature flag kinh điển: hành vi mới nằm sau công tắc per-shop, mở dần.

2. Byte-identical: shop chưa bật đi nhánh cũ nguyên vẹn. Nhánh if (!useV2) chính là code V1 cũ, không đụng tới — khách chưa bật nhận email y hệt từng byte.

↔ Backend Đây là strangler fig pattern: đường mới mọc bên cạnh đường cũ, gate quyết định đi đường nào; đường cũ chỉ "chết" khi đường mới đã nuốt hết traffic. Không "big-bang rewrite".

3. Fail-safe: lỗi V2 → tự lui V1, không mất mail. Cả khối V2 bọc trong try/catch. Render lỗi → log → useV2 = false → rơi xuống nhánh V1. Một email luôn được gửi — tệ nhất là gửi bản V1 thay vì V2.

flowchart TB
  S["Email to send"] --> G{"useEmailEditorV2 AND emailDocV2?"}
  G -- "no" --> V1["V1 path (byte-identical)"]
  G -- "yes" --> T["renderTree mode=send → prepareBodyHtml"]
  T -- "error (try/catch)" --> V1
  T -- "ok" --> OK["V2 email"]
    

Vì sao render V2 vẫn đi qua Liquid như V1

Sau khi renderTree trả HTML, host vẫn chạy prepareBodyHtml({templateContent: html, variable}) — đúng cái Liquid pass V1 dùng, với đúng variable map V1 dùng. Vì renderTree ở mode send giữ {{tag}} raw (mục 5), backend thay giá trị thật ở đúng một chỗ, giống hệt V1. Kết quả: {{tag}} + {% if %} resolve y như V1 — không có đường xử lý biến nào mới phát sinh.

Bài học: ship feature lớn = mặc định OFF + nhánh cũ nguyên + fail-safe

  1. Mặc định an toàn — flag OFF, không ai bị ảnh hưởng cho tới khi chủ động bật.
  2. Đường cũ bất khả xâm phạm — không refactor đường cũ khi thêm đường mới; gate chọn đường.
  3. Hỏng thì lui, đừng sập — code mới bọc try/catch, fallback về cái đã tin được.

Khung này áp được cho bất kỳ migration backend nào (đổi thuật toán, đổi data store): chạy song song, gate per-entity, so sánh, fail-safe.

8State, undo & cây immutable

MỌI thay đổi đi qua một cửa dispatch(action)

Trong editor có hàng chục thao tác đổi dữ liệu. Nếu mỗi component tự sửa doc theo cách riêng thì không ai truy được "ai đổi gì, lúc nào" — và undo thành ác mộng. EditorStore áp một luật: "EVERY change goes through one chokepoint, dispatch(action). No component touches the model/DOM directly." Component không sửa cây; nó mô tả ý định bằng một EditorAction rồi dispatch:

type EditorAction =
  | {kind:'insert'; parentId; index; node}
  | {kind:'remove'; id}
  | {kind:'move'; id; toParentId; toIndex}
  | {kind:'updateProps'; id; props}
  | {kind:'updateText'; id; locale; value}
  | {kind:'duplicate'; id}
  | {kind:'setDocTheme'; theme}
  | {kind:'select'; id} | {kind:'setLocale'; locale} | ...;
↔ Backend Đây là command pattern = mỗi thay đổi là một object lệnh tường minh. Rất gần với CQRS/event log: thay vì setter rải rác, phát ra một "command" mô tả ý định, một chỗ duy nhất diễn dịch nó. Một cửa = một chỗ để log, validate, và (quan trọng) ghi lịch sử undo.

Luồng một lần dispatch

flowchart TB
  C["Component"] -- "dispatch(action)" --> D{"action type?"}
  D -- "view-state: select/setLocale" --> V["change selectedId/locale — NO undo entry"]
  D -- "doc-mutation" --> R["reduceDoc → treeOps"]
  R -- "doc === before (no-op)" --> SKIP["skip, no history entry"]
  R -- "new doc" --> H["push old snapshot to past, clear future"]
  H --> N["present = new state"]
    

reduceDoc ánh xạ mỗi action.kind về một lời gọi treeOps, và tận dụng immutable để phát hiện no-op:

// updateProps: nếu giá trị y hệt cũ → trả về CÙNG doc ref → caller coi là no-op, không ghi history
const changed = Object.keys(action.props).some(k => node.props[k] !== action.props[k]);
if (!changed) return {doc, selectedId: prev.selectedId};

Undo là HỆ QUẢ của command model, không phải bolt-on

Vì (a) mọi thay đổi qua một cửa, và (b) cây immutable (mỗi op sinh EmailDoc mới — mục 4), nên giữ lịch sử undo chỉ là giữ các snapshot cũ:

interface HistoryState {
  present: EditorState;   // {doc, selectedId, locale}
  past: EditorState[];    // stack snapshot cũ
  future: EditorState[];  // stack cho redo
}
function undo() {
  const prev = past[past.length-1];
  future.push(present); present = prev; past.pop();   // present mang old selectedId → khôi phục cả selection
}

Mỗi entry là full snapshot EditorState — nên undo khôi phục cả selection + locale, không chỉ cây. Vì immutable + structural sharing (immer tái dùng các nhánh không đổi), giữ 200 snapshot không tốn như "copy sâu 200 lần".

↔ Backend Như event sourcing / write-ahead log — có lịch sử trạng thái, "undo" = tua về snapshot trước. Tính năng này gần như miễn phí vì đã chọn immutable + một cửa ghi. Đây là minh hoạ "quyết định kiến trúc đúng làm tính năng khó trở nên dễ".

Coalescing: gõ liên tục = MỘT bước undo

Nếu mỗi keystroke là một entry undo thì gõ một câu rồi Ctrl-Z sẽ xoá từng-chữ-một. Store gộp các thao tác cùng loại, cùng target, trong cửa sổ ~300ms thành một bước undo:

const canCoalesce =
  COALESCIBLE.has(action.kind) &&            // updateText/updateProps/setDocTheme/setSubject/setPreheader
  lc?.kind === action.kind &&
  lc.targetId === targetId &&
  t - lc.time <= coalesceMs;                 // 300ms

Coalescing gãy khi: đổi selection, đổi loại thao tác, hoặc quá cửa sổ thời gian — convention của các editor quen thuộc ("một ý chỉnh sửa = một undo").

Vì sao tách state khỏi model

model (treeOps) là phép toán cây thuần — không biết undo, không biết selection. state (EditorStore) điều phối lịch sử + selection + coalescing, và uỷ thác mọi thay đổi cây xuống treeOps. Tách như vậy để treeOps test được một mình (hàm thuần), còn logic undo/coalesce không lẫn vào phép toán cây. Store dùng zustand/vanilla + immer — chọn "compose primitive" thay vì tự viết.

9Merge-tags, Liquid & migrate V1→V2

Ba mảnh "ráp với hệ thống cũ": dữ liệu động, backend Liquid sẵn có, và hàng nghìn email V1 đang tồn tại.

A. Merge-tags: {{tag}} preview-vs-send

Email loyalty đầy biến động: {{points_balance}}, {{customer.first_name}}, {{reward_url}}… Một MergeTag:

interface MergeTag { tag: string; label: string; sample: string; }
// vd: { tag:'{{points_balance}}', label:'Points balance', sample:'1,250' }

Package ship vocabulary (~48–60 tag) phản chiếu nguồn dữ liệu email của host, kèm sample để preview + test. Resolver có hai mode:

resolve(raw, mode) {
  if (mode === 'send') return raw;                       // INVARIANT: send = identity (giữ {{tag}} raw)
  try { return engine.parseAndRenderSync(raw, sampleVars); }  // preview: chạy Liquid với sample
  catch { return raw.replace(mergeTagMatcher(), (full,name)=> sampleByName.get(name) ?? full); }
}
  • send = identity (raw === resolve(raw,'send')): tag đi xuyên renderTree còn nguyên, để backend thay ở bước sau (đây là cái neo của đường send — mục 5).
  • preview chạy liquidjs với dữ liệu sample → canvas hiển thị được cả {% if %}/{% for %}/filter chứ không chỉ {{tag}} đơn. Liquid hỏng → bắt lỗi → lui về thay {{tag}} đơn giản bằng regex. Không bao giờ throw (canvas render đồng bộ).

Vì sao preview kéo liquidjs mà send thì không: server entry cố tình không export resolver, để liquidjs không lọt vào bundle Node send (mục 6).

B. Pipeline Liquid: ai resolve {{tag}}, lúc nào

Mấu chốt: render và resolve-biến là hai bước tách rời, do hai lớp khác nhau làm.

flowchart TB
  Doc[("EmailDoc")] -- "renderTree mode=send (resolveMergeTags = identity)" --> HTML["HTML keeps {{tag}} raw"]
  HTML -- "prepareBodyHtml (Liquid backend, same variable map)" --> FINAL["final HTML + real values"]
  FINAL --> Mailgun["send"]
    
  1. renderTree (package) sinh template HTML còn {{tag}}không thấy dữ liệu khách.
  2. prepareBodyHtml (host) chạy Liquid thay giá trị thật, dùng đúng variable map V1 dùng{{tag}}{% if %} resolve y hệt V1 (mục 7).
↔ Backend Tách template khỏi data binding — như một SQL prepared statement: cấu trúc (template, {{tag}} ~ placeholder) cố định, giá trị bơm vào sau. Lợi ích: package không bao giờ chạm PII khách; backend là chỗ duy nhất biết dữ liệu thật.

Escape boundary (một gotcha đáng nhớ)

Thứ tự escape là resolveMergeTags → escapeHtml, làm ở block layer: với send, resolve giữ {{tag}}escapeHtml là no-op trên dấu ngoặc nhọn (tag chỉ chứa ký tự word + dấu chấm) → tag tới backend còn nguyên. Giá trị backend bơm vào {{tag}} ở send-time thì backend phải escape (auto-escape hoặc filter escape) — package không thể, vì giá trị được chèn sau khi template đã render. Ngoài ra render layer còn escape attribute value để một dấu nháy lạc trong font-family không đóng sớm style="..." làm mất các prop sau. Content thì không re-escape — nó là HTML thân bài cố ý (vd strong của rich-text).

C. Migrate V1 → V2: tolerant loader cho dữ liệu cũ

Hàng nghìn email V1 đang ở dạng blob HTML (notificationContent) + map visibleBlocks (boolean show/hide vùng cố định) — không phải cây block. migrateFromV1 suy ra cây từ các vùng cố định:

banner(logo) → header-image(hero) → [heading, content, button] → footer
  • Mỗi vùng visibleBlocks.<x> === falsekhông tạo block đó (mặc định ON).
  • emailMode === 'html' → một block html cô lập giữ customHtml nguyên văn, không reverse-parse.
  • translations[lang] (6 field phẳng) → bơm vào block.text[lang] (per-locale).
  • Block nội dung (heading/button/footer) khai allowedParents:['column'] → migration bọc chúng vào columns(1) > column; riêng header-image/banner ở mức section.

Và nó tolerant: input không phải object → coi như V1 rỗng, không crash:

const src = isPlainObject(v1raw) ? v1raw : {};

Vì sao tolerant quan trọng ở migrate: nó chạy trên dữ liệu thật, cũ, hỗn tạp không kiểm soát được hết hình thù. Một doc dị dạng không được làm sập cả batch. Kết hợp với schema additive-only (mục 4), đây là điều khiến rollout/rollback an toàn: code mới đọc được doc cũ, và doc V1 luôn còn đó để lui về. Lưu ý kiến trúc: migrateFromV1 trả về một EmailDoc sạch (chỉ cây V2). Việc giữ field V1 trên doc DB để rollback là việc của lớp wiring host phía trên, không nhồi field V1 vào EmailDoc. Mỗi lớp giữ đúng phần của mình.

10Cách tư duy khi build feature cỡ này

Phần đúc rút. Chín mục trước mô tả cái đã build; mục này rút ra cách nghĩ đã dựng nên nó — một khung áp dụng được cho bất kỳ feature lớn nào.

Trình tự build theo phase

Feature được dựng theo một trình tự rất sạch — không theo "cái gì user thấy trước", mà theo dependency + rủi ro:

Phase 0  Hợp đồng: contracts.ts (khoá) trước tiên
Phase 1  Lõi rủi-ro-cao, mỗi cái một spike có test:
           TreeOps (no-cycle) · EditorStore (undo) · renderTree (MJML) · Persistence
Phase 2  Blocks + registry · rich-text · theme · canvas UI (dnd/overlay/resize/inspector/preview)
Phase 3  Tích hợp host: route + persistence + send-gate (flag, fail-safe)

Bốn bài học gói trong trình tự đó.

1. Bắt đầu từ HỢP ĐỒNG, không từ tính năng

Nước đi đầu tiên không phải "một cái nút kéo được" — mà là contracts.ts được khoá. Hợp đồng là giao diện giữa các phần chưa tồn tại; khoá nó trước = mọi layer sau có một mục tiêu đứng yên để build against. Nó ép quyết định những thứ khó trước khi viết logic (cây trông thế nào? render nhận/nhả gì? action có những loại nào?) — trả lời ở mức type rẻ hơn nhiều so với phát hiện sai sau khi đã code 3 layer.

↔ Bài học Trước khi gõ logic, viết ra các type/interface dùng chungđóng băng chúng. Nếu chưa biết một interface nên thế nào → đó là tín hiệu chưa hiểu bài toán đủ để code.

2. Build từ TRONG ra NGOÀI, lõi-rủi-ro-cao trước

Thứ tự: model (đáy) → state/renderblocksui (đỉnh) → host. Và Phase 1 cố tình spike các lõi khó nhất trước: cây immutable có chống được cycle không? render MJML có giữ {{tag}} qua được không? undo có đúng không? Mỗi spike có test xanh rồi mới xây tiếp lên.

↔ Bài học "Riskiest-first": tấn công chỗ-dễ-sai-nhất khi còn rẻ để sai. Nếu renderTree hoá ra không khả thi, ta muốn biết sớm, không phải sau khi đã dựng cả UI lên trên nó.

3. Bất biến (invariant) = LUẬT, không phải gợi ý

Cả package được giữ thẳng hàng bởi một nhúm bất biến viết ngay đầu contracts.ts: deps một chiều (mục 2), một renderTree (mục 5), cây JSON là source-of-truth (mục 4), schema additive-only. Điểm mấu chốt: bất biến được viết ra thành chữkiểm chứng được — vài cái còn được test/đo tự động ("send bundle react=0" chứng minh bằng esbuild metafile; "send là identity" có test round-trip; cross-check test chống hai registry trôi khỏi nhau).

↔ Bài học Một bất biến giống một luật bất biến trong domain (vd "số dư không âm") — không chỉ hy vọng nó đúng, mà dựng ràng buộc/test để nó không thể sai. Viết 4–6 câu "luật", dán lên đầu file hợp đồng, và tìm cách enforce (lint, test, đo bundle).

4. Mỗi layer phát triển độc lập, ghép bằng hợp đồng

Ba điều trên khiến các layer có thể được phát triển độc lập (kể cả song song) rồi ghép lại trơn tru:

  • Hợp đồng khoá → các phần không cần "nói chuyện" liên tục; chúng build against cùng type.
  • Deps một chiều → layer dưới không bị layer trên kéo theo.
  • Invariant là luật → người review có tiêu chuẩn khách quan để bắt lỗi, không cãi cảm tính.

Và có review gate trước khi tích hợp: một layer báo xong → review theo contract + invariant → chạy typecheck/test/build (+ visual/e2e khi có UI) → mới ghép.

↔ Bài học Module hoá tốt cho phép song song hoá; song song hoá phơi bày module hoá tồi. Chia việc theo ranh giới layer; "hoàn thành một layer" = khớp hợp đồng + qua review + test xanh, rồi mới sang layer khác. Tách vai "viết" và vai "review", ngay cả khi cùng một người ở hai thời điểm.

5. Tách concern theo cả RUNTIME, không chỉ theo "loại việc"

Cú tách *.tomjml (mục 6) và flag-gate fail-safe (mục 7) đến từ một câu hỏi người mới hay bỏ qua: "code này chạy ở đâu, và môi trường đó được phép có dependency gì?" Trình duyệt được có React; bundle gửi mail ở Node thì không. Đường mới được bật cho ai, và hỏng thì lui về đâu? Những câu đó định hình các đường cắt quan trọng nhất.

Tóm gọn — khung 6 câu hỏi cho feature lớn kế tiếp

  1. Hợp đồng: các type/interface dùng chung là gì? Khoá được chưa?
  2. Rủi ro: phần nào sai-là-vỡ-kiến-trúc? Spike nó trước, có test.
  3. Hướng phụ thuộc: deps có một chiều về core không? Có import nào không nên tồn tại?
  4. Bất biến: 4–6 luật bất khả xâm phạm là gì? Enforce bằng gì?
  5. Runtime: mỗi phần chạy ở đâu, được phép kéo dependency gì?
  6. Rollout: bật cho ai (flag), khách cũ có byte-identical không, hỏng thì lui về đâu (fail-safe)?

Trả lời được sáu câu này trước khi code giúp đội ngũ kiểm soát kiến trúc thay vì để nó trôi — khác biệt giữa việc nhận một hộp đen và việc thiết kế có chủ đích.