[WIP] Feat/marketing emails (#21173)

Marketing/campaign emails on top of the emailing-domain (SES) feature:
send a broadcast to a hand-picked list, with per-customer-domain
unsubscribe links and opt-out-only **unsubscribe topics**.

## Model

Standard objects (workspace schema, flat-metadata):
- `messageCampaign` — a campaign send (subject, body template, from
address, status, list, optional unsubscribe topic).
- `messageList` + `messageListMember` — the hand-picked audience (person
↔ list join). A campaign's recipients are its list's members; everyone
is sendable unless suppressed.

Core entities (`core` schema, workspace-scoped — readable by the public
unsubscribe flow without a workspace context):
- `unsubscribeTopic` — an opt-out-only category (name, description,
visibility). There is no opt-in subscription state.
- `messageSuppression` — the single consent store: a row with
`unsubscribeTopicId` NULL is a global block; a row with an
`unsubscribeTopicId` and reason `UNSUBSCRIBE` is a per-topic opt-out.
Two partial unique indexes dedupe global vs per-topic rows (Postgres
treats NULLs as distinct).
- `emailingDomain` — the workspace's SES sending domain,
auto-provisioned when an email channel is added (and cleaned up when its
last channel is removed), with verification status + DNS records.

Campaign messages reuse the existing `message` / `messageThread` /
`messageParticipant` model — one outbound `message` per recipient with a
`deliveryStatus` state machine.

## Sending

- `sendMessageCampaign` resolves the audience **under the caller's
permissions**, creates the campaign, and enqueues a single fan-out job
(the request never materializes per-recipient rows or jobs).
- The fan-out job materializes one QUEUED message per recipient
(deterministic ids → idempotent re-runs, reconciles crash-orphaned rows)
and fans out per-recipient send jobs carrying **only ids**.
- Each send job renders per-recipient `{{variable}}` merge fields and
sends via `EmailingDomainSenderService`, which applies suppression
(global + per-topic) and the unsubscribe footer/headers. Suppressed
recipients are recorded `SKIPPED`.
- The campaign finalizes `SENT`, or `SENT_WITH_ERRORS` if any recipient
terminally failed.
- `previewMessageCampaignAudience` returns a pre-send breakdown (total /
without-email / duplicate / globally-unsubscribed / topic-unsubscribed /
sendable), shown as a hint under the composer pickers.

## Unsubscribe

- Encrypted (AES-256-GCM) token carrying workspaceId, address, optional
`unsubscribeTopicId`, `issuedAt`, and a `preview` flag.
- One-click POST (RFC 8058) + `mailto:` — topic-scoped when the token
carries a topic, global otherwise.
- Preferences page: a checkbox per visible topic (checked = still
receiving); submitting creates per-topic opt-outs for unchecked topics
and lifts re-checked ones (UNSUBSCRIBE only — never
`BOUNCE`/`COMPLAINT`, never a global block).
- A **Preview** action in settings opens the live page via a
preview-claim token; opt-out POSTs are no-ops for preview tokens, so
previewing never mutates state.
- SES webhooks: inbound unsubscribe + outbound bounce/complaint →
suppression (race-safe against at-least-once delivery, with reason
escalation that never downgrades).
- Per-customer unsubscribe hostname (Cloudflare DNS); sends are gated on
it being active, except in LOG/demo mode.

## Architecture

Campaign orchestration, suppression, the sender, the unsubscribe
controller, and the SES webhook handlers live in `src/modules/emailing`
+ `src/modules/messaging-webhooks` (the workspace-feature layer).
`core-modules/emailing-domain` keeps the SES driver, domain
provisioning, the `unsubscribeTopic` / `messageSuppression` core
entities, and the unsubscribe token/hostname plumbing. Domain creation
is validated (`CreateEmailingDomainInput` — domain-format regex,
lowercased) before any value reaches SES or the unsubscribe hostname.

## Frontend

- Campaign composer side panel (from / list / unsubscribe topic /
subject / body) with a live audience-preview hint.
- Email settings: email channels each showing their auto-provisioned
sending domain in a single section (status + DNS records + a "Check
verification" action), plus an **Unsubscribe Topics** section to
create/manage topics and preview the recipient page. A demo-mode banner
is shown when the LOG driver is active.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-06-13 22:07:39 +05:30
committed by GitHub
parent 2c5da39dc5
commit 5d892bdfd0
238 changed files with 10433 additions and 3517 deletions
@@ -89,6 +89,8 @@ export const SettingsAccountsMessageChannelDetails = ({
const supportsFolderImportPolicy =
messageChannel.type === MessageChannelType.EMAIL;
const isGroupMailbox = messageChannel.type === MessageChannelType.EMAIL_GROUP;
return (
<StyledDetailsContainer>
{supportsFolderImportPolicy && (
@@ -103,21 +105,23 @@ export const SettingsAccountsMessageChannelDetails = ({
/>
</Section>
)}
<Section>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconUsers}
title={t`Exclude group emails`}
description={t`Don't sync emails from team@ support@ noreply@...`}
checked={messageChannel.excludeGroupEmails}
onChange={() =>
handleIsGroupEmailExcludedToggle(
!messageChannel.excludeGroupEmails,
)
}
/>
</Card>
</Section>
{!isGroupMailbox && (
<Section>
<Card rounded>
<SettingsOptionCardContentToggle
Icon={IconUsers}
title={t`Exclude group emails`}
description={t`Don't sync emails from team@ support@ noreply@...`}
checked={messageChannel.excludeGroupEmails}
onChange={() =>
handleIsGroupEmailExcludedToggle(
!messageChannel.excludeGroupEmails,
)
}
/>
</Card>
</Section>
)}
<Section>
<H2Title
title={t`Visibility`}
@@ -39,14 +39,14 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
}
} catch {
enqueueErrorSnackBar({
message: t`Failed to create email handle. Email handles may not be configured on this server.`,
message: t`Failed to create email channel. Email channels may not be configured on this server.`,
});
}
}, [createEmailGroupChannel, handle, navigate, enqueueErrorSnackBar, t]);
return (
<SettingsPageLayout
title={t`New Email Handle`}
title={t`New Email Channel`}
links={[
{
children: t`Workspace`,
@@ -56,7 +56,7 @@ export const SettingsAccountsNewEmailGroupChannel = () => {
children: t`Email`,
href: getSettingsPath(SettingsPath.WorkspaceEmail),
},
{ children: t`New Email Handle` },
{ children: t`New Email Channel` },
]}
actionButton={
<SaveAndCancelButtons
@@ -9,6 +9,7 @@ import {
import { CREATE_EMAIL_GROUP_CHANNEL } from '@/settings/accounts/graphql/mutations/createEmailGroupChannel';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { GET_ALL_EMAILING_DOMAINS } from '@/settings/emailing-domains/graphql/queries/getAllEmailingDomains';
type CreateEmailGroupChannelResult = {
createEmailGroupChannel: {
@@ -39,6 +40,7 @@ export const useCreateEmailGroupChannel = () => {
refetchQueries: [
{ query: GET_MY_CONNECTED_ACCOUNTS },
{ query: GET_MY_MESSAGE_CHANNELS },
{ query: GET_ALL_EMAILING_DOMAINS },
],
});
@@ -3,6 +3,7 @@ import { useMutation } from '@apollo/client/react';
import { DELETE_EMAIL_GROUP_CHANNEL } from '@/settings/accounts/graphql/mutations/deleteEmailGroupChannel';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { GET_ALL_EMAILING_DOMAINS } from '@/settings/emailing-domains/graphql/queries/getAllEmailingDomains';
type DeleteEmailGroupChannelResult = {
deleteEmailGroupChannel: {
@@ -22,6 +23,7 @@ export const useDeleteEmailGroupChannel = () => {
refetchQueries: [
{ query: GET_MY_CONNECTED_ACCOUNTS },
{ query: GET_MY_MESSAGE_CHANNELS },
{ query: GET_ALL_EMAILING_DOMAINS },
],
});
@@ -0,0 +1,22 @@
import { getEmailChannelDomain } from '@/settings/accounts/utils/getEmailChannelDomain';
describe('getEmailChannelDomain', () => {
it('should return the lowercased domain after the last @', () => {
expect(getEmailChannelDomain('Jane@Example.COM')).toBe('example.com');
});
it('should use the domain after the last @ when several are present', () => {
expect(getEmailChannelDomain('weird@local@Sub.Example.com')).toBe(
'sub.example.com',
);
});
it('should return undefined when there is no @', () => {
expect(getEmailChannelDomain('not-an-email')).toBeUndefined();
});
it('should return undefined for null or undefined input', () => {
expect(getEmailChannelDomain(null)).toBeUndefined();
expect(getEmailChannelDomain(undefined)).toBeUndefined();
});
});
@@ -0,0 +1,15 @@
export const getEmailChannelDomain = (
handle: string | null | undefined,
): string | undefined => {
if (handle === null || handle === undefined) {
return undefined;
}
const lastAtIndex = handle.lastIndexOf('@');
if (lastAtIndex === -1) {
return undefined;
}
return handle.slice(lastAtIndex + 1).toLowerCase();
};
@@ -1,4 +1,4 @@
import { type ComponentType } from 'react';
import { type ComponentType, type ReactNode } from 'react';
import { styled } from '@linaria/react';
@@ -48,6 +48,7 @@ export type SettingsTableListSectionColumn<Item> = {
type SettingsTableListSectionProps<Item extends { id: string }> = {
title: string;
description: string;
headerAdornment?: ReactNode;
items: Item[];
columns: SettingsTableListSectionColumn<Item>[];
gridAutoColumns: string;
@@ -61,6 +62,7 @@ export const SettingsTableListSection = <
>({
title,
description,
headerAdornment,
items,
columns,
gridAutoColumns,
@@ -69,7 +71,11 @@ export const SettingsTableListSection = <
onFooterButtonClick,
}: SettingsTableListSectionProps<Item>) => (
<Section>
<H2Title title={title} description={description} />
<H2Title
title={title}
description={description}
adornment={headerAdornment}
/>
{items.length > 0 && (
<Table>
<TableRow gridAutoColumns={gridAutoColumns}>
@@ -1,30 +0,0 @@
import { styled } from '@linaria/react';
import { type GetEmailingDomainsQuery } from '~/generated-metadata/graphql';
import {
IconMail,
OverflowingTextWithTooltip,
} from 'twenty-ui-deprecated/display';
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
const StyledNameCell = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
`;
type SettingsEmailingDomainNameCellProps = {
item: GetEmailingDomainsQuery['getEmailingDomains'][0];
};
export const SettingsEmailingDomainNameCell = ({
item,
}: SettingsEmailingDomainNameCellProps) => (
<StyledNameCell>
<IconMail size={16} />
<OverflowingTextWithTooltip text={item.domain} />
</StyledNameCell>
);
@@ -1,17 +0,0 @@
import { type GetEmailingDomainsQuery } from '~/generated-metadata/graphql';
import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
import { Status } from 'twenty-ui-deprecated/display';
type SettingsEmailingDomainStatusCellProps = {
item: GetEmailingDomainsQuery['getEmailingDomains'][0];
};
export const SettingsEmailingDomainStatusCell = ({
item,
}: SettingsEmailingDomainStatusCellProps) => (
<Status
color={getColorByEmailingDomainStatus(item.status)}
text={getTextByEmailingDomainStatus(item.status)}
/>
);
@@ -1,70 +0,0 @@
import { SettingsDnsRecordsTable } from '@/settings/components/SettingsDnsRecordsTable';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { H2Title, IconRefresh } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { Section } from 'twenty-ui-deprecated/layout';
import { useMutation } from '@apollo/client/react';
import {
type EmailingDomain,
VerifyEmailingDomainDocument,
} from '~/generated-metadata/graphql';
type SettingsEmailingDomainVerificationRecordsProps = {
domain: EmailingDomain;
};
export const SettingsEmailingDomainVerificationRecords = ({
domain,
}: SettingsEmailingDomainVerificationRecordsProps) => {
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [verifyEmailingDomainMutation, { loading: isVerifying }] = useMutation(
VerifyEmailingDomainDocument,
);
if (!domain.verificationRecords || domain.verificationRecords.length === 0) {
return null;
}
const handleVerifyEmailingDomain = async () => {
try {
await verifyEmailingDomainMutation({
variables: {
id: domain.id,
},
});
enqueueSuccessSnackBar({
message: t`Started verification process`,
});
} catch (error) {
enqueueErrorSnackBar({
...(CombinedGraphQLErrors.is(error) ? { apolloError: error } : {}),
});
}
};
return (
<Section>
<H2Title
title={t`DNS Records`}
description={t`Add these records to verify your domain.`}
adornment={
<Button
onClick={handleVerifyEmailingDomain}
isLoading={isVerifying}
variant="secondary"
Icon={IconRefresh}
size="small"
title={t`Check verification`}
disabled={isVerifying}
/>
}
/>
<SettingsDnsRecordsTable records={domain.verificationRecords} />
</Section>
);
};
@@ -0,0 +1,45 @@
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { VerifyEmailingDomainDocument } from '~/generated-metadata/graphql';
import { IconRefresh } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
type SettingsEmailingDomainVerifyButtonProps = {
emailingDomainId: string;
};
export const SettingsEmailingDomainVerifyButton = ({
emailingDomainId,
}: SettingsEmailingDomainVerifyButtonProps) => {
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [verifyEmailingDomain, { loading }] = useMutation(
VerifyEmailingDomainDocument,
);
const handleVerify = async () => {
try {
await verifyEmailingDomain({ variables: { id: emailingDomainId } });
enqueueSuccessSnackBar({ message: t`Started verification process` });
} catch (error) {
enqueueErrorSnackBar({
...(CombinedGraphQLErrors.is(error) ? { apolloError: error } : {}),
});
}
};
return (
<Button
onClick={handleVerify}
isLoading={loading}
variant="secondary"
Icon={IconRefresh}
size="small"
title={t`Check verification`}
disabled={loading}
/>
);
};
@@ -1,20 +0,0 @@
import { gql } from '@apollo/client';
export const CREATE_EMAILING_DOMAIN = gql`
mutation CreateEmailingDomain($domain: String!) {
createEmailingDomain(domain: $domain) {
id
domain
status
verifiedAt
verificationRecords {
type
key
value
priority
}
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,86 @@
import { useLazyQuery } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useUnsubscribeTopics } from '@/activities/emails/hooks/useUnsubscribeTopics';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { GET_UNSUBSCRIBE_PAGE_PREVIEW_URL } from '@/settings/unsubscribe-topics/graphql/queries/getUnsubscribePagePreviewUrl';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type UnsubscribeTopicsQuery,
UnsubscribeTopicVisibility,
} from '~/generated-metadata/graphql';
import { IconExternalLink, Status } from 'twenty-ui-deprecated/display';
import { Button } from 'twenty-ui-deprecated/input';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type UnsubscribeTopic = UnsubscribeTopicsQuery['unsubscribeTopics'][number];
export const SettingsWorkspaceUnsubscribeTopicSection = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const { unsubscribeTopics } = useUnsubscribeTopics();
const [getPreviewUrl] = useLazyQuery<{
unsubscribePagePreviewUrl: string;
}>(GET_UNSUBSCRIBE_PAGE_PREVIEW_URL);
// Open the tab synchronously on click (so it isn't popup-blocked), then point
// it at the freshly minted preview URL once the query resolves.
const handlePreview = () => {
const previewWindow = window.open('', '_blank');
void getPreviewUrl()
.then(({ data }) => {
const url = data?.unsubscribePagePreviewUrl;
if (isDefined(previewWindow) && isDefined(url)) {
previewWindow.location.href = url;
} else {
previewWindow?.close();
}
})
.catch(() => previewWindow?.close());
};
return (
<SettingsTableListSection<UnsubscribeTopic>
title={t`Unsubscribe Topics`}
description={t`Email categories recipients can opt out of.`}
headerAdornment={
<Button
title={t`Preview`}
variant="secondary"
size="small"
Icon={IconExternalLink}
onClick={handlePreview}
/>
}
items={unsubscribeTopics}
columns={[
{
label: t`Name`,
Cell: ({ item }) => <>{item.name ?? t`Untitled topic`}</>,
},
{
label: t`Visibility`,
Cell: ({ item }) =>
item.visibility === UnsubscribeTopicVisibility.PUBLIC ? (
<Status color="blue" text={t`Public`} />
) : (
<Status color="gray" text={t`Private`} />
),
},
]}
gridAutoColumns="1fr 1fr"
onRowClick={(topic) =>
navigateSettings(SettingsPath.UnsubscribeTopicDetail, {
unsubscribeTopicId: topic.id,
})
}
footerButtonLabel={t`Add unsubscribe topic`}
onFooterButtonClick={() =>
navigateSettings(SettingsPath.NewUnsubscribeTopic)
}
/>
);
};
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const CREATE_UNSUBSCRIBE_TOPIC = gql`
mutation CreateUnsubscribeTopic($input: CreateUnsubscribeTopicInput!) {
createUnsubscribeTopic(input: $input) {
id
name
description
visibility
}
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_UNSUBSCRIBE_TOPIC = gql`
mutation DeleteUnsubscribeTopic($id: String!) {
deleteUnsubscribeTopic(id: $id)
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const UPDATE_UNSUBSCRIBE_TOPIC = gql`
mutation UpdateUnsubscribeTopic($input: UpdateUnsubscribeTopicInput!) {
updateUnsubscribeTopic(input: $input) {
id
name
description
visibility
}
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const GET_UNSUBSCRIBE_PAGE_PREVIEW_URL = gql`
query UnsubscribePagePreviewUrl {
unsubscribePagePreviewUrl
}
`;
@@ -0,0 +1,23 @@
import { useMutation } from '@apollo/client/react';
import { UNSUBSCRIBE_TOPICS } from '@/activities/emails/graphql/metadata-queries/unsubscribeTopics';
import { CREATE_UNSUBSCRIBE_TOPIC } from '@/settings/unsubscribe-topics/graphql/mutations/createUnsubscribeTopic';
import {
type CreateUnsubscribeTopicInput,
type CreateUnsubscribeTopicMutation,
type CreateUnsubscribeTopicMutationVariables,
} from '~/generated-metadata/graphql';
export const useCreateUnsubscribeTopic = () => {
const [mutate, { loading, error }] = useMutation<
CreateUnsubscribeTopicMutation,
CreateUnsubscribeTopicMutationVariables
>(CREATE_UNSUBSCRIBE_TOPIC, {
refetchQueries: [{ query: UNSUBSCRIBE_TOPICS }],
});
const createUnsubscribeTopic = (input: CreateUnsubscribeTopicInput) =>
mutate({ variables: { input } });
return { createUnsubscribeTopic, loading, error };
};
@@ -0,0 +1,21 @@
import { useMutation } from '@apollo/client/react';
import { UNSUBSCRIBE_TOPICS } from '@/activities/emails/graphql/metadata-queries/unsubscribeTopics';
import { DELETE_UNSUBSCRIBE_TOPIC } from '@/settings/unsubscribe-topics/graphql/mutations/deleteUnsubscribeTopic';
import {
type DeleteUnsubscribeTopicMutation,
type DeleteUnsubscribeTopicMutationVariables,
} from '~/generated-metadata/graphql';
export const useDeleteUnsubscribeTopic = () => {
const [mutate, { loading, error }] = useMutation<
DeleteUnsubscribeTopicMutation,
DeleteUnsubscribeTopicMutationVariables
>(DELETE_UNSUBSCRIBE_TOPIC, {
refetchQueries: [{ query: UNSUBSCRIBE_TOPICS }],
});
const deleteUnsubscribeTopic = (id: string) => mutate({ variables: { id } });
return { deleteUnsubscribeTopic, loading, error };
};
@@ -0,0 +1,23 @@
import { useMutation } from '@apollo/client/react';
import { UNSUBSCRIBE_TOPICS } from '@/activities/emails/graphql/metadata-queries/unsubscribeTopics';
import { UPDATE_UNSUBSCRIBE_TOPIC } from '@/settings/unsubscribe-topics/graphql/mutations/updateUnsubscribeTopic';
import {
type UpdateUnsubscribeTopicInput,
type UpdateUnsubscribeTopicMutation,
type UpdateUnsubscribeTopicMutationVariables,
} from '~/generated-metadata/graphql';
export const useUpdateUnsubscribeTopic = () => {
const [mutate, { loading, error }] = useMutation<
UpdateUnsubscribeTopicMutation,
UpdateUnsubscribeTopicMutationVariables
>(UPDATE_UNSUBSCRIBE_TOPIC, {
refetchQueries: [{ query: UNSUBSCRIBE_TOPICS }],
});
const updateUnsubscribeTopic = (input: UpdateUnsubscribeTopicInput) =>
mutate({ variables: { input } });
return { updateUnsubscribeTopic, loading, error };
};
@@ -0,0 +1,35 @@
import { useQuery } from '@apollo/client/react';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { getEmailChannelDomain } from '@/settings/accounts/utils/getEmailChannelDomain';
import { GetEmailingDomainsDocument } from '~/generated-metadata/graphql';
import { getColorByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusColor';
import { getTextByEmailingDomainStatus } from '~/pages/settings/emailing-domains/utils/getEmailingDomainStatusText';
import { isDefined } from 'twenty-shared/utils';
import { Status } from 'twenty-ui-deprecated/display';
type SettingsWorkspaceEmailChannelDomainStatusCellProps = {
item: MessageChannel;
};
export const SettingsWorkspaceEmailChannelDomainStatusCell = ({
item,
}: SettingsWorkspaceEmailChannelDomainStatusCellProps) => {
const { data } = useQuery(GetEmailingDomainsDocument);
const channelDomain = getEmailChannelDomain(item.connectedAccount?.handle);
const emailingDomain = data?.getEmailingDomains?.find(
(domain) => domain.domain.toLowerCase() === channelDomain,
);
if (!isDefined(emailingDomain)) {
return null;
}
return (
<Status
color={getColorByEmailingDomainStatus(emailingDomain.status)}
text={getTextByEmailingDomainStatus(emailingDomain.status)}
/>
);
};
@@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react/macro';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { SettingsWorkspaceEmailChannelDomainStatusCell } from '@/settings/workspace/components/SettingsWorkspaceEmailChannelDomainStatusCell';
import { SettingsWorkspaceEmailGroupForwardingCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupForwardingCell';
import { SettingsWorkspaceEmailGroupSourceCell } from '@/settings/workspace/components/SettingsWorkspaceEmailGroupSourceCell';
import { MessageChannelType, SettingsPath } from 'twenty-shared/types';
@@ -19,7 +20,7 @@ export const SettingsWorkspaceEmailGroupSection = () => {
return (
<SettingsTableListSection<MessageChannel>
title={t`Email Handles`}
title={t`Email Channels`}
description={t`Shared addresses your workspace uses to send and receive email.`}
items={emailGroupChannels}
columns={[
@@ -28,14 +29,19 @@ export const SettingsWorkspaceEmailGroupSection = () => {
label: t`Forwarding address`,
Cell: SettingsWorkspaceEmailGroupForwardingCell,
},
{
label: t`Domain`,
align: 'right',
Cell: SettingsWorkspaceEmailChannelDomainStatusCell,
},
]}
gridAutoColumns="1fr 1fr"
gridAutoColumns="1fr 1fr 1fr"
onRowClick={(channel) =>
navigateSettings(SettingsPath.EmailGroupChannelDetail, {
messageChannelId: channel.id,
})
}
footerButtonLabel={t`Add email handle`}
footerButtonLabel={t`Add email channel`}
onFooterButtonClick={() =>
navigateSettings(SettingsPath.NewEmailGroupChannel)
}
@@ -1,48 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { useQuery } from '@apollo/client/react';
import { SettingsTableListSection } from '@/settings/components/SettingsTableListSection';
import { SettingsEmailingDomainNameCell } from '@/settings/emailing-domains/components/SettingsEmailingDomainNameCell';
import { SettingsEmailingDomainStatusCell } from '@/settings/emailing-domains/components/SettingsEmailingDomainStatusCell';
import { SettingsPath } from 'twenty-shared/types';
import {
type GetEmailingDomainsQuery,
GetEmailingDomainsDocument,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
type EmailingDomain = GetEmailingDomainsQuery['getEmailingDomains'][0];
export const SettingsWorkspaceEmailingDomainsSection = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const { data } = useQuery(GetEmailingDomainsDocument);
const emailingDomains = data?.getEmailingDomains ?? [];
return (
<SettingsTableListSection<EmailingDomain>
title={t`Emailing Domains`}
description={t`Verify domains so the workspace can send outbound email through them.`}
items={emailingDomains}
columns={[
{ label: t`Domain`, Cell: SettingsEmailingDomainNameCell },
{
label: t`Status`,
align: 'right',
Cell: SettingsEmailingDomainStatusCell,
},
]}
gridAutoColumns="1fr 1fr"
onRowClick={(emailingDomain) =>
navigateSettings(SettingsPath.EmailingDomainDetail, {
domainId: emailingDomain.id,
})
}
footerButtonLabel={t`Add emailing domain`}
onFooterButtonClick={() =>
navigateSettings(SettingsPath.NewEmailingDomain)
}
/>
);
};