feat: add email forwarding message channel (#19535)

## Summary

- Add email forwarding as a new message channel type, allowing users to
forward emails from addresses like `support@mycompany.com` into Twenty
- Inbound emails arrive via S3 (SES → S3 bucket), are polled by a cron
job, parsed, routed to the correct workspace/channel, and persisted as
messages
- Dedicated settings page at `/settings/accounts/new-email-forwarding`
where users provide their source email handle and receive a unique
forwarding address
- Forwarding channels bypass the IMAP/mailbox sync state machine — they
skip cron-driven sync, relaunch, and message-list-fetch lifecycle stages
- Forwarding address section shown at the top of the Emails settings
page so users can find/copy their addresses after initial setup
- Tab names for forwarding channels display the user-provided handle
(e.g. `support@mycompany.com`) instead of the internal routing address
- Shared utilities extracted from IMAP driver: `extractThreadId`,
`extractParticipants`, `extractAddresses` to avoid code duplication
- Uses the existing S3 bucket (STORAGE_S3_*) with `inbound-email/`
prefix — no separate bucket needed
- Feature gated behind `isEmailForwardingEnabled` client config
(requires `INBOUND_EMAIL_DOMAIN` + S3 storage)

## New backend modules

- `InboundEmailS3ClientProvider` — lazy-initialized S3 client using
existing storage config
- `InboundEmailStorageService` — S3 operations (get, move to
processed/unmatched/failed)
- `InboundEmailParserService` — RFC 822 parsing via `postal-mime`,
builds `MessageWithParticipants`
- `InboundEmailImportService` — orchestrates download → parse → route →
persist → archive
- `MessagingInboundEmailPollCronJob` — polls S3 `incoming/` prefix,
enqueues import jobs
- `CreateEmailForwardingChannelInput` DTO — accepts user-provided
`handle`

## New frontend components

- `SettingsAccountsNewEmailForwardingChannel` — dedicated page with
handle input form + forwarding address result
- `SettingsAccountsEmailForwardingSection` — forwarding address list on
the Emails settings page
- `useConnectedAccountHandleMap` — shared hook for account ID → handle
lookup
- `useCreateEmailForwardingChannel` — mutation hook accepting handle
parameter

## Test plan

- [x] 17 unit tests for inbound email import service (all outcomes:
imported, unmatched, loop_dropped, unconfigured, parse_failed,
persist_failed)
- [x] 16 tests for `computeSyncStatus` including EMAIL_FORWARDING cases
- [x] 11 tests for `extractEnvelopeRecipient` utility
- [x] TypeScript typechecks pass for both twenty-server and twenty-front
- [x] Lint passes for both packages
- [ ] Manual: create forwarding channel, verify forwarding address
generated
- [ ] Manual: send email to forwarding address, verify it appears in
Twenty

https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
This commit is contained in:
Félix Malfait
2026-05-09 11:00:57 +02:00
committed by GitHub
parent 23aa859502
commit 4da8878697
81 changed files with 1967 additions and 309 deletions
@@ -1,4 +1,5 @@
import {
type MessageChannelType,
type MessageChannelContactAutoCreationPolicy,
type MessageChannelSyncStage,
type MessageChannelSyncStatus,
@@ -10,7 +11,7 @@ export type MessageChannel = {
id: string;
handle: string;
visibility: MessageChannelVisibility;
type: string;
type: MessageChannelType;
isContactAutoCreationEnabled: boolean;
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
messageFolderImportPolicy: MessageFolderImportPolicy;
@@ -21,6 +22,10 @@ export type MessageChannel = {
syncStage: MessageChannelSyncStage;
syncStageStartedAt: string | null;
connectedAccountId: string;
connectedAccount: {
id: string;
handle: string;
} | null;
createdAt: string;
updatedAt: string;
__typename: 'MessageChannel';
@@ -27,6 +27,7 @@ export const getMissingDraftEmailScopes = (
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.EMAIL_GROUP:
case ConnectedAccountProvider.APP:
return [];
default:
@@ -74,6 +74,14 @@ const SettingsEditImapSmtpCaldavConnection = lazy(() =>
})),
);
const SettingsNewEmailGroupChannel = lazy(() =>
import(
'@/settings/accounts/components/SettingsAccountsNewEmailGroupChannel'
).then((module) => ({
default: module.SettingsAccountsNewEmailGroupChannel,
})),
);
const SettingsObjectDetailPage = lazy(() =>
import('~/pages/settings/data-model/SettingsObjectDetailPage').then(
(module) => ({
@@ -120,6 +128,14 @@ const SettingsWorkspace = lazy(() =>
})),
);
const SettingsWorkspaceEmailGroupChannelDetail = lazy(() =>
import(
'~/pages/settings/workspace/SettingsWorkspaceEmailGroupChannelDetail'
).then((module) => ({
default: module.SettingsWorkspaceEmailGroupChannelDetail,
})),
);
const SettingsDomains = lazy(() =>
import('~/pages/settings/domains/SettingsDomains').then((module) => ({
default: module.SettingsDomains,
@@ -620,6 +636,14 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
}
>
<Route path={SettingsPath.Workspace} element={<SettingsWorkspace />} />
<Route
path={SettingsPath.NewEmailGroupChannel}
element={<SettingsNewEmailGroupChannel />}
/>
<Route
path={SettingsPath.EmailGroupChannelDetail}
element={<SettingsWorkspaceEmailGroupChannelDetail />}
/>
<Route path={SettingsPath.Domains} element={<SettingsDomains />} />
<Route
path={SettingsPath.ApiWebhooks}
@@ -13,12 +13,13 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
import { isEmailGroupEnabledState } from '@/client-config/states/isEmailGroupEnabledState';
import { isEmailingDomainsEnabledState } from '@/client-config/states/isEmailingDomainsEnabledState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
import { isImapSmtpCaldavEnabledState } from '@/client-config/states/isImapSmtpCaldavEnabledState';
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
@@ -100,13 +101,16 @@ export const useClientConfig = (): UseClientConfigResult => {
const setCalendarBookingPageId = useSetAtomState(calendarBookingPageIdState);
const setIsImapSmtpCaldavEnabled = useSetAtomState(
isImapSmtpCaldavEnabledState,
);
const setIsEmailGroupEnabled = useSetAtomState(isEmailGroupEnabledState);
const setIsEmailingDomainsEnabled = useSetAtomState(
isEmailingDomainsEnabledState,
);
const setIsImapSmtpCaldavEnabled = useSetAtomState(
isImapSmtpCaldavEnabledState,
);
const setAllowRequestsToTwentyIcons = useSetAtomState(
allowRequestsToTwentyIconsState,
);
@@ -195,6 +199,7 @@ export const useClientConfig = (): UseClientConfigResult => {
setCalendarBookingPageId(clientConfig?.calendarBookingPageId ?? null);
setIsImapSmtpCaldavEnabled(clientConfig?.isImapSmtpCaldavEnabled);
setIsEmailGroupEnabled(clientConfig?.isEmailGroupEnabled ?? false);
setIsEmailingDomainsEnabled(clientConfig?.isEmailingDomainsEnabled);
setAllowRequestsToTwentyIcons(clientConfig?.allowRequestsToTwentyIcons);
setIsCloudflareIntegrationEnabled(
@@ -233,6 +238,7 @@ export const useClientConfig = (): UseClientConfigResult => {
setIsDeveloperDefaultSignInPrefilled,
setIsEmailVerificationRequired,
setIsImapSmtpCaldavEnabled,
setIsEmailGroupEnabled,
setIsMultiWorkspaceEnabled,
setIsEmailingDomainsEnabled,
setIsClickHouseConfigured,
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isEmailGroupEnabledState = createAtomState<boolean>({
key: 'isEmailGroupEnabled',
defaultValue: false,
});
@@ -31,6 +31,7 @@ export type ClientConfig = {
isMicrosoftMessagingEnabled: boolean;
isMultiWorkspaceEnabled: boolean;
isImapSmtpCaldavEnabled: boolean;
isEmailGroupEnabled: boolean;
isEmailingDomainsEnabled: boolean;
isCloudflareIntegrationEnabled: boolean;
isClickHouseConfigured: boolean;
@@ -5,14 +5,14 @@ import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicros
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconAt, IconGoogle, IconMicrosoft } from 'twenty-ui/display';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCardsContainer = styled.div`
@@ -26,6 +26,7 @@ export const SettingsAccountsListEmptyStateCard = () => {
const { triggerApisOAuth } = useTriggerApisOAuth();
const { t } = useLingui();
const isGoogleMessagingEnabled = useAtomStateValue(
isGoogleMessagingEnabledState,
);
@@ -69,7 +70,7 @@ export const SettingsAccountsListEmptyStateCard = () => {
>
<SettingsCard
Icon={<IconAt size={theme.icon.size.md} />}
title={t`Connect Account`}
title={t`Connect via IMAP/SMTP`}
/>
</UndecoratedLink>
)}
@@ -1,21 +1,23 @@
import { styled } from '@linaria/react';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { UPDATE_MESSAGE_CHANNEL } from '@/settings/accounts/graphql/mutations/updateMessageChannel';
import { useMutation } from '@apollo/client/react';
import { SettingsAccountsMessageAutoCreationCard } from '@/settings/accounts/components/SettingsAccountsMessageAutoCreationCard';
import { SettingsAccountsMessageFolderCard } from '@/settings/accounts/components/SettingsAccountsMessageFolderCard';
import { SettingsAccountsMessageVisibilityCard } from '@/settings/accounts/components/SettingsAccountsMessageVisibilityCard';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import {
type MessageChannelContactAutoCreationPolicy,
MessageChannelType,
type MessageFolderImportPolicy,
} from 'twenty-shared/types';
import { H2Title, IconBriefcase, IconUsers } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { SettingsAccountsMessageAutoCreationCard } from '@/settings/accounts/components/SettingsAccountsMessageAutoCreationCard';
import { SettingsAccountsMessageFolderCard } from '@/settings/accounts/components/SettingsAccountsMessageFolderCard';
import { SettingsAccountsMessageVisibilityCard } from '@/settings/accounts/components/SettingsAccountsMessageVisibilityCard';
import { UPDATE_MESSAGE_CHANNEL } from '@/settings/accounts/graphql/mutations/updateMessageChannel';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { type MessageChannelVisibility } from '~/generated/graphql';
import {
type MessageChannelContactAutoCreationPolicy,
type MessageFolderImportPolicy,
} from 'twenty-shared/types';
type SettingsAccountsMessageChannelDetailsProps = {
messageChannel: Pick<
@@ -27,9 +29,18 @@ type SettingsAccountsMessageChannelDetailsProps = {
| 'excludeGroupEmails'
| 'isSyncEnabled'
| 'messageFolderImportPolicy'
| 'type'
>;
};
type MessageChannelUpdateInput = Partial<{
visibility: MessageChannelVisibility;
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
excludeGroupEmails: boolean;
excludeNonProfessionalEmails: boolean;
messageFolderImportPolicy: MessageFolderImportPolicy;
}>;
const StyledDetailsContainer = styled.div`
display: flex;
flex-direction: column;
@@ -39,10 +50,10 @@ const StyledDetailsContainer = styled.div`
export const SettingsAccountsMessageChannelDetails = ({
messageChannel,
}: SettingsAccountsMessageChannelDetailsProps) => {
const [updateMetadataChannel] = useMutation(UPDATE_MESSAGE_CHANNEL);
const [updateMessageChannel] = useMutation(UPDATE_MESSAGE_CHANNEL);
const updateChannel = (update: Record<string, unknown>) => {
updateMetadataChannel({
const updateChannel = (update: MessageChannelUpdateInput) => {
updateMessageChannel({
variables: { input: { id: messageChannel.id, update } },
});
};
@@ -71,18 +82,23 @@ export const SettingsAccountsMessageChannelDetails = ({
updateChannel({ messageFolderImportPolicy: value });
};
const supportsFolderImportPolicy =
messageChannel.type === MessageChannelType.EMAIL;
return (
<StyledDetailsContainer>
<Section>
<H2Title
title={t`Import`}
description={t`Emails from the blocklist will be ignored. Manage blocklist on the "Accounts" setting page.`}
/>
<SettingsAccountsMessageFolderCard
onChange={handleMessageFolderImportPolicyChange}
value={messageChannel.messageFolderImportPolicy}
/>
</Section>
{supportsFolderImportPolicy && (
<Section>
<H2Title
title={t`Import`}
description={t`Emails from the blocklist will be ignored. Manage blocklist on the "Accounts" setting page.`}
/>
<SettingsAccountsMessageFolderCard
onChange={handleMessageFolderImportPolicyChange}
value={messageChannel.messageFolderImportPolicy}
/>
</Section>
)}
<Section>
<Card rounded>
<SettingsOptionCardContentToggle
@@ -11,7 +11,10 @@ import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTab
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import React, { useCallback } from 'react';
import { MessageChannelSyncStage } from 'twenty-shared/types';
import {
MessageChannelSyncStage,
MessageChannelType,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -33,7 +36,8 @@ export const SettingsAccountsMessageChannelsContainer = () => {
const messageChannels = allMessageChannels.filter(
(channel) =>
channel.isSyncEnabled &&
channel.syncStage !== MessageChannelSyncStage.PENDING_CONFIGURATION,
channel.syncStage !== MessageChannelSyncStage.PENDING_CONFIGURATION &&
channel.type !== MessageChannelType.EMAIL_GROUP,
);
const tabs = messageChannels.map((messageChannel) => ({
@@ -0,0 +1,89 @@
import { useLingui } from '@lingui/react/macro';
import { useCallback, useState } from 'react';
import { z } from 'zod';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { useCreateEmailGroupChannel } from '@/settings/accounts/hooks/useCreateEmailGroupChannel';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const SettingsAccountsNewEmailGroupChannel = () => {
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueErrorSnackBar } = useSnackBar();
const { createEmailGroupChannel, loading } = useCreateEmailGroupChannel();
const [handle, setHandle] = useState('');
const isHandleValidEmail = z.email().safeParse(handle).success;
const canSave = isHandleValidEmail && !loading;
const handleSave = useCallback(async () => {
try {
const result = await createEmailGroupChannel(handle);
const messageChannelId =
result.data?.createEmailGroupChannel.messageChannel.id;
if (messageChannelId) {
navigate(SettingsPath.EmailGroupChannelDetail, {
messageChannelId,
});
}
} catch {
enqueueErrorSnackBar({
message: t`Failed to create email group channel. Email group may not be configured on this server.`,
});
}
}, [createEmailGroupChannel, handle, navigate, enqueueErrorSnackBar, t]);
return (
<SubMenuTopBarContainer
title={t`New Email Group`}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`General`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: t`New Email Group` },
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!canSave}
isCancelDisabled={loading}
isLoading={loading}
onCancel={() => navigate(SettingsPath.Workspace)}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Email Address`}
description={t`Enter the email address you want to forward emails from (e.g. support@mycompany.com).`}
/>
<SettingsTextInput
instanceId="email-group-handle"
label={t`Source Email Address`}
placeholder="support@mycompany.com"
value={handle}
onChange={setHandle}
disabled={loading}
/>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -2,6 +2,7 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
import {
MessageChannelContactAutoCreationPolicy,
MessageChannelType,
MessageFolderImportPolicy,
} from 'twenty-shared/types';
import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails';
@@ -22,6 +23,7 @@ const meta: Meta<typeof SettingsAccountsMessageChannelDetails> = {
args: {
messageChannel: {
id: '20202020-ef5a-4822-9e08-ce6e6a4dcb6a',
type: MessageChannelType.EMAIL,
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy.SENT,
excludeNonProfessionalEmails: true,
excludeGroupEmails: false,
@@ -0,0 +1,18 @@
import { gql } from '@apollo/client';
export const CREATE_EMAIL_GROUP_CHANNEL = gql`
mutation CreateEmailGroupChannel($input: CreateEmailGroupChannelInput!) {
createEmailGroupChannel(input: $input) {
messageChannel {
id
handle
visibility
type
isSyncEnabled
excludeGroupEmails
contactAutoCreationPolicy
}
forwardingAddress
}
}
`;
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_EMAIL_GROUP_CHANNEL = gql`
mutation DeleteEmailGroupChannel($id: UUID!) {
deleteEmailGroupChannel(id: $id) {
id
}
}
`;
@@ -17,6 +17,10 @@ export const GET_MY_MESSAGE_CHANNELS = gql`
syncStage
syncStageStartedAt
connectedAccountId
connectedAccount {
id
handle
}
createdAt
updatedAt
}
@@ -0,0 +1,49 @@
import { useMutation } from '@apollo/client/react';
import {
type MessageChannelContactAutoCreationPolicy,
type MessageChannelType,
type MessageChannelVisibility,
} from 'twenty-shared/types';
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';
type CreateEmailGroupChannelResult = {
createEmailGroupChannel: {
messageChannel: {
id: string;
handle: string;
visibility: MessageChannelVisibility;
type: MessageChannelType;
isSyncEnabled: boolean;
excludeGroupEmails: boolean;
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
};
forwardingAddress: string;
};
};
type CreateEmailGroupChannelVariables = {
input: {
handle: string;
};
};
export const useCreateEmailGroupChannel = () => {
const [mutate, { loading, error }] = useMutation<
CreateEmailGroupChannelResult,
CreateEmailGroupChannelVariables
>(CREATE_EMAIL_GROUP_CHANNEL, {
refetchQueries: [
{ query: GET_MY_CONNECTED_ACCOUNTS },
{ query: GET_MY_MESSAGE_CHANNELS },
],
});
const createEmailGroupChannel = (handle: string) =>
mutate({ variables: { input: { handle } } });
return { createEmailGroupChannel, loading, error };
};
@@ -0,0 +1,31 @@
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';
type DeleteEmailGroupChannelResult = {
deleteEmailGroupChannel: {
id: string;
};
};
type DeleteEmailGroupChannelVariables = {
id: string;
};
export const useDeleteEmailGroupChannel = () => {
const [mutate, { loading, error }] = useMutation<
DeleteEmailGroupChannelResult,
DeleteEmailGroupChannelVariables
>(DELETE_EMAIL_GROUP_CHANNEL, {
refetchQueries: [
{ query: GET_MY_CONNECTED_ACCOUNTS },
{ query: GET_MY_MESSAGE_CHANNELS },
],
});
const deleteEmailGroupChannel = (id: string) => mutate({ variables: { id } });
return { deleteEmailGroupChannel, loading, error };
};
@@ -5,6 +5,7 @@ import {
CalendarChannelSyncStatus,
MessageChannelSyncStage,
MessageChannelSyncStatus,
MessageChannelType,
} from 'twenty-shared/types';
describe('computeSyncStatus', () => {
@@ -14,6 +15,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.NOT_SYNCED,
syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.NOT_SYNCED,
@@ -29,6 +31,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.NOT_SYNCED,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.NOT_SYNCED,
@@ -44,6 +47,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.ACTIVE,
syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.ACTIVE,
@@ -59,6 +63,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN,
syncStage: MessageChannelSyncStage.FAILED,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.FAILED_UNKNOWN,
@@ -74,6 +79,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN,
syncStage: MessageChannelSyncStage.FAILED,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.ACTIVE,
@@ -89,6 +95,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
syncStage: MessageChannelSyncStage.FAILED,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.ACTIVE,
@@ -104,6 +111,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.ACTIVE,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.FAILED_UNKNOWN,
@@ -119,6 +127,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.ACTIVE,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
@@ -134,6 +143,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.ONGOING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_ONGOING,
type: MessageChannelType.EMAIL,
},
{
@@ -150,6 +160,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.ACTIVE,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.ONGOING,
@@ -165,6 +176,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.NOT_SYNCED,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.NOT_SYNCED,
@@ -180,6 +192,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.ACTIVE,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.NOT_SYNCED,
@@ -195,6 +208,7 @@ describe('computeSyncStatus', () => {
{
syncStatus: MessageChannelSyncStatus.NOT_SYNCED,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
type: MessageChannelType.EMAIL,
},
{
syncStatus: CalendarChannelSyncStatus.ACTIVE,
@@ -9,7 +9,7 @@ import {
} from 'twenty-shared/types';
export const computeSyncStatus = (
messageChannel?: Pick<MessageChannel, 'syncStatus' | 'syncStage'>,
messageChannel?: Pick<MessageChannel, 'syncStatus' | 'syncStage' | 'type'>,
calendarChannel?: Pick<CalendarChannel, 'syncStatus' | 'syncStage'>,
): SyncStatus => {
const {
@@ -0,0 +1,136 @@
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { MessageChannelType, SettingsPath } from 'twenty-shared/types';
import { H2Title, IconMail, IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const GRID_AUTO_COLUMNS = '1fr 1fr';
const StyledTableRows = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
padding-top: ${themeCssVariables.spacing[2]};
`;
const StyledClickableRow = styled.div`
> * {
&:hover {
background-color: ${themeCssVariables.background.transparent.light};
cursor: pointer;
}
}
`;
const StyledNameCell = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
`;
const StyledHandle = styled.span`
font-weight: ${themeCssVariables.font.weight.medium};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledForwardingCell = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-family: monospace;
font-size: ${themeCssVariables.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledFooter = styled.div`
border-top: 1px solid ${themeCssVariables.border.color.light};
display: flex;
justify-content: flex-end;
padding-top: ${themeCssVariables.spacing[2]};
`;
export const SettingsWorkspaceEmailGroupSection = () => {
const { t } = useLingui();
const navigateSettings = useNavigateSettings();
const { channels } = useMyMessageChannels();
const emailGroupChannels = channels.filter(
(channel) => channel.type === MessageChannelType.EMAIL_GROUP,
);
return (
<Section>
<H2Title
title={t`Email Groups`}
description={t`Workspace-level shared addresses that receive forwarded mail.`}
/>
{emailGroupChannels.length > 0 && (
<Table>
<TableRow gridAutoColumns={GRID_AUTO_COLUMNS}>
<TableHeader
padding={`0 ${themeCssVariables.spacing[2]} 0 ${themeCssVariables.spacing[2]}`}
>
<Trans>Source</Trans>
</TableHeader>
<TableHeader
padding={`0 ${themeCssVariables.spacing[2]} 0 ${themeCssVariables.spacing[2]}`}
>
<Trans>Forwarding address</Trans>
</TableHeader>
</TableRow>
<StyledTableRows>
{emailGroupChannels.map((channel) => {
const sourceHandle =
channel.connectedAccount?.handle ?? channel.handle;
return (
<StyledClickableRow key={channel.id}>
<TableRow
gridAutoColumns={GRID_AUTO_COLUMNS}
onClick={() =>
navigateSettings(SettingsPath.EmailGroupChannelDetail, {
messageChannelId: channel.id,
})
}
>
<TableCell>
<StyledNameCell>
<IconMail size={16} />
<StyledHandle>{sourceHandle}</StyledHandle>
</StyledNameCell>
</TableCell>
<TableCell>
<StyledForwardingCell>
{channel.handle}
</StyledForwardingCell>
</TableCell>
</TableRow>
</StyledClickableRow>
);
})}
</StyledTableRows>
</Table>
)}
<StyledFooter>
<Button
Icon={IconPlus}
title={t`Add email group`}
variant="secondary"
size="small"
onClick={() => navigateSettings(SettingsPath.NewEmailGroupChannel)}
/>
</StyledFooter>
</Section>
);
};
@@ -30,6 +30,7 @@ const PROVIDERS_ICON_MAPPING = {
[ConnectedAccountProvider.IMAP_SMTP_CALDAV]: IconMail,
[ConnectedAccountProvider.OIDC]: IconMail,
[ConnectedAccountProvider.SAML]: IconMail,
[ConnectedAccountProvider.EMAIL_GROUP]: IconMail,
// App-managed connections aren't email accounts; this case is unreachable
// for the EMAIL source but the lookup type still requires every provider.
[ConnectedAccountProvider.APP]: IconMail,