Feat/email composer improvements (#23188)

- Move composer to dedicated page
- Add test email option
- Auto saved as draft can be revisited from `objects/messageCampaigns`
later
- Campaign stats component



https://github.com/user-attachments/assets/9e523116-e79b-496d-9c9d-3887e0c9213f



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23188?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>
This commit is contained in:
neo773
2026-07-28 16:43:00 +05:30
committed by GitHub
parent 30bbf4149a
commit 1e58c3073c
119 changed files with 5345 additions and 2606 deletions
@@ -7,10 +7,12 @@ import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { CampaignAudiencePreviewDTO } from 'src/engine/core-modules/emailing-domain/dtos/campaign-audience-preview.dto';
import { EmailGroupAccessGraphqlApiExceptionFilter } from 'src/engine/core-modules/emailing-domain/filters/email-group-access-graphql-api-exception.filter';
import { EmailingDomainGraphqlApiExceptionFilter } from 'src/engine/core-modules/emailing-domain/filters/emailing-domain-graphql-api-exception.filter';
import { PreviewMessageCampaignAudienceInput } from 'src/engine/core-modules/emailing-domain/dtos/preview-message-campaign-audience.input';
import { SendEmailViaDomainInput } from 'src/engine/core-modules/emailing-domain/dtos/send-email-via-domain.input';
import { SendEmailViaDomainOutputDTO } from 'src/engine/core-modules/emailing-domain/dtos/send-email-via-domain-output.dto';
import { SendMessageCampaignInput } from 'src/engine/core-modules/emailing-domain/dtos/send-message-campaign.input';
import { SendMessageCampaignTestInput } from 'src/engine/core-modules/emailing-domain/dtos/send-message-campaign-test.input';
import { SendMessageCampaignOutputDTO } from 'src/engine/core-modules/emailing-domain/dtos/send-message-campaign-output.dto';
import { EmailGroupAccessService } from 'src/engine/core-modules/emailing-domain/services/email-group-access.service';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -32,7 +34,10 @@ import { MessageCampaignService } from 'src/modules/emailing/services/message-ca
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
)
@UseFilters(EmailGroupAccessGraphqlApiExceptionFilter)
@UseFilters(
EmailGroupAccessGraphqlApiExceptionFilter,
EmailingDomainGraphqlApiExceptionFilter,
)
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
export class EmailingSendResolver {
@@ -84,12 +89,31 @@ export class EmailingSendResolver {
return this.messageCampaignService.send({
workspaceId: currentWorkspace.id,
userWorkspaceId,
campaignId: input.campaignId,
});
}
@Mutation(() => SendEmailViaDomainOutputDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_EMAIL_GROUP_ENABLED)
async sendMessageCampaignTest(
@Args('input') input: SendMessageCampaignTestInput,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<SendEmailViaDomainOutputDTO> {
this.emailGroupAccessService.validateEmailGroupAccessOrThrow();
await this.emailBillingService.validateEmailCreditsOrThrow(
currentWorkspace.id,
);
const result = await this.messageCampaignService.sendTest({
workspaceId: currentWorkspace.id,
toAddress: input.toAddress,
unsubscribeTopicId: input.unsubscribeTopicId,
listId: input.listId,
subject: input.subject,
html: input.body,
fromAddress: input.fromAddress,
});
return { messageId: result.messageId };
}
@Query(() => CampaignAudiencePreviewDTO)
@@ -1,6 +1,7 @@
import { Injectable, Logger, type Type } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { z } from 'zod';
import { In, type ObjectLiteral } from 'typeorm';
import { v4, v5 } from 'uuid';
@@ -8,7 +9,6 @@ import {
CAMPAIGN_MESSAGE_DELIVERY_STATUS,
CAMPAIGN_MESSAGE_ID_NAMESPACE,
CAMPAIGN_STATS_REFRESH_DELAY_MS,
CAMPAIGN_STATUS,
MATERIALIZE_CAMPAIGN_JOB,
MAX_CAMPAIGN_RECIPIENTS,
REFRESH_CAMPAIGN_STATS_JOB,
@@ -19,6 +19,10 @@ import {
EmailingDomainDriverExceptionCode,
} from 'src/engine/core-modules/emailing-domain/drivers/exceptions/emailing-domain-driver.exception';
import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type';
import {
EmailingDomainException,
EmailingDomainExceptionCode,
} from 'src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception';
import { type EmailingDomainSendEmailResult } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-result.type';
import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity';
import { type CampaignRecipient } from 'src/engine/core-modules/emailing-domain/types/campaign-recipient.type';
@@ -47,7 +51,9 @@ import { MessageCampaignStatisticsService } from 'src/modules/emailing/services/
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 { 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';
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
@@ -55,13 +61,22 @@ import { MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/stand
import { MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { createHtmlToTextConverter } from 'src/modules/messaging/message-import-manager/utils/create-html-to-text-converter.util';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { MessageParticipantRole } from 'twenty-shared/types';
import {
MessageParticipantRole,
MessageCampaignStatus,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { getDomainFromEmail } from 'src/utils/get-domain-from-email';
type SendCampaignArgs = {
workspaceId: string;
userWorkspaceId: string;
listId: string;
campaignId: string;
};
type SendCampaignTestArgs = {
workspaceId: string;
toAddress: string;
subject: string;
html: string;
fromAddress: string;
@@ -85,6 +100,8 @@ type CampaignAudiencePreview = {
type CampaignMessageRecipient = CampaignRecipient & { messageId: string };
type SendableDraftCampaign = z.infer<typeof sendableDraftCampaignSchema>;
const toRawRecipient = (person: {
id: string;
emails?: { primaryEmail?: string | null } | null;
@@ -136,31 +153,35 @@ export class MessageCampaignService {
async send({
workspaceId,
userWorkspaceId,
unsubscribeTopicId,
subject,
html,
fromAddress,
listId,
campaignId,
}: SendCampaignArgs): Promise<SendCampaignResult> {
const fromDomain = getDomainFromEmail(fromAddress)?.toLowerCase();
const emailingDomain = await this.emailingDomainRepository.findOne(
workspaceId,
{ where: { domain: fromDomain, status: EmailingDomainStatus.VERIFIED } },
);
if (emailingDomain === null) {
throw new Error(
`No verified emailing domain matches the from address ${fromAddress}`,
);
}
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
workspaceId,
userWorkspaceId,
});
const { campaignId, recipients, skipped } =
const { fromAddress, listId } =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const sendableCampaign = await this.findSendableDraftCampaignOrThrow(
workspaceId,
campaignId,
roleId,
);
return {
fromAddress: sendableCampaign.fromAddress.primaryEmail,
listId: sendableCampaign.listId,
};
},
);
const emailingDomain = await this.findVerifiedEmailingDomainOrThrow(
workspaceId,
fromAddress,
);
const { recipients, skipped } =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const rawRecipients = await this.resolveRecipientsFromList(
@@ -174,18 +195,26 @@ export class MessageCampaignService {
MAX_CAMPAIGN_RECIPIENTS,
);
const newCampaignId = await this.createCampaign({
const campaignRepository = await this.getUserRepository(
workspaceId,
MessageCampaignWorkspaceEntity,
roleId,
subject,
html,
fromAddress,
unsubscribeTopicId,
listId,
});
);
// Conditional update so two concurrent sends cannot both enqueue
const { affected } = await campaignRepository.update(
{ id: campaignId, status: MessageCampaignStatus.DRAFT },
{ status: MessageCampaignStatus.SENDING },
);
if (affected !== 1) {
throw new EmailingDomainException(
`Campaign ${campaignId} is no longer a sendable draft`,
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE,
);
}
return {
campaignId: newCampaignId,
recipients: normalized.recipients,
skipped: normalized.skipped,
};
@@ -214,6 +243,60 @@ export class MessageCampaignService {
return { campaignId, queuedCount: recipients.length, skipped };
}
async sendTest({
workspaceId,
toAddress,
subject,
html,
fromAddress,
unsubscribeTopicId,
}: SendCampaignTestArgs): Promise<EmailingDomainSendEmailResult> {
const emailingDomain = await this.findVerifiedEmailingDomainOrThrow(
workspaceId,
fromAddress,
);
const variables = this.buildTemplateVariables(null);
const renderedSubject = renderCampaignTemplate(subject, variables, {
escapeValues: false,
});
const renderedHtml = await renderCampaignBodyToHtml(html, variables);
return this.emailingDomainSenderService.sendEmail(
workspaceId,
emailingDomain.id,
{
from: fromAddress,
to: [toAddress],
subject: renderedSubject,
text: this.htmlToText(renderedHtml),
html: renderedHtml,
unsubscribeTopicId,
},
);
}
private async findVerifiedEmailingDomainOrThrow(
workspaceId: string,
fromAddress: string,
): Promise<EmailingDomainEntity> {
const fromDomain = getDomainFromEmail(fromAddress)?.toLowerCase();
const emailingDomain = await this.emailingDomainRepository.findOne(
workspaceId,
{ where: { domain: fromDomain, status: EmailingDomainStatus.VERIFIED } },
);
if (!isDefined(emailingDomain)) {
throw new EmailingDomainException(
`No verified emailing domain matches the from address ${fromAddress}`,
EmailingDomainExceptionCode.EMAILING_DOMAIN_NOT_VERIFIED,
);
}
return emailingDomain;
}
async processMaterializeJob(data: MaterializeCampaignJobData): Promise<void> {
const {
workspaceId,
@@ -233,7 +316,7 @@ export class MessageCampaignService {
where: { id: campaignId },
});
if (campaign === null) {
if (!isDefined(campaign)) {
return;
}
@@ -321,7 +404,7 @@ export class MessageCampaignService {
});
if (
message === null ||
!isDefined(message) ||
(message.deliveryStatus !== CAMPAIGN_MESSAGE_DELIVERY_STATUS.QUEUED &&
message.deliveryStatus !== CAMPAIGN_MESSAGE_DELIVERY_STATUS.FAILED)
) {
@@ -337,7 +420,7 @@ export class MessageCampaignService {
where: { id: campaignId },
});
if (campaign === null) {
if (!isDefined(campaign)) {
return;
}
@@ -358,12 +441,9 @@ export class MessageCampaignService {
escapeValues: false,
},
);
const html = renderCampaignTemplate(
const html = await renderCampaignBodyToHtml(
campaign.bodyTemplate ?? '',
variables,
{
escapeValues: true,
},
);
const text = this.htmlToText(html);
const fromAddress = campaign.fromAddress?.primaryEmail ?? '';
@@ -420,7 +500,7 @@ export class MessageCampaignService {
);
const isRetryable =
code === null ||
!isDefined(code) ||
code === EmailingDomainDriverExceptionCode.TEMPORARY_ERROR ||
code === EmailingDomainDriverExceptionCode.UNKNOWN;
@@ -480,7 +560,7 @@ export class MessageCampaignService {
where: { headerMessageId: providerMessageId },
});
if (message === null || message.messageCampaignId === null) {
if (!isDefined(message) || !isDefined(message.messageCampaignId)) {
return;
}
@@ -500,39 +580,40 @@ export class MessageCampaignService {
}, buildSystemAuthContext(workspaceId));
}
private async createCampaign({
workspaceId,
roleId,
subject,
html,
fromAddress,
unsubscribeTopicId,
listId,
}: {
workspaceId: string;
roleId: string;
subject: string;
html: string;
fromAddress: string;
unsubscribeTopicId?: string;
listId: string;
}): Promise<string> {
private async findSendableDraftCampaignOrThrow(
workspaceId: string,
campaignId: string,
roleId: string,
): Promise<SendableDraftCampaign> {
const campaignRepository = await this.getUserRepository(
workspaceId,
MessageCampaignWorkspaceEntity,
roleId,
);
const { identifiers } = await campaignRepository.insert({
subject,
bodyTemplate: html,
fromAddress: { primaryEmail: fromAddress, additionalEmails: null },
status: CAMPAIGN_STATUS.SENDING,
unsubscribeTopicId: unsubscribeTopicId ?? null,
listId,
const campaign = await campaignRepository.findOne({
where: { id: campaignId },
});
return identifiers[0].id;
if (!isDefined(campaign)) {
throw new EmailingDomainException(
`Campaign ${campaignId} not found`,
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_FOUND,
);
}
const sendableCampaign = sendableDraftCampaignSchema.safeParse(campaign);
if (!sendableCampaign.success) {
throw new EmailingDomainException(
`Campaign ${campaignId} is not sendable: ${sendableCampaign.error.issues
.map((issue) => `${issue.path.join('.')} ${issue.message}`)
.join(', ')}`,
EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE,
);
}
return sendableCampaign.data;
}
private async materializeCampaignMessages({
@@ -553,7 +634,11 @@ export class MessageCampaignService {
recipients: CampaignMessageRecipient[];
}): Promise<void> {
const now = new Date();
const text = this.htmlToText(bodyTemplate);
// The stored message keeps the unresolved template, so placeholders stay
// visible on the campaign's message records.
const text = this.htmlToText(
await renderCampaignBodyToHtml(bodyTemplate, null),
);
const rows = recipients.map((recipient) => ({
recipient,
messageId: recipient.messageId,
@@ -675,12 +760,12 @@ export class MessageCampaignService {
);
await campaignRepository.update(
{ id: campaignId, status: CAMPAIGN_STATUS.SENDING },
{ id: campaignId, status: MessageCampaignStatus.SENDING },
{
status:
failedCount > 0
? CAMPAIGN_STATUS.SENT_WITH_ERRORS
: CAMPAIGN_STATUS.SENT,
? MessageCampaignStatus.SENT_WITH_ERRORS
: MessageCampaignStatus.SENT,
sentAt: new Date(),
},
);
@@ -7,6 +7,7 @@ import { type MessageParticipantWorkspaceEntity } from 'src/modules/messaging/co
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
export class MessageCampaignWorkspaceEntity extends BaseWorkspaceEntity {
name: string;
subject: string | null;
bodyTemplate: string | null;
fromAddress: EmailsMetadata | null;
@@ -0,0 +1,175 @@
import { renderCampaignBodyToHtml } from 'src/modules/emailing/utils/render-campaign-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',
);
const VARIABLES = {
firstName: 'Ada',
lastName: 'Lovelace',
fullName: 'Ada Lovelace',
email: 'ada@example.com',
};
const buildDocument = (text: string) =>
JSON.stringify({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
});
const renderedDocument = () => renderRichTextToHtml.mock.calls[0][0];
describe('renderCampaignBodyToHtml', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render a TipTap document through the email renderer', async () => {
const html = await renderCampaignBodyToHtml(
buildDocument('Hello there'),
VARIABLES,
);
expect(html).toBe('<p>rendered html</p>');
expect(renderedDocument()).toEqual({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello there' }],
},
],
});
});
it('should substitute variables inside text nodes', async () => {
await renderCampaignBodyToHtml(
buildDocument('Hi {{firstName}}, from {{fullName}}'),
VARIABLES,
);
expect(renderedDocument().content[0].content[0].text).toBe(
'Hi Ada, from Ada Lovelace',
);
});
it('should substitute variables nested under marks and lists', async () => {
const document = JSON.stringify({
type: 'doc',
content: [
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Dear {{firstName}}',
marks: [{ type: 'bold' }],
},
],
},
],
},
],
},
],
});
await renderCampaignBodyToHtml(document, VARIABLES);
const textNode =
renderedDocument().content[0].content[0].content[0].content[0];
expect(textNode.text).toBe('Dear Ada');
expect(textNode.marks).toEqual([{ type: 'bold' }]);
});
it('should replace unknown variables with an empty string', async () => {
await renderCampaignBodyToHtml(buildDocument('Hi {{unknown}}!'), VARIABLES);
expect(renderedDocument().content[0].content[0].text).toBe('Hi !');
});
it('should leave a value containing markup for the renderer to escape', async () => {
await renderCampaignBodyToHtml(buildDocument('{{firstName}}'), {
...VARIABLES,
firstName: '<script>alert(1)</script>',
});
expect(renderedDocument().content[0].content[0].text).toBe(
'<script>alert(1)</script>',
);
});
it('should keep placeholders in place when no variables are given', async () => {
await renderCampaignBodyToHtml(buildDocument('Hi {{firstName}}'), null);
expect(renderedDocument().content[0].content[0].text).toBe(
'Hi {{firstName}}',
);
});
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 () => {
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 treat a document with a non-array content as a legacy body', async () => {
const body = '{"type":"doc","content":"not an array"}';
expect(await renderCampaignBodyToHtml(body, VARIABLES)).toBe(body);
expect(renderRichTextToHtml).not.toHaveBeenCalled();
});
it('should render a document with no content at all', async () => {
const html = await renderCampaignBodyToHtml('{"type":"doc"}', VARIABLES);
expect(html).toBe('<p>rendered html</p>');
expect(renderedDocument()).toEqual({ type: 'doc' });
});
});
@@ -0,0 +1,65 @@
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';
// 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));
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 (!isRenderableDocument(tipTapDocument)) {
return isDefined(variables)
? renderCampaignTemplate(bodyTemplate, variables, { escapeValues: true })
: bodyTemplate;
}
// 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(
isDefined(variables)
? substituteVariables(tipTapDocument, variables)
: tipTapDocument,
);
};
@@ -1,6 +1,7 @@
import { escapeHtml } from 'src/engine/core-modules/emailing-domain/utils/escape-html.util';
const CAMPAIGN_VARIABLE_PATTERN = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
export const CAMPAIGN_VARIABLE_PATTERN =
/\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
export const renderCampaignTemplate = (
template: string,
@@ -0,0 +1,72 @@
import { MessageCampaignStatus } from 'twenty-shared/types';
import { sendableDraftCampaignSchema } from 'src/modules/emailing/zod-schemas/sendable-draft-campaign.zod-schema';
describe('sendableDraftCampaignSchema', () => {
const sendableDraftCampaign = {
status: MessageCampaignStatus.DRAFT,
subject: 'Monthly newsletter',
bodyTemplate: '<p>Hello {{firstName}}</p>',
fromAddress: { primaryEmail: 'news@company.com' },
listId: '20202020-0000-4000-8000-000000000001',
};
it('should accept a draft campaign with a subject, body, from address and list', () => {
expect(
sendableDraftCampaignSchema.safeParse(sendableDraftCampaign).success,
).toBe(true);
});
it('should reject a campaign that already left DRAFT', () => {
expect(
sendableDraftCampaignSchema.safeParse({
...sendableDraftCampaign,
status: MessageCampaignStatus.SENT,
}).success,
).toBe(false);
});
it('should reject a draft without a subject', () => {
expect(
sendableDraftCampaignSchema.safeParse({
...sendableDraftCampaign,
subject: '',
}).success,
).toBe(false);
});
it('should reject a draft without a body', () => {
expect(
sendableDraftCampaignSchema.safeParse({
...sendableDraftCampaign,
bodyTemplate: '',
}).success,
).toBe(false);
});
it('should reject a draft with a malformed from address', () => {
expect(
sendableDraftCampaignSchema.safeParse({
...sendableDraftCampaign,
fromAddress: { primaryEmail: 'not-an-email' },
}).success,
).toBe(false);
});
it('should reject a draft without a from address', () => {
expect(
sendableDraftCampaignSchema.safeParse({
...sendableDraftCampaign,
fromAddress: null,
}).success,
).toBe(false);
});
it('should reject a draft without a recipient list', () => {
expect(
sendableDraftCampaignSchema.safeParse({
...sendableDraftCampaign,
listId: null,
}).success,
).toBe(false);
});
});
@@ -0,0 +1,10 @@
import { MessageCampaignStatus } from 'twenty-shared/types';
import { z } from 'zod';
export const sendableDraftCampaignSchema = z.object({
status: z.literal(MessageCampaignStatus.DRAFT),
subject: z.string().min(1),
bodyTemplate: z.string().min(1),
fromAddress: z.object({ primaryEmail: z.email() }),
listId: z.string().min(1),
});