Feat/advanced text editor capability presets (#23657)

# Email editor for Compose campaigns

## Short version

Campaign bodies are currently plain rich text. This PR turns the
composer into an email editor: a centered email canvas with section,
column, button, divider, image and raw-HTML blocks, each editable
through a settings side panel, rendered to email-safe HTML per recipient
at send time. Modelled on Resend's Broadcast editor.

**Product**

- Email canvas with page/body styling (background, width, padding,
corner radius, border, text colour, alignment)
- Blocks: section, 2/3 columns, button, divider, raw HTML, images —
insertable from a floating left rail or the slash menu
- A **section is a container whose typography cascades to its
contents**, so one part of an email can have its own look
- Block settings panel focuses whatever you select and shows its
effective values
- Per-recipient variables (`{{firstName}}`, `{{lastName}}`,
`{{fullName}}`, `{{email}}`, `{{personId}}`) usable in text,
button/link/image URLs, image labels and raw HTML
- Image upload by drag-drop, paste or file picker

**Technical**

- Presets now declare **capabilities** instead of surfaces forking the
editor; the UI derives itself from loaded extensions
- Editor behavior lives in `twenty-front`; the versioned email-document
schema and structural traversal live in `twenty-shared`; rendering lives
in `twenty-emails` — HTML is produced server-side per recipient
- Section typography cascade is **resolved at render time**, not left to
CSS: react-email hardcodes `fontSize`/`lineHeight` on every paragraph
and Outlook ignores `inherit`
- Logic vendored from Resend (MIT); all controls rebuilt on `twenty-ui`
+ Linaria

**Also fixes:** the unsubscribe footer was being appended *after*
`</html>`, outside the document, where Gmail strips it — legally
significant since unsubscribe is required.


---

## Detailed version

### Product requirements

**Problem.** The Compose campaign body was a single rich-text field.
Marketing email needs layout — banded sections, columns, call-to-action
buttons, images with links — and it needs that layout to survive
Outlook, which means table-based HTML rather than the divs a text editor
produces. It also needs per-recipient personalisation.

**Reference.** Resend's Broadcast editor, chosen because it solves the
same problem (TipTap authoring → react-email output) and is MIT
licensed.

#### What a user can now do

| Area | Capability |
|---|---|
| Canvas | Email renders as a centered page with its own background,
width, padding, corner radius and border |
| Blocks | Section, 2/3 columns, button, divider, raw HTML, image |
| Insertion | Floating left rail (pointer-first) or the `/` slash menu
(keyboard-first) |
| Sections | Own text colour, font size, line height, letter spacing and
alignment, cascading to everything inside |
| Images | Upload by drag-drop, paste or picker; link URL, alt text,
width, spacing, border |
| Raw HTML | Edited as source in the panel, previewed on the canvas with
scripts neutralised |
| Variables | `{{firstName}}`, `{{lastName}}`, `{{fullName}}`,
`{{email}}`, `{{personId}}` in text, button URLs, link hrefs and raw
HTML |
| Settings panel | Follows selection; shows effective values; opens
automatically when a block is clicked |

#### Deliberate product decisions

- **Variables display as literal placeholders**, not prose labels, so
the syntax is copyable into HTML blocks and button URLs by hand.
- **Sections inherit until they override.** The panel shows what
actually renders rather than blank fields, but writes nothing until you
edit — so changing the body text colour still flows into sections.
- **Headings keep their own scale** inside a styled section; only
colour, family and spacing cascade, otherwise every heading would
collapse to body size.
- **Clicking a block opens its settings**, but only on whole-node
selections, so typing inside a section does not reopen a panel you just
closed.

### Technical strategy

#### 1. Capability presets (the foundation)

Per-surface variation previously worked by **forking**: three separate
`useEditor` call sites with hardcoded extension arrays. Inside the
shared tree there was no variation at all — all five surfaces received a
byte-identical extension list, and presets controlled only sizing,
chrome and serialization format. Adding email blocks that way meant
either leaking section/column nodes into the record rich-text field and
workflow email body, or writing a fourth fork.

Now:

- a preset declares a **capability list** (`basicMarks`, `headings`,
`lists`, `links`, `images`, `campaignVariables`, `slashCommand`,
`blocks`, `mentions`)
- capabilities resolve to extensions through a factory registry
- the UI derives itself from the loaded extensions via
`hasEditorExtension` — no capability list is prop-drilled into a menu,
because the `Editor` already knows what it can do

The acceptance test was collapsing the AI chat fork into an `aiChat`
preset with no visible change to that composer. `campaignBody` is the
only preset opting into the shared `EMAIL_DOCUMENT_CAPABILITIES` today.
Workflow email keeps its current field UI, but can opt into the same
canvas, block settings and image uploader later without adding another
schema or renderer.

#### 2. Schema / renderer split

The hard constraint: **our HTML is produced server-side, per recipient,
at send time**, because variables substitute into nodes rather than into
a serialized string. That rules out Resend's
`renderToReactEmail`-on-the-extension pattern.

```
twenty-front     TipTap extensions + node views + shared email settings UI
twenty-shared    versioned email-document schema + structural traversal
twenty-emails    react-email renderers (imported by twenty-server)
twenty-server    surface-specific variable resolution, validation, send
```

Logic was **vendored, not depended on** — Resend's TipTap is 3.17
against our 3.4, and their UI is Radix. We copied the schema/serializer
approach and rebuilt every control on `twenty-ui` + Linaria.

#### 3. Section typography cascade

The subtle part, and the one that would have silently shipped broken.

Section typography *looks* like it should cascade via CSS. It does not:

```js
// react-email's Text
style: { fontSize: "14px", lineHeight: "24px", ...style, ...margins }
```

Every paragraph re-declares `fontSize` and `lineHeight`, overriding any
enclosing section. `inherit` is not a fix either — Outlook's Word engine
ignores it.

So the cascade is **resolved in the renderer**: the tree walk threads
the enclosing section's typography down and writes computed values
explicitly onto each text node. Nested sections refine what they
inherit.

Verified against real rendered output:

| | rendered |
|---|---|
| paragraph inside section | `font-size:22px; color:rgb(255,0,0);
letter-spacing:2px` |
| h1 inside section | `font-size:32px` (own scale) + section colour and
spacing |
| paragraph outside | `font-size:14px`, no colour — untouched |

#### 4. Storage

`bodyTemplate` stays serialized TipTap JSON in a `TEXT` column. Block
attributes are ProseMirror node attrs, so richer blocks add keys to JSON
already being serialized — no migration, and it flows into the existing
500 ms debounced draft save unchanged.

Since the feature has not shipped, the legacy HTML-string body path was
removed rather than maintained. That is a tightening, not just a
deletion: `bodyTemplate` is writable through the record API, and the old
fallback would interpolate an arbitrary string and email it as markup. A
body that is neither empty nor a valid TipTap document is now rejected
at the send gate.

#### 5. Image hosting

Inline assets use an `EmailImage` file folder with
`ignoreExpirationToken: true` and immutable cache headers, because
recipients' mail clients never authenticate and may open an email years
later. The shared uploader returns `{ fileId, url }`; the image node
keeps both the durable file identity and its delivery URL so
ownership/lifecycle or URL resolution can evolve later without a
document migration. The server verifies the uploaded bytes and only
accepts GIF, JPEG, PNG and WebP.

This is intentionally separate from workflow/email **attachments**.
Attachments remain private files that the server reads and embeds as
MIME parts at send time; inline images need a durable recipient-facing
URL. A future workflow canvas should reuse `useUploadEmailImage` for
inline content while keeping its existing attachment control unchanged.

Adding the folder requires three registrations — the folder config, the
route guard's `SUPPORTED_FILE_FOLDERS`, and `DIRECT_UPLOAD_FILE_FOLDERS`
in the upload service.

### Bugs fixed along the way

- **Unsubscribe footer was appended after `</html>`**, outside the
document, where Gmail strips it. Legally significant, since an
unsubscribe link is required. Now inserted before `</body>`.
- **Body text colour never reached the email.**
- **`onImageUpload` was declared but never passed** by any production
call site, so drag-drop and paste image upload were inert everywhere
outside Storybook.
- **Message lists were not user-facing**, so members could not be added
from the list page.
- **Image resize wrote an undeclared `width` attribute** that TipTap
silently dropped.
- **The text bubble menu appeared over selected atom blocks** with
nothing to format.

### Review notes / known limitations

**Security posture to check.** Anything in `EmailImage` is readable by
anyone holding the URL, forever. The server now enforces an image-only
MIME allowlist from sniffed bytes, but it cannot determine whether the
image itself is confidential. This remains a deliberate trade-off for
recipient-visible inline assets.

**Test gap.** The section typography cascade has no regression test:
react-email's `render()` hangs under Jest (tried 60s), and
`twenty-emails` has no test target at all. Verified by rendering through
the built package instead. Adding a test target there is worthwhile
follow-up.

**Sending needs configuration.** `EMAILING_DOMAIN_DRIVER` defaults to
`LOG`, which fakes a messageId, reports any domain as verified, and only
logs — a campaign reaches "sent" with nothing delivered. Real sending
needs `AWS_SES`.

**Unrelated platform bug found.** The pinned "Create new record" command
throws on viewless objects like `messageListMember`, because
`recordIndexId` derives from the current view.

**Not done.** Panel chrome from the reference: breadcrumb (`Page style /
Section`), collapsible groups, per-side spacing grid, and a
variable-insert button inside link fields. All presentation over the
same data.

**Deferred.** Drag-to-reorder blocks.
`@tiptap/extension-drag-handle-react@3.4.2` matches our pinned versions
exactly, so no upgrade is needed, but its behaviour around atom node
views (HTML block, image) is unverified and belongs in its own change.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23657?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Marie
2026-08-05 12:38:06 +02:00
committed by GitHub
parent 5effee7754
commit 1d755983ff
243 changed files with 7620 additions and 569 deletions
@@ -0,0 +1,6 @@
export const EMAIL_IMAGE_MIME_TYPES = [
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
] as const;
@@ -30,6 +30,7 @@ export type { DocumentationPath } from './DocumentationPaths';
export { DOCUMENTATION_PATHS } from './DocumentationPaths';
export type { DocumentationSupportedLanguage } from './DocumentationSupportedLanguages';
export { DOCUMENTATION_SUPPORTED_LANGUAGES } from './DocumentationSupportedLanguages';
export { EMAIL_IMAGE_MIME_TYPES } from './EmailImageMimeTypes';
export type { EnterpriseInstanceType } from './EnterpriseInstanceType';
export { ENTERPRISE_INSTANCE_TYPE } from './EnterpriseInstanceType';
export { EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA } from './ExcludedFieldNamesFromAgentToolSchema';
@@ -9,6 +9,7 @@ export enum FileFolder {
Dependencies = 'dependencies',
Workflow = 'workflow',
EmailAttachment = 'email-attachment',
EmailImage = 'email-image',
AppTarball = 'app-tarball',
GeneratedSdkClient = 'generated-sdk-client',
Dpa = 'dpa',
@@ -28,4 +28,5 @@ export enum SidePanelPages {
PageLayoutRecordPageWidgetTypeSelect = 'page-layout-record-page-widget-type-select',
ComposeEmail = 'compose-email',
SendCampaignTest = 'send-campaign-test',
EmailBlockSettings = 'email-block-settings',
}
+18 -7
View File
@@ -197,17 +197,28 @@ export { pascalToKebab } from './strings/pascalToKebab';
export { stringifySafely } from './strings/stringifySafely';
export { uncapitalize } from './strings/uncapitalize';
export { getSubdomainSlugFromDisplayName } from './subdomain/getSubdomainSlugFromDisplayName';
export type { CanvasTheme } from './tiptap/canvas-theme';
export { CANVAS_THEME_DEFAULTS } from './tiptap/canvas-theme';
export type { EmailDocumentNode } from './tiptap/email-document-node';
export { EMAIL_DOCUMENT_SCHEMA_VERSION } from './tiptap/email-document-schema-version';
export type { EmailDocument } from './tiptap/email-document-schema';
export { emailDocumentSchema } from './tiptap/email-document-schema';
export type { EmailDocumentStringContext } from './tiptap/email-document-string-context';
export { isCanvasTheme } from './tiptap/is-canvas-theme';
export type { CampaignVariableDefinition } from './tiptap/list-campaign-variables-for-fields';
export { listCampaignVariablesForFields } from './tiptap/list-campaign-variables-for-fields';
export { parseEmailDocument } from './tiptap/parse-email-document';
export { resolveCanvasTheme } from './tiptap/resolve-canvas-theme';
export type {
TipTapMarkType,
TipTapNodeType,
LinkMarkAttributes,
TipTapMark,
} from './tiptap/tiptap-marks';
export {
TIPTAP_MARK_TYPES,
TIPTAP_NODE_TYPES,
TIPTAP_MARKS_RENDER_ORDER,
} from './tiptap/tiptap-marks';
} from './tiptap/tiptap-mark-types';
export { TIPTAP_MARK_TYPES } from './tiptap/tiptap-mark-types';
export { TIPTAP_MARKS_RENDER_ORDER } from './tiptap/tiptap-marks-render-order';
export type { TipTapNodeType } from './tiptap/tiptap-node-types';
export { TIPTAP_NODE_TYPES } from './tiptap/tiptap-node-types';
export { transformEmailDocumentStrings } from './tiptap/transform-email-document-strings';
export type { StringPropertyKeys } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
export { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
export { trimAndRemoveDuplicatedWhitespacesFromString } from './trim-and-remove-duplicated-whitespaces-from-string';
@@ -0,0 +1,75 @@
import { FieldMetadataType } from '../../../types/FieldMetadataType';
import { listCampaignVariablesForFields } from '../list-campaign-variables-for-fields';
describe('listCampaignVariablesForFields', () => {
it('should expose scalar fields under their own name', () => {
expect(
listCampaignVariablesForFields([
{ name: 'city', label: 'City', type: FieldMetadataType.TEXT },
{ name: 'score', label: 'Score', type: FieldMetadataType.NUMBER },
]),
).toEqual([
{
name: 'city',
label: 'City',
fieldName: 'city',
fieldType: FieldMetadataType.TEXT,
},
{
name: 'score',
label: 'Score',
fieldName: 'score',
fieldType: FieldMetadataType.NUMBER,
},
]);
});
it('should expand the name composite and skip contact-detail composites', () => {
const definitions = listCampaignVariablesForFields([
{ name: 'name', label: 'Name', type: FieldMetadataType.FULL_NAME },
{ name: 'emails', label: 'Emails', type: FieldMetadataType.EMAILS },
{
name: 'linkedinLink',
label: 'LinkedIn',
type: FieldMetadataType.LINKS,
},
{ name: 'phones', label: 'Phones', type: FieldMetadataType.PHONES },
]);
expect(definitions.map((definition) => definition.name)).toEqual([
'name.firstName',
'name.lastName',
]);
expect(definitions[0].label).toBe('Name · First name');
});
it('should skip system, inactive and unsupported fields', () => {
expect(
listCampaignVariablesForFields([
{
name: 'searchVector',
label: 'Search vector',
type: FieldMetadataType.TEXT,
isSystem: true,
},
{
name: 'oldField',
label: 'Old field',
type: FieldMetadataType.TEXT,
isActive: false,
},
{
name: 'company',
label: 'Company',
type: FieldMetadataType.RELATION,
},
{
name: 'createdBy',
label: 'Created by',
type: FieldMetadataType.ACTOR,
},
]),
).toEqual([]);
});
});
@@ -0,0 +1,231 @@
import { EMAIL_DOCUMENT_SCHEMA_VERSION } from '../email-document-schema-version';
import { parseEmailDocument } from '../parse-email-document';
const paragraph = (text: string) => ({
type: 'paragraph',
content: [{ type: 'text', text }],
});
describe('parseEmailDocument', () => {
it('should accept a full composer document', () => {
const document = {
type: 'doc',
attrs: {
schemaVersion: EMAIL_DOCUMENT_SCHEMA_VERSION,
canvasTheme: {
pageBackground: '#f4f4f5',
bodyBackground: '#ffffff',
width: '600px',
textAlign: 'left',
},
},
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Hello' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Hi ' },
{ type: 'variableTag', attrs: { variable: '{{firstName}}' } },
{ type: 'hardBreak' },
{
type: 'text',
text: 'read this',
marks: [
{ type: 'bold' },
{ type: 'link', attrs: { href: 'https://example.com' } },
],
},
],
},
{
type: 'section',
attrs: { style: { padding: '12px', backgroundColor: '#eeeeee' } },
content: [
paragraph('Inside the section'),
{
type: 'section',
attrs: { style: { padding: '4px' } },
content: [paragraph('Nested')],
},
],
},
{
type: 'columns',
attrs: { style: {} },
content: [
{
type: 'column',
attrs: { style: {} },
content: [paragraph('Left')],
},
{
type: 'column',
attrs: { style: {} },
content: [paragraph('Right')],
},
],
},
{
type: 'button',
attrs: {
href: 'https://example.com/{{personId}}',
style: { color: '#fff' },
},
content: [{ type: 'text', text: 'Click me' }],
},
{
type: 'bulletList',
content: [{ type: 'listItem', content: [paragraph('Item')] }],
},
{
type: 'image',
attrs: {
fileId: '3c5bc42f-e6a8-4a56-a0ca-8b36f3e31db6',
src: 'https://example.com/a.png',
alt: null,
width: 300,
href: '',
},
},
{ type: 'divider', attrs: { style: { borderTopWidth: '1px' } } },
{ type: 'html', attrs: { html: '<p>raw</p>' } },
],
};
expect(parseEmailDocument(document)).toEqual({
success: true,
document: expect.objectContaining({ type: 'doc' }),
});
});
it('should accept a document without schemaVersion or theme', () => {
const result = parseEmailDocument({
type: 'doc',
content: [paragraph('Legacy body')],
});
expect(result.success).toBe(true);
});
it('should accept an empty document', () => {
expect(parseEmailDocument({ type: 'doc' }).success).toBe(true);
});
it('should keep unknown attribute keys', () => {
const result = parseEmailDocument({
type: 'doc',
content: [
{
type: 'image',
attrs: { src: 'https://a.png', futureAttribute: 'kept' },
},
],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.document.content?.[0].attrs?.futureAttribute).toBe('kept');
}
});
it('should reject an unknown node type', () => {
const result = parseEmailDocument({
type: 'doc',
content: [{ type: 'countdownTimer', attrs: {} }],
});
expect(result).toEqual({
success: false,
error: expect.stringContaining('content.0'),
});
});
it('should reject a document from a future schema version', () => {
const result = parseEmailDocument({
type: 'doc',
attrs: { schemaVersion: EMAIL_DOCUMENT_SCHEMA_VERSION + 1 },
content: [paragraph('Hello')],
});
expect(result.success).toBe(false);
});
it('should reject a heading level outside 1-3', () => {
const result = parseEmailDocument({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 4 },
content: [{ type: 'text', text: 'Hi' }],
},
],
});
expect(result.success).toBe(false);
});
it('should reject columns with fewer than two columns', () => {
const result = parseEmailDocument({
type: 'doc',
content: [
{
type: 'columns',
attrs: { style: {} },
content: [
{
type: 'column',
attrs: { style: {} },
content: [paragraph('Only')],
},
],
},
],
});
expect(result.success).toBe(false);
});
it('should reject an empty section', () => {
const result = parseEmailDocument({
type: 'doc',
content: [{ type: 'section', attrs: { style: {} }, content: [] }],
});
expect(result.success).toBe(false);
});
it('should reject an image without src', () => {
const result = parseEmailDocument({
type: 'doc',
content: [{ type: 'image', attrs: { alt: 'no source' } }],
});
expect(result.success).toBe(false);
});
it('should reject an invalid uploaded image file id', () => {
const result = parseEmailDocument({
type: 'doc',
content: [
{
type: 'image',
attrs: { fileId: 'not-a-uuid', src: 'https://example.com/a.png' },
},
],
});
expect(result.success).toBe(false);
});
it('should reject non-document values', () => {
expect(parseEmailDocument(null).success).toBe(false);
expect(parseEmailDocument('a string').success).toBe(false);
expect(parseEmailDocument({ type: 'paragraph' }).success).toBe(false);
});
});
@@ -0,0 +1,60 @@
import { transformEmailDocumentStrings } from '../transform-email-document-strings';
describe('transformEmailDocumentStrings', () => {
it('should transform every supported string location with its context', () => {
const contexts: string[] = [];
const document = transformEmailDocumentStrings(
{
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'variableTag', attrs: { variable: '{{name}}' } },
{
type: 'text',
text: 'Profile',
marks: [{ type: 'link', attrs: { href: '/people/{{id}}' } }],
},
],
},
{
type: 'image',
attrs: {
src: '/images/{{id}}',
href: '/people/{{id}}',
alt: '{{name}}',
title: '{{name}}',
},
},
{ type: 'html', attrs: { html: '<p>{{name}}</p>' } },
],
},
(value, context) => {
contexts.push(context);
return `[${context}]${value}`;
},
);
expect(document.content?.[0].content?.[0].attrs?.variable).toBe(
'[text]{{name}}',
);
expect(
(
document.content?.[0].content?.[1].marks?.[0] as {
attrs: { href: string };
}
).attrs.href,
).toBe('[url]/people/{{id}}');
expect(document.content?.[1].attrs).toEqual({
src: '[url]/images/{{id}}',
href: '[url]/people/{{id}}',
alt: '[text]{{name}}',
title: '[text]{{name}}',
});
expect(document.content?.[2].attrs?.html).toBe('[html]<p>{{name}}</p>');
expect(contexts).toContain('html');
expect(contexts).toContain('text');
expect(contexts).toContain('url');
});
});
@@ -0,0 +1,25 @@
export type CanvasTheme = {
pageBackground: string;
pagePadding: string;
textAlign: 'left' | 'center' | 'right';
bodyBackground: string;
textColor: string;
width: string;
padding: string;
cornerRadius: string;
borderWidth: string;
borderColor: string;
};
export const CANVAS_THEME_DEFAULTS: CanvasTheme = {
pageBackground: '#ffffff',
pagePadding: '24px',
textAlign: 'left',
bodyBackground: '',
textColor: '#18181b',
width: '600px',
padding: '24px',
cornerRadius: '0px',
borderWidth: '0px',
borderColor: '',
};
@@ -0,0 +1,7 @@
export type EmailDocumentNode = {
type: string;
text?: string;
attrs?: Record<string, unknown>;
marks?: unknown[];
content?: EmailDocumentNode[];
};
@@ -0,0 +1 @@
export const EMAIL_DOCUMENT_SCHEMA_VERSION = 1;
@@ -0,0 +1,184 @@
import { z } from 'zod';
import { type EmailDocumentNode } from './email-document-node';
import { EMAIL_DOCUMENT_SCHEMA_VERSION } from './email-document-schema-version';
import { TIPTAP_MARK_TYPES } from './tiptap-mark-types';
import { TIPTAP_NODE_TYPES } from './tiptap-node-types';
const styleAttributeSchema = z
.record(
z
.string()
.regex(/^[a-zA-Z]+$/)
.max(40),
z.string().max(400),
)
.optional();
const markSchema = z.discriminatedUnion('type', [
z.looseObject({ type: z.literal(TIPTAP_MARK_TYPES.BOLD) }),
z.looseObject({ type: z.literal(TIPTAP_MARK_TYPES.ITALIC) }),
z.looseObject({ type: z.literal(TIPTAP_MARK_TYPES.UNDERLINE) }),
z.looseObject({ type: z.literal(TIPTAP_MARK_TYPES.STRIKE) }),
z.looseObject({
type: z.literal(TIPTAP_MARK_TYPES.LINK),
attrs: z.looseObject({ href: z.string().max(4_000) }).optional(),
}),
]);
const textNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.TEXT),
text: z.string().min(1),
marks: z.array(markSchema).optional(),
});
const variableTagNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.VARIABLE_TAG),
attrs: z.looseObject({ variable: z.string().nullable() }),
});
const hardBreakNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.HARD_BREAK),
});
const inlineNodeSchema = z.discriminatedUnion('type', [
textNodeSchema,
variableTagNodeSchema,
hardBreakNodeSchema,
]);
const blockContentSchema = z.array(
z.lazy((): z.ZodType<EmailDocumentNode> => blockNodeSchema),
);
const paragraphNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.PARAGRAPH),
attrs: z.looseObject({}).optional(),
content: z.array(inlineNodeSchema).optional(),
});
const headingNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.HEADING),
attrs: z.looseObject({
level: z.union([z.literal(1), z.literal(2), z.literal(3)]),
}),
content: z.array(inlineNodeSchema).optional(),
});
const listItemNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.LIST_ITEM),
attrs: z.looseObject({}).optional(),
content: blockContentSchema.min(1),
});
const bulletListNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.BULLET_LIST),
attrs: z.looseObject({}).optional(),
content: z.array(listItemNodeSchema).min(1),
});
const orderedListNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.ORDERED_LIST),
attrs: z.looseObject({}).optional(),
content: z.array(listItemNodeSchema).min(1),
});
const imageNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.IMAGE),
attrs: z.looseObject({
fileId: z.uuid().nullable().optional(),
src: z.string().max(4_000),
alt: z.string().nullable().optional(),
title: z.string().nullable().optional(),
align: z.string().nullable().optional(),
width: z.union([z.string(), z.number()]).nullable().optional(),
href: z.string().max(4_000).nullable().optional(),
}),
});
const sectionNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.SECTION),
attrs: z.looseObject({ style: styleAttributeSchema }),
content: blockContentSchema.min(1),
});
const columnNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.COLUMN),
attrs: z.looseObject({ style: styleAttributeSchema }),
content: blockContentSchema.min(1),
});
const columnsNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.COLUMNS),
attrs: z.looseObject({ style: styleAttributeSchema }),
content: z.array(columnNodeSchema).min(2).max(4),
});
const buttonNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.BUTTON),
attrs: z.looseObject({
href: z.string().max(4_000).nullable(),
style: styleAttributeSchema,
}),
content: z
.array(
z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.TEXT),
text: z.string().min(1),
}),
)
.optional(),
});
const dividerNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.DIVIDER),
attrs: z.looseObject({ style: styleAttributeSchema }),
});
const htmlNodeSchema = z.looseObject({
type: z.literal(TIPTAP_NODE_TYPES.HTML),
attrs: z.looseObject({ html: z.string().max(100_000) }),
});
const blockNodeSchema = z.discriminatedUnion('type', [
paragraphNodeSchema,
headingNodeSchema,
bulletListNodeSchema,
orderedListNodeSchema,
imageNodeSchema,
sectionNodeSchema,
columnsNodeSchema,
buttonNodeSchema,
dividerNodeSchema,
htmlNodeSchema,
]);
const canvasThemeAttributeSchema = z.looseObject({
pageBackground: z.string().optional(),
pagePadding: z.string().optional(),
textAlign: z.enum(['left', 'center', 'right']).optional(),
bodyBackground: z.string().optional(),
textColor: z.string().optional(),
width: z.string().optional(),
padding: z.string().optional(),
cornerRadius: z.string().optional(),
borderWidth: z.string().optional(),
borderColor: z.string().optional(),
});
export const emailDocumentSchema = z.looseObject({
type: z.literal('doc'),
attrs: z
.looseObject({
schemaVersion: z
.int()
.min(1)
.max(EMAIL_DOCUMENT_SCHEMA_VERSION)
.optional(),
canvasTheme: canvasThemeAttributeSchema.nullable().optional(),
})
.optional(),
content: blockContentSchema.optional(),
});
export type EmailDocument = z.infer<typeof emailDocumentSchema>;
@@ -0,0 +1 @@
export type EmailDocumentStringContext = 'html' | 'text' | 'url';
@@ -1 +1,13 @@
export * from './tiptap-marks';
export * from './canvas-theme';
export * from './email-document-node';
export * from './email-document-schema';
export * from './email-document-schema-version';
export * from './email-document-string-context';
export * from './is-canvas-theme';
export * from './list-campaign-variables-for-fields';
export * from './parse-email-document';
export * from './resolve-canvas-theme';
export * from './tiptap-mark-types';
export * from './tiptap-marks-render-order';
export * from './tiptap-node-types';
export * from './transform-email-document-strings';
@@ -0,0 +1,4 @@
import { type CanvasTheme } from './canvas-theme';
export const isCanvasTheme = (value: unknown): value is Partial<CanvasTheme> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -0,0 +1,79 @@
import { FieldMetadataType } from '@/types';
export type CampaignVariableDefinition = {
name: string;
label: string;
fieldName: string;
fieldType: FieldMetadataType;
subFieldName?: string;
};
type CampaignVariableEligibleField = {
name: string;
label: string;
type: FieldMetadataType;
isSystem?: boolean | null;
isActive?: boolean | null;
};
const SCALAR_CAMPAIGN_VARIABLE_FIELD_TYPES: FieldMetadataType[] = [
FieldMetadataType.TEXT,
FieldMetadataType.NUMBER,
FieldMetadataType.BOOLEAN,
FieldMetadataType.DATE,
FieldMetadataType.DATE_TIME,
FieldMetadataType.SELECT,
FieldMetadataType.RATING,
];
const COMPOSITE_CAMPAIGN_VARIABLE_SUBFIELDS: Partial<
Record<FieldMetadataType, { subFieldName: string; subFieldLabel: string }[]>
> = {
[FieldMetadataType.FULL_NAME]: [
{ subFieldName: 'firstName', subFieldLabel: 'First name' },
{ subFieldName: 'lastName', subFieldLabel: 'Last name' },
],
};
export const listCampaignVariablesForFields = (
fields: CampaignVariableEligibleField[],
): CampaignVariableDefinition[] => {
const definitions: CampaignVariableDefinition[] = [];
for (const field of fields) {
if (field.isSystem === true || field.isActive === false) {
continue;
}
if (SCALAR_CAMPAIGN_VARIABLE_FIELD_TYPES.includes(field.type)) {
definitions.push({
name: field.name,
label: field.label,
fieldName: field.name,
fieldType: field.type,
});
continue;
}
const subFields = COMPOSITE_CAMPAIGN_VARIABLE_SUBFIELDS[field.type];
if (!subFields) {
continue;
}
for (const { subFieldName, subFieldLabel } of subFields) {
definitions.push({
name: `${field.name}.${subFieldName}`,
label:
subFields.length === 1
? field.label
: `${field.label} · ${subFieldLabel}`,
fieldName: field.name,
fieldType: field.type,
subFieldName,
});
}
}
return definitions;
};
@@ -0,0 +1,23 @@
import {
type EmailDocument,
emailDocumentSchema,
} from './email-document-schema';
export const parseEmailDocument = (
value: unknown,
):
| { success: true; document: EmailDocument }
| { success: false; error: string } => {
const result = emailDocumentSchema.safeParse(value);
if (result.success) {
return { success: true, document: result.data };
}
const issues = result.error.issues
.slice(0, 10)
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
.join('; ');
return { success: false, error: issues };
};
@@ -0,0 +1,5 @@
import { CANVAS_THEME_DEFAULTS, type CanvasTheme } from './canvas-theme';
import { isCanvasTheme } from './is-canvas-theme';
export const resolveCanvasTheme = (value: unknown): CanvasTheme | null =>
isCanvasTheme(value) ? { ...CANVAS_THEME_DEFAULTS, ...value } : null;
@@ -0,0 +1,21 @@
export const TIPTAP_MARK_TYPES = {
BOLD: 'bold',
ITALIC: 'italic',
UNDERLINE: 'underline',
STRIKE: 'strike',
LINK: 'link',
} as const;
export type TipTapMarkType =
(typeof TIPTAP_MARK_TYPES)[keyof typeof TIPTAP_MARK_TYPES];
export interface LinkMarkAttributes {
href?: string;
target?: string;
rel?: string;
}
export interface TipTapMark {
type: TipTapMarkType;
attrs?: LinkMarkAttributes | Record<string, unknown>;
}
@@ -0,0 +1,9 @@
import { TIPTAP_MARK_TYPES, type TipTapMarkType } from './tiptap-mark-types';
export const TIPTAP_MARKS_RENDER_ORDER: readonly TipTapMarkType[] = [
TIPTAP_MARK_TYPES.UNDERLINE,
TIPTAP_MARK_TYPES.BOLD,
TIPTAP_MARK_TYPES.ITALIC,
TIPTAP_MARK_TYPES.STRIKE,
TIPTAP_MARK_TYPES.LINK,
] as const;
@@ -1,48 +0,0 @@
// Shared TipTap types for consistency between frontend and email renderer
export const TIPTAP_MARK_TYPES = {
BOLD: 'bold',
ITALIC: 'italic',
UNDERLINE: 'underline',
STRIKE: 'strike',
LINK: 'link',
} as const;
export const TIPTAP_NODE_TYPES = {
PARAGRAPH: 'paragraph',
TEXT: 'text',
HEADING: 'heading',
VARIABLE_TAG: 'variableTag',
IMAGE: 'image',
BULLET_LIST: 'bulletList',
ORDERED_LIST: 'orderedList',
LIST_ITEM: 'listItem',
HARD_BREAK: 'hardBreak',
} as const;
export type TipTapMarkType =
(typeof TIPTAP_MARK_TYPES)[keyof typeof TIPTAP_MARK_TYPES];
export type TipTapNodeType =
(typeof TIPTAP_NODE_TYPES)[keyof typeof TIPTAP_NODE_TYPES];
// Order for mark rendering (inner to outer)
export const TIPTAP_MARKS_RENDER_ORDER: readonly TipTapMarkType[] = [
TIPTAP_MARK_TYPES.UNDERLINE,
TIPTAP_MARK_TYPES.BOLD,
TIPTAP_MARK_TYPES.ITALIC,
TIPTAP_MARK_TYPES.STRIKE,
TIPTAP_MARK_TYPES.LINK,
] as const;
// Link mark attributes interface
export interface LinkMarkAttributes {
href?: string;
target?: string;
rel?: string;
}
// Generic mark interface
export interface TipTapMark {
type: TipTapMarkType;
attrs?: LinkMarkAttributes | Record<string, unknown>;
}
@@ -0,0 +1,20 @@
export const TIPTAP_NODE_TYPES = {
PARAGRAPH: 'paragraph',
TEXT: 'text',
HEADING: 'heading',
VARIABLE_TAG: 'variableTag',
IMAGE: 'image',
BULLET_LIST: 'bulletList',
ORDERED_LIST: 'orderedList',
LIST_ITEM: 'listItem',
HARD_BREAK: 'hardBreak',
SECTION: 'section',
COLUMNS: 'columns',
COLUMN: 'column',
BUTTON: 'button',
DIVIDER: 'divider',
HTML: 'html',
} as const;
export type TipTapNodeType =
(typeof TIPTAP_NODE_TYPES)[keyof typeof TIPTAP_NODE_TYPES];
@@ -0,0 +1,104 @@
import { type EmailDocumentNode } from './email-document-node';
import { type EmailDocumentStringContext } from './email-document-string-context';
import { TIPTAP_MARK_TYPES } from './tiptap-mark-types';
import { TIPTAP_NODE_TYPES } from './tiptap-node-types';
type StringTransformer = (
value: string,
context: EmailDocumentStringContext,
) => string;
const transformAttribute = (
attributes: Record<string, unknown> | undefined,
attributeName: string,
context: EmailDocumentStringContext,
transform: StringTransformer,
): Record<string, unknown> | undefined => {
const value = attributes?.[attributeName];
if (typeof value !== 'string') {
return attributes;
}
return {
...attributes,
[attributeName]: transform(value, context),
};
};
export const transformEmailDocumentStrings = <TNode extends EmailDocumentNode>(
node: TNode,
transform: StringTransformer,
): TNode => {
let attributes = node.attrs;
if (node.type === TIPTAP_NODE_TYPES.VARIABLE_TAG) {
attributes = transformAttribute(attributes, 'variable', 'text', transform);
}
if (node.type === TIPTAP_NODE_TYPES.BUTTON) {
attributes = transformAttribute(attributes, 'href', 'url', transform);
}
if (node.type === TIPTAP_NODE_TYPES.IMAGE) {
for (const attributeName of ['src', 'href']) {
attributes = transformAttribute(
attributes,
attributeName,
'url',
transform,
);
}
for (const attributeName of ['alt', 'title']) {
attributes = transformAttribute(
attributes,
attributeName,
'text',
transform,
);
}
}
if (node.type === TIPTAP_NODE_TYPES.HTML) {
attributes = transformAttribute(attributes, 'html', 'html', transform);
}
const marks = node.marks?.map((mark) => {
if (
typeof mark !== 'object' ||
mark === null ||
!('type' in mark) ||
mark.type !== TIPTAP_MARK_TYPES.LINK ||
!('attrs' in mark) ||
typeof mark.attrs !== 'object' ||
mark.attrs === null
) {
return mark;
}
return {
...mark,
attrs: transformAttribute(
mark.attrs as Record<string, unknown>,
'href',
'url',
transform,
),
};
});
return {
...node,
...(typeof node.text === 'string' && {
text: transform(node.text, 'text'),
}),
...(attributes && { attrs: attributes }),
...(marks && { marks }),
...(node.content && {
content: node.content.map((childNode) =>
transformEmailDocumentStrings(childNode, transform),
),
}),
} as TNode;
};