Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -106,7 +106,7 @@ export function registerTemplateTools(server: McpToolRegistrar): void {
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
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
5 changes: 5 additions & 0 deletions apps/api/src/templates/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,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
2 changes: 1 addition & 1 deletion apps/docs/content/docs/developers/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 73 additions & 2 deletions apps/docs/content/docs/email-blocks/email-editor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -32,6 +32,71 @@ 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
<EmailEditor
email={email}
onChange={setEmail}
subject={subject}
onSubjectChange={setSubject}
variables={[
{
tag: "{{ subscriber.name }}",
description: "The name of the subscriber",
sample: "Priya",
},
]}
senderName="Acme"
theme="sendlit" // "sendlit" | "neutral" | "dark"
/>
```

Without `subject` the subject field is hidden, so a host that edits it elsewhere
keeps working. Saving the subject is up to the host. The editor's look is a set
of `--sle-*` CSS variables on the `.sle` element (colours, radii, fonts), so it
can be re-skinned without touching the components. Import
`@sendlit/email-editor/styles.css` for the styles.

### Choosing blocks

`blocks` replaces the default list, so a host can add its own blocks and leave
out any of the built-in ones, including all of them. Custom blocks are stored
and rendered exactly as given. Writing paragraph by paragraph (Enter starts a
new paragraph, `#` makes a heading, `-` a list) applies to the built-in `text`
block. Without it, or with a host block registered under the name `text`, the
editor still works: blocks are inserted from the menu, selected, reordered and
edited through their settings, and the "keep writing" prompt is hidden.

### Built-in blocks

| Block | Settings |
| ------------- | ------------------------------------------------------------------ |
| `text` | Markdown `content`: paragraphs, headings, lists and quotes |
| `link` | A link, or a button when `isButton` is set |
| `image` | `src`, `alt`, size and alignment |
| `separator` | A divider line |
| `spacer` | Empty `height` |
| `columns` | Two markdown columns, `ratio`, optional stacking on small screens |
| `social` | `links`: `{ network, url }` buttons for your profiles |
| `html` | Raw `code`, sent as written |
| `conditional` | Markdown `content` shown only to contacts with (or without) a tag |

A `conditional` section is rendered as a Liquid condition on
`subscriber.tags`, so it only works where a subscriber exists (marketing sends).
`renderEmailToHtml` keeps the condition by default; pass `showConditionals` for
a preview that shows every section.

### Custom image uploader

Use `blocks` to replace the default image block settings with a wrapper that
Expand Down Expand Up @@ -117,6 +182,12 @@ compliance. Host applications can register add-on blocks with:
- `placement: "any" | "first" | "last"`
- an optional `renderContext` supplied by the host

- an optional `quickEdit` component (and `quickEditLayout: "row" | "column"`)
with the few fields worth editing while writing, such as a button's label and
link. Write mode shows it in a small popover under the selected block, with
Style, duplicate and delete; the full `settings` form stays in Style mode.
Without one, the popover offers just those three actions.

Mutation handlers enforce these capabilities as well as hiding unavailable
controls. The render context is passed to preview and HTML rendering but is
never serialized into the `Email` document.
Expand Down
5 changes: 5 additions & 0 deletions apps/web/app/(dashboard)/templates/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ mock.module("@sendlit/email-editor/blocks", () => ({
Text: {},
Separator: {},
Link: {},
Spacer: {},
Columns: {},
Social: {},
HtmlSnippet: {},
Conditional: {},
ImageBlock: { configure: () => ({}) },
}));
mock.module("@/components/dashboard/email-image-upload-dialog", () => ({
Expand Down
22 changes: 8 additions & 14 deletions apps/web/app/editor/templates/[templateId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Banner } from "@/components/dashboard/banner";
import { EmailEditorScreen } from "@/components/dashboard/email-editor-screen";
import { Loading } from "@/components/dashboard/loading";
import { Input } from "@/components/ui/codelit/input";
import { Label } from "@/components/ui/codelit/label";
import { getTemplate, updateTemplate } from "@/lib/api";
import { ApiError } from "@/lib/api-client";

Expand Down Expand Up @@ -42,7 +41,6 @@ export default function TemplateEditorPage({
return (
<EmailEditorScreen
exitFallbackHref="/templates"
screenTitle="Editing template"
saveLabel="Save template"
initialContent={content}
purpose={purpose}
Expand All @@ -54,18 +52,14 @@ export default function TemplateEditorPage({
});
setRequiredVariables(updated.requiredVariables);
}}
header={
<div className="flex max-w-md items-center gap-3">
<Label htmlFor="template-title" className="shrink-0 text-muted-foreground">
Title
</Label>
<Input
id="template-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Welcome email"
/>
</div>
title={
<Input
aria-label="Template title"
className="max-w-sm"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Welcome email"
/>
}
/>
);
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "../../../packages/email-editor/src/editor.css";

@source "../../../packages/email-editor/src/**/*.{ts,tsx}";
@source "../../../packages/email-blocks/src/**/*.{ts,tsx}";
Expand Down
28 changes: 22 additions & 6 deletions apps/web/components/dashboard/email-editor-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ mock.module("@sendlit/email-editor/blocks", () => ({
Text: {},
Separator: {},
Link: {},
Spacer: {},
Columns: {},
Social: {},
HtmlSnippet: {},
Conditional: { metadata: { name: "conditional" } },
ImageBlock: { configure: () => ({}) },
}));
mock.module("@/components/dashboard/email-image-upload-dialog", () => ({
Expand Down Expand Up @@ -178,9 +183,15 @@ describe("EmailEditorScreen", () => {
block.capabilities?.deletable === false,
),
).toBe(true);
expect(screen.getByText("{{ subscriber.email }}")).toBeTruthy();
expect(screen.getByText("{{ subscriber.tags }}")).toBeTruthy();
expect(screen.queryByText("{{ address }}")).toBeNull();
const tags = mocks.editorProps.variables.map((variable: any) => variable.tag);
expect(tags).toContain("{{ subscriber.email }}");
expect(tags).toContain("{{ subscriber.tags }}");
expect(tags).not.toContain("{{ address }}");
expect(
mocks.editorProps.blocks.some(
(block: any) => block.metadata?.name === "conditional",
),
).toBe(true);
});

it("keeps transactional editing footer-free and exposes required business variables", () => {
Expand All @@ -198,9 +209,14 @@ describe("EmailEditorScreen", () => {
mocks.editorProps.blocks.some((block: any) => block.metadata?.name === "footer"),
).toBe(false);
expect(mocks.editorProps.renderContext).toBeUndefined();
expect(screen.getByText("{{ customer.name }}")).toBeTruthy();
expect(screen.getByText("{{ otp }}")).toBeTruthy();
expect(screen.queryByText("{{ subscriber.email }}")).toBeNull();
const tags = mocks.editorProps.variables.map((variable: any) => variable.tag);
expect(tags).toEqual(["{{ customer.name }}", "{{ otp }}"]);
// Conditional sections need a subscriber, which transactional sends lack.
expect(
mocks.editorProps.blocks.some(
(block: any) => block.metadata?.name === "conditional",
),
).toBe(false);
});

it("does not expose template duplication from the editor", async () => {
Expand Down
Loading
Loading