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:
@@ -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,
|
||||
|
||||
+131
@@ -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(', ');
|
||||
};
|
||||
+1
-1
@@ -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
@@ -7,6 +7,13 @@ export const VERIFY_EMAILING_DOMAIN = gql`
|
||||
domain
|
||||
status
|
||||
verifiedAt
|
||||
verificationRecords {
|
||||
type
|
||||
key
|
||||
value
|
||||
priority
|
||||
status
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
+49
-42
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+1
-1
@@ -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` },
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ export const SettingsWorkspaceUnsubscribeTopicDetail = () => {
|
||||
href: getSettingsPath(SettingsPath.General),
|
||||
},
|
||||
{
|
||||
children: t`Communications`,
|
||||
children: t`Communication`,
|
||||
href: getSettingsPath(SettingsPath.WorkspaceCommunications),
|
||||
},
|
||||
{ children: topicName },
|
||||
|
||||
+4
@@ -37,6 +37,10 @@ export class DnsManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return isDefined(this.cloudflareClient);
|
||||
}
|
||||
|
||||
async registerHostname(customDomain: string, options?: DnsManagerOptions) {
|
||||
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
|
||||
+23
-7
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
+39
-2
@@ -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})`);
|
||||
}
|
||||
|
||||
+1
@@ -3,4 +3,5 @@ export type VerificationRecord = {
|
||||
key: string;
|
||||
value: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
+3
@@ -13,4 +13,7 @@ export class VerificationRecordDTO {
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
priority?: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
status?: string;
|
||||
}
|
||||
|
||||
+4
-10
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+9
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user