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:
+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