Feat/email settings v2 (#23180)

Settings pages changes

- Add `displayName`
- Unsubscribers Page

<img width="1496" height="844" alt="Screenshot 2026-07-22 at 8 52 15 PM"
src="https://github.com/user-attachments/assets/69bc1993-4547-4a64-83a6-b47fef1a4e40"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23180?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-27 02:18:30 +05:30
committed by GitHub
parent 8326fd186f
commit 3fb29db28a
49 changed files with 1913 additions and 389 deletions
@@ -1,5 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator';
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
@InputType('CreateEmailGroupChannelInput')
export class CreateEmailGroupChannelInput {
@@ -9,4 +15,10 @@ export class CreateEmailGroupChannelInput {
@IsEmail()
@MaxLength(254)
handle: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(255)
displayName?: string;
}
@@ -39,6 +39,11 @@ export class MessageChannelDTO {
@Field()
handle: string;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
displayName: string | null;
@IsEnum(MessageChannelType)
@IsNotEmpty()
@Field(() => MessageChannelType)
@@ -0,0 +1,16 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType('UpdateEmailGroupChannelInput')
export class UpdateEmailGroupChannelInput {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@MaxLength(255)
displayName?: string | null;
}
@@ -68,6 +68,9 @@ export class MessageChannelEntity extends WorkspaceRelatedEntity {
@Column({ type: 'varchar', nullable: false })
handle: string;
@Column({ type: 'varchar', nullable: true })
displayName: string | null;
@Column({
type: 'enum',
enum: MessageChannelType,
@@ -213,10 +213,12 @@ export class MessageChannelMetadataService {
async createEmailGroupChannel({
handle,
displayName,
userWorkspaceId,
workspaceId,
}: {
handle: string;
displayName?: string;
userWorkspaceId: string;
workspaceId: string;
}): Promise<CreateEmailGroupChannelOutput> {
@@ -268,9 +270,14 @@ export class MessageChannelMetadataService {
visibility: 'workspace',
});
const trimmedDisplayName = displayName?.trim();
const messageChannel = await this.create({
workspaceId,
handle: forwardingAddress,
displayName: isNonEmptyString(trimmedDisplayName)
? trimmedDisplayName
: null,
connectedAccountId: connectedAccount.id,
type: MessageChannelType.EMAIL_GROUP,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
@@ -340,6 +347,48 @@ export class MessageChannelMetadataService {
return messageChannel;
}
async updateEmailGroupChannel({
id,
displayName,
userWorkspaceId,
workspaceId,
}: {
id: string;
displayName?: string | null;
userWorkspaceId: string;
workspaceId: string;
}): Promise<MessageChannelDTO> {
const messageChannel = await this.verifyOwnership({
id,
userWorkspaceId,
workspaceId,
});
if (messageChannel.type !== MessageChannelType.EMAIL_GROUP) {
throw new MessageChannelException(
`Message channel ${id} is not an email group`,
MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT,
);
}
// An omitted displayName leaves the current one untouched; an explicit null clears it
if (displayName === undefined) {
return messageChannel;
}
const trimmedDisplayName = displayName?.trim();
return this.update({
id,
workspaceId,
data: {
displayName: isNonEmptyString(trimmedDisplayName)
? trimmedDisplayName
: null,
},
});
}
async deleteEmailGroupChannel({
id,
userWorkspaceId,
@@ -17,6 +17,7 @@ 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';
import { CreateEmailGroupChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.input';
import { UpdateEmailGroupChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/update-email-group-channel.input';
import { CreateEmailGroupChannelOutput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-group-channel.output';
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
import { UpdateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/update-message-channel.input';
@@ -174,6 +175,22 @@ export class MessageChannelResolver {
): Promise<CreateEmailGroupChannelOutput> {
return this.messageChannelMetadataService.createEmailGroupChannel({
handle: input.handle,
displayName: input.displayName,
userWorkspaceId,
workspaceId: workspace.id,
});
}
@Mutation(() => MessageChannelDTO)
@UseGuards(NoPermissionGuard)
async updateEmailGroupChannel(
@Args('input') input: UpdateEmailGroupChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<MessageChannelDTO> {
return this.messageChannelMetadataService.updateEmailGroupChannel({
id: input.id,
displayName: input.displayName,
userWorkspaceId,
workspaceId: workspace.id,
});