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
@@ -51,6 +51,8 @@ export enum EngineComponentKey {
REPLY_TO_EMAIL_THREAD = 'REPLY_TO_EMAIL_THREAD',
COMPOSE_EMAIL = 'COMPOSE_EMAIL',
COMPOSE_CAMPAIGN = 'COMPOSE_CAMPAIGN',
SEND_MESSAGE_CAMPAIGN = 'SEND_MESSAGE_CAMPAIGN',
SEND_MESSAGE_CAMPAIGN_TEST = 'SEND_MESSAGE_CAMPAIGN_TEST',
// TODO: Remove deprecated keys once upgrade:1-21:refactor-navigation-commands has run on all workspaces
// Deprecated: replaced by NAVIGATION engine key with payload
@@ -95,6 +95,12 @@ export class FlatPageLayoutWidgetTypeValidatorService {
EMAIL_THREAD: validateSimpleRecordPageWidgetForCreation(
WidgetConfigurationType.EMAIL_THREAD,
),
MESSAGE_CAMPAIGN_BODY: validateSimpleRecordPageWidgetForCreation(
WidgetConfigurationType.MESSAGE_CAMPAIGN_BODY,
),
MESSAGE_CAMPAIGN_DETAILS: validateSimpleRecordPageWidgetForCreation(
WidgetConfigurationType.MESSAGE_CAMPAIGN_DETAILS,
),
};
private readonly PAGE_LAYOUT_WIDGET_TYPE_VALIDATOR_FOR_UPDATE_HASHMAP: FlatPageLayoutWidgetTypeValidatorForUpdate =
@@ -149,6 +155,12 @@ export class FlatPageLayoutWidgetTypeValidatorService {
EMAIL_THREAD: validateSimpleRecordPageWidgetForUpdate(
WidgetConfigurationType.EMAIL_THREAD,
),
MESSAGE_CAMPAIGN_BODY: validateSimpleRecordPageWidgetForUpdate(
WidgetConfigurationType.MESSAGE_CAMPAIGN_BODY,
),
MESSAGE_CAMPAIGN_DETAILS: validateSimpleRecordPageWidgetForUpdate(
WidgetConfigurationType.MESSAGE_CAMPAIGN_DETAILS,
),
};
public validateFlatPageLayoutWidgetTypeSpecificitiesForCreation(
@@ -376,6 +376,8 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
case WidgetConfigurationType.IFRAME:
case WidgetConfigurationType.STANDALONE_RICH_TEXT:
case WidgetConfigurationType.EMAIL_THREAD:
case WidgetConfigurationType.MESSAGE_CAMPAIGN_BODY:
case WidgetConfigurationType.MESSAGE_CAMPAIGN_DETAILS:
return configuration;
}
};
@@ -2,6 +2,7 @@ import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { Not, Repository } from 'typeorm';
@@ -13,6 +14,7 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountPublicDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account-public.dto';
@@ -167,7 +169,7 @@ export class MessageChannelResolver {
}
@Mutation(() => CreateEmailGroupChannelOutput)
@UseGuards(NoPermissionGuard)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.WORKSPACE))
async createEmailGroupChannel(
@Args('input') input: CreateEmailGroupChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -182,7 +184,7 @@ export class MessageChannelResolver {
}
@Mutation(() => MessageChannelDTO)
@UseGuards(NoPermissionGuard)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.WORKSPACE))
async updateEmailGroupChannel(
@Args('input') input: UpdateEmailGroupChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -197,7 +199,7 @@ export class MessageChannelResolver {
}
@Mutation(() => MessageChannelDTO)
@UseGuards(NoPermissionGuard)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.WORKSPACE))
async deleteEmailGroupChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -41,6 +41,15 @@ export const messageChannelGraphqlApiExceptionHandler = (error: Error) => {
switch (error.code) {
case EmailingDomainExceptionCode.EMAILING_DOMAIN_ALREADY_REGISTERED:
throw new ConflictError(error);
case EmailingDomainExceptionCode.MESSAGE_SUPPRESSION_NOT_FOUND:
throw new NotFoundError(error);
case EmailingDomainExceptionCode.MESSAGE_SUPPRESSION_NOT_REMOVABLE:
throw new ForbiddenError(error);
case EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_FOUND:
throw new NotFoundError(error);
case EmailingDomainExceptionCode.MESSAGE_CAMPAIGN_NOT_SENDABLE:
case EmailingDomainExceptionCode.EMAILING_DOMAIN_NOT_VERIFIED:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
@@ -1,5 +1,7 @@
import { AggregateChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto';
import { EmailThreadConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/email-thread-configuration.dto';
import { MessageCampaignBodyConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/message-campaign-body-configuration.dto';
import { MessageCampaignDetailsConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/message-campaign-details-configuration.dto';
import { BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto';
import { CalendarConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/calendar-configuration.dto';
import { EmailsConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/emails-configuration.dto';
@@ -35,6 +37,8 @@ export const ALL_WIDGET_CONFIGURATION_TYPE_VALIDATOR_BY_WIDGET_CONFIGURATION_TYP
FRONT_COMPONENT: FrontComponentConfigurationDTO,
EMAILS: EmailsConfigurationDTO,
EMAIL_THREAD: EmailThreadConfigurationDTO,
MESSAGE_CAMPAIGN_BODY: MessageCampaignBodyConfigurationDTO,
MESSAGE_CAMPAIGN_DETAILS: MessageCampaignDetailsConfigurationDTO,
FIELD: FieldConfigurationDTO,
FIELD_RICH_TEXT: FieldRichTextConfigurationDTO,
FIELDS: FieldsConfigurationDTO,
@@ -0,0 +1,14 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsIn, IsNotEmpty } from 'class-validator';
import { type MessageCampaignBodyConfiguration } from 'twenty-shared/types';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
@ObjectType('MessageCampaignBodyConfiguration')
export class MessageCampaignBodyConfigurationDTO implements MessageCampaignBodyConfiguration {
@Field(() => WidgetConfigurationType)
@IsIn([WidgetConfigurationType.MESSAGE_CAMPAIGN_BODY])
@IsNotEmpty()
configurationType: WidgetConfigurationType.MESSAGE_CAMPAIGN_BODY;
}
@@ -0,0 +1,14 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsIn, IsNotEmpty } from 'class-validator';
import { type MessageCampaignDetailsConfiguration } from 'twenty-shared/types';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
@ObjectType('MessageCampaignDetailsConfiguration')
export class MessageCampaignDetailsConfigurationDTO implements MessageCampaignDetailsConfiguration {
@Field(() => WidgetConfigurationType)
@IsIn([WidgetConfigurationType.MESSAGE_CAMPAIGN_DETAILS])
@IsNotEmpty()
configurationType: WidgetConfigurationType.MESSAGE_CAMPAIGN_DETAILS;
}
@@ -27,6 +27,8 @@ export enum WidgetConfigurationType {
FRONT_COMPONENT = 'FRONT_COMPONENT',
RECORD_TABLE = 'RECORD_TABLE',
EMAIL_THREAD = 'EMAIL_THREAD',
MESSAGE_CAMPAIGN_BODY = 'MESSAGE_CAMPAIGN_BODY',
MESSAGE_CAMPAIGN_DETAILS = 'MESSAGE_CAMPAIGN_DETAILS',
}
export type AllGraphWidgetConfigurationType =
| WidgetConfigurationType.AGGREGATE_CHART
@@ -18,4 +18,6 @@ export enum WidgetType {
FRONT_COMPONENT = 'FRONT_COMPONENT',
RECORD_TABLE = 'RECORD_TABLE',
EMAIL_THREAD = 'EMAIL_THREAD',
MESSAGE_CAMPAIGN_BODY = 'MESSAGE_CAMPAIGN_BODY',
MESSAGE_CAMPAIGN_DETAILS = 'MESSAGE_CAMPAIGN_DETAILS',
}