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:
+4
-3
@@ -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>
|
||||
)}
|
||||
|
||||
+41
-25
@@ -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
|
||||
|
||||
+6
-2
@@ -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) => ({
|
||||
|
||||
+89
@@ -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
@@ -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,
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_EMAIL_GROUP_CHANNEL = gql`
|
||||
mutation DeleteEmailGroupChannel($id: UUID!) {
|
||||
deleteEmailGroupChannel(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
+4
@@ -17,6 +17,10 @@ export const GET_MY_MESSAGE_CHANNELS = gql`
|
||||
syncStage
|
||||
syncStageStartedAt
|
||||
connectedAccountId
|
||||
connectedAccount {
|
||||
id
|
||||
handle
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
+49
@@ -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 };
|
||||
};
|
||||
+31
@@ -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 };
|
||||
};
|
||||
+14
@@ -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 {
|
||||
|
||||
+136
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user