Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a48af67
feat(email-editor): writing-first editor with Write and Style modes a…
s3rot0n1n Nov 1, 2026
fa08ab0
feat(api): validate the new email blocks and keep conditionals out of…
s3rot0n1n Nov 1, 2026
acdd705
feat(web): use the new editor for templates and sequence emails, with…
s3rot0n1n Nov 1, 2026
4dc3d8c
docs: document the new editor, its blocks and conditional_not_allowed…
s3rot0n1n Nov 1, 2026
b75a8a5
fix(email-editor): drop the up/down side bar while writing; the grip …
s3rot0n1n Nov 1, 2026
c6fdea7
fix(email-editor): make block settings usable from the keyboard while…
s3rot0n1n Nov 1, 2026
9e829b4
fix(email-editor): keyboard access for every block while writing (#169)
s3rot0n1n Nov 1, 2026
4d9c5d4
fix(email-editor): paragraphs typed after a title block use body text…
s3rot0n1n Nov 1, 2026
58fa0e1
feat(templates): drop the all-caps label above each marketing starter…
s3rot0n1n Nov 1, 2026
e6d5c45
fix(email-editor): keyboard and screen reader pass over the whole edi…
s3rot0n1n Nov 1, 2026
06268f8
fix(email-editor): keep the editor's button reset off shadcn controls…
s3rot0n1n Nov 2, 2026
0cbb93a
fix(email-editor): dark inbox preview flips each block's own colours …
s3rot0n1n Nov 2, 2026
f604ca9
fix(email-editor): a palette recolours blocks that set their own colo…
s3rot0n1n Nov 2, 2026
8a3d010
fix(email-editor): quotes are tinted from the palette, not light grey…
s3rot0n1n Nov 2, 2026
28dc039
fix(email-editor): readable text on every palette, in Write and dark …
s3rot0n1n Nov 2, 2026
5073ede
docs(email-editor): beginner-friendly README with accessibility stand…
s3rot0n1n Nov 2, 2026
1d4f0c0
Merge remote-tracking branch 'origin/main' into t3/implement-issue-169
s3rot0n1n Nov 2, 2026
169ede0
style: sort imports in files changed for #169
s3rot0n1n Nov 2, 2026
3277753
feat(email-editor): fonts are set for the whole email, not per block …
s3rot0n1n Nov 2, 2026
578cb42
Merge remote-tracking branch 'origin/main' into t3/implement-issue-169
s3rot0n1n Nov 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/richer-email-editor.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions apps/api/docs/transactional-vs-marketing-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_`
Expand Down Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions apps/api/src/mail/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/mcp/tools/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
68 changes: 0 additions & 68 deletions apps/api/src/templates/system-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -201,7 +186,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array<
},
},
createFooterEmailBlock({
fontFamily: "Helvetica, sans-serif",
paddingTop: "0px",
paddingBottom: "0px",
}),
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -356,7 +325,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array<
},
},
createFooterEmailBlock({
fontFamily: "Helvetica, sans-serif",
paddingTop: "0px",
paddingBottom: "0px",
}),
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -513,7 +465,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array<
},
},
createFooterEmailBlock({
fontFamily: "Helvetica, sans-serif",
paddingTop: "0px",
paddingBottom: "0px",
}),
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -689,7 +624,6 @@ const MARKETING_SYSTEM_TEMPLATES: Array<
},
},
createFooterEmailBlock({
fontFamily: "Arial, sans-serif",
paddingTop: "0px",
paddingBottom: "0px",
}),
Expand Down Expand Up @@ -763,15 +697,13 @@ 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",
paddingBottom: "12px",
},
},
createFooterEmailBlock({
fontFamily: "Helvetica, sans-serif",
paddingTop: "8px",
paddingBottom: "0px",
}),
Expand Down
59 changes: 59 additions & 0 deletions apps/api/src/templates/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>Hi</p>" } },
{
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"),
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/templates/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/developers/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading