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:
@@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+175
@@ -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,
|
||||
|
||||
+72
@@ -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);
|
||||
});
|
||||
});
|
||||
+10
@@ -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),
|
||||
});
|
||||
Reference in New Issue
Block a user