feat(email-group): communications UX, per-record DNS status (#23002)

- Rename Communications label to singular, remove docs-home banner
- Provision unsubscribe Cloudflare records at domain creation and
surface per-record status badges; skip Cloudflare when not configured
- Move sending-domain status into the section header and only show the
records table when a record is unverified
- Reply to the original recipients when replying to your own message
- Fix DNS records table column/badge alignment; emit synthetic records
in the log driver for local testing

<img width="1496" height="849" alt="Screenshot 2026-07-17 at 7 47 24 PM"
src="https://github.com/user-attachments/assets/a5a59adb-2df4-4154-98b2-acf87a8008da"
/>
This commit is contained in:
neo773
2026-07-18 02:11:25 +05:30
committed by GitHub
parent 80ff1716e5
commit 5bedd5b8cc
23 changed files with 364 additions and 86 deletions
@@ -2454,6 +2454,7 @@ type VerificationRecord {
key: String!
value: String!
priority: Float
status: String
}
type EmailingDomain {
@@ -2160,6 +2160,7 @@ export interface VerificationRecord {
key: Scalars['String']
value: Scalars['String']
priority?: Scalars['Float']
status?: Scalars['String']
__typename: 'VerificationRecord'
}
@@ -5312,6 +5313,7 @@ export interface VerificationRecordGenqlSelection{
key?: boolean | number
value?: boolean | number
priority?: boolean | number
status?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -4887,6 +4887,9 @@ export default {
"priority": [
12
],
"status": [
1
],
"__typename": [
1
]
@@ -5941,6 +5941,7 @@ export type VerificationRecord = {
__typename?: 'VerificationRecord';
key: Scalars['String']['output'];
priority?: Maybe<Scalars['Float']['output']>;
status?: Maybe<Scalars['String']['output']>;
type: Scalars['String']['output'];
value: Scalars['String']['output'];
};
@@ -8059,12 +8060,12 @@ export type VerifyEmailingDomainMutationVariables = Exact<{
}>;
export type VerifyEmailingDomainMutation = { __typename?: 'Mutation', verifyEmailingDomain: { __typename?: 'EmailingDomain', id: string, domain: string, status: EmailingDomainStatus, verifiedAt?: string | null, createdAt: string, updatedAt: string } };
export type VerifyEmailingDomainMutation = { __typename?: 'Mutation', verifyEmailingDomain: { __typename?: 'EmailingDomain', id: string, domain: string, status: EmailingDomainStatus, verifiedAt?: string | null, createdAt: string, updatedAt: string, verificationRecords?: Array<{ __typename?: 'VerificationRecord', type: string, key: string, value: string, priority?: number | null, status?: string | null }> | null } };
export type GetEmailingDomainsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetEmailingDomainsQuery = { __typename?: 'Query', getEmailingDomains: Array<{ __typename?: 'EmailingDomain', id: string, domain: string, status: EmailingDomainStatus, verifiedAt?: string | null, createdAt: string, updatedAt: string, verificationRecords?: Array<{ __typename?: 'VerificationRecord', type: string, key: string, value: string, priority?: number | null }> | null }> };
export type GetEmailingDomainsQuery = { __typename?: 'Query', getEmailingDomains: Array<{ __typename?: 'EmailingDomain', id: string, domain: string, status: EmailingDomainStatus, verifiedAt?: string | null, createdAt: string, updatedAt: string, verificationRecords?: Array<{ __typename?: 'VerificationRecord', type: string, key: string, value: string, priority?: number | null, status?: string | null }> | null }> };
export type RefreshEnterpriseValidityTokenMutationVariables = Exact<{ [key: string]: never; }>;
@@ -9010,8 +9011,8 @@ export const CreatePublicDomainDocument = {"kind":"Document","definitions":[{"ki
export const DeletePublicDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeletePublicDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletePublicDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}}]}]}}]} as unknown as DocumentNode<DeletePublicDomainMutation, DeletePublicDomainMutationVariables>;
export const FindManyPublicDomainsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyPublicDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findManyPublicDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"isValidated"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>;
export const DeleteEmailingDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteEmailingDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteEmailingDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode<DeleteEmailingDomainMutation, DeleteEmailingDomainMutationVariables>;
export const VerifyEmailingDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"VerifyEmailingDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyEmailingDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<VerifyEmailingDomainMutation, VerifyEmailingDomainMutationVariables>;
export const GetEmailingDomainsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetEmailingDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getEmailingDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"verificationRecords"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetEmailingDomainsQuery, GetEmailingDomainsQueryVariables>;
export const VerifyEmailingDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"VerifyEmailingDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyEmailingDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"verificationRecords"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<VerifyEmailingDomainMutation, VerifyEmailingDomainMutationVariables>;
export const GetEmailingDomainsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetEmailingDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getEmailingDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"verificationRecords"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetEmailingDomainsQuery, GetEmailingDomainsQueryVariables>;
export const RefreshEnterpriseValidityTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RefreshEnterpriseValidityToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"refreshEnterpriseValidityToken"}}]}}]} as unknown as DocumentNode<RefreshEnterpriseValidityTokenMutation, RefreshEnterpriseValidityTokenMutationVariables>;
export const ReleaseEnterpriseServerBindingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReleaseEnterpriseServerBinding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"releaseEnterpriseServerBinding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isValid"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionId"}}]}}]}}]} as unknown as DocumentNode<ReleaseEnterpriseServerBindingMutation, ReleaseEnterpriseServerBindingMutationVariables>;
export const SetEnterpriseKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetEnterpriseKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"enterpriseKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setEnterpriseKey"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"enterpriseKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"enterpriseKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isValid"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionId"}}]}}]}}]} as unknown as DocumentNode<SetEnterpriseKeyMutation, SetEnterpriseKeyMutationVariables>;
@@ -1,12 +1,11 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useMemo } from 'react';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient';
import {
type ReplyContext,
type ReplyContextReady,
} from '@/activities/emails/types/ReplyContext';
import { getReplyToRecipients } from '@/activities/emails/utils/getReplyToRecipients';
import { isDefined } from 'twenty-shared/utils';
export type { ReplyContext, ReplyContextReady };
@@ -17,6 +16,7 @@ export const useReplyContext = (
const {
messages,
connectedAccountId,
connectedAccountHandle,
connectedAccountProvider,
messageChannelLoading,
threadLoading,
@@ -52,13 +52,10 @@ export const useReplyContext = (
};
}
const senderHandle = lastSentMessage.sender?.handle ?? '';
const replyTo = isNonEmptyString(senderHandle)
? formatEmailRecipient({
address: senderHandle,
displayName: lastSentMessage.sender?.displayName,
})
: '';
const replyTo = getReplyToRecipients({
message: lastSentMessage,
connectedAccountHandle,
});
const rawSubject = lastSentMessage.subject ?? '';
const subject = rawSubject.startsWith('Re: ')
@@ -76,6 +73,7 @@ export const useReplyContext = (
}, [
messages,
connectedAccountId,
connectedAccountHandle,
connectedAccountProvider,
messageChannelLoading,
threadLoading,
@@ -0,0 +1,131 @@
import { getReplyToRecipients } from '@/activities/emails/utils/getReplyToRecipients';
import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { MessageParticipantRole } from 'twenty-shared/types';
const buildParticipant = (
role: MessageParticipantRole,
handle: string,
displayName = '',
): EmailThreadMessageParticipant =>
({
role,
handle,
displayName,
}) as EmailThreadMessageParticipant;
const buildMessage = (
participants: EmailThreadMessageParticipant[],
): EmailThreadMessageWithSender => {
const sender =
participants.find(
(participant) => participant.role === MessageParticipantRole.FROM,
) ?? participants[0];
return {
sender,
messageParticipants: participants,
} as unknown as EmailThreadMessageWithSender;
};
const CONNECTED_ACCOUNT_HANDLE = 'me@company.com';
describe('getReplyToRecipients', () => {
it('replies to the original sender when someone else sent the message', () => {
const message = buildMessage([
buildParticipant(
MessageParticipantRole.FROM,
'alice@example.com',
'Alice',
),
buildParticipant(MessageParticipantRole.TO, CONNECTED_ACCOUNT_HANDLE),
]);
expect(
getReplyToRecipients({
message,
connectedAccountHandle: CONNECTED_ACCOUNT_HANDLE,
}),
).toBe('Alice <alice@example.com>');
});
it('prefers the Reply-To address over the sender when one is set', () => {
const message = buildMessage([
buildParticipant(MessageParticipantRole.FROM, 'no-reply@example.com'),
buildParticipant(
MessageParticipantRole.REPLY_TO,
'support@example.com',
'Support',
),
buildParticipant(MessageParticipantRole.TO, CONNECTED_ACCOUNT_HANDLE),
]);
expect(
getReplyToRecipients({
message,
connectedAccountHandle: CONNECTED_ACCOUNT_HANDLE,
}),
).toBe('Support <support@example.com>');
});
it('replies to the original TO and CC recipients when replying to your own message', () => {
const message = buildMessage([
buildParticipant(MessageParticipantRole.FROM, CONNECTED_ACCOUNT_HANDLE),
buildParticipant(MessageParticipantRole.TO, 'bob@example.com', 'Bob'),
buildParticipant(MessageParticipantRole.CC, 'carol@example.com', 'Carol'),
buildParticipant(MessageParticipantRole.BCC, 'dan@example.com', 'Dan'),
]);
expect(
getReplyToRecipients({
message,
connectedAccountHandle: CONNECTED_ACCOUNT_HANDLE,
}),
).toBe('Bob <bob@example.com>, Carol <carol@example.com>');
});
it('excludes the connected account own address from the recipients', () => {
const message = buildMessage([
buildParticipant(MessageParticipantRole.FROM, CONNECTED_ACCOUNT_HANDLE),
buildParticipant(MessageParticipantRole.TO, CONNECTED_ACCOUNT_HANDLE),
buildParticipant(MessageParticipantRole.TO, 'bob@example.com', 'Bob'),
]);
expect(
getReplyToRecipients({
message,
connectedAccountHandle: CONNECTED_ACCOUNT_HANDLE,
}),
).toBe('Bob <bob@example.com>');
});
it('deduplicates an address present in both To and Cc case-insensitively', () => {
const message = buildMessage([
buildParticipant(MessageParticipantRole.FROM, CONNECTED_ACCOUNT_HANDLE),
buildParticipant(MessageParticipantRole.TO, 'bob@example.com', 'Bob'),
buildParticipant(MessageParticipantRole.CC, 'Bob@Example.com', 'Bob'),
]);
expect(
getReplyToRecipients({
message,
connectedAccountHandle: CONNECTED_ACCOUNT_HANDLE,
}),
).toBe('Bob <bob@example.com>');
});
it('ignores participants without a handle', () => {
const message = buildMessage([
buildParticipant(MessageParticipantRole.FROM, CONNECTED_ACCOUNT_HANDLE),
buildParticipant(MessageParticipantRole.TO, '', 'Ghost'),
buildParticipant(MessageParticipantRole.TO, 'bob@example.com', 'Bob'),
]);
expect(
getReplyToRecipients({
message,
connectedAccountHandle: CONNECTED_ACCOUNT_HANDLE,
}),
).toBe('Bob <bob@example.com>');
});
});
@@ -0,0 +1,64 @@
import { isNonEmptyString } from '@sniptt/guards';
import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { MessageParticipantRole } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const getReplyToRecipients = ({
message,
connectedAccountHandle,
}: {
message: EmailThreadMessageWithSender;
connectedAccountHandle: string | null | undefined;
}): string => {
const senderHandle = message.sender?.handle ?? '';
const wasSentByConnectedAccount =
isNonEmptyString(connectedAccountHandle) &&
senderHandle.toLowerCase() === connectedAccountHandle.toLowerCase();
const replyToParticipants = message.messageParticipants.filter(
(participant) => participant.role === MessageParticipantRole.REPLY_TO,
);
const replyParticipants = wasSentByConnectedAccount
? message.messageParticipants.filter(
(participant) =>
participant.role === MessageParticipantRole.TO ||
participant.role === MessageParticipantRole.CC,
)
: replyToParticipants.length > 0
? replyToParticipants
: isDefined(message.sender)
? [message.sender]
: [];
const seenHandles = new Set<string>();
return replyParticipants
.filter((participant) => isNonEmptyString(participant.handle))
.filter(
(participant) =>
!isNonEmptyString(connectedAccountHandle) ||
participant.handle.toLowerCase() !==
connectedAccountHandle.toLowerCase(),
)
.filter((participant) => {
const normalizedHandle = participant.handle.toLowerCase();
if (seenHandles.has(normalizedHandle)) {
return false;
}
seenHandles.add(normalizedHandle);
return true;
})
.map((participant) =>
formatEmailRecipient({
address: participant.handle,
displayName: participant.displayName,
}),
)
.join(', ');
};
@@ -45,7 +45,7 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
href: getSettingsPath(SettingsPath.General),
},
{
children: t`Communications`,
children: t`Communication`,
href: getSettingsPath(SettingsPath.WorkspaceCommunications),
},
{ children: t`New Email Channel` },
@@ -63,9 +63,9 @@ export const SettingsDnsRecordsTable = ({
'100px',
'minmax(0, 1fr)',
'minmax(0, 1.5fr)',
...(hasPriorityColumn ? ['max-content'] : []),
...(hasTtlColumn ? ['max-content'] : []),
...(hasStatusColumn ? ['max-content'] : []),
...(hasPriorityColumn ? ['80px'] : []),
...(hasTtlColumn ? ['80px'] : []),
...(hasStatusColumn ? ['112px'] : []),
].join(' ');
return (
@@ -79,7 +79,7 @@ export const SettingsDnsRecordsTable = ({
)}
{hasTtlColumn && <TableHeader align="center">{t`TTL`}</TableHeader>}
{hasStatusColumn && (
<TableHeader align="center">{t`Status`}</TableHeader>
<TableHeader align="right">{t`Status`}</TableHeader>
)}
</TableRow>
@@ -138,7 +138,7 @@ export const SettingsDnsRecordsTable = ({
)}
{hasTtlColumn && <TableCell align="center">{record.ttl}</TableCell>}
{hasStatusColumn && (
<TableCell align="center">
<TableCell align="right">
{isDefined(record.status) && isDefined(record.statusColor) && (
<Status
color={record.statusColor}
@@ -7,6 +7,13 @@ export const VERIFY_EMAILING_DOMAIN = gql`
domain
status
verifiedAt
verificationRecords {
type
key
value
priority
status
}
createdAt
updatedAt
}
@@ -12,6 +12,7 @@ export const GET_ALL_EMAILING_DOMAINS = gql`
key
value
priority
status
}
createdAt
updatedAt
@@ -174,7 +174,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
isHidden: !permissionMap[PermissionFlagType.AI_SETTINGS],
},
{
label: t`Communications`,
label: t`Communication`,
path: SettingsPath.WorkspaceCommunications,
Icon: IconMessageCircle,
isHidden:
@@ -17,17 +17,18 @@ import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { MessageChannelType, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { GetEmailingDomainsDocument } from '~/generated-metadata/graphql';
import {
EmailingDomainStatus,
GetEmailingDomainsDocument,
} from '~/generated-metadata/graphql';
import { Status } from 'twenty-ui/data-display';
import { IconCopy, IconTrash } from 'twenty-ui/icon';
import { H2Title } from 'twenty-ui/typography';
import { Button } from 'twenty-ui/input';
import { InlineBanner } from 'twenty-ui/feedback';
import { Section } from 'twenty-ui/layout';
import { Card } from 'twenty-ui/surfaces';
import { type ThemeColor } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { NotFound } from '~/pages/not-found/NotFound';
import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
@@ -47,20 +48,17 @@ const StyledForwardingInputContainer = styled.div`
margin-right: ${themeCssVariables.spacing[2]};
`;
const StyledDomainStatusRow = styled.div`
const StyledSendingDomainAdornment = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
justify-content: space-between;
padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]};
`;
const StyledDomainName = styled.div`
color: ${themeCssVariables.font.color.secondary};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const RECORD_STATUS_TO_COLOR: Partial<Record<string, ThemeColor>> = {
success: 'green',
pending: 'yellow',
error: 'red',
};
export const SettingsWorkspaceCommunicationGroupChannelDetail = () => {
const { t } = useLingui();
@@ -96,6 +94,30 @@ export const SettingsWorkspaceCommunicationGroupChannelDetail = () => {
(domain) => domain.domain.toLowerCase() === channelDomain,
);
const verificationRecords = (emailingDomain?.verificationRecords ?? []).map(
(record) => ({
...record,
status: record.status ?? undefined,
statusColor: isDefined(record.status)
? (RECORD_STATUS_TO_COLOR[record.status] ?? 'gray')
: undefined,
}),
);
const isDomainVerified =
verificationRecords.length > 0 &&
verificationRecords.every((record) => record.status === 'success');
const hasFailedRecord = verificationRecords.some(
(record) => record.status === 'error',
);
const domainStatus = isDomainVerified
? EmailingDomainStatus.VERIFIED
: hasFailedRecord
? EmailingDomainStatus.FAILED
: EmailingDomainStatus.PENDING;
const handleDelete = async () => {
try {
await deleteEmailGroupChannel(channel.id);
@@ -116,7 +138,7 @@ export const SettingsWorkspaceCommunicationGroupChannelDetail = () => {
href: getSettingsPath(SettingsPath.General),
},
{
children: t`Communications`,
children: t`Communication`,
href: getSettingsPath(SettingsPath.WorkspaceCommunications),
},
]}
@@ -133,18 +155,6 @@ export const SettingsWorkspaceCommunicationGroupChannelDetail = () => {
}
>
<SettingsPageContainer>
<InlineBanner
message={t`Need help to configure your shared mailbox?`}
button={{
title: t`Go to documentation`,
onClick: () =>
window.open(
getDocumentationUrl({}),
'_blank',
'noopener,noreferrer',
),
}}
/>
<Section>
<H2Title
title={t`Shared email`}
@@ -189,25 +199,22 @@ export const SettingsWorkspaceCommunicationGroupChannelDetail = () => {
title={t`Sending domain`}
description={t`Outbound mail from this channel is sent through this domain. It must be verified before email can be delivered.`}
adornment={
<SettingsEmailingDomainVerifyButton
emailingDomainId={emailingDomain.id}
/>
<StyledSendingDomainAdornment>
<Status
color={getColorByEmailingDomainStatus(domainStatus)}
text={getTextByEmailingDomainStatus(domainStatus)}
/>
{!isDomainVerified && (
<SettingsEmailingDomainVerifyButton
emailingDomainId={emailingDomain.id}
/>
)}
</StyledSendingDomainAdornment>
}
/>
{isDefined(emailingDomain.verificationRecords) && (
<SettingsDnsRecordsTable
records={emailingDomain.verificationRecords}
/>
{!isDomainVerified && (
<SettingsDnsRecordsTable records={verificationRecords} />
)}
<Card rounded>
<StyledDomainStatusRow>
<StyledDomainName>{emailingDomain.domain}</StyledDomainName>
<Status
color={getColorByEmailingDomainStatus(emailingDomain.status)}
text={getTextByEmailingDomainStatus(emailingDomain.status)}
/>
</StyledDomainStatusRow>
</Card>
</Section>
)}
</SettingsPageContainer>
@@ -60,13 +60,13 @@ export const SettingsWorkspaceCommunications = () => {
return (
<SettingsPageLayout
title={t`Communications`}
title={t`Communication`}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.General),
},
{ children: t`Communications` },
{ children: t`Communication` },
]}
>
<SettingsPageContainer>
@@ -80,7 +80,7 @@ export const SettingsWorkspaceNewUnsubscribeTopic = () => {
href: getSettingsPath(SettingsPath.General),
},
{
children: t`Communications`,
children: t`Communication`,
href: getSettingsPath(SettingsPath.WorkspaceCommunications),
},
{ children: t`New Unsubscribe Topic` },
@@ -123,7 +123,7 @@ export const SettingsWorkspaceUnsubscribeTopicDetail = () => {
href: getSettingsPath(SettingsPath.General),
},
{
children: t`Communications`,
children: t`Communication`,
href: getSettingsPath(SettingsPath.WorkspaceCommunications),
},
{ children: topicName },
@@ -37,6 +37,10 @@ export class DnsManagerService {
}
}
isConfigured(): boolean {
return isDefined(this.cloudflareClient);
}
async registerHostname(customDomain: string, options?: DnsManagerOptions) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
@@ -60,7 +60,7 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
const tenantName = this.buildTenantName(input.workspaceId);
const { isVerified, verificationRecords } =
const { isVerified, status, verificationRecords } =
await this.createOrUpdateEmailIdentity(input.domain, tenantName);
if (isVerified) {
@@ -68,10 +68,8 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
}
return {
status: isVerified
? EmailingDomainStatus.VERIFIED
: EmailingDomainStatus.PENDING,
verificationRecords,
status,
verificationRecords: this.withRecordStatus(verificationRecords, status),
};
} catch (error) {
this.logger.error(`Failed to verify domain ${input.domain}: ${error}`);
@@ -101,7 +99,7 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
return {
status,
verificationRecords,
verificationRecords: this.withRecordStatus(verificationRecords, status),
};
} catch (error) {
if (error instanceof NotFoundException) {
@@ -252,6 +250,7 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
tenantName: string,
): Promise<{
isVerified: boolean;
status: EmailingDomainStatus;
verificationRecords: VerificationRecordDTO[];
}> {
const sesClient = this.awsSesClientProvider.getSESClient();
@@ -263,6 +262,7 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
const existingIdentity = await sesClient.send(getIdentityCommand);
const isVerified = existingIdentity.VerifiedForSendingStatus === true;
const status = this.determineVerificationStatus(existingIdentity);
const verificationRecords = this.buildVerificationRecords(
domain,
existingIdentity.DkimAttributes?.Tokens || [],
@@ -270,7 +270,7 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
await this.associateResourceWithTenant(domain, tenantName);
return { isVerified, verificationRecords };
return { isVerified, status, verificationRecords };
} catch (error) {
if (error instanceof NotFoundException) {
return await this.createNewEmailIdentity(domain, tenantName);
@@ -284,6 +284,7 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
tenantName: string,
): Promise<{
isVerified: boolean;
status: EmailingDomainStatus;
verificationRecords: VerificationRecordDTO[];
}> {
const sesClient = this.awsSesClientProvider.getSESClient();
@@ -305,6 +306,7 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
return {
isVerified: false,
status: EmailingDomainStatus.PENDING,
verificationRecords,
};
}
@@ -383,4 +385,18 @@ export class AwsSesDriver implements EmailingDomainDriverInterface {
return EmailingDomainStatus.PENDING;
}
private withRecordStatus(
records: VerificationRecordDTO[],
status: EmailingDomainStatus,
): VerificationRecordDTO[] {
const recordStatus =
status === EmailingDomainStatus.VERIFIED
? 'success'
: status === EmailingDomainStatus.FAILED
? 'error'
: 'pending';
return records.map((record) => ({ ...record, status: recordStatus }));
}
}
@@ -46,7 +46,7 @@ export class LogEmailingDomainDriver implements EmailingDomainDriverInterface {
return {
status: EmailingDomainStatus.VERIFIED,
verificationRecords: [],
verificationRecords: this.buildSyntheticVerificationRecords(input.domain),
};
}
@@ -57,10 +57,47 @@ export class LogEmailingDomainDriver implements EmailingDomainDriverInterface {
return {
status: EmailingDomainStatus.VERIFIED,
verificationRecords: [],
verificationRecords: this.buildSyntheticVerificationRecords(input.domain),
};
}
private buildSyntheticVerificationRecords(
domain: string,
): EmailingDomainVerificationResult['verificationRecords'] {
return [
{
type: 'CNAME',
key: `synthetic1._domainkey.${domain}`,
value: `synthetic1.dkim.amazonses.example`,
status: 'success',
},
{
type: 'CNAME',
key: `synthetic2._domainkey.${domain}`,
value: `synthetic2.dkim.amazonses.example`,
status: 'success',
},
{
type: 'CNAME',
key: `synthetic3._domainkey.${domain}`,
value: `synthetic3.dkim.amazonses.example`,
status: 'pending',
},
{
type: 'CNAME',
key: `${UNSUBSCRIBE_HOSTNAME_PREFIX}.${domain}`,
value: `app.localhost`,
status: 'pending',
},
{
type: 'CNAME',
key: `_acme-challenge.${UNSUBSCRIBE_HOSTNAME_PREFIX}.${domain}`,
value: `${domain}.dcv.cloudflare.example`,
status: 'error',
},
];
}
async registerDomain(input: EmailingDomainResourceInput): Promise<void> {
this.logger.log(`[log-driver] registerDomain(${input.domain})`);
}
@@ -3,4 +3,5 @@ export type VerificationRecord = {
key: string;
value: string;
priority?: number;
status?: string;
};
@@ -13,4 +13,7 @@ export class VerificationRecordDTO {
@Field(() => Number, { nullable: true })
priority?: number;
@Field(() => String, { nullable: true })
status?: string;
}
@@ -80,15 +80,9 @@ export class EmailingDomainService {
},
);
if (isVerifiedOnCreation) {
await this.unsubscribeHostnameService.sync(
workspaceId,
emailingDomain.id,
{
provision: true,
},
);
}
await this.unsubscribeHostnameService.sync(workspaceId, emailingDomain.id, {
provision: true,
});
return this.unsubscribeHostnameService.withDnsRecords(
await this.emailingDomainRepository.findOneOrFail(workspaceId, {
@@ -227,7 +221,7 @@ export class EmailingDomainService {
workspace.id,
emailingDomain.id,
{
provision: verificationResult.status === EmailingDomainStatus.VERIFIED,
provision: true,
},
);
@@ -90,7 +90,10 @@ export class UnsubscribeHostnameService {
}
async deprovision(emailingDomain: EmailingDomainEntity): Promise<void> {
if (!isNonEmptyString(emailingDomain.unsubscribeHostname)) {
if (
!this.dnsManagerService.isConfigured() ||
!isNonEmptyString(emailingDomain.unsubscribeHostname)
) {
return;
}
@@ -104,6 +107,10 @@ export class UnsubscribeHostnameService {
emailingDomainId: string,
{ provision }: { provision: boolean },
): Promise<void> {
if (!this.dnsManagerService.isConfigured()) {
return;
}
try {
const emailingDomain = await this.emailingDomainRepository.findOneOrFail(
workspaceId,
@@ -165,6 +172,7 @@ export class UnsubscribeHostnameService {
type: 'CNAME' as const,
key: record.key,
value: record.value,
status: record.status,
}));
} catch (error) {
this.logger.warn(