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
@@ -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>
);
};