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
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { AddEmailBlockSettingsCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785921674941-add-email-block-settings-command-menu-item.command';
import { RepairOrphanCoreWorkflowVersionsCommand } from 'src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785600000000-repair-orphan-core-workflow-versions.command';
import { SyncDiscardDraftWorkflowAvailabilityExpressionCommand } from 'src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1786000000000-sync-discard-draft-workflow-availability-expression.command';
import { SyncDiscardDraftWorkflowAvailabilityExpressionCommand } from 'src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785858486000-sync-discard-draft-workflow-availability-expression.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
@@ -17,6 +18,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceIteratorModule,
],
providers: [
AddEmailBlockSettingsCommandMenuItemCommand,
RepairOrphanCoreWorkflowVersionsCommand,
SyncDiscardDraftWorkflowAvailabilityExpressionCommand,
],
@@ -9,7 +9,7 @@ import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/deco
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@RegisteredWorkspaceCommand('2.28.0', 1786000000000)
@RegisteredWorkspaceCommand('2.28.0', 1785858486000)
@Command({
name: 'upgrade:2-28:sync-discard-draft-workflow-availability-expression',
description:
@@ -0,0 +1,139 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const EMAIL_BLOCK_SETTINGS_UNIVERSAL_IDENTIFIER =
STANDARD_COMMAND_MENU_ITEMS.emailBlockSettings.universalIdentifier;
@RegisteredWorkspaceCommand('2.28.0', 1785921674941)
@Command({
name: 'upgrade:2-28:add-email-block-settings-command-menu-item',
description:
'Add the pinned Block Settings command menu item on message campaign record pages to existing workspaces',
})
export class AddEmailBlockSettingsCommandMenuItemCommand extends ProvisionedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const {
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
flatObjectMetadataMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatCommandMenuItemMaps',
'flatObjectMetadataMaps',
]);
if (
!isDefined(
flatObjectMetadataMaps.byUniversalIdentifier[
STANDARD_OBJECTS.messageCampaign.universalIdentifier
],
)
) {
this.logger.log(
`Message campaign object does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
if (
isDefined(
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
EMAIL_BLOCK_SETTINGS_UNIVERSAL_IDENTIFIER
],
)
) {
this.logger.log(
`Block Settings command menu item already exists for workspace ${workspaceId}, skipping`,
);
return;
}
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const itemToCreate =
standardAllFlatEntityMaps.flatCommandMenuItemMaps.byUniversalIdentifier[
EMAIL_BLOCK_SETTINGS_UNIVERSAL_IDENTIFIER
];
if (!isDefined(itemToCreate)) {
throw new Error(
`Block Settings command menu item is missing from the standard application definition`,
);
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Adding the Block Settings command menu item for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunLegacyWorkspaceMigration(
{
isSystemBuild: true,
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [itemToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to add the Block Settings command menu item for workspace ${workspaceId}: ${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
}
this.logger.log(
`Added the Block Settings command menu item for workspace ${workspaceId}`,
);
}
}
@@ -5,6 +5,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import { type EmailingDomainSendEmailInput } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-input.type';
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
import { buildUnsubscribeHeaders } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-headers.util';
import { appendHtmlFooter } from 'src/engine/core-modules/emailing-domain/utils/append-html-footer.util';
import { buildUnsubscribeHtmlFooter } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-html-footer.util';
import { buildUnsubscribeTextFooter } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-text-footer.util';
import { buildUnsubscribeUrls } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-urls.util';
@@ -41,7 +42,10 @@ export class UnsubscribeContentService {
...email,
text: `${email.text}${buildUnsubscribeTextFooter(unsubscribeUrls.webUrl)}`,
html: isNonEmptyString(email.html)
? `${email.html}${buildUnsubscribeHtmlFooter(unsubscribeUrls.webUrl)}`
? appendHtmlFooter(
email.html,
buildUnsubscribeHtmlFooter(unsubscribeUrls.webUrl),
)
: email.html,
headers: [
...(email.headers ?? []),
@@ -0,0 +1,43 @@
import { appendHtmlFooter } from 'src/engine/core-modules/emailing-domain/utils/append-html-footer.util';
const FOOTER = '<p>Unsubscribe</p>';
describe('appendHtmlFooter', () => {
it('should insert the footer inside the body of a full document', () => {
const html = appendHtmlFooter(
'<html><head></head><body><p>Hello</p></body></html>',
FOOTER,
);
expect(html).toBe(
'<html><head></head><body><p>Hello</p><p>Unsubscribe</p></body></html>',
);
});
it('should insert before the closing html tag when there is no body', () => {
const html = appendHtmlFooter('<html><p>Hello</p></html>', FOOTER);
expect(html).toBe('<html><p>Hello</p><p>Unsubscribe</p></html>');
});
it('should append to a bare fragment', () => {
expect(appendHtmlFooter('<p>Hello</p>', FOOTER)).toBe(
'<p>Hello</p><p>Unsubscribe</p>',
);
});
it('should match the closing tag case-insensitively', () => {
expect(appendHtmlFooter('<HTML><BODY>Hi</BODY></HTML>', FOOTER)).toBe(
'<HTML><BODY>Hi<p>Unsubscribe</p></BODY></HTML>',
);
});
it('should use the last closing body tag when one appears in content', () => {
const html = appendHtmlFooter(
'<html><body><p>talk about &lt;/body&gt;</p></body></html>',
FOOTER,
);
expect(html).toContain('<p>Unsubscribe</p></body></html>');
});
});
@@ -0,0 +1,19 @@
export const appendHtmlFooter = (html: string, footer: string): string => {
const closingBodyIndex = html.search(/<\/body>(?![\s\S]*<\/body>)/i);
if (closingBodyIndex !== -1) {
return (
html.slice(0, closingBodyIndex) + footer + html.slice(closingBodyIndex)
);
}
const closingHtmlIndex = html.search(/<\/html>(?![\s\S]*<\/html>)/i);
if (closingHtmlIndex !== -1) {
return (
html.slice(0, closingHtmlIndex) + footer + html.slice(closingHtmlIndex)
);
}
return `${html}${footer}`;
};
@@ -190,29 +190,30 @@ describe('FileUploadService', () => {
expect(result.contentType).toBe('application/octet-stream');
});
it.each([FileFolder.EmailAttachment, FileFolder.AgentChat])(
'should support direct upload for the %s folder',
async (fileFolder) => {
fileStorageService.getPresignedUploadUrl.mockResolvedValueOnce(
'https://bucket/presigned-put',
);
it.each([
FileFolder.EmailAttachment,
FileFolder.EmailImage,
FileFolder.AgentChat,
])('should support direct upload for the %s folder', async (fileFolder) => {
fileStorageService.getPresignedUploadUrl.mockResolvedValueOnce(
'https://bucket/presigned-put',
);
const result = await service.createFileUpload({
workspaceId: 'workspace-id',
filename: 'document.pdf',
size: 1024,
const result = await service.createFileUpload({
workspaceId: 'workspace-id',
filename: 'document.pdf',
size: 1024,
fileFolder,
});
expect(fileStorageService.createPendingFile).toHaveBeenCalledWith(
expect.objectContaining({
fileFolder,
});
expect(fileStorageService.createPendingFile).toHaveBeenCalledWith(
expect.objectContaining({
fileFolder,
resourcePath: 'mocked-file-id.pdf',
}),
);
expect(result.uploadUrl).toBe('https://bucket/presigned-put');
},
);
resourcePath: 'mocked-file-id.pdf',
}),
);
expect(result.uploadUrl).toBe('https://bucket/presigned-put');
});
});
describe('completeFileUpload', () => {
@@ -342,6 +343,49 @@ describe('FileUploadService', () => {
expect(fileRepository.update).not.toHaveBeenCalled();
});
it('should reject non-image content from the email image folder', async () => {
fileRepository.findOne.mockResolvedValueOnce({
...pendingFile,
path: `${FileFolder.EmailImage}/file-id.pdf`,
});
fileStorageService.getFileMetadata.mockResolvedValueOnce({ size: 1024 });
fileStorageService.readFile.mockResolvedValueOnce(
Readable.from(PDF_BYTES),
);
await expect(
service.completeFileUpload({
workspaceId: 'workspace-id',
fileId: 'file-id',
}),
).rejects.toMatchObject({
code: FileUploadExceptionCode.BAD_REQUEST,
});
expect(fileRepository.update).not.toHaveBeenCalled();
});
it('should accept image content in the email image folder', async () => {
fileRepository.findOne.mockResolvedValueOnce({
...pendingFile,
path: `${FileFolder.EmailImage}/file-id.png`,
});
fileStorageService.getFileMetadata.mockResolvedValueOnce({ size: 1024 });
fileStorageService.readFile.mockResolvedValueOnce(
Readable.from(PNG_BYTES),
);
await service.completeFileUpload({
workspaceId: 'workspace-id',
fileId: 'file-id',
});
expect(fileRepository.update).toHaveBeenCalledWith(
'workspace-id',
{ id: 'file-id' },
{ status: FILE_STATUS.UPLOADED, mimeType: 'image/png' },
);
});
it('should be idempotent when the file is already UPLOADED', async () => {
fileRepository.findOne.mockResolvedValueOnce({
...pendingFile,
@@ -31,6 +31,7 @@ import { FILE_STATUS } from 'src/engine/core-modules/file/types/file-status.type
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -43,6 +44,7 @@ export const DIRECT_UPLOAD_FILE_FOLDERS = [
FileFolder.Workflow,
FileFolder.EmailAttachment,
FileFolder.AgentChat,
FileFolder.EmailImage,
] as const;
@Injectable()
@@ -365,6 +367,8 @@ export class FileUploadService {
filename: file.path,
});
this.assertMimeTypeAllowedForFolder(fileFolder as FileFolder, mimeType);
await this.fileRepository.update(
workspaceId,
{ id: fileId },
@@ -411,6 +415,25 @@ export class FileUploadService {
return mimeType;
}
private assertMimeTypeAllowedForFolder(
fileFolder: FileFolder,
mimeType: string,
): void {
const { allowedMimeTypes } = fileFolderConfigs[fileFolder];
if (!allowedMimeTypes || allowedMimeTypes.includes(mimeType)) {
return;
}
throw new FileUploadException(
`MIME type ${mimeType} is not allowed in file folder ${fileFolder}`,
FileUploadExceptionCode.BAD_REQUEST,
{
userFriendlyMessage: msg`This file format is not supported.`,
},
);
}
private async resolveUploadLocation({
workspaceId,
fileFolder,
@@ -13,6 +13,7 @@ export const SUPPORTED_FILE_FOLDERS = [
FileFolder.Workflow,
FileFolder.AgentChat,
FileFolder.EmailAttachment,
FileFolder.EmailImage,
FileFolder.AppTarball,
FileFolder.Dpa,
] as const;
@@ -1,5 +1,6 @@
import { registerEnumType } from '@nestjs/graphql';
import { EMAIL_IMAGE_MIME_TYPES } from 'twenty-shared/constants';
import { FileFolder } from 'twenty-shared/types';
registerEnumType(FileFolder, {
@@ -9,6 +10,7 @@ registerEnumType(FileFolder, {
export type FileFolderConfig = {
ignoreExpirationToken: boolean;
cacheControl: string | null;
allowedMimeTypes?: readonly string[];
};
export const IMMUTABLE_FILE_CACHE_CONTROL = 'private, max-age=86400, immutable';
@@ -61,6 +63,11 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
ignoreExpirationToken: false,
cacheControl: IMMUTABLE_FILE_CACHE_CONTROL,
},
[FileFolder.EmailImage]: {
ignoreExpirationToken: true,
cacheControl: IMMUTABLE_FILE_CACHE_CONTROL,
allowedMimeTypes: EMAIL_IMAGE_MIME_TYPES,
},
[FileFolder.AppTarball]: {
ignoreExpirationToken: false,
cacheControl: null,
@@ -6872,6 +6872,11 @@ msgstr "Loopies"
msgid "Saturday"
msgstr "Saterdag"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Hierdie veldtoestemming is reeds vir die rol ingestel"
msgid "This field references an object that could not be found"
msgstr "Hierdie veld verwys na n objek wat nie gevind kon word nie"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "عمليات التشغيل"
msgid "Saturday"
msgstr "السبت"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "صلاحية الحقل هذه مضبوطة بالفعل للدور"
msgid "This field references an object that could not be found"
msgstr "يشير هذا الحقل إلى كائن تعذّر العثور عليه"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Execucions"
msgid "Saturday"
msgstr "Dissabte"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Aquest permís de camp ja està assignat al rol"
msgid "This field references an object that could not be found"
msgstr "Aquest camp fa referència a un objecte que no s'ha pogut trobar"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Běhy"
msgid "Saturday"
msgstr "Sobota"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Toto oprávnění k poli je již pro roli nastaveno"
msgid "This field references an object that could not be found"
msgstr "Toto pole odkazuje na objekt, který nelze najít"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Køringer"
msgid "Saturday"
msgstr "Lørdag"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Denne felttilladelse er allerede angivet for rollen"
msgid "This field references an object that could not be found"
msgstr "Dette felt refererer til et objekt, der ikke kunne findes"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Läufe"
msgid "Saturday"
msgstr "Samstag"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Diese Feldberechtigung ist für die Rolle bereits festgelegt"
msgid "This field references an object that could not be found"
msgstr "Dieses Feld verweist auf ein Objekt, das nicht gefunden werden konnte"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Εκτελέσεις"
msgid "Saturday"
msgstr "Σάββατο"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Αυτό το δικαίωμα πεδίου έχει ήδη οριστε
msgid "This field references an object that could not be found"
msgstr "Αυτό το πεδίο αναφέρεται σε αντικείμενο που δεν βρέθηκε"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6867,6 +6867,11 @@ msgstr "Runs"
msgid "Saturday"
msgstr "Saturday"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr "Save Campaign"
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8120,6 +8125,11 @@ msgstr "This field permission is already set for the role"
msgid "This field references an object that could not be found"
msgstr "This field references an object that could not be found"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr "This file format is not supported."
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Ejecuciones"
msgid "Saturday"
msgstr "Sábado"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Este permiso del campo ya está configurado para el rol"
msgid "This field references an object that could not be found"
msgstr "Este campo hace referencia a un objeto que no se pudo encontrar"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Suoritukset"
msgid "Saturday"
msgstr "Lauantai"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Tämä kentän käyttöoikeus on jo asetettu roolille"
msgid "This field references an object that could not be found"
msgstr "Tämä kenttä viittaa objektiin, jota ei löytynyt"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Exécutions"
msgid "Saturday"
msgstr "Samedi"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Cette autorisation de champ est déjà définie pour le rôle"
msgid "This field references an object that could not be found"
msgstr "Ce champ fait référence à un objet qui n'a pas pu être trouvé"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -6872,6 +6872,11 @@ msgstr "ריצות"
msgid "Saturday"
msgstr "יום שבת"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "הרשאת השדה הזו כבר מוגדרת עבור התפקיד"
msgid "This field references an object that could not be found"
msgstr "שדה זה מפנה לאובייקט שלא ניתן היה למצוא"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Futtatások"
msgid "Saturday"
msgstr "Szombat"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Ez a mezőengedély már be van állítva a szerepkör számára"
msgid "This field references an object that could not be found"
msgstr "Ez a mező egy olyan objektumra hivatkozik, amely nem található"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Esecuzioni"
msgid "Saturday"
msgstr "Sabato"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Questo permesso del campo è già impostato per il ruolo"
msgid "This field references an object that could not be found"
msgstr "Questo campo fa riferimento a un oggetto che non è stato trovato"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "実行回数"
msgid "Saturday"
msgstr "土曜日"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "このフィールド権限はそのロールに既に設定されてい
msgid "This field references an object that could not be found"
msgstr "このフィールドは見つからないオブジェクトを参照しています"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "실행"
msgid "Saturday"
msgstr "토요일"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "이 필드 권한은 이미 해당 역할에 설정되어 있습니다"
msgid "This field references an object that could not be found"
msgstr "이 필드는 찾을 수 없는 객체를 참조합니다."
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Uitvoeringen"
msgid "Saturday"
msgstr "Zaterdag"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Deze veldmachtiging is al ingesteld voor de rol"
msgid "This field references an object that could not be found"
msgstr "Dit veld verwijst naar een object dat niet kon worden gevonden"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Kjøringer"
msgid "Saturday"
msgstr "Lørdag"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Denne felttillatelsen er allerede satt for rollen"
msgid "This field references an object that could not be found"
msgstr "Dette feltet refererer til et objekt som ikke ble funnet"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Uruchomienia"
msgid "Saturday"
msgstr "Sobota"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "To uprawnienie pola jest już ustawione dla tej roli"
msgid "This field references an object that could not be found"
msgstr "To pole odnosi się do obiektu, którego nie można znaleźć"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6867,6 +6867,11 @@ msgstr ""
msgid "Saturday"
msgstr ""
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8120,6 +8125,11 @@ msgstr ""
msgid "This field references an object that could not be found"
msgstr ""
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Execuções"
msgid "Saturday"
msgstr "Sábado"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Esta permissão do campo já está definida para o papel"
msgid "This field references an object that could not be found"
msgstr "Este campo faz referência a um objeto que não pôde ser encontrado"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Execuções"
msgid "Saturday"
msgstr "Sábado"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Esta permissão do campo já está definida para a função"
msgid "This field references an object that could not be found"
msgstr "Este campo referencia um objeto que não pôde ser encontrado"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Rulări"
msgid "Saturday"
msgstr "Sâmbătă"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Această permisiune pe câmp este deja setată pentru rol"
msgid "This field references an object that could not be found"
msgstr "Acest câmp face referire la un obiect care nu a putut fi găsit"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Запуски"
msgid "Saturday"
msgstr "Суббота"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Это разрешение поля уже установлено дл
msgid "This field references an object that could not be found"
msgstr "Это поле ссылается на объект, который не удалось найти"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Покретања"
msgid "Saturday"
msgstr "Субота"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Ова дозвола поља је већ додељена улози"
msgid "This field references an object that could not be found"
msgstr "Ово поље референцира објекат који није пронађен"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6876,6 +6876,11 @@ msgstr ""
"L\n"
"urdag"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8131,6 +8136,11 @@ msgstr "Den här fältbehörigheten är redan angiven för rollen"
msgid "This field references an object that could not be found"
msgstr "Detta fält refererar till ett objekt som inte kunde hittas"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Çalıştırmalar"
msgid "Saturday"
msgstr "Cumartesi"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Bu alan izni rol için zaten ayarlı"
msgid "This field references an object that could not be found"
msgstr "Bu alan, bulunamayan bir nesneye referans veriyor"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Запуски"
msgid "Saturday"
msgstr "Субота"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Цей дозвіл на поле вже встановлено для
msgid "This field references an object that could not be found"
msgstr "Це поле посилається на об'єкт, який не вдалося знайти"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "Lượt chạy"
msgid "Saturday"
msgstr "Thứ Bảy"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "Quyền của trường này đã được thiết lập cho vai trò"
msgid "This field references an object that could not be found"
msgstr "Trường này tham chiếu đến một đối tượng không tìm thấy được"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "运行"
msgid "Saturday"
msgstr "星期六"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "该角色已设置此字段权限"
msgid "This field references an object that could not be found"
msgstr "此字段引用了一个无法找到的对象"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -6872,6 +6872,11 @@ msgstr "運行"
msgid "Saturday"
msgstr "星期六"
#. js-lingui-id: H2SymB
#: src/engine/core-modules/tool-provider/constants/action-tool-label.constant.ts
msgid "Save Campaign"
msgstr ""
#. js-lingui-id: 4ba0NE
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-call-recording-standard-flat-field-metadata.util.ts
#: src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-campaign-standard-flat-field-metadata.util.ts
@@ -8125,6 +8130,11 @@ msgstr "此欄位權限已為該角色設定"
msgid "This field references an object that could not be found"
msgstr "此欄位參照的物件無法找到"
#. js-lingui-id: 5F02zD
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file format is not supported."
msgstr ""
#. js-lingui-id: HUJCXI
#: src/engine/core-modules/file/file-upload/services/file-upload.service.ts
msgid "This file has already been uploaded."
@@ -11,6 +11,7 @@ export const ACTION_TOOL_IDS = [
'search_help_center',
'code_interpreter',
'navigate_app',
'save_campaign',
] as const;
export type ActionToolId = (typeof ACTION_TOOL_IDS)[number];
@@ -37,4 +38,7 @@ export const ACTION_TOOL_LABELS: Record<ActionToolId, ActionToolLabel> = {
navigate_app: {
label: i18nLabel(msg`Navigate App`),
},
save_campaign: {
label: i18nLabel(msg`Save Campaign`),
},
};
@@ -33,6 +33,7 @@ import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { SaveCampaignTool } from 'src/modules/emailing/tools/save-campaign-tool';
@Injectable()
export class ActionToolProvider implements ToolProvider {
@@ -50,6 +51,7 @@ export class ActionToolProvider implements ToolProvider {
private readonly navigateAppTool: NavigateAppTool,
private readonly extractJsonPathsTool: ExtractJsonPathsTool,
private readonly searchOutputTool: SearchOutputTool,
private readonly saveCampaignTool: SaveCampaignTool,
private readonly codeInterpreterService: CodeInterpreterService,
private readonly permissionsService: PermissionsService,
private readonly i18nService: I18nService,
@@ -64,6 +66,7 @@ export class ActionToolProvider implements ToolProvider {
['navigate_app', this.navigateAppTool],
['extract_json_paths', this.extractJsonPathsTool],
['search_output', this.searchOutputTool],
['save_campaign', this.saveCampaignTool],
]);
}
@@ -172,6 +175,15 @@ export class ActionToolProvider implements ToolProvider {
),
);
descriptors.push(
this.buildDescriptor(
'save_campaign',
this.saveCampaignTool,
includeSchemas,
context.locale,
),
);
const hasCodeInterpreterPermission =
this.codeInterpreterService.isEnabled() &&
(await this.permissionsService.hasToolPermission(
@@ -32,6 +32,7 @@ import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WebhookModule } from 'src/engine/metadata-modules/webhook/webhook.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { EmailingModule } from 'src/modules/emailing/emailing.module';
import { ToolIndexResolver } from './resolvers/tool-index.resolver';
import { ToolRegistryService } from './services/tool-registry.service';
@@ -66,6 +67,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
WebhookModule,
RoleModule,
UserRoleModule,
EmailingModule,
TypeOrmModule.forFeature([UserEntity]),
],
providers: [
@@ -23,6 +23,7 @@ import {
import { type ComposeEmailParams } from 'src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type';
import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { renderEmailBodyToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-email-body.util';
import { selectConnectedAccountIdForCaller } from 'src/engine/core-modules/tool/tools/email-tool/utils/select-connected-account-id-for-caller.util';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@@ -421,7 +422,8 @@ export class EmailComposerService {
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const sanitizedHtmlBody = purify.sanitize(body || '');
const htmlBody = await renderEmailBodyToHtml(body ?? '');
const sanitizedHtmlBody = purify.sanitize(htmlBody);
const plainTextBody = toPlainText(sanitizedHtmlBody);
const sanitizedSubject = purify.sanitize(subject || '');
@@ -1,4 +1,4 @@
import { isValidUuid } from 'twenty-shared/utils';
import { emailDocumentSchema, isValidUuid } from 'twenty-shared/utils';
import { workflowFileSchema } from 'twenty-shared/workflow';
import { z } from 'zod';
@@ -24,7 +24,11 @@ export const EmailToolInputZodSchema = z.object({
'Recipients object with to, cc, and bcc fields (comma-separated)',
),
subject: z.string().describe('The email subject line'),
body: z.string().describe('The email body content in HTML format'),
body: z
.union([emailDocumentSchema, z.string()])
.describe(
'The email body. Preferred: a structured email document ({type: "doc", content: [...]} with paragraph, heading, bulletList/orderedList, image, button, section, divider and html blocks), rendered to email-safe HTML server-side. An HTML string is also accepted. Campaign-style {{variables}} are not substituted in 1:1 emails.',
),
connectedAccountId: z
.string()
.refine((val) => isValidUuid(val))
@@ -1,4 +1,5 @@
import { type EmailAttachment } from 'twenty-shared/types';
import { type EmailDocument } from 'twenty-shared/utils';
export type ComposeEmailParams = {
recipients: {
@@ -7,7 +8,7 @@ export type ComposeEmailParams = {
bcc?: string;
};
subject: string;
body: string;
body: string | EmailDocument;
connectedAccountId?: string;
files?: Array<EmailAttachment>;
inReplyTo?: string;
@@ -0,0 +1,42 @@
import { renderEmailBodyToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-email-body.util';
jest.mock(
'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util',
() => ({
renderRichTextToHtml: jest.fn().mockResolvedValue('<p>rendered html</p>'),
}),
);
const { renderRichTextToHtml } = jest.requireMock(
'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util',
);
describe('renderEmailBodyToHtml', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should pass an HTML string through untouched', async () => {
const html = '<p>Hello <strong>there</strong></p>';
await expect(renderEmailBodyToHtml(html)).resolves.toBe(html);
expect(renderRichTextToHtml).not.toHaveBeenCalled();
});
it('should render an email document through the shared renderer', async () => {
const document = {
type: 'doc' as const,
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello' }],
},
],
};
await expect(renderEmailBodyToHtml(document)).resolves.toBe(
'<p>rendered html</p>',
);
expect(renderRichTextToHtml).toHaveBeenCalledWith(document);
});
});
@@ -0,0 +1,254 @@
import { renderRichTextToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util';
describe('renderRichTextToHtml', () => {
beforeAll(() => {
jest.useRealTimers();
});
it('should render an email section with its inline styles', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
content: [
{
type: 'section',
attrs: { style: { backgroundColor: '#f4f4f5', padding: '24px' } },
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Inside the section' }],
},
],
},
],
});
expect(html).toContain('Inside the section');
expect(html).toContain('background-color:#f4f4f5');
expect(html).toContain('padding:24px');
});
it('should render columns as a table row with one cell per column', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
content: [
{
type: 'columns',
content: [
{
type: 'column',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Left cell' }],
},
],
},
{
type: 'column',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Right cell' }],
},
],
},
],
},
],
});
expect(html).toContain('Left cell');
expect(html).toContain('Right cell');
expect(html).toContain('width:50%');
});
it('should render a button as a styled link', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
content: [
{
type: 'button',
attrs: {
href: 'https://twenty.com',
style: { backgroundColor: '#1961ed', color: '#ffffff' },
},
content: [{ type: 'text', text: 'Visit Twenty' }],
},
],
});
expect(html).toContain('Visit Twenty');
expect(html).toContain('https://twenty.com');
expect(html).toContain('background-color:#1961ed');
});
it('should render a divider as an hr with its styles', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
content: [
{
type: 'divider',
attrs: { style: { borderTop: '2px dashed #ff0000' } },
},
],
});
expect(html).toContain('<hr');
expect(html).toContain('2px dashed #ff0000');
});
it('should wrap themed documents in a styled page and centered container', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
attrs: {
canvasTheme: {
pageBackground: '#f4f4f5',
bodyBackground: '#ffffff',
textColor: '#18181b',
width: '600px',
padding: '24px',
cornerRadius: '8px',
border: 'none',
},
},
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Themed content' }],
},
],
});
expect(html).toContain('Themed content');
expect(html).toContain('background-color:#f4f4f5');
expect(html).toContain('background-color:#ffffff');
expect(html).toContain('max-width:600px');
});
it('should keep the bare body for documents without a theme', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Workflow email' }],
},
],
});
expect(html).toContain('Workflow email');
expect(html).not.toContain('max-width:600px');
});
it('should embed raw HTML blocks verbatim', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
content: [
{
type: 'html',
attrs: {
html: '<table role="presentation"><tr><td>custom cell</td></tr></table>',
},
},
],
});
expect(html).toContain('custom cell');
expect(html).toContain('<table role="presentation">');
});
it('should wrap linked images in an anchor', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
content: [
{
type: 'image',
attrs: {
src: 'https://example.com/banner.png',
alt: 'Banner',
href: 'https://example.com/landing',
},
},
],
});
expect(html).toContain('https://example.com/banner.png');
expect(html).toContain('href="https://example.com/landing"');
expect(html).toContain('alt="Banner"');
});
it('should render nothing for unknown node types', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
content: [
{ type: 'someFutureNode', content: [{ type: 'text', text: 'lost' }] },
{
type: 'paragraph',
content: [{ type: 'text', text: 'still rendered' }],
},
],
});
expect(html).not.toContain('lost');
expect(html).toContain('still rendered');
});
describe('unsafe URL schemes', () => {
it('should drop javascript: hrefs from buttons, links and images', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
content: [
{
type: 'button',
attrs: { href: 'javascript:alert(1)', style: {} },
content: [{ type: 'text', text: 'Click' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'link',
marks: [
{ type: 'link', attrs: { href: ' javascript:alert(1)' } },
],
},
],
},
{
type: 'image',
attrs: { src: 'javascript:alert(1)', href: 'data:text/html,x' },
},
],
});
expect(html).not.toContain('javascript:');
expect(html).not.toContain('data:text/html');
});
it('should keep http, mailto and variable-bearing URLs', async () => {
const html = await renderRichTextToHtml({
type: 'doc',
content: [
{
type: 'button',
attrs: { href: 'https://hello/{{personId}}', style: {} },
content: [{ type: 'text', text: 'Go' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'mail',
marks: [{ type: 'link', attrs: { href: 'mailto:a@b.c' } }],
},
],
},
],
});
expect(html).toContain('https://hello/{{personId}}');
expect(html).toContain('mailto:a@b.c');
});
});
});
@@ -0,0 +1,38 @@
import DOMPurify from 'dompurify';
import { type JSONContent } from 'twenty-emails';
import {
type EmailDocument,
type EmailDocumentNode,
transformEmailDocumentStrings,
} from 'twenty-shared/utils';
import { renderRichTextToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util';
let purifyInstance: ReturnType<typeof DOMPurify> | null = null;
const getPurify = async () => {
if (purifyInstance === null) {
const { JSDOM } = await import('jsdom');
purifyInstance = DOMPurify(new JSDOM('').window);
}
return purifyInstance;
};
export const renderEmailBodyToHtml = async (
body: string | EmailDocument,
): Promise<string> => {
if (typeof body === 'string') {
return body;
}
const purify = await getPurify();
const sanitizedBody = transformEmailDocumentStrings(
body as EmailDocumentNode,
(value, context) => (context === 'html' ? purify.sanitize(value) : value),
);
return renderRichTextToHtml(sanitizedBody as JSONContent);
};
@@ -53,6 +53,7 @@ export enum EngineComponentKey {
COMPOSE_CAMPAIGN = 'COMPOSE_CAMPAIGN',
SEND_MESSAGE_CAMPAIGN = 'SEND_MESSAGE_CAMPAIGN',
SEND_MESSAGE_CAMPAIGN_TEST = 'SEND_MESSAGE_CAMPAIGN_TEST',
EMAIL_BLOCK_SETTINGS = 'EMAIL_BLOCK_SETTINGS',
// TODO: Remove deprecated keys once upgrade:1-21:refactor-navigation-commands has run on all workspaces
// Deprecated: replaced by NAVIGATION engine key with payload
@@ -771,6 +771,22 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
engineComponentKey: EngineComponentKey.SEND_MESSAGE_CAMPAIGN_TEST,
hotKeys: null,
},
emailBlockSettings: {
universalIdentifier: '5c8a2f41-97be-4f3d-9a46-2f18d17f30a2',
label: 'Block Settings',
icon: 'IconAdjustments',
isPinned: true,
position: 70,
shortLabel: 'Design',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and numberOfSelectedRecords == 1 and everyEquals(selectedRecords, "status", "DRAFT") and noneDefined(selectedRecords, "deletedAt")',
availabilityObjectMetadataUniversalIdentifier:
STANDARD_OBJECTS.messageCampaign.universalIdentifier,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.EMAIL_BLOCK_SETTINGS,
hotKeys: null,
},
goToSettings: {
universalIdentifier: 'ef9aba44-0068-453e-930a-f8c182af18ee',
label: 'Go to Settings',
@@ -0,0 +1,2 @@
export const CAMPAIGN_VARIABLE_PATTERN =
/\{\{\s*([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z][a-zA-Z0-9_]*)*)\s*\}\}/g;
@@ -11,6 +11,7 @@ import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channe
import { MessageChannelMetadataModule } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@@ -18,12 +19,15 @@ import { UnsubscribeController } from 'src/modules/emailing/controllers/unsubscr
import { EmailingSendResolver } from 'src/modules/emailing/resolvers/emailing-send.resolver';
import { MessageSuppressionResolver } from 'src/modules/emailing/resolvers/message-suppression.resolver';
import { UnsubscribeTopicResolver } from 'src/modules/emailing/resolvers/unsubscribe-topic.resolver';
import { CampaignVariableService } from 'src/modules/emailing/services/campaign-variable.service';
import { EmailBillingService } from 'src/modules/emailing/services/email-billing.service';
import { EmailingDomainSenderService } from 'src/modules/emailing/services/emailing-domain-sender.service';
import { MessageCampaignDraftService } from 'src/modules/emailing/services/message-campaign-draft.service';
import { MessageCampaignStatisticsService } from 'src/modules/emailing/services/message-campaign-statistics.service';
import { MessageCampaignService } from 'src/modules/emailing/services/message-campaign.service';
import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service';
import { UnsubscribeTopicService } from 'src/modules/emailing/services/unsubscribe-topic.service';
import { SaveCampaignTool } from 'src/modules/emailing/tools/save-campaign-tool';
@Module({
imports: [
@@ -35,6 +39,7 @@ import { UnsubscribeTopicService } from 'src/modules/emailing/services/unsubscri
BillingModule,
WorkspaceEventEmitterModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
TypeOrmModule.forFeature([
MessageChannelEntity,
EmailingDomainEntity,
@@ -44,12 +49,15 @@ import { UnsubscribeTopicService } from 'src/modules/emailing/services/unsubscri
],
controllers: [UnsubscribeController],
providers: [
CampaignVariableService,
EmailBillingService,
MessageCampaignService,
MessageCampaignDraftService,
MessageCampaignStatisticsService,
MessageSuppressionService,
UnsubscribeTopicService,
EmailingDomainSenderService,
SaveCampaignTool,
EmailingSendResolver,
MessageSuppressionResolver,
UnsubscribeTopicResolver,
@@ -60,9 +68,11 @@ import { UnsubscribeTopicService } from 'src/modules/emailing/services/unsubscri
exports: [
EmailingDomainSenderService,
MessageCampaignService,
MessageCampaignDraftService,
MessageCampaignStatisticsService,
MessageSuppressionService,
UnsubscribeTopicService,
SaveCampaignTool,
],
})
export class EmailingModule {}
@@ -0,0 +1,168 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { EmailingDomainException } from 'src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { CampaignVariableService } from 'src/modules/emailing/services/campaign-variable.service';
import { type PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
const field = (
id: string,
name: string,
label: string,
type: FieldMetadataType,
overrides: Record<string, unknown> = {},
) => ({
id,
universalIdentifier: `universal-${id}`,
name,
label,
type,
isSystem: false,
isActive: true,
...overrides,
});
const personFields = [
field('field-1', 'name', 'Name', FieldMetadataType.FULL_NAME),
field('field-2', 'emails', 'Emails', FieldMetadataType.EMAILS),
field('field-3', 'city', 'City', FieldMetadataType.TEXT),
field('field-4', 'tier', 'Tier', FieldMetadataType.SELECT, {
options: [
{ value: 'ENTERPRISE', label: 'Enterprise' },
{ value: 'STARTER', label: 'Starter' },
],
}),
field('field-5', 'signupDate', 'Signup date', FieldMetadataType.DATE_TIME),
field('field-6', 'searchVector', 'Search vector', FieldMetadataType.TEXT, {
isSystem: true,
}),
field('field-7', 'company', 'Company', FieldMetadataType.RELATION),
];
const buildFlatMaps = () => ({
flatObjectMetadataMaps: {
byUniversalIdentifier: {
[STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person]: {
id: 'person-object-id',
nameSingular: 'person',
fieldIds: personFields.map((personField) => personField.id),
},
},
},
flatFieldMetadataMaps: {
byUniversalIdentifier: Object.fromEntries(
personFields.map((personField) => [
personField.universalIdentifier,
personField,
]),
),
universalIdentifierById: Object.fromEntries(
personFields.map((personField) => [
personField.id,
personField.universalIdentifier,
]),
),
},
});
describe('CampaignVariableService', () => {
let service: CampaignVariableService;
const workspaceId = 'workspace-1';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CampaignVariableService,
{
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
useValue: {
getOrRecomputeManyOrAllFlatEntityMaps: jest
.fn()
.mockResolvedValue(buildFlatMaps()),
},
},
],
}).compile();
service = module.get(CampaignVariableService);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should derive variables from person field metadata', async () => {
const { definitions, knownVariableNames } =
await service.getPersonCampaignVariables(workspaceId);
expect(definitions.map((definition) => definition.name)).toEqual([
'name.firstName',
'name.lastName',
'city',
'tier',
'signupDate',
]);
expect(knownVariableNames.has('fullName')).toBe(true);
expect(knownVariableNames.has('personId')).toBe(true);
expect(knownVariableNames.has('firstName')).toBe(false);
expect(knownVariableNames.has('searchVector')).toBe(false);
expect(knownVariableNames.has('company')).toBe(false);
});
it('should build values for a person, formatting by field type', async () => {
const person = {
id: 'person-1',
name: { firstName: 'Ada', lastName: 'Lovelace' },
emails: { primaryEmail: 'ada@example.com' },
city: 'London',
tier: 'ENTERPRISE',
signupDate: '2026-03-04T10:30:00.000Z',
} as unknown as PersonWorkspaceEntity;
const variables = await service.buildVariablesForPerson(
workspaceId,
person,
);
expect(variables).toMatchObject({
'name.firstName': 'Ada',
'name.lastName': 'Lovelace',
city: 'London',
tier: 'Enterprise',
signupDate: '2026-03-04',
fullName: 'Ada Lovelace',
personId: 'person-1',
});
expect(variables).not.toHaveProperty('firstName');
});
it('should resolve every variable to an empty string without a person', async () => {
const variables = await service.buildVariablesForPerson(workspaceId, null);
expect(variables.city).toBe('');
expect(variables.fullName).toBe('');
expect(variables.personId).toBe('');
});
it('should accept known variables and reject unknown ones', async () => {
await expect(
service.assertKnownVariables(workspaceId, ['city', 'name.firstName']),
).resolves.toBeUndefined();
await expect(
service.assertKnownVariables(workspaceId, ['city', 'firstNam']),
).rejects.toThrow(EmailingDomainException);
await expect(
service.assertKnownVariables(workspaceId, ['firstNam']),
).rejects.toThrow(/Unknown campaign variables: firstNam/);
});
});
@@ -0,0 +1,297 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { MessageCampaignStatus } from 'twenty-shared/types';
import {
EmailingDomainException,
EmailingDomainExceptionCode,
} from 'src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { CampaignVariableService } from 'src/modules/emailing/services/campaign-variable.service';
import { MessageCampaignDraftService } from 'src/modules/emailing/services/message-campaign-draft.service';
describe('MessageCampaignDraftService', () => {
let service: MessageCampaignDraftService;
let assertKnownVariables: jest.Mock;
let campaignRepository: {
findOne: jest.Mock;
insert: jest.Mock;
update: jest.Mock;
};
const workspaceId = 'workspace-1';
const userWorkspaceId = 'user-workspace-1';
const campaignId = '20202020-0000-4000-8000-000000000001';
const validDocument = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Hello ' },
{ type: 'variableTag', attrs: { variable: '{{firstName}}' } },
],
},
],
};
beforeEach(async () => {
assertKnownVariables = jest.fn().mockResolvedValue(undefined);
campaignRepository = {
findOne: jest.fn().mockResolvedValue({
id: campaignId,
name: 'Monthly newsletter',
status: MessageCampaignStatus.DRAFT,
}),
insert: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
MessageCampaignDraftService,
{
provide: CampaignVariableService,
useValue: { assertKnownVariables },
},
{
provide: UserRoleService,
useValue: {
getRoleIdForUserWorkspace: jest.fn().mockResolvedValue('role-1'),
},
},
{
provide: GlobalWorkspaceOrmManager,
useValue: {
getRepository: jest.fn().mockResolvedValue(campaignRepository),
executeInWorkspaceContext: jest.fn(
(callback: () => Promise<unknown>) => callback(),
),
},
},
],
}).compile();
service = module.get(MessageCampaignDraftService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('creating', () => {
it('should insert a draft with name, subject and stamped body', async () => {
const result = await service.saveDraft({
workspaceId,
userWorkspaceId,
name: 'Launch announcement',
subject: 'Hi {{firstName}}, we launched',
body: validDocument,
});
expect(result.created).toBe(true);
expect(result.campaignName).toBe('Launch announcement');
expect(result.variablesUsed).toEqual(['firstName']);
const [inserted] = campaignRepository.insert.mock.calls[0];
expect(inserted.name).toBe('Launch announcement');
expect(inserted.subject).toBe('Hi {{firstName}}, we launched');
expect(inserted.status).toBe(MessageCampaignStatus.DRAFT);
const storedDocument = JSON.parse(inserted.bodyTemplate);
expect(storedDocument.attrs.schemaVersion).toBe(1);
expect(storedDocument.attrs.canvasTheme).toBeDefined();
expect(campaignRepository.update).not.toHaveBeenCalled();
});
it('should create an empty draft with a default name', async () => {
const result = await service.saveDraft({ workspaceId, userWorkspaceId });
expect(result.created).toBe(true);
expect(result.campaignName).toBe('Untitled campaign');
const [inserted] = campaignRepository.insert.mock.calls[0];
expect(inserted.subject).toBeUndefined();
expect(inserted.bodyTemplate).toBeUndefined();
expect(assertKnownVariables).not.toHaveBeenCalled();
});
it('should validate subject variables on create', async () => {
assertKnownVariables.mockRejectedValue(
new EmailingDomainException(
'Unknown campaign variables: frstName',
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE,
),
);
await expect(
service.saveDraft({
workspaceId,
userWorkspaceId,
subject: 'Hi {{frstName}}',
}),
).rejects.toThrow(/Unknown campaign variables/);
expect(campaignRepository.insert).not.toHaveBeenCalled();
});
});
describe('editing', () => {
it('should update only the provided fields', async () => {
const result = await service.saveDraft({
workspaceId,
userWorkspaceId,
campaignId,
subject: 'New subject',
});
expect(result.created).toBe(false);
expect(result.campaignName).toBe('Monthly newsletter');
const [criteria, update] = campaignRepository.update.mock.calls[0];
expect(criteria).toEqual({
id: campaignId,
status: MessageCampaignStatus.DRAFT,
});
expect(update).toEqual({ subject: 'New subject' });
});
it('should stamp and write a body update', async () => {
await service.saveDraft({
workspaceId,
userWorkspaceId,
campaignId,
body: validDocument,
});
const [, update] = campaignRepository.update.mock.calls[0];
const storedDocument = JSON.parse(update.bodyTemplate);
expect(storedDocument.attrs.schemaVersion).toBe(1);
expect(storedDocument.attrs.canvasTheme).toBeDefined();
});
it('should keep an explicit theme instead of stamping defaults', async () => {
await service.saveDraft({
workspaceId,
userWorkspaceId,
campaignId,
body: {
...validDocument,
attrs: { canvasTheme: { bodyBackground: '#101010', width: '480px' } },
},
});
const [, update] = campaignRepository.update.mock.calls[0];
const storedDocument = JSON.parse(update.bodyTemplate);
expect(storedDocument.attrs.canvasTheme.bodyBackground).toBe('#101010');
});
it('should reject an edit with nothing to update', async () => {
await expect(
service.saveDraft({ workspaceId, userWorkspaceId, campaignId }),
).rejects.toThrow(/Nothing to update/);
expect(campaignRepository.update).not.toHaveBeenCalled();
});
it('should reject a document with an unknown block type without writing', async () => {
await expect(
service.saveDraft({
workspaceId,
userWorkspaceId,
campaignId,
body: {
type: 'doc',
content: [{ type: 'countdownTimer', attrs: {} }],
},
}),
).rejects.toThrow(EmailingDomainException);
expect(campaignRepository.update).not.toHaveBeenCalled();
});
it('should reject unknown variables without writing', async () => {
assertKnownVariables.mockRejectedValue(
new EmailingDomainException(
'Unknown campaign variables: firstNam',
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE,
),
);
await expect(
service.saveDraft({
workspaceId,
userWorkspaceId,
campaignId,
body: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hi {{firstNam}}' }],
},
],
},
}),
).rejects.toThrow(/Unknown campaign variables: firstNam/);
expect(assertKnownVariables).toHaveBeenCalledWith(workspaceId, [
'firstNam',
]);
expect(campaignRepository.update).not.toHaveBeenCalled();
});
it('should reject when the campaign does not exist', async () => {
campaignRepository.findOne.mockResolvedValue(null);
await expect(
service.saveDraft({
workspaceId,
userWorkspaceId,
campaignId,
subject: 'New subject',
}),
).rejects.toThrow(/not found/);
});
it('should reject when the campaign already left DRAFT', async () => {
campaignRepository.findOne.mockResolvedValue({
id: campaignId,
name: 'Monthly newsletter',
status: MessageCampaignStatus.SENT,
});
await expect(
service.saveDraft({
workspaceId,
userWorkspaceId,
campaignId,
subject: 'New subject',
}),
).rejects.toThrow(/only draft campaigns/);
expect(campaignRepository.update).not.toHaveBeenCalled();
});
it('should surface a lost race against a concurrent send', async () => {
campaignRepository.update.mockResolvedValue({ affected: 0 });
await expect(
service.saveDraft({
workspaceId,
userWorkspaceId,
campaignId,
subject: 'New subject',
}),
).rejects.toThrow(/no longer an editable draft/);
});
});
});
@@ -0,0 +1,192 @@
import { Injectable } from '@nestjs/common';
import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import {
type CampaignVariableDefinition,
isDefined,
listCampaignVariablesForFields,
} from 'twenty-shared/utils';
import {
EmailingDomainException,
EmailingDomainExceptionCode,
} from 'src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
export type PersonCampaignVariables = {
definitions: CampaignVariableDefinition[];
knownVariableNames: Set<string>;
};
const COMPUTED_VARIABLE_NAMES = ['fullName', 'personId'];
const MAX_VARIABLES_IN_ERROR_MESSAGE = 40;
@Injectable()
export class CampaignVariableService {
constructor(
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async getPersonCampaignVariables(
workspaceId: string,
): Promise<PersonCampaignVariables> {
const fields = await this.getPersonFields(workspaceId);
const definitions = listCampaignVariablesForFields(fields);
const knownVariableNames = new Set<string>([
...definitions.map((definition) => definition.name),
...COMPUTED_VARIABLE_NAMES,
]);
return { definitions, knownVariableNames };
}
async assertKnownVariables(
workspaceId: string,
usedVariableNames: Iterable<string>,
): Promise<void> {
const { definitions, knownVariableNames } =
await this.getPersonCampaignVariables(workspaceId);
const unknownVariables = [...usedVariableNames].filter(
(variableName) => !knownVariableNames.has(variableName),
);
if (unknownVariables.length === 0) {
return;
}
const availableList = [
...COMPUTED_VARIABLE_NAMES,
...definitions.map((definition) => definition.name),
]
.slice(0, MAX_VARIABLES_IN_ERROR_MESSAGE)
.join(', ');
throw new EmailingDomainException(
`Unknown campaign variables: ${unknownVariables.join(', ')}. ` +
`Available variables: ${availableList}`,
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE,
);
}
async buildVariablesForPerson(
workspaceId: string,
person: PersonWorkspaceEntity | null,
): Promise<Record<string, string>> {
const { definitions } = await this.getPersonCampaignVariables(workspaceId);
const fieldsByName = await this.getPersonFieldsByName(workspaceId);
const variables: Record<string, string> = {};
for (const definition of definitions) {
variables[definition.name] = this.formatValue(
this.resolveValue(person, definition.name),
definition,
fieldsByName.get(definition.fieldName),
);
}
variables.personId = this.stringify(this.resolveValue(person, 'id'));
variables.fullName = [
this.stringify(this.resolveValue(person, 'name.firstName')),
this.stringify(this.resolveValue(person, 'name.lastName')),
]
.filter(Boolean)
.join(' ');
return variables;
}
private async getPersonFields(
workspaceId: string,
): Promise<FlatFieldMetadata[]> {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const personFlatObject =
flatObjectMetadataMaps.byUniversalIdentifier[
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person
];
if (!isDefined(personFlatObject)) {
return [];
}
return getFlatFieldsFromFlatObjectMetadata(
personFlatObject,
flatFieldMetadataMaps,
);
}
private async getPersonFieldsByName(
workspaceId: string,
): Promise<Map<string, FlatFieldMetadata>> {
const fields = await this.getPersonFields(workspaceId);
return new Map(fields.map((field) => [field.name, field]));
}
private resolveValue(
person: PersonWorkspaceEntity | null,
path: string,
): unknown {
if (!isDefined(person)) {
return null;
}
return path
.split('.')
.reduce<unknown>(
(value, segment) =>
typeof value === 'object' && value !== null
? (value as Record<string, unknown>)[segment]
: null,
person,
);
}
private formatValue(
value: unknown,
definition: CampaignVariableDefinition,
field: FlatFieldMetadata | undefined,
): string {
if (!isDefined(value) || value === '') {
return '';
}
switch (definition.fieldType) {
case FieldMetadataType.DATE:
case FieldMetadataType.DATE_TIME: {
const date = new Date(value as string);
return Number.isNaN(date.getTime())
? this.stringify(value)
: date.toISOString().slice(0, 10);
}
case FieldMetadataType.SELECT:
case FieldMetadataType.RATING: {
const option = field?.options?.find(
(fieldOption) => fieldOption.value === value,
);
return option?.label ?? this.stringify(value);
}
default:
return this.stringify(value);
}
}
private stringify(value: unknown): string {
return isDefined(value) ? String(value) : '';
}
}
@@ -0,0 +1,208 @@
import { Injectable, type Type } from '@nestjs/common';
import { MessageCampaignStatus } from 'twenty-shared/types';
import {
EMAIL_DOCUMENT_SCHEMA_VERSION,
CANVAS_THEME_DEFAULTS,
type EmailDocument,
isDefined,
parseEmailDocument,
} from 'twenty-shared/utils';
import { type ObjectLiteral } from 'typeorm';
import { v4 } from 'uuid';
import {
EmailingDomainException,
EmailingDomainExceptionCode,
} from 'src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { CampaignVariableService } from 'src/modules/emailing/services/campaign-variable.service';
import { MessageCampaignWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-campaign.workspace-entity';
import { collectCampaignVariableNames } from 'src/modules/emailing/utils/collect-campaign-variable-names.util';
import { collectCampaignVariableNamesFromString } from 'src/modules/emailing/utils/collect-campaign-variable-names-from-string.util';
export type SaveDraftCampaignArgs = {
workspaceId: string;
userWorkspaceId: string;
campaignId?: string;
name?: string;
subject?: string;
body?: unknown;
};
export type SaveDraftCampaignResult = {
campaignId: string;
campaignName: string;
created: boolean;
blockCount?: number;
variablesUsed: string[];
};
const DEFAULT_CAMPAIGN_NAME = 'Untitled campaign';
@Injectable()
export class MessageCampaignDraftService {
constructor(
private readonly userRoleService: UserRoleService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly campaignVariableService: CampaignVariableService,
) {}
private getRoleScopedRepository<T extends ObjectLiteral>(
workspaceId: string,
entity: Type<T>,
roleId: string,
) {
return this.globalWorkspaceOrmManager.getRepository(workspaceId, entity, {
unionOf: [roleId],
});
}
async saveDraft({
workspaceId,
userWorkspaceId,
campaignId,
name,
subject,
body,
}: SaveDraftCampaignArgs): Promise<SaveDraftCampaignResult> {
const stampedDocument = isDefined(body)
? this.parseAndStampDocument(body)
: undefined;
const variablesUsed = [
...new Set([
...(isDefined(stampedDocument)
? collectCampaignVariableNames(stampedDocument)
: []),
...(isDefined(subject)
? collectCampaignVariableNamesFromString(subject)
: []),
]),
].sort();
if (variablesUsed.length > 0) {
await this.campaignVariableService.assertKnownVariables(
workspaceId,
variablesUsed,
);
}
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
workspaceId,
userWorkspaceId,
});
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const campaignRepository = await this.getRoleScopedRepository(
workspaceId,
MessageCampaignWorkspaceEntity,
roleId,
);
if (!isDefined(campaignId)) {
const createdCampaignId = v4();
const campaignName = name ?? DEFAULT_CAMPAIGN_NAME;
await campaignRepository.insert({
id: createdCampaignId,
name: campaignName,
status: MessageCampaignStatus.DRAFT,
...(isDefined(subject) && { subject }),
...(isDefined(stampedDocument) && {
bodyTemplate: JSON.stringify(stampedDocument),
}),
});
return {
campaignId: createdCampaignId,
campaignName,
created: true,
blockCount: stampedDocument?.content?.length,
variablesUsed,
};
}
if (!isDefined(name) && !isDefined(subject) && !isDefined(body)) {
throw new EmailingDomainException(
'Nothing to update: provide a name, subject or body',
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE,
);
}
const campaign = await campaignRepository.findOne({
where: { id: campaignId },
});
if (!isDefined(campaign)) {
throw new EmailingDomainException(
`Campaign ${campaignId} not found`,
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_FOUND,
);
}
if (campaign.status !== MessageCampaignStatus.DRAFT) {
throw new EmailingDomainException(
`Campaign ${campaignId} is ${campaign.status}; only draft campaigns can be edited`,
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE,
);
}
const { affected } = await campaignRepository.update(
{ id: campaignId, status: MessageCampaignStatus.DRAFT },
{
...(isDefined(name) && { name }),
...(isDefined(subject) && { subject }),
...(isDefined(stampedDocument) && {
bodyTemplate: JSON.stringify(stampedDocument),
}),
},
);
if (affected !== 1) {
throw new EmailingDomainException(
`Campaign ${campaignId} is no longer an editable draft`,
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE,
);
}
return {
campaignId,
campaignName: name ?? campaign.name,
created: false,
blockCount: stampedDocument?.content?.length,
variablesUsed,
};
},
);
}
private parseAndStampDocument(body: unknown): EmailDocument {
const parseResult = parseEmailDocument(body);
if (!parseResult.success) {
throw new EmailingDomainException(
`Invalid email document: ${parseResult.error}`,
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE,
);
}
return this.stampDocumentDefaults(parseResult.document);
}
private stampDocumentDefaults(document: EmailDocument): EmailDocument {
return {
...document,
attrs: {
...document.attrs,
schemaVersion:
document.attrs?.schemaVersion ?? EMAIL_DOCUMENT_SCHEMA_VERSION,
canvasTheme: isDefined(document.attrs?.canvasTheme)
? document.attrs.canvasTheme
: CANVAS_THEME_DEFAULTS,
},
};
}
}
@@ -45,12 +45,14 @@ import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspac
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
import { CampaignVariableService } from 'src/modules/emailing/services/campaign-variable.service';
import { EmailBillingService } from 'src/modules/emailing/services/email-billing.service';
import { EmailingDomainSenderService } from 'src/modules/emailing/services/emailing-domain-sender.service';
import { MessageCampaignStatisticsService } from 'src/modules/emailing/services/message-campaign-statistics.service';
import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service';
import { MessageCampaignWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-campaign.workspace-entity';
import { MessageListMemberWorkspaceEntity } from 'src/modules/emailing/standard-objects/message-list-member.workspace-entity';
import { collectCampaignVariableNamesFromTemplates } from 'src/modules/emailing/utils/collect-campaign-variable-names-from-templates.util';
import { renderCampaignBodyToHtml } from 'src/modules/emailing/utils/render-campaign-body.util';
import { renderCampaignTemplate } from 'src/modules/emailing/utils/render-campaign-template.util';
import { sendableDraftCampaignSchema } from 'src/modules/emailing/zod-schemas/sendable-draft-campaign.zod-schema';
@@ -127,11 +129,12 @@ export class MessageCampaignService {
private readonly userRoleService: UserRoleService,
private readonly messageCampaignStatisticsService: MessageCampaignStatisticsService,
private readonly emailBillingService: EmailBillingService,
private readonly campaignVariableService: CampaignVariableService,
@InjectCacheStorage(CacheStorageNamespace.ModuleEmailing)
private readonly cacheStorageService: CacheStorageService,
) {}
private getUserRepository<T extends ObjectLiteral>(
private getRoleScopedRepository<T extends ObjectLiteral>(
workspaceId: string,
entity: Type<T>,
roleId: string,
@@ -195,7 +198,7 @@ export class MessageCampaignService {
MAX_CAMPAIGN_RECIPIENTS,
);
const campaignRepository = await this.getUserRepository(
const campaignRepository = await this.getRoleScopedRepository(
workspaceId,
MessageCampaignWorkspaceEntity,
roleId,
@@ -256,7 +259,11 @@ export class MessageCampaignService {
fromAddress,
);
const variables = this.buildTemplateVariables(null);
const variables =
await this.campaignVariableService.buildVariablesForPerson(
workspaceId,
null,
);
const renderedSubject = renderCampaignTemplate(subject, variables, {
escapeValues: false,
});
@@ -433,7 +440,11 @@ export class MessageCampaignService {
where: { id: personId },
});
const variables = this.buildTemplateVariables(person);
const variables =
await this.campaignVariableService.buildVariablesForPerson(
workspaceId,
person,
);
const subject = renderCampaignTemplate(
campaign.subject ?? '',
variables,
@@ -585,7 +596,7 @@ export class MessageCampaignService {
campaignId: string,
roleId: string,
): Promise<SendableDraftCampaign> {
const campaignRepository = await this.getUserRepository(
const campaignRepository = await this.getRoleScopedRepository(
workspaceId,
MessageCampaignWorkspaceEntity,
roleId,
@@ -613,6 +624,14 @@ export class MessageCampaignService {
);
}
await this.campaignVariableService.assertKnownVariables(
workspaceId,
collectCampaignVariableNamesFromTemplates({
subject: sendableCampaign.data.subject,
bodyTemplate: sendableCampaign.data.bodyTemplate,
}),
);
return sendableCampaign.data;
}
@@ -877,7 +896,7 @@ export class MessageCampaignService {
listId: string,
roleId: string,
): Promise<RawCampaignRecipient[]> {
const listMemberRepository = await this.getUserRepository(
const listMemberRepository = await this.getRoleScopedRepository(
workspaceId,
MessageListMemberWorkspaceEntity,
roleId,
@@ -903,7 +922,7 @@ export class MessageCampaignService {
return [];
}
const personRepository = await this.getUserRepository(
const personRepository = await this.getRoleScopedRepository(
workspaceId,
PersonWorkspaceEntity,
roleId,
@@ -916,20 +935,6 @@ export class MessageCampaignService {
return people.map(toRawRecipient);
}
private buildTemplateVariables(
person: PersonWorkspaceEntity | null,
): Record<string, string> {
const firstName = person?.name?.firstName ?? '';
const lastName = person?.name?.lastName ?? '';
return {
firstName,
lastName,
fullName: [firstName, lastName].filter(Boolean).join(' '),
email: person?.emails?.primaryEmail ?? '',
};
}
private campaignMessageId(campaignId: string, personId: string): string {
return v5(`${campaignId}:${personId}`, CAMPAIGN_MESSAGE_ID_NAMESPACE);
}
@@ -0,0 +1,129 @@
import { Test, type TestingModule } from '@nestjs/testing';
import {
EmailingDomainException,
EmailingDomainExceptionCode,
} from 'src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception';
import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-tool-json-schema.util';
import { MessageCampaignDraftService } from 'src/modules/emailing/services/message-campaign-draft.service';
import { SaveCampaignTool } from 'src/modules/emailing/tools/save-campaign-tool';
import { SaveCampaignToolInputZodSchema } from 'src/modules/emailing/tools/save-campaign-tool.schema';
describe('SaveCampaignTool', () => {
let tool: SaveCampaignTool;
let saveDraft: jest.Mock;
const campaignId = '20202020-0000-4000-8000-000000000001';
beforeEach(async () => {
saveDraft = jest.fn().mockResolvedValue({
campaignId,
campaignName: 'Monthly newsletter',
created: false,
blockCount: 3,
variablesUsed: ['firstName'],
});
const module: TestingModule = await Test.createTestingModule({
providers: [
SaveCampaignTool,
{
provide: MessageCampaignDraftService,
useValue: { saveDraft },
},
],
}).compile();
tool = module.get(SaveCampaignTool);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should edit a campaign and reference the record', async () => {
const output = await tool.execute(
{ campaignId, body: { type: 'doc', content: [] } },
{ workspaceId: 'workspace-1', userWorkspaceId: 'user-workspace-1' },
);
expect(output.success).toBe(true);
expect(output.message).toContain('updated');
expect(output.recordReferences).toEqual([
{
objectNameSingular: 'messageCampaign',
recordId: campaignId,
displayName: 'Monthly newsletter',
},
]);
expect(saveDraft).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
userWorkspaceId: 'user-workspace-1',
campaignId,
name: undefined,
subject: undefined,
body: { type: 'doc', content: [] },
});
});
it('should create a campaign when no id is provided', async () => {
saveDraft.mockResolvedValue({
campaignId,
campaignName: 'Launch announcement',
created: true,
variablesUsed: [],
});
const output = await tool.execute(
{ name: 'Launch announcement' },
{ workspaceId: 'workspace-1', userWorkspaceId: 'user-workspace-1' },
);
expect(output.success).toBe(true);
expect(output.message).toContain('created');
});
it('should refuse to run without a workspace member context', async () => {
const output = await tool.execute(
{ name: 'Launch announcement' },
{ workspaceId: 'workspace-1' },
);
expect(output.success).toBe(false);
expect(saveDraft).not.toHaveBeenCalled();
});
it('should expose an input schema convertible to JSON schema for the LLM', () => {
const jsonSchema = toToolJsonSchema(SaveCampaignToolInputZodSchema) as {
properties?: Record<string, unknown>;
required?: string[];
};
expect(jsonSchema.properties?.campaignId).toBeDefined();
expect(jsonSchema.properties?.name).toBeDefined();
expect(jsonSchema.properties?.subject).toBeDefined();
expect(jsonSchema.properties?.body).toBeDefined();
expect(jsonSchema.required ?? []).toEqual([]);
expect(JSON.stringify(jsonSchema)).toContain('section');
});
it('should surface domain errors as tool output', async () => {
saveDraft.mockRejectedValue(
new EmailingDomainException(
'Campaign is SENT; only draft campaigns can be edited',
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE,
),
);
const output = await tool.execute(
{ campaignId, subject: 'New subject' },
{ workspaceId: 'workspace-1', userWorkspaceId: 'user-workspace-1' },
);
expect(output).toEqual({
success: false,
message: 'Failed to save campaign',
error: 'Campaign is SENT; only draft campaigns can be edited',
});
});
});
@@ -0,0 +1,38 @@
import { emailDocumentSchema, isValidUuid } from 'twenty-shared/utils';
import { z } from 'zod';
export const SaveCampaignToolInputZodSchema = z.object({
campaignId: z
.string()
.refine((value) => isValidUuid(value))
.optional()
.describe(
'Omit to create a new draft campaign. Provide the UUID of an existing messageCampaign record to edit it; when the user refers to the campaign they are viewing, take it from the browsing context.',
),
name: z
.string()
.min(1)
.max(255)
.optional()
.describe('The internal campaign name shown in the campaign list.'),
subject: z
.string()
.max(998)
.optional()
.describe(
'The email subject line. Supports the same {{variables}} as the body.',
),
body: emailDocumentSchema
.optional()
.describe(
'The full email document that replaces the campaign body. ' +
'A document is {type: "doc", content: [...blocks]}. Blocks: paragraph and heading (level 1-3) hold inline text, variableTag chips ({attrs: {variable: "{{firstName}}"}}) and hardBreak; section wraps blocks in a styled band; columns holds 2-4 column children; button is a call-to-action with an href; image, divider, bulletList/orderedList and html (raw HTML) complete the set. ' +
'Style attributes are objects of camelCase CSS properties, e.g. {"paddingTop": "12px", "backgroundColor": "#f4f4f5"}. Use longhand box properties (paddingTop/Right/Bottom/Left). ' +
'Per-recipient variables reference person fields by path, e.g. {{name.firstName}}, {{emails.primaryEmail}}, {{city}} or any custom person field; {{firstName}}, {{lastName}}, {{fullName}}, {{email}} and {{personId}} also work. They apply in text, button and link URLs and raw HTML, and unknown names are rejected with the available list. ' +
'To modify an existing body, read the record first, edit the parsed document and send the whole result back.',
),
});
export type SaveCampaignToolInput = z.infer<
typeof SaveCampaignToolInputZodSchema
>;
@@ -0,0 +1,85 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { EmailingDomainException } from 'src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { MessageCampaignDraftService } from 'src/modules/emailing/services/message-campaign-draft.service';
import {
type SaveCampaignToolInput,
SaveCampaignToolInputZodSchema,
} from 'src/modules/emailing/tools/save-campaign-tool.schema';
@Injectable()
export class SaveCampaignTool implements Tool {
private readonly logger = new Logger(SaveCampaignTool.name);
description =
'Create a draft email campaign (messageCampaign record) or edit an existing one: name, subject and body. ' +
'The body is a structured email document validated against the campaign email schema before anything is written. ' +
'Only draft campaigns can be edited, and this tool never sends anything. ' +
'Requires create/update permission on campaigns.';
inputSchema = SaveCampaignToolInputZodSchema;
constructor(
private readonly messageCampaignDraftService: MessageCampaignDraftService,
) {}
async execute(
parameters: SaveCampaignToolInput,
context: ToolExecutionContext,
): Promise<ToolOutput> {
if (!isDefined(context.userWorkspaceId)) {
return {
success: false,
message: 'Failed to save campaign',
error: 'This tool can only run on behalf of a workspace member',
};
}
try {
const result = await this.messageCampaignDraftService.saveDraft({
workspaceId: context.workspaceId,
userWorkspaceId: context.userWorkspaceId,
campaignId: parameters.campaignId,
name: parameters.name,
subject: parameters.subject,
body: parameters.body,
});
return {
success: true,
message: result.created
? `Draft campaign "${result.campaignName}" created`
: `Campaign "${result.campaignName}" updated`,
result,
recordReferences: [
{
objectNameSingular: 'messageCampaign',
recordId: result.campaignId,
displayName: result.campaignName,
},
],
};
} catch (error) {
if (error instanceof EmailingDomainException) {
return {
success: false,
message: 'Failed to save campaign',
error: error.message,
};
}
this.logger.error(`Failed to save campaign: ${error}`);
return {
success: false,
message: 'Failed to save campaign',
error:
error instanceof Error ? error.message : 'Failed to save campaign',
};
}
}
}
@@ -0,0 +1,89 @@
import { collectCampaignVariableNames } from 'src/modules/emailing/utils/collect-campaign-variable-names.util';
describe('collectCampaignVariableNames', () => {
it('should collect variables from every substitution site', () => {
const document = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Hello {{firstName}}' },
{ type: 'variableTag', attrs: { variable: '{{lastName}}' } },
{
type: 'text',
text: 'link',
marks: [
{
type: 'link',
attrs: { href: 'https://example.com/{{personId}}' },
},
],
},
],
},
{
type: 'button',
attrs: { href: 'https://example.com/?email={{email}}', style: {} },
content: [{ type: 'text', text: 'Open' }],
},
{
type: 'image',
attrs: {
src: 'https://example.com/{{imagePath}}',
alt: '{{imageAlt}}',
title: '{{imageTitle}}',
},
},
{
type: 'section',
attrs: { style: {} },
content: [
{
type: 'html',
attrs: { html: '<p>{{fullName}}</p>' },
},
],
},
],
};
expect([...collectCampaignVariableNames(document)].sort()).toEqual([
'email',
'firstName',
'fullName',
'imageAlt',
'imagePath',
'imageTitle',
'lastName',
'personId',
]);
});
it('should collect nothing from a document without variables', () => {
expect(
collectCampaignVariableNames({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Hi' }] },
],
}).size,
).toBe(0);
});
it('should deduplicate repeated variables', () => {
expect([
...collectCampaignVariableNames({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '{{firstName}} and {{ firstName }}' },
],
},
],
}),
]).toEqual(['firstName']);
});
});
@@ -16,6 +16,7 @@ const VARIABLES = {
lastName: 'Lovelace',
fullName: 'Ada Lovelace',
email: 'ada@example.com',
personId: 'person-123',
};
const buildDocument = (text: string) =>
@@ -60,6 +61,112 @@ describe('renderCampaignBodyToHtml', () => {
);
});
it('should substitute variables carried by variable chip attributes', async () => {
await renderCampaignBodyToHtml(
JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Dear ' },
{ type: 'variableTag', attrs: { variable: '{{firstName}}' } },
],
},
],
}),
VARIABLES,
);
expect(renderedDocument().content[0].content[1].attrs.variable).toBe('Ada');
});
it('should substitute variables inside button and link URLs', async () => {
await renderCampaignBodyToHtml(
JSON.stringify({
type: 'doc',
content: [
{
type: 'button',
attrs: { href: 'https://example.com/p/{{personId}}' },
content: [{ type: 'text', text: 'Open' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'here',
marks: [
{
type: 'link',
attrs: { href: 'https://example.com/u/{{personId}}' },
},
],
},
],
},
],
}),
VARIABLES,
);
expect(renderedDocument().content[0].attrs.href).toBe(
'https://example.com/p/person-123',
);
expect(renderedDocument().content[1].content[0].marks[0].attrs.href).toBe(
'https://example.com/u/person-123',
);
});
it('should substitute variables inside image link URLs', async () => {
await renderCampaignBodyToHtml(
JSON.stringify({
type: 'doc',
content: [
{
type: 'image',
attrs: {
src: 'https://example.com/{{personId}}/banner.png',
href: 'https://example.com/promo/{{personId}}',
alt: 'Banner for {{firstName}}',
},
},
],
}),
VARIABLES,
);
expect(renderedDocument().content[0].attrs.href).toBe(
'https://example.com/promo/person-123',
);
expect(renderedDocument().content[0].attrs.src).toBe(
'https://example.com/person-123/banner.png',
);
expect(renderedDocument().content[0].attrs.alt).toBe('Banner for Ada');
});
it('should substitute variables inside raw HTML blocks with escaping', async () => {
await renderCampaignBodyToHtml(
JSON.stringify({
type: 'doc',
content: [
{
type: 'html',
attrs: {
html: '<a href="https://example.com/p/{{personId}}">Hi {{firstName}}</a>',
},
},
],
}),
{ ...VARIABLES, firstName: '<b>Ada</b>' },
);
expect(renderedDocument().content[0].attrs.html).toBe(
'<a href="https://example.com/p/person-123">Hi &lt;b&gt;Ada&lt;/b&gt;</a>',
);
});
it('should substitute variables nested under marks and lists', async () => {
const document = JSON.stringify({
type: 'doc',
@@ -121,49 +228,31 @@ describe('renderCampaignBodyToHtml', () => {
);
});
it('should interpolate legacy html bodies without rendering them again', async () => {
const html = await renderCampaignBodyToHtml(
'<p>Hi {{firstName}}</p>',
VARIABLES,
);
expect(html).toBe('<p>Hi Ada</p>');
expect(renderRichTextToHtml).not.toHaveBeenCalled();
});
it('should escape values interpolated into legacy html bodies', async () => {
const html = await renderCampaignBodyToHtml('<p>{{firstName}}</p>', {
...VARIABLES,
firstName: '<script>alert(1)</script>',
});
expect(html).not.toContain('<script>');
});
it('should return a legacy html body untouched when no variables are given', async () => {
const body = '<p>Hi {{firstName}}</p>';
expect(await renderCampaignBodyToHtml(body, null)).toBe(body);
expect(renderRichTextToHtml).not.toHaveBeenCalled();
});
it('should treat an empty body as a legacy body', async () => {
it('should render an empty body as empty without calling the renderer', async () => {
expect(await renderCampaignBodyToHtml('', VARIABLES)).toBe('');
expect(await renderCampaignBodyToHtml(' ', VARIABLES)).toBe('');
expect(renderRichTextToHtml).not.toHaveBeenCalled();
});
it('should treat a JSON value that is not a document as a legacy body', async () => {
const body = '{"foo":"bar"}';
expect(await renderCampaignBodyToHtml(body, VARIABLES)).toBe(body);
expect(renderRichTextToHtml).not.toHaveBeenCalled();
it('should reject a body that is not JSON', async () => {
await expect(
renderCampaignBodyToHtml('<p>Hi {{firstName}}</p>', VARIABLES),
).rejects.toThrow('not a renderable email document');
});
it('should treat a document with a non-array content as a legacy body', async () => {
const body = '{"type":"doc","content":"not an array"}';
it('should reject a JSON value that is not a document', async () => {
await expect(
renderCampaignBodyToHtml('{"foo":"bar"}', VARIABLES),
).rejects.toThrow('not a renderable email document');
});
expect(await renderCampaignBodyToHtml(body, VARIABLES)).toBe(body);
expect(renderRichTextToHtml).not.toHaveBeenCalled();
it('should reject a document with a non-array content', async () => {
await expect(
renderCampaignBodyToHtml(
'{"type":"doc","content":"not an array"}',
VARIABLES,
),
).rejects.toThrow('not a renderable email document');
});
it('should render a document with no content at all', async () => {
@@ -0,0 +1,17 @@
import { CAMPAIGN_VARIABLE_PATTERN } from 'src/modules/emailing/constants/campaign-variable-pattern.constant';
export const collectCampaignVariableNamesFromString = (
value: unknown,
): Set<string> => {
const names = new Set<string>();
if (typeof value !== 'string') {
return names;
}
for (const match of value.matchAll(CAMPAIGN_VARIABLE_PATTERN)) {
names.add(match[1]);
}
return names;
};
@@ -0,0 +1,31 @@
import { type EmailDocumentNode, parseJson } from 'twenty-shared/utils';
import { collectCampaignVariableNames } from 'src/modules/emailing/utils/collect-campaign-variable-names.util';
import { collectCampaignVariableNamesFromString } from 'src/modules/emailing/utils/collect-campaign-variable-names-from-string.util';
export const collectCampaignVariableNamesFromTemplates = ({
subject,
bodyTemplate,
}: {
subject: string;
bodyTemplate: string;
}): Set<string> => {
const names = new Set<string>(
collectCampaignVariableNamesFromString(subject),
);
const parsedBody = parseJson<EmailDocumentNode>(bodyTemplate);
const collected =
typeof parsedBody === 'object' &&
parsedBody !== null &&
parsedBody.type === 'doc'
? collectCampaignVariableNames(parsedBody)
: collectCampaignVariableNamesFromString(bodyTemplate);
for (const name of collected) {
names.add(name);
}
return names;
};
@@ -0,0 +1,22 @@
import {
type EmailDocumentNode,
transformEmailDocumentStrings,
} from 'twenty-shared/utils';
import { collectCampaignVariableNamesFromString } from 'src/modules/emailing/utils/collect-campaign-variable-names-from-string.util';
export const collectCampaignVariableNames = (
node: EmailDocumentNode,
): Set<string> => {
const names = new Set<string>();
transformEmailDocumentStrings(node, (value) => {
for (const name of collectCampaignVariableNamesFromString(value)) {
names.add(name);
}
return value;
});
return names;
};
@@ -1,65 +1,37 @@
import { type JSONContent } from '@tiptap/core';
import { isDefined, parseJson } from 'twenty-shared/utils';
import { renderRichTextToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util';
import {
CAMPAIGN_VARIABLE_PATTERN,
renderCampaignTemplate,
} from 'src/modules/emailing/utils/render-campaign-template.util';
isDefined,
parseJson,
parseEmailDocument,
transformEmailDocumentStrings,
} from 'twenty-shared/utils';
// bodyTemplate is a plain text field, so anything can be written to it through
// the record API. The renderer maps over content without checking it, so a
// document carrying a non-array content would throw mid-send rather than fall
// back. A document with no content at all renders as empty and is fine.
const isRenderableDocument = (
document: JSONContent | null,
): document is JSONContent =>
isDefined(document) &&
document.type === 'doc' &&
(!isDefined(document.content) || Array.isArray(document.content));
import { renderEmailBodyToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-email-body.util';
import { renderCampaignTemplate } from 'src/modules/emailing/utils/render-campaign-template.util';
const substituteVariables = (
node: JSONContent,
variables: Record<string, string>,
): JSONContent => ({
...node,
...(typeof node.text === 'string' && {
text: node.text.replace(
CAMPAIGN_VARIABLE_PATTERN,
(_match, variableName: string) => variables[variableName] ?? '',
),
}),
...(Array.isArray(node.content) && {
content: node.content.map((childNode) =>
substituteVariables(childNode, variables),
),
}),
});
// Bodies authored in the campaign composer are TipTap JSON and go through
// react-email, which emits the table markup Outlook needs. Bodies authored
// before the composer moved to JSON are HTML strings and keep the old
// string-interpolation path. Pass null variables to render the template with
// its placeholders left in place.
export const renderCampaignBodyToHtml = async (
bodyTemplate: string,
variables: Record<string, string> | null,
): Promise<string> => {
const tipTapDocument = parseJson<JSONContent>(bodyTemplate);
if (bodyTemplate.trim() === '') {
return '';
}
if (!isRenderableDocument(tipTapDocument)) {
return isDefined(variables)
? renderCampaignTemplate(bodyTemplate, variables, { escapeValues: true })
: bodyTemplate;
const parseResult = parseEmailDocument(parseJson<unknown>(bodyTemplate));
if (!parseResult.success) {
throw new Error('Campaign bodyTemplate is not a renderable email document');
}
// Values are substituted into text nodes rather than into the serialized
// JSON, so a value containing quotes or braces cannot corrupt the document.
// react-email escapes them when it renders.
return renderRichTextToHtml(
return renderEmailBodyToHtml(
isDefined(variables)
? substituteVariables(tipTapDocument, variables)
: tipTapDocument,
? transformEmailDocumentStrings(parseResult.document, (value, context) =>
renderCampaignTemplate(value, variables, {
escapeValues: context === 'html',
}),
)
: parseResult.document,
);
};
@@ -1,7 +1,5 @@
import { escapeHtml } from 'src/engine/core-modules/emailing-domain/utils/escape-html.util';
export const CAMPAIGN_VARIABLE_PATTERN =
/\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
import { CAMPAIGN_VARIABLE_PATTERN } from 'src/modules/emailing/constants/campaign-variable-pattern.constant';
export const renderCampaignTemplate = (
template: string,

Some files were not shown because too many files have changed in this diff Show More