diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx index 742a576e98..da60c6f82e 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsNewEmailGroupChannel.tsx @@ -10,7 +10,6 @@ import { Section } from 'twenty-ui/layout'; import { useCreateEmailGroupChannel } from '@/settings/accounts/hooks/useCreateEmailGroupChannel'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; @@ -18,7 +17,6 @@ import { useNavigateSettings } from '~/hooks/useNavigateSettings'; export const SettingsAccountsNewEmailGroupChannel = () => { const { t } = useLingui(); const navigate = useNavigateSettings(); - const { enqueueErrorSnackBar } = useSnackBar(); const { createEmailGroupChannel, loading } = useCreateEmailGroupChannel(); const [handle, setHandle] = useState(''); @@ -27,22 +25,16 @@ export const SettingsAccountsNewEmailGroupChannel = () => { const canSave = isHandleValidEmail && !loading; const handleSave = useCallback(async () => { - try { - const result = await createEmailGroupChannel(handle); - const messageChannelId = - result.data?.createEmailGroupChannel.messageChannel.id; + const result = await createEmailGroupChannel(handle); + const messageChannelId = + result.data?.createEmailGroupChannel.messageChannel.id; - if (messageChannelId) { - navigate(SettingsPath.EmailGroupChannelDetail, { - messageChannelId, - }); - } - } catch { - enqueueErrorSnackBar({ - message: t`Failed to create email channel. Email channels may not be configured on this server.`, + if (messageChannelId) { + navigate(SettingsPath.EmailGroupChannelDetail, { + messageChannelId, }); } - }, [createEmailGroupChannel, handle, navigate, enqueueErrorSnackBar, t]); + }, [createEmailGroupChannel, handle, navigate]); return ( { + const { enqueueErrorSnackBar } = useSnackBar(); + const [mutate, { loading, error }] = useMutation< CreateEmailGroupChannelResult, CreateEmailGroupChannelVariables @@ -45,7 +48,12 @@ export const useCreateEmailGroupChannel = () => { }); const createEmailGroupChannel = (handle: string) => - mutate({ variables: { input: { handle } } }); + mutate({ + variables: { input: { handle } }, + onError: (mutationError) => { + enqueueErrorSnackBar({ apolloError: mutationError }); + }, + }); return { createEmailGroupChannel, loading, error }; }; diff --git a/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts b/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts index b293a02ab4..75c34dd359 100644 --- a/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts +++ b/packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts @@ -50,6 +50,9 @@ const WORKSPACE_SCOPED_EXEMPTIONS = new Set([ // Only injection lives in a frozen historical upgrade-version-command // directory that CI's mutation-guard refuses to let us edit. 'DataSourceEntity', + // The domain column is globally unique across workspaces, so duplicate + // preflight checks must query cross-workspace; writes stay on the wrapper. + 'EmailingDomainEntity', ]); // Everything else must use @InjectWorkspaceScopedRepository. diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts index 84b99ece92..757cbf7a6a 100644 --- a/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/emailing-domain.resolver.ts @@ -8,6 +8,7 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato import { CreateEmailingDomainInput } from 'src/engine/core-modules/emailing-domain/dtos/create-emailing-domain.input'; import { EmailingDomainDTO } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.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 { EmailGroupAccessService } from 'src/engine/core-modules/emailing-domain/services/email-group-access.service'; import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; @@ -25,7 +26,10 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; FeatureFlagGuard, SettingsPermissionGuard(PermissionFlagType.WORKSPACE), ) -@UseFilters(EmailGroupAccessGraphqlApiExceptionFilter) +@UseFilters( + EmailGroupAccessGraphqlApiExceptionFilter, + EmailingDomainGraphqlApiExceptionFilter, +) @UsePipes(ResolverValidationPipe) @MetadataResolver(() => EmailingDomainDTO) export class EmailingDomainResolver { diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception.ts new file mode 100644 index 0000000000..b2f53107c9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception.ts @@ -0,0 +1,34 @@ +import { type MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { assertUnreachable } from 'twenty-shared/utils'; + +import { CustomException } from 'src/utils/custom-exception'; + +export enum EmailingDomainExceptionCode { + EMAILING_DOMAIN_ALREADY_REGISTERED = 'EMAILING_DOMAIN_ALREADY_REGISTERED', +} + +const getEmailingDomainExceptionUserFriendlyMessage = ( + code: EmailingDomainExceptionCode, +) => { + switch (code) { + case EmailingDomainExceptionCode.EMAILING_DOMAIN_ALREADY_REGISTERED: + return msg`This domain is already registered.`; + default: + assertUnreachable(code); + } +}; + +export class EmailingDomainException extends CustomException { + constructor( + message: string, + code: EmailingDomainExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + ) { + super(message, code, { + userFriendlyMessage: + userFriendlyMessage ?? + getEmailingDomainExceptionUserFriendlyMessage(code), + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/filters/emailing-domain-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/filters/emailing-domain-graphql-api-exception.filter.ts new file mode 100644 index 0000000000..c5cb4e0b4b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/filters/emailing-domain-graphql-api-exception.filter.ts @@ -0,0 +1,22 @@ +import { Catch, type ExceptionFilter } from '@nestjs/common'; + +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + EmailingDomainException, + EmailingDomainExceptionCode, +} from 'src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception'; +import { ConflictError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +@Catch(EmailingDomainException) +export class EmailingDomainGraphqlApiExceptionFilter implements ExceptionFilter { + catch(exception: EmailingDomainException) { + switch (exception.code) { + case EmailingDomainExceptionCode.EMAILING_DOMAIN_ALREADY_REGISTERED: + throw new ConflictError(exception); + default: { + assertUnreachable(exception.code); + } + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts index dc801ee30a..2895be42ee 100644 --- a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts +++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/emailing-domain.service.ts @@ -1,7 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + import { EmailingDomainDriverException, EmailingDomainDriverExceptionCode, @@ -9,6 +12,10 @@ import { import { EmailingDomainDriverFactory } from 'src/engine/core-modules/emailing-domain/drivers/emailing-domain-driver.factory'; import { EmailingDomainStatus } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-status.type'; import { EmailingDomainEntity } from 'src/engine/core-modules/emailing-domain/emailing-domain.entity'; +import { + EmailingDomainException, + EmailingDomainExceptionCode, +} from 'src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception'; import { UnsubscribeHostnameService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-hostname.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; @@ -21,6 +28,10 @@ export class EmailingDomainService { constructor( @InjectWorkspaceScopedRepository(EmailingDomainEntity) private readonly emailingDomainRepository: WorkspaceScopedRepository, + // Domain is globally unique across workspaces, so existence checks need + // an unscoped repository + @InjectRepository(EmailingDomainEntity) + private readonly globalEmailingDomainRepository: Repository, private readonly emailingDomainDriverFactory: EmailingDomainDriverFactory, private readonly unsubscribeHostnameService: UnsubscribeHostnameService, ) {} @@ -29,17 +40,15 @@ export class EmailingDomainService { domain: string, workspaceId: string, ): Promise { - const existingEmailingDomain = await this.emailingDomainRepository.findOne( - workspaceId, - { + const existingEmailingDomain = + await this.globalEmailingDomainRepository.findOne({ where: { domain }, - }, - ); + }); - if (existingEmailingDomain) { - throw new EmailingDomainDriverException( - 'Emailing domain already exists for this workspace', - EmailingDomainDriverExceptionCode.CONFIGURATION_ERROR, + if (isDefined(existingEmailingDomain)) { + throw new EmailingDomainException( + 'Emailing domain is already registered', + EmailingDomainExceptionCode.EMAILING_DOMAIN_ALREADY_REGISTERED, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/message-channel/utils/message-channel-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/message-channel/utils/message-channel-graphql-api-exception-handler.util.ts index f6de0151b4..4ce5ac9bed 100644 --- a/packages/twenty-server/src/engine/metadata-modules/message-channel/utils/message-channel-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/message-channel/utils/message-channel-graphql-api-exception-handler.util.ts @@ -1,6 +1,11 @@ import { assertUnreachable } from 'twenty-shared/utils'; import { + EmailingDomainException, + EmailingDomainExceptionCode, +} from 'src/engine/core-modules/emailing-domain/exceptions/emailing-domain.exception'; +import { + ConflictError, ForbiddenError, InternalServerError, NotFoundError, @@ -32,6 +37,16 @@ export const messageChannelGraphqlApiExceptionHandler = (error: Error) => { } } + if (error instanceof EmailingDomainException) { + switch (error.code) { + case EmailingDomainExceptionCode.EMAILING_DOMAIN_ALREADY_REGISTERED: + throw new ConflictError(error); + default: { + return assertUnreachable(error.code); + } + } + } + if (error instanceof ConnectedAccountException) { switch (error.code) { case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION: