diff --git a/.changeset/richer-email-editor.md b/.changeset/richer-email-editor.md new file mode 100644 index 00000000..991516f8 --- /dev/null +++ b/.changeset/richer-email-editor.md @@ -0,0 +1,9 @@ +--- +"@sendlit/email-editor": minor +"@sendlit/email-blocks": minor +"@sendlit/api-contract": minor +--- + +A redesigned email editor focused on writing. Write mode puts the subject and preview text above the email, edits text inline as markdown with a format bar, a `/` block menu and `{{` merge tags, and reorders blocks by dragging; Style mode previews desktop, mobile, sample data and a dark inbox beside a palette, typography and block panel. Text is written paragraph by paragraph: Enter starts a new paragraph or list item, Backspace joins paragraphs, markdown shortcuts (`#`, `##`, `-`, `1.`, `>`) turn a paragraph into a heading, list or quote, and merge tags show as chips; text blocks are still stored as one markdown block each. Blocks can define `quickEdit` for the compact popover shown while writing. Adds undo and redo, a "Before you send" checklist (with one-click fallbacks for merge tags), and `subject`, `onSubjectChange`, `variables`, `senderName`, `theme` and `defaultMode` props on `EmailEditor`. + +New blocks: `spacer`, `columns`, `social`, `html` and `conditional` (a section shown to contacts with or without a tag; marketing only). The API validates them on write, and rejects `conditional` blocks in transactional content with `conditional_not_allowed`. `renderEmailToHtml` takes `showConditionals` for previews. diff --git a/apps/api/docs/transactional-vs-marketing-templates.md b/apps/api/docs/transactional-vs-marketing-templates.md index 4f6beccf..ddcaedf3 100644 --- a/apps/api/docs/transactional-vs-marketing-templates.md +++ b/apps/api/docs/transactional-vs-marketing-templates.md @@ -51,7 +51,9 @@ unsubscribe footer and do not require a mailing address. 3. API callers never provide `address` or `unsubscribe_link`. 4. Marketing templates contain exactly one final `footer` block with SendLit-controlled address and unsubscribe content. -5. Transactional templates cannot contain a `footer` block. +5. Transactional templates cannot contain a `footer` block or a `conditional` + section (it reads the subscriber's tags, and a transactional send has no + subscriber). 6. Missing unguarded transactional variables return a synchronous `422`; no transactional row or queue job is created. 7. System templates are starters. They must be copied into a team-owned `tpl_` @@ -164,7 +166,8 @@ The transactional path: 5. Allows explicit optional values through a Liquid `default` filter or a guarded `{% if value %}` block. 6. Does not inject `address`, `unsubscribe_link`, or `subscriber`. -7. Rejects a `footer` block if one is submitted in transactional content. +7. Rejects a `footer` block or a `conditional` block (`footer_not_allowed`, + `conditional_not_allowed`) if one is submitted in transactional content. When a transactional template is created or updated, reject reserved marketing variables with a validation error such as: diff --git a/apps/api/src/mail/render.test.ts b/apps/api/src/mail/render.test.ts index 89ef1533..12b64370 100644 --- a/apps/api/src/mail/render.test.ts +++ b/apps/api/src/mail/render.test.ts @@ -39,6 +39,38 @@ describe("mail render helpers", () => { expect(html).toContain("Hello Ada"); }); + it("shows a conditional section only to contacts with the tag", async () => { + const content = { + ...defaultEmail, + content: [ + { blockType: "text" as const, settings: { content: "Hello" } }, + { + blockType: "conditional" as const, + settings: { tag: "customer", has: true, content: "Customers only" }, + }, + { + blockType: "conditional" as const, + settings: { tag: "customer", has: false, content: "Not yet a customer" }, + }, + ], + }; + const send = (tags: string[]) => + renderEmailContent({ content, variables: { subscriber: { tags } } }); + + const customer = await send(["beta", "customer"]); + expect(customer).toContain("Customers only"); + expect(customer).not.toContain("Not yet a customer"); + expect(customer).not.toContain("{%"); + + const other = await send(["beta"]); + expect(other).not.toContain("Customers only"); + expect(other).toContain("Not yet a customer"); + expect(other).not.toContain("{%"); + + const untagged = await send([]); + expect(untagged).not.toContain("Customers only"); + }); + it("renders the shared managed footer from server-owned context", async () => { const content = { ...defaultEmail, diff --git a/apps/api/src/mcp/tools/templates.ts b/apps/api/src/mcp/tools/templates.ts index b431939e..ef66d9ab 100644 --- a/apps/api/src/mcp/tools/templates.ts +++ b/apps/api/src/mcp/tools/templates.ts @@ -88,7 +88,7 @@ export const templateTools = (): SendLitMcpTool[] => [ title: z.string().min(1), purpose: templatePurposeSchema, content: emailContentInputSchema.describe( - "A complete SendLit email document. Include all style sections (colors, typography.header/text/link, interactives, and structure) and only text, link, image, separator, or footer blocks. Use list_system_templates and duplicate_template as the safest valid starting point.", + "A complete SendLit email document. Include all style sections (colors, typography.header/text/link, interactives, and structure) and only text, link, image, separator, spacer, columns, social, html, conditional (marketing only), or footer blocks. Use list_system_templates and duplicate_template as the safest valid starting point.", ), }), outputSchema: templateSchema, diff --git a/apps/api/src/templates/system-templates.ts b/apps/api/src/templates/system-templates.ts index 95f40acd..eb3a0f4a 100644 --- a/apps/api/src/templates/system-templates.ts +++ b/apps/api/src/templates/system-templates.ts @@ -97,18 +97,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< "A polished announcement template for launches, updates, and key news.", }, content: [ - { - blockType: "text", - settings: { - content: "JUST ANNOUNCED", - alignment: "center", - fontFamily: "Helvetica, sans-serif", - fontSize: "12px", - foregroundColor: "#db2777", - paddingTop: "6px", - paddingBottom: "4px", - }, - }, { blockType: "image", settings: { @@ -126,7 +114,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< settings: { content: "# Make your next announcement feel premium", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "26px", foregroundColor: "#111827", paddingTop: "8px", @@ -139,7 +126,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "This layout is designed for launches, waitlist openings, seasonal updates, and event drops where the headline and CTA need to carry the message clearly.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "17px", lineHeight: "1.7", foregroundColor: "#475569", @@ -182,7 +168,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "## Why this template works\n\n- Fast visual hierarchy.\n- Clean body copy.\n- Strong CTA treatment.\n- Enough polish to feel current without being overdesigned.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "16px", lineHeight: "1.7", foregroundColor: "#111827", @@ -201,7 +186,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< }, }, createFooterEmailBlock({ - fontFamily: "Helvetica, sans-serif", paddingTop: "0px", paddingBottom: "0px", }), @@ -274,24 +258,11 @@ const MARKETING_SYSTEM_TEMPLATES: Array< "A friendly welcome email for onboarding new users and subscribers.", }, content: [ - { - blockType: "text", - settings: { - content: "WELCOME", - alignment: "center", - fontFamily: "Helvetica, sans-serif", - fontSize: "12px", - foregroundColor: "#2563eb", - paddingTop: "8px", - paddingBottom: "0px", - }, - }, { blockType: "text", settings: { content: "# Welcome aboard", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "26px", foregroundColor: "#111827", paddingTop: "6px", @@ -304,7 +275,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "Thanks for joining us. This template works well for onboarding, first-touch education, getting-started checklists, and setting expectations for what comes next.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "17px", lineHeight: "1.7", foregroundColor: "#475569", @@ -337,7 +307,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "## What to do next\n\n- Complete your profile or setup.\n- Explore your dashboard or core feature.\n- Save this email for quick access later.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "16px", lineHeight: "1.7", foregroundColor: "#111827", @@ -356,7 +325,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< }, }, createFooterEmailBlock({ - fontFamily: "Helvetica, sans-serif", paddingTop: "0px", paddingBottom: "0px", }), @@ -430,25 +398,11 @@ const MARKETING_SYSTEM_TEMPLATES: Array< "A polished upsell email to spotlight related products and premium offers.", }, content: [ - { - blockType: "text", - settings: { - content: "RECOMMENDED FOR YOU", - alignment: "center", - fontFamily: "Helvetica, sans-serif", - fontSize: "12px", - foregroundColor: "#7c3aed", - backgroundColor: "#f5f3ff", - paddingTop: "8px", - paddingBottom: "8px", - }, - }, { blockType: "text", settings: { content: "# Products your customers are ready to buy next", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "26px", foregroundColor: "#0f172a", paddingTop: "8px", @@ -461,7 +415,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "Use this for upgrades, bundles, complementary offers, limited-time incentives, and premium add-ons that naturally follow an initial purchase.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "17px", lineHeight: "1.7", foregroundColor: "#475569", @@ -494,7 +447,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "## Why this converts\n\n- Relevant add-on recommendation.\n- Clear next step.\n- A premium but simple visual treatment.\n- Plenty of room for benefits, pricing, or testimonials.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "16px", lineHeight: "1.7", foregroundColor: "#0f172a", @@ -513,7 +465,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< }, }, createFooterEmailBlock({ - fontFamily: "Helvetica, sans-serif", paddingTop: "0px", paddingBottom: "0px", }), @@ -583,24 +534,11 @@ const MARKETING_SYSTEM_TEMPLATES: Array< "A clean, editorial newsletter for recurring updates and curated stories.", }, content: [ - { - blockType: "text", - settings: { - content: "WEEKLY NEWSLETTER", - alignment: "left", - fontFamily: "Arial, sans-serif", - fontSize: "12px", - foregroundColor: "#0f766e", - paddingTop: "6px", - paddingBottom: "0px", - }, - }, { blockType: "text", settings: { content: "# What happened this week", alignment: "left", - fontFamily: "Arial, sans-serif", fontSize: "26px", foregroundColor: "#0f172a", paddingTop: "10px", @@ -613,7 +551,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "Use this for curated updates, editorial roundups, key announcements, and useful links your audience will want to save.", alignment: "left", - fontFamily: "Arial, sans-serif", fontSize: "17px", lineHeight: "1.7", foregroundColor: "#475569", @@ -637,7 +574,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "## Featured update\n\nShare the biggest story or insight first. A short explanation plus one strong CTA is usually enough.", alignment: "left", - fontFamily: "Arial, sans-serif", fontSize: "16px", lineHeight: "1.7", foregroundColor: "#0f172a", @@ -670,7 +606,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "## Also inside\n\n- A quick tip or takeaway.\n- One resource worth sharing.\n- A subtle CTA to your offer or archive.", alignment: "left", - fontFamily: "Arial, sans-serif", fontSize: "16px", lineHeight: "1.7", foregroundColor: "#0f172a", @@ -689,7 +624,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< }, }, createFooterEmailBlock({ - fontFamily: "Arial, sans-serif", paddingTop: "0px", paddingBottom: "0px", }), @@ -763,7 +697,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< content: "# Start writing here\n\nAdd your message, links, and call to action.", alignment: "left", - fontFamily: "Helvetica, sans-serif", fontSize: "16px", foregroundColor: "#111827", paddingTop: "12px", @@ -771,7 +704,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array< }, }, createFooterEmailBlock({ - fontFamily: "Helvetica, sans-serif", paddingTop: "8px", paddingBottom: "0px", }), diff --git a/apps/api/src/templates/validation.test.ts b/apps/api/src/templates/validation.test.ts index fb2b33e4..dc92a8fc 100644 --- a/apps/api/src/templates/validation.test.ts +++ b/apps/api/src/templates/validation.test.ts @@ -88,6 +88,65 @@ describe("template content validation", () => { ); }); + it("accepts the new blocks and rejects malformed ones at write time", () => { + const email = (block: Email["content"][number]): Email => ({ + ...defaultEmail, + content: [block, createFooterEmailBlock()], + }); + const valid: Email["content"] = [ + { blockType: "spacer", settings: { height: "24px" } }, + { blockType: "columns", settings: { left: "a", right: "b", ratio: "12" } }, + { + blockType: "social", + settings: { links: [{ network: "x", url: "https://x.com/sendlit" }] }, + }, + { blockType: "html", settings: { code: "
Hi
" } }, + { + blockType: "conditional", + settings: { tag: "customer", has: false, content: "Hi" }, + }, + ]; + for (const block of valid) { + expect(() => + validateTemplateInputContent(email(block), "marketing"), + ).not.toThrow(); + } + + const invalid: Email["content"] = [ + { blockType: "spacer", settings: { height: "tall" } }, + { blockType: "columns", settings: { left: "a" } }, + { + blockType: "social", + settings: { links: [{ network: "x", url: "javascript:alert(1)" }] }, + }, + { blockType: "html", settings: {} }, + { + blockType: "conditional", + settings: { tag: 'a" %}{% endif %}', content: "Hi" }, + }, + ]; + for (const block of invalid) { + expect(() => + validateTemplateInputContent(email(block), "marketing"), + ).toThrowError(expect.objectContaining({ message: "invalid_email_content" })); + } + }); + + it("keeps conditional sections out of transactional content", () => { + const transactional: Email = { + ...defaultEmail, + content: [ + { + blockType: "conditional", + settings: { tag: "customer", content: "Hi" }, + }, + ], + }; + expect(() => + validateTemplateInputContent(transactional, "transactional"), + ).toThrowError(expect.objectContaining({ message: "conditional_not_allowed" })); + }); + it("rejects footer and reserved marketing variables transactionally", () => { expect(() => validateTemplateContent(content("Hello", "marketing"), "transactional"), diff --git a/apps/api/src/templates/validation.ts b/apps/api/src/templates/validation.ts index a022efdb..b29a30f0 100644 --- a/apps/api/src/templates/validation.ts +++ b/apps/api/src/templates/validation.ts @@ -2,6 +2,8 @@ import { emailContentInputSchema, type TemplatePurpose } from "@sendlit/api-cont import type { Email, EmailBlock } from "@sendlit/email-editor"; import { discoverRequiredTemplateVariables } from "../mail/render"; +// fontFamily is still accepted so older templates save, but the footer now +// uses the email's text font and ignores it. const FOOTER_SETTINGS = new Set([ "alignment", "fontFamily", @@ -122,6 +124,11 @@ export function validateTemplateContent( if (footerIndexes.length > 0) { throw new TemplateValidationError("footer_not_allowed"); } + // Conditional sections read the subscriber's tags; transactional sends have + // no subscriber. + if (contentBlocks.some((block) => block.blockType === "conditional")) { + throw new TemplateValidationError("conditional_not_allowed"); + } const reservedVariables = findReservedMarketingVariables(content); if (reservedVariables.length > 0) { throw new TemplateValidationError( diff --git a/apps/docs/content/docs/developers/errors.mdx b/apps/docs/content/docs/developers/errors.mdx index c15dc7b0..07e0019d 100644 --- a/apps/docs/content/docs/developers/errors.mdx +++ b/apps/docs/content/docs/developers/errors.mdx @@ -14,6 +14,7 @@ on the code rather than human-readable descriptions. | `422` | `template_conversion_requires_changes` | Marketing-only references remain outside the managed footer. | | `422` | `footer_required` | Marketing content does not contain exactly one final managed footer. | | `422` | `footer_not_allowed` | Transactional content contains a managed footer. | +| `422` | `conditional_not_allowed` | Transactional content contains a conditional section. | | `422` | `marketing_variables_not_allowed` | Transactional content references marketing-owned values. | Missing and conversion errors include the affected paths: diff --git a/apps/docs/content/docs/developers/mcp.mdx b/apps/docs/content/docs/developers/mcp.mdx index e9b0e97b..b3f50f09 100644 --- a/apps/docs/content/docs/developers/mcp.mdx +++ b/apps/docs/content/docs/developers/mcp.mdx @@ -109,7 +109,7 @@ sendable templates. Use `duplicate_template` to copy or safely convert one. Template and sequence-email content is validated as a complete SendLit email document on every write. The `style` must include `colors`, `typography.header`, `typography.text`, `typography.link`, `interactives`, and -`structure`; blocks must be one of text, link, image, separator, or footer. +`structure`; blocks must be one of text, link, image, separator, spacer, columns, social, html, conditional, or footer. A conditional section shows its content only to contacts with (or without) a tag, so it is marketing-only (`conditional_not_allowed` on transactional content). Metadata supports only optional `previewText` and an optional `utm` object with `source`, `medium`, and `campaign` strings. For AI-created content, start with `list_system_templates` and diff --git a/apps/docs/content/docs/email-blocks/email-editor.mdx b/apps/docs/content/docs/email-blocks/email-editor.mdx index fd0f53a8..92acc948 100644 --- a/apps/docs/content/docs/email-blocks/email-editor.mdx +++ b/apps/docs/content/docs/email-blocks/email-editor.mdx @@ -3,8 +3,8 @@ title: EmailEditor description: WYSIWYG editor for an email's content. --- -`EmailEditor` edits an `Email`'s block content (text, separator, image, link -by default) and reports changes via `onChange`; pass `blocks` to customize +`EmailEditor` edits an `Email`'s block content (text, link, image, separator, spacer, +columns, social, html and conditional by default) and reports changes via `onChange`; pass `blocks` to customize which block types are available. If all you need is the editor and renderer — no filters, triggers, or the @@ -32,6 +32,90 @@ function Example() { } ``` +### Writing, subject and merge tags + +The editor opens in **Write** mode: the subject and preview text sit above the +email like a document title, text is written inline as markdown (select text for +a format bar, type `/` for blocks, type `{{` for merge tags), and blocks can be +dragged by their handle. **Style** mode shows the email as readers will see it +(desktop or mobile, sample data, a dark inbox) with a panel for the palette, +typography and the selected block. A "Before you send" checklist flags a missing +subject or preview text, empty buttons and images without alt text. + +Pass the optional props below to turn those on: + +```tsx +