Compare commits

...
Author SHA1 Message Date
Claude c38b7c6d71 fix: hide email forwarding button when server is not configured
Use clientConfig pattern to expose isEmailForwardingEnabled flag
(derived from STORAGE_TYPE=S3 + INBOUND_EMAIL_DOMAIN) so the
frontend hides the "Add Email Forwarding" card instead of showing
it and failing with a confusing error snackbar.

https://claude.ai/code/session_01NXChiAdtrPmSvb7SZHgrdk
2026-04-09 15:06:58 +00:00
Claude c83fe36e43 style: fix prettier formatting in shared email util files
https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm
2026-04-09 12:15:04 +00:00
Claude ba8e1d6650 refactor: address PR review comments for email forwarding
- 1 export per file: split constants, types, job data into individual files
- Extract shared email parsing utils (extractThreadId, extractParticipants,
  extractAddresses) from IMAP and inbound-email drivers into shared utils
- Reuse existing S3 bucket (STORAGE_S3_NAME) instead of separate
  INBOUND_EMAIL_S3_BUCKET; remove redundant config vars
- Remove attachment handling from inbound parser (not managed)
- Remove unnecessary/obvious comments throughout
- Rename "Connect Account" to "Connect via IMAP/SMTP"
- Remove description from email forwarding card for UI consistency
- Move EMAIL_FORWARDING_MODAL_ID to its own constant file
- Remove duplicate channel check (support multiple forwarding channels)

https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm
2026-04-09 12:12:03 +00:00
Claude 854c9f2b16 fix: address review findings for email forwarding feature
- P0: Move S3 object to failed/ on download error (prevents infinite retry)
- P1: Return existing channel if user already has one (idempotent creation)
- P1: Map EMAIL_FORWARDING_NOT_CONFIGURED to InternalServerError
- P1: Remove redundant MessageChannelType guard in hasPendingConfiguration
- P1: Reset modal forwardingAddress state on close
- Update test to verify moveToFailed on download error

https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm
2026-04-09 11:20:59 +00:00
Claude e7b4ea49cc fix: email forwarding channels bypass mailbox sync lifecycle
Forwarding channels are driven by the S3 poll cron, not the standard
mailbox sync state machine. This commit ensures they never enter the
mailbox fetch pipeline:

- Create forwarding channels with MESSAGE_LIST_FETCH_PENDING + ACTIVE
  status instead of PENDING_CONFIGURATION (avoids "Complete setup" UI)
- Guard channel-sync.service, message-list-fetch cron, messages-import
  cron, and relaunch-failed cron against EMAIL_FORWARDING type
- Frontend: computeSyncStatus returns SYNCED for forwarding channels,
  dropdown hides calendar/reconnect for forwarding accounts
- Add computeSyncStatus tests for EMAIL_FORWARDING type
- Auto-format server files with Prettier

https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm
2026-04-09 10:57:46 +00:00
Claude 11b39d980c fix: validate full inbound email config before creating forwarding channel
The creation mutation now checks INBOUND_EMAIL_DOMAIN, INBOUND_EMAIL_S3_BUCKET,
and a region (INBOUND_EMAIL_S3_REGION or AWS_SES_REGION) — matching the same
config gate the runtime import pipeline uses. Previously only the domain was
checked, which allowed channel creation on partially configured deployments
where inbound mail would never be consumed.

https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm
2026-04-09 10:35:20 +00:00
Claude a2ace9ce49 fix: address review issues for email forwarding feature
Critical:
- Add database migration to extend messageChannel_type_enum with EMAIL_FORWARDING
- Fix cron duplicate processing by using S3 key as BullMQ job ID for dedup

Major:
- Add refetchQueries to Apollo mutation so new channel appears in list immediately
- Make buildArchiveKey private (only used internally)

Minor:
- Fix import ordering in modal component (external -> @/ -> ~/)
- Use crypto.randomUUID() instead of Math.random() for thread ID fallback

https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm
2026-04-09 10:09:26 +00:00
Claude b36859055b feat: add email forwarding message channel
Adds a new EMAIL_FORWARDING message channel type that lets users receive
emails from Google Groups and Microsoft 365 shared mailboxes via SES
inbound routing to S3.

Backend:
- New enum values: MessageChannelType.EMAIL_FORWARDING, ConnectedAccountProvider.EMAIL_FORWARDING
- S3-based inbound email pipeline: poll cron -> parse MIME -> extract envelope recipient -> match channel -> save messages
- Loop prevention via X-Twenty-Origin header on outbound mail
- createEmailForwardingChannel mutation generates ch_<hex>@<domain> addresses
- Exhaustive switch coverage across auth, refresh tokens, outbound, drafts, email aliases

Frontend:
- "Add Email Forwarding" card in account settings
- Modal showing generated forwarding address with copy-to-clipboard
- GraphQL mutation + hook for channel creation

Tests:
- extractEnvelopeRecipient util: Received header, Delivered-To, X-Original-To, To/Cc fallback, domain mismatch
- InboundEmailImportService: imported, unmatched, loop_dropped, unconfigured, parse_failed, persist_failed outcomes

https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm
2026-04-09 09:51:40 +00:00
59 changed files with 1764 additions and 116 deletions
@@ -27,6 +27,7 @@ export const getMissingDraftEmailScopes = (
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.EMAIL_FORWARDING:
return [];
default:
assertUnreachable(
@@ -18,6 +18,7 @@ import { isEmailingDomainsEnabledState } from '@/client-config/states/isEmailing
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
import { isEmailForwardingEnabledState } from '@/client-config/states/isEmailForwardingEnabledState';
import { isImapSmtpCaldavEnabledState } from '@/client-config/states/isImapSmtpCaldavEnabledState';
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
@@ -103,6 +104,9 @@ export const useClientConfig = (): UseClientConfigResult => {
const setIsImapSmtpCaldavEnabled = useSetAtomState(
isImapSmtpCaldavEnabledState,
);
const setIsEmailForwardingEnabled = useSetAtomState(
isEmailForwardingEnabledState,
);
const setIsEmailingDomainsEnabled = useSetAtomState(
isEmailingDomainsEnabledState,
);
@@ -195,6 +199,9 @@ export const useClientConfig = (): UseClientConfigResult => {
setCalendarBookingPageId(clientConfig?.calendarBookingPageId ?? null);
setIsImapSmtpCaldavEnabled(clientConfig?.isImapSmtpCaldavEnabled);
setIsEmailForwardingEnabled(
clientConfig?.isEmailForwardingEnabled ?? false,
);
setIsEmailingDomainsEnabled(clientConfig?.isEmailingDomainsEnabled);
setAllowRequestsToTwentyIcons(clientConfig?.allowRequestsToTwentyIcons);
setIsCloudflareIntegrationEnabled(
@@ -233,6 +240,7 @@ export const useClientConfig = (): UseClientConfigResult => {
setIsDeveloperDefaultSignInPrefilled,
setIsEmailVerificationRequired,
setIsImapSmtpCaldavEnabled,
setIsEmailForwardingEnabled,
setIsMultiWorkspaceEnabled,
setIsEmailingDomainsEnabled,
setIsClickHouseConfigured,
@@ -0,0 +1,5 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isEmailForwardingEnabledState = createAtomState<boolean>({
key: 'isEmailForwardingEnabled',
defaultValue: false,
});
@@ -31,6 +31,7 @@ export type ClientConfig = {
isMicrosoftMessagingEnabled: boolean;
isMultiWorkspaceEnabled: boolean;
isImapSmtpCaldavEnabled: boolean;
isEmailForwardingEnabled: boolean;
isEmailingDomainsEnabled: boolean;
isCloudflareIntegrationEnabled: boolean;
isClickHouseConfigured: boolean;
@@ -0,0 +1,127 @@
import { EMAIL_FORWARDING_MODAL_ID } from '@/settings/accounts/constants/EmailForwardingModalId';
import { ModalStatefulWrapper } from '@/ui/layout/modal/components/ModalStatefulWrapper';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconCopy, IconMail } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledModalContent = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[6]};
padding: ${themeCssVariables.spacing[6]};
`;
const StyledTitle = styled.h2`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.lg};
font-weight: ${themeCssVariables.font.weight.semiBold};
margin: 0;
`;
const StyledDescription = styled.p`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.sm};
line-height: 1.5;
margin: 0;
`;
const StyledAddressContainer = styled.div`
align-items: center;
background: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
`;
const StyledAddress = styled.span`
color: ${themeCssVariables.font.color.primary};
flex: 1;
font-family: monospace;
font-size: ${themeCssVariables.font.size.sm};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledInstructions = styled.ol`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.sm};
line-height: 1.6;
margin: 0;
padding-left: ${themeCssVariables.spacing[6]};
`;
const StyledButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
`;
type SettingsAccountsEmailForwardingModalProps = {
forwardingAddress: string;
onClose?: () => void;
};
export const SettingsAccountsEmailForwardingModal = ({
forwardingAddress,
onClose,
}: SettingsAccountsEmailForwardingModalProps) => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const { closeModal } = useModal();
return (
<ModalStatefulWrapper
modalInstanceId={EMAIL_FORWARDING_MODAL_ID}
size="medium"
isClosable
>
<StyledModalContent>
<StyledTitle>{t`Email Forwarding Channel Created`}</StyledTitle>
<StyledDescription>
{t`Add this address to a Google Group or Microsoft 365 shared mailbox to start receiving emails in Twenty.`}
</StyledDescription>
<StyledAddressContainer>
<IconMail size={16} />
<StyledAddress>{forwardingAddress}</StyledAddress>
<Button
Icon={IconCopy}
title={t`Copy`}
variant="secondary"
size="small"
onClick={() =>
copyToClipboard(
forwardingAddress,
t`Forwarding address copied to clipboard`,
)
}
/>
</StyledAddressContainer>
<StyledInstructions>
<li>{t`Copy the forwarding address above`}</li>
<li>{t`Add it as a member of your Google Group or Microsoft 365 shared mailbox`}</li>
<li>{t`Emails sent to the group will automatically appear in Twenty`}</li>
</StyledInstructions>
<StyledButtonContainer>
<Button
title={t`Done`}
variant="primary"
size="small"
onClick={() => {
closeModal(EMAIL_FORWARDING_MODAL_ID);
onClose?.();
}}
/>
</StyledButtonContainer>
</StyledModalContent>
</ModalStatefulWrapper>
);
};
@@ -1,18 +1,24 @@
import { isEmailForwardingEnabledState } from '@/client-config/states/isEmailForwardingEnabledState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMessagingEnabledState';
import { isImapSmtpCaldavEnabledState } from '@/client-config/states/isImapSmtpCaldavEnabledState';
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import { SettingsAccountsEmailForwardingModal } from '@/settings/accounts/components/SettingsAccountsEmailForwardingModal';
import { EMAIL_FORWARDING_MODAL_ID } from '@/settings/accounts/constants/EmailForwardingModalId';
import { useCreateEmailForwardingChannel } from '@/settings/accounts/hooks/useCreateEmailForwardingChannel';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useCallback, useContext, useState } from 'react';
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconAt, IconGoogle, IconMicrosoft } from 'twenty-ui/display';
import { IconAt, IconGoogle, IconMail, 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 +32,14 @@ export const SettingsAccountsListEmptyStateCard = () => {
const { triggerApisOAuth } = useTriggerApisOAuth();
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
const { openModal } = useModal();
const { createEmailForwardingChannel, loading: isCreatingForwarding } =
useCreateEmailForwardingChannel();
const [forwardingAddress, setForwardingAddress] = useState<string | null>(
null,
);
const isGoogleMessagingEnabled = useAtomStateValue(
isGoogleMessagingEnabledState,
);
@@ -45,34 +59,74 @@ export const SettingsAccountsListEmptyStateCard = () => {
isImapSmtpCaldavEnabledState,
);
const isEmailForwardingEnabled = useAtomStateValue(
isEmailForwardingEnabledState,
);
const handleCreateEmailForwardingChannel = useCallback(async () => {
try {
const result = await createEmailForwardingChannel();
const address =
result.data?.createEmailForwardingChannel.forwardingAddress;
if (address) {
setForwardingAddress(address);
openModal(EMAIL_FORWARDING_MODAL_ID);
}
} catch {
enqueueErrorSnackBar({
message: t`Failed to create email forwarding channel. Email forwarding may not be configured on this server.`,
});
}
}, [createEmailForwardingChannel, openModal, enqueueErrorSnackBar, t]);
return (
<StyledCardsContainer>
{(isGoogleMessagingEnabled || isGoogleCalendarEnabled) && (
<SettingsCard
Icon={<IconGoogle size={theme.icon.size.md} />}
title={t`Connect with Google`}
onClick={() => triggerApisOAuth(ConnectedAccountProvider.GOOGLE)}
/>
)}
{(isMicrosoftMessagingEnabled || isMicrosoftCalendarEnabled) && (
<SettingsCard
Icon={<IconMicrosoft size={theme.icon.size.md} />}
title={t`Connect with Microsoft`}
onClick={() => triggerApisOAuth(ConnectedAccountProvider.MICROSOFT)}
/>
)}
{isImapSmtpCaldavEnabled && (
<UndecoratedLink
to={getSettingsPath(SettingsPath.NewImapSmtpCaldavConnection)}
>
<>
<StyledCardsContainer>
{(isGoogleMessagingEnabled || isGoogleCalendarEnabled) && (
<SettingsCard
Icon={<IconAt size={theme.icon.size.md} />}
title={t`Connect Account`}
Icon={<IconGoogle size={theme.icon.size.md} />}
title={t`Connect with Google`}
onClick={() => triggerApisOAuth(ConnectedAccountProvider.GOOGLE)}
/>
</UndecoratedLink>
)}
{(isMicrosoftMessagingEnabled || isMicrosoftCalendarEnabled) && (
<SettingsCard
Icon={<IconMicrosoft size={theme.icon.size.md} />}
title={t`Connect with Microsoft`}
onClick={() => triggerApisOAuth(ConnectedAccountProvider.MICROSOFT)}
/>
)}
{isImapSmtpCaldavEnabled && (
<UndecoratedLink
to={getSettingsPath(SettingsPath.NewImapSmtpCaldavConnection)}
>
<SettingsCard
Icon={<IconAt size={theme.icon.size.md} />}
title={t`Connect via IMAP/SMTP`}
/>
</UndecoratedLink>
)}
{isEmailForwardingEnabled && (
<SettingsCard
Icon={<IconMail size={theme.icon.size.md} />}
title={t`Add Email Forwarding`}
disabled={isCreatingForwarding}
onClick={handleCreateEmailForwardingChannel}
/>
)}
</StyledCardsContainer>
{forwardingAddress && (
<SettingsAccountsEmailForwardingModal
forwardingAddress={forwardingAddress}
onClose={() => setForwardingAddress(null)}
/>
)}
</StyledCardsContainer>
</>
);
};
@@ -52,15 +52,19 @@ export const SettingsAccountsRowDropdownMenu = ({
);
const { triggerProviderReconnect } = useTriggerProviderReconnect();
const isEmailForwarding =
account.provider === ConnectedAccountProvider.EMAIL_FORWARDING;
const hasPendingConfiguration =
account.messageChannels.some(
!isEmailForwarding &&
(account.messageChannels.some(
(channel) =>
channel.syncStage === MessageChannelSyncStage.PENDING_CONFIGURATION,
) ||
account.calendarChannels.some(
(channel) =>
channel.syncStage === CalendarChannelSyncStage.PENDING_CONFIGURATION,
);
account.calendarChannels.some(
(channel) =>
channel.syncStage === CalendarChannelSyncStage.PENDING_CONFIGURATION,
));
const deleteAccount = async () => {
await deleteConnectedAccountMutation({
@@ -113,15 +117,17 @@ export const SettingsAccountsRowDropdownMenu = ({
closeDropdown(dropdownId);
}}
/>
<MenuItem
LeftIcon={IconCalendarEvent}
text={t`Calendar settings`}
onClick={() => {
navigate(SettingsPath.AccountsCalendars);
closeDropdown(dropdownId);
}}
/>
{account.authFailedAt && (
{!isEmailForwarding && (
<MenuItem
LeftIcon={IconCalendarEvent}
text={t`Calendar settings`}
onClick={() => {
navigate(SettingsPath.AccountsCalendars);
closeDropdown(dropdownId);
}}
/>
)}
{!isEmailForwarding && account.authFailedAt && (
<MenuItem
LeftIcon={IconRefresh}
text={t`Reconnect`}
@@ -0,0 +1 @@
export const EMAIL_FORWARDING_MODAL_ID = 'email-forwarding-address-modal';
@@ -0,0 +1,18 @@
import { gql } from '@apollo/client';
export const CREATE_EMAIL_FORWARDING_CHANNEL = gql`
mutation CreateEmailForwardingChannel {
createEmailForwardingChannel {
messageChannel {
id
handle
visibility
type
isSyncEnabled
excludeGroupEmails
contactAutoCreationPolicy
}
forwardingAddress
}
}
`;
@@ -0,0 +1,35 @@
import { useMutation } from '@apollo/client/react';
import { CREATE_EMAIL_FORWARDING_CHANNEL } from '@/settings/accounts/graphql/mutations/createEmailForwardingChannel';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
type CreateEmailForwardingChannelResult = {
createEmailForwardingChannel: {
messageChannel: {
id: string;
handle: string;
visibility: string;
type: string;
isSyncEnabled: boolean;
excludeGroupEmails: boolean;
contactAutoCreationPolicy: string;
};
forwardingAddress: string;
};
};
export const useCreateEmailForwardingChannel = () => {
const [createEmailForwardingChannel, { loading, error }] =
useMutation<CreateEmailForwardingChannelResult>(
CREATE_EMAIL_FORWARDING_CHANNEL,
{
refetchQueries: [
{ query: GET_MY_CONNECTED_ACCOUNTS },
{ query: GET_MY_MESSAGE_CHANNELS },
],
},
);
return { createEmailForwardingChannel, loading, error };
};
@@ -5,6 +5,7 @@ import {
CalendarChannelSyncStatus,
MessageChannelSyncStage,
MessageChannelSyncStatus,
MessageChannelType,
} from 'twenty-shared/types';
describe('computeSyncStatus', () => {
@@ -204,5 +205,31 @@ describe('computeSyncStatus', () => {
).toEqual(SyncStatus.SYNCED);
});
test('should return SYNCED for EMAIL_FORWARDING channel regardless of sync stage or status', () => {
expect(
computeSyncStatus(
{
syncStatus: MessageChannelSyncStatus.NOT_SYNCED,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
type: MessageChannelType.EMAIL_FORWARDING,
},
undefined,
),
).toEqual(SyncStatus.SYNCED);
});
test('should return SYNCED for EMAIL_FORWARDING channel even with PENDING_CONFIGURATION stage', () => {
expect(
computeSyncStatus(
{
syncStatus: MessageChannelSyncStatus.NOT_SYNCED,
syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION,
type: MessageChannelType.EMAIL_FORWARDING,
},
undefined,
),
).toEqual(SyncStatus.SYNCED);
});
test('should return NOT_SYNCED when channels are empty', () => {});
});
@@ -6,12 +6,18 @@ import {
CalendarChannelSyncStatus,
MessageChannelSyncStage,
MessageChannelSyncStatus,
MessageChannelType,
} from 'twenty-shared/types';
export const computeSyncStatus = (
messageChannel?: Pick<MessageChannel, 'syncStatus' | 'syncStage'>,
messageChannel?: Pick<MessageChannel, 'syncStatus' | 'syncStage'> &
Partial<Pick<MessageChannel, 'type'>>,
calendarChannel?: Pick<CalendarChannel, 'syncStatus' | 'syncStage'>,
): SyncStatus => {
if (messageChannel?.type === MessageChannelType.EMAIL_FORWARDING) {
return SyncStatus.SYNCED;
}
const {
syncStatus: messageChannelSyncStatus,
syncStage: messageChannelSyncStage,
@@ -30,6 +30,7 @@ const PROVIDERS_ICON_MAPPING = {
[ConnectedAccountProvider.IMAP_SMTP_CALDAV]: IconMail,
[ConnectedAccountProvider.OIDC]: IconMail,
[ConnectedAccountProvider.SAML]: IconMail,
[ConnectedAccountProvider.EMAIL_FORWARDING]: IconMail,
default: IconMail,
},
CALENDAR: {
@@ -0,0 +1,36 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddEmailForwardingChannelType1775729181000
implements MigrationInterface
{
name = 'AddEmailForwardingChannelType1775729181000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "core"."messageChannel_type_enum" RENAME TO "messageChannel_type_enum_old"`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_type_enum" AS ENUM('EMAIL', 'SMS', 'EMAIL_FORWARDING')`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" ALTER COLUMN "type" TYPE "core"."messageChannel_type_enum" USING "type"::"text"::"core"."messageChannel_type_enum"`,
);
await queryRunner.query(`DROP TYPE "core"."messageChannel_type_enum_old"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "core"."messageChannel" WHERE "type" = 'EMAIL_FORWARDING'`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_type_enum_old" AS ENUM('EMAIL', 'SMS')`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" ALTER COLUMN "type" TYPE "core"."messageChannel_type_enum_old" USING "type"::"text"::"core"."messageChannel_type_enum_old"`,
);
await queryRunner.query(`DROP TYPE "core"."messageChannel_type_enum"`);
await queryRunner.query(
`ALTER TYPE "core"."messageChannel_type_enum_old" RENAME TO "messageChannel_type_enum"`,
);
}
}
@@ -1127,6 +1127,8 @@ export class AuthService {
return [];
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return [];
case ConnectedAccountProvider.EMAIL_FORWARDING:
return [];
default:
throw new Error(
`Unsupported connected account provider: ${provider satisfies never}`,
@@ -300,6 +300,9 @@ export class ClientConfig {
@Field(() => Boolean)
isImapSmtpCaldavEnabled: boolean;
@Field(() => Boolean)
isEmailForwardingEnabled: boolean;
@Field(() => Boolean)
allowRequestsToTwentyIcons: boolean;
@@ -4,6 +4,8 @@ import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type AiSdkPackage } from 'twenty-shared/ai';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface';
import {
AI_SDK_ANTHROPIC,
AI_SDK_BEDROCK,
@@ -232,6 +234,12 @@ export class ClientConfigService {
isImapSmtpCaldavEnabled: this.twentyConfigService.get(
'IS_IMAP_SMTP_CALDAV_ENABLED',
),
isEmailForwardingEnabled:
this.twentyConfigService.get('STORAGE_TYPE') ===
StorageDriverType.S_3 &&
isNonEmptyString(
this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN'),
),
allowRequestsToTwentyIcons: this.twentyConfigService.get(
'ALLOW_REQUESTS_TO_TWENTY_ICONS',
),
@@ -1622,6 +1622,25 @@ export class ConfigVariables {
@IsOptional()
AWS_SES_ACCOUNT_ID: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AWS_SES_SETTINGS,
description:
'Domain used for inbound email forwarding (the right-hand side of ch_xxx@<domain>). Required to enable email forwarding channels.',
type: ConfigVariableType.STRING,
})
@IsOptional()
INBOUND_EMAIL_DOMAIN: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AWS_SES_SETTINGS,
description:
'Maximum number of S3 keys listed per poll of the inbound-email folder.',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsOptional()
INBOUND_EMAIL_POLL_BATCH_SIZE: number = 500;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
description: 'Timeout in milliseconds for primary database queries',
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
@ObjectType('CreateEmailForwardingChannelOutput')
export class CreateEmailForwardingChannelOutput {
@Field(() => MessageChannelDTO)
messageChannel: MessageChannelDTO;
@Field()
forwardingAddress: string;
}
@@ -1,21 +1,33 @@
import { randomBytes } from 'crypto';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { In, Repository } from 'typeorm';
import {
ConnectedAccountProvider,
MessageChannelContactAutoCreationPolicy,
MessageChannelPendingGroupEmailsAction,
MessageChannelSyncStage,
MessageChannelSyncStatus,
MessageChannelType,
MessageChannelVisibility,
} from 'twenty-shared/types';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { CreateEmailForwardingChannelOutput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-forwarding-channel.output';
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import {
MessageChannelException,
MessageChannelExceptionCode,
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
import { INBOUND_EMAIL_LOCAL_PART_PREFIX } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-prefix.constant';
import { INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-local-part-random-bytes.constant';
@Injectable()
export class MessageChannelMetadataService {
@@ -23,6 +35,7 @@ export class MessageChannelMetadataService {
@InjectRepository(MessageChannelEntity)
private readonly repository: Repository<MessageChannelEntity>,
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async findAll(workspaceId: string): Promise<MessageChannelDTO[]> {
@@ -172,6 +185,63 @@ export class MessageChannelMetadataService {
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async createEmailForwardingChannel({
userWorkspaceId,
workspaceId,
}: {
userWorkspaceId: string;
workspaceId: string;
}): Promise<CreateEmailForwardingChannelOutput> {
const inboundEmailDomain = this.twentyConfigService.get(
'INBOUND_EMAIL_DOMAIN',
);
const storageType = this.twentyConfigService.get('STORAGE_TYPE');
if (
!isNonEmptyString(inboundEmailDomain) ||
storageType !== StorageDriverType.S_3
) {
throw new MessageChannelException(
'Email forwarding is not configured: INBOUND_EMAIL_DOMAIN must be set and STORAGE_TYPE must be S3',
MessageChannelExceptionCode.EMAIL_FORWARDING_NOT_CONFIGURED,
);
}
const localPart =
INBOUND_EMAIL_LOCAL_PART_PREFIX +
randomBytes(INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES).toString('hex');
const forwardingAddress = `${localPart}@${inboundEmailDomain}`;
const connectedAccount = await this.connectedAccountMetadataService.create({
workspaceId,
handle: forwardingAddress,
provider: ConnectedAccountProvider.EMAIL_FORWARDING,
userWorkspaceId,
accessToken: null,
refreshToken: null,
});
const messageChannel = await this.create({
workspaceId,
handle: forwardingAddress,
connectedAccountId: connectedAccount.id,
type: MessageChannelType.EMAIL_FORWARDING,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
syncStatus: MessageChannelSyncStatus.ACTIVE,
isSyncEnabled: true,
isContactAutoCreationEnabled: true,
contactAutoCreationPolicy:
MessageChannelContactAutoCreationPolicy.SENT_AND_RECEIVED,
excludeGroupEmails: false,
excludeNonProfessionalEmails: false,
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction.NONE,
});
return { messageChannel, forwardingAddress };
}
async delete({
id,
workspaceId,
@@ -8,6 +8,7 @@ export enum MessageChannelExceptionCode {
MESSAGE_CHANNEL_NOT_FOUND = 'MESSAGE_CHANNEL_NOT_FOUND',
INVALID_MESSAGE_CHANNEL_INPUT = 'INVALID_MESSAGE_CHANNEL_INPUT',
MESSAGE_CHANNEL_OWNERSHIP_VIOLATION = 'MESSAGE_CHANNEL_OWNERSHIP_VIOLATION',
EMAIL_FORWARDING_NOT_CONFIGURED = 'EMAIL_FORWARDING_NOT_CONFIGURED',
}
const getMessageChannelExceptionUserFriendlyMessage = (
@@ -20,6 +21,8 @@ const getMessageChannelExceptionUserFriendlyMessage = (
return msg`Invalid message channel input.`;
case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION:
return msg`You do not have access to this message channel.`;
case MessageChannelExceptionCode.EMAIL_FORWARDING_NOT_CONFIGURED:
return msg`Email forwarding is not configured on this server.`;
default:
assertUnreachable(code);
}
@@ -19,6 +19,7 @@ import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-worksp
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateEmailForwardingChannelOutput } from 'src/engine/metadata-modules/message-channel/dtos/create-email-forwarding-channel.output';
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
import { UpdateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/update-message-channel.input';
import {
@@ -130,4 +131,16 @@ export class MessageChannelResolver {
data: input.update,
});
}
@Mutation(() => CreateEmailForwardingChannelOutput)
@UseGuards(NoPermissionGuard)
async createEmailForwardingChannel(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<CreateEmailForwardingChannelOutput> {
return this.messageChannelMetadataService.createEmailForwardingChannel({
userWorkspaceId,
workspaceId: workspace.id,
});
}
}
@@ -2,6 +2,7 @@ import { assertUnreachable } from 'twenty-shared/utils';
import {
ForbiddenError,
InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@@ -23,6 +24,8 @@ export const messageChannelGraphqlApiExceptionHandler = (error: Error) => {
throw new UserInputError(error);
case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION:
throw new ForbiddenError(error);
case MessageChannelExceptionCode.EMAIL_FORWARDING_NOT_CONFIGURED:
throw new InternalServerError(error);
default: {
return assertUnreachable(error.code);
}
@@ -286,6 +286,13 @@ export const buildMessageChannelStandardFlatFieldMetadatas = ({
position: 1,
color: 'blue',
},
{
id: '20202020-7f22-4e58-aa33-9c3e2c72ab10',
value: MessageChannelType.EMAIL_FORWARDING,
label: i18nLabel(msg`Email Forwarding`),
position: 2,
color: 'turquoise',
},
],
},
standardObjectMetadataRelatedEntityIds,
@@ -1,12 +1,13 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Not, Repository } from 'typeorm';
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
MessageChannelSyncStage,
MessageChannelType,
} from 'twenty-shared/types';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -63,6 +64,7 @@ export class ChannelSyncService {
where: {
connectedAccountId,
syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION,
type: Not(MessageChannelType.EMAIL_FORWARDING),
workspaceId,
},
});
@@ -43,6 +43,7 @@ export class EmailAliasManagerService {
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.EMAIL_FORWARDING:
handleAliases = [];
break;
default:
@@ -117,6 +117,7 @@ export class ConnectedAccountRefreshTokensService {
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.EMAIL_FORWARDING:
return true;
default:
return assertUnreachable(
@@ -144,6 +145,7 @@ export class ConnectedAccountRefreshTokensService {
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.EMAIL_FORWARDING:
throw new ConnectedAccountRefreshAccessTokenException(
`Token refresh is not supported for ${connectedAccount.provider} provider for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
@@ -0,0 +1,33 @@
import { Command, CommandRunner } from 'nest-commander';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import {
MESSAGING_INBOUND_EMAIL_POLL_CRON_PATTERN,
MessagingInboundEmailPollCronJob,
} from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-inbound-email-poll.cron.job';
@Command({
name: 'cron:messaging:inbound-email-poll',
description:
'Starts a cron job to poll the SES inbound S3 bucket and enqueue one import job per message.',
})
export class MessagingInboundEmailPollCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: MessagingInboundEmailPollCronJob.name,
data: undefined,
options: {
repeat: { pattern: MESSAGING_INBOUND_EMAIL_POLL_CRON_PATTERN },
},
});
}
}
@@ -0,0 +1,65 @@
import { Logger } from '@nestjs/common';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider';
import { InboundEmailStorageService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service';
import { type MessagingInboundEmailImportJobData } from 'src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import-job-data.type';
import { MessagingInboundEmailImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import.job';
export const MESSAGING_INBOUND_EMAIL_POLL_CRON_PATTERN = '* * * * *';
@Processor(MessageQueue.cronQueue)
export class MessagingInboundEmailPollCronJob {
private readonly logger = new Logger(MessagingInboundEmailPollCronJob.name);
constructor(
@InjectMessageQueue(MessageQueue.messagingQueue)
private readonly messageQueueService: MessageQueueService,
private readonly inboundEmailS3ClientProvider: InboundEmailS3ClientProvider,
private readonly inboundEmailStorageService: InboundEmailStorageService,
private readonly twentyConfigService: TwentyConfigService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
@Process(MessagingInboundEmailPollCronJob.name)
@SentryCronMonitor(
MessagingInboundEmailPollCronJob.name,
MESSAGING_INBOUND_EMAIL_POLL_CRON_PATTERN,
)
async handle(): Promise<void> {
if (!this.inboundEmailS3ClientProvider.isConfigured()) {
return;
}
try {
const batchSize = this.twentyConfigService.get(
'INBOUND_EMAIL_POLL_BATCH_SIZE',
);
const keys =
await this.inboundEmailStorageService.listIncoming(batchSize);
if (keys.length === 0) {
return;
}
this.logger.log(`Enqueuing ${keys.length} inbound emails for import`);
for (const s3Key of keys) {
await this.messageQueueService.add<MessagingInboundEmailImportJobData>(
MessagingInboundEmailImportJob.name,
{ s3Key },
{ id: `inbound-email:${s3Key}` },
);
}
} catch (error) {
this.exceptionHandlerService.captureExceptions([error]);
}
}
}
@@ -3,7 +3,10 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { DataSource, Repository } from 'typeorm';
import { MessageChannelSyncStage } from 'twenty-shared/types';
import {
MessageChannelSyncStage,
MessageChannelType,
} from 'twenty-shared/types';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -49,7 +52,7 @@ export class MessagingMessageListFetchCronJob {
const [messageChannels] = await this.coreDataSource.query(
`UPDATE core."messageChannel" SET "syncStage" = '${MessageChannelSyncStage.MESSAGE_LIST_FETCH_SCHEDULED}', "syncStageStartedAt" = COALESCE("syncStageStartedAt", '${now}')
WHERE "workspaceId" = '${activeWorkspace.id}' AND "isSyncEnabled" = true AND "syncStage" = '${MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING}' RETURNING *`,
WHERE "workspaceId" = '${activeWorkspace.id}' AND "isSyncEnabled" = true AND "syncStage" = '${MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING}' AND "type" != '${MessageChannelType.EMAIL_FORWARDING}' RETURNING *`,
);
for (const messageChannel of messageChannels) {
@@ -4,7 +4,10 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { DataSource, Repository } from 'typeorm';
import { MessageChannelSyncStage } from 'twenty-shared/types';
import {
MessageChannelSyncStage,
MessageChannelType,
} from 'twenty-shared/types';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -54,7 +57,7 @@ export class MessagingMessagesImportCronJob {
const [messageChannels] = await this.coreDataSource.query(
`UPDATE core."messageChannel" SET "syncStage" = '${MessageChannelSyncStage.MESSAGES_IMPORT_SCHEDULED}', "syncStageStartedAt" = COALESCE("syncStageStartedAt", '${now}')
WHERE "workspaceId" = '${activeWorkspace.id}' AND "isSyncEnabled" = true AND "syncStage" = '${MessageChannelSyncStage.MESSAGES_IMPORT_PENDING}' RETURNING *`,
WHERE "workspaceId" = '${activeWorkspace.id}' AND "isSyncEnabled" = true AND "syncStage" = '${MessageChannelSyncStage.MESSAGES_IMPORT_PENDING}' AND "type" != '${MessageChannelType.EMAIL_FORWARDING}' RETURNING *`,
);
for (const messageChannel of messageChannels) {
@@ -6,6 +6,7 @@ import { DataSource, Repository } from 'typeorm';
import {
MessageChannelSyncStage,
MessageChannelSyncStatus,
MessageChannelType,
} from 'twenty-shared/types';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
@@ -53,7 +54,7 @@ export class MessagingRelaunchFailedMessageChannelsCronJob {
const schemaName = getWorkspaceSchemaName(activeWorkspace.id);
const failedMessageChannels = await this.coreDataSource.query(
`SELECT * FROM ${schemaName}."messageChannel" WHERE "syncStage" = '${MessageChannelSyncStage.FAILED}' AND "syncStatus" = '${MessageChannelSyncStatus.FAILED_UNKNOWN}'`,
`SELECT * FROM ${schemaName}."messageChannel" WHERE "syncStage" = '${MessageChannelSyncStage.FAILED}' AND "syncStatus" = '${MessageChannelSyncStatus.FAILED_UNKNOWN}' AND "type" != '${MessageChannelType.EMAIL_FORWARDING}'`,
);
for (const messageChannel of failedMessageChannels) {
@@ -1,8 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ImapFlow } from 'imapflow';
import { Address, type Email as ParsedMail } from 'postal-mime';
import { MessageParticipantRole } from 'twenty-shared/types';
import { type Email as ParsedMail } from 'postal-mime';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util';
@@ -11,9 +10,10 @@ import { ImapMessageParserService } from 'src/modules/messaging/message-import-m
import { ImapMessageTextExtractorService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-text-extractor.service';
import { ImapMessagesImportErrorHandler } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-messages-import-error-handler.service';
import { parseMessageId } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-message-id.util';
import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util';
import { extractAddressesFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util';
import { extractParticipantsFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util';
import { extractThreadIdFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-thread-id-from-parsed-email.util';
import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util';
type ConnectedAccount = Pick<
@@ -164,7 +164,7 @@ export class ImapGetMessagesService {
folderExternalId: string,
connectedAccount: Pick<ConnectedAccountEntity, 'handle' | 'handleAliases'>,
): MessageWithParticipants {
const fromAddresses = this.extractAddresses(parsed.from);
const fromAddresses = extractAddressesFromParsedEmail(parsed.from);
const senderAddress = fromAddresses[0]?.address ?? '';
const text = sanitizeString(
@@ -173,75 +173,17 @@ export class ImapGetMessagesService {
return {
externalId: `${folderPath}:${uid}`,
messageThreadExternalId: this.extractThreadId(parsed),
messageThreadExternalId: extractThreadIdFromParsedEmail(parsed),
headerMessageId: parsed.messageId || String(uid),
subject: sanitizeString(parsed.subject || ''),
text,
receivedAt: parsed.date ? new Date(parsed.date) : null,
direction: computeMessageDirection(senderAddress, connectedAccount),
attachments: this.extractAttachments(parsed),
participants: this.extractParticipants(parsed),
attachments: (parsed.attachments || []).map((attachment) => ({
filename: attachment.filename || 'unnamed-attachment',
})),
participants: extractParticipantsFromParsedEmail(parsed),
messageFolderExternalIds: [folderExternalId],
};
}
private extractThreadId(parsed: ParsedMail): string {
if (Array.isArray(parsed.references) && parsed.references[0]?.trim()) {
return parsed.references[0].trim();
}
if (parsed.inReplyTo) {
const inReplyTo = String(parsed.inReplyTo).trim();
if (inReplyTo) {
return inReplyTo;
}
}
if (parsed.messageId?.trim()) {
return parsed.messageId.trim();
}
return `thread-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
private extractParticipants(parsed: ParsedMail) {
const addressFields = [
{ field: parsed.from, role: MessageParticipantRole.FROM },
{ field: parsed.to, role: MessageParticipantRole.TO },
{ field: parsed.cc, role: MessageParticipantRole.CC },
{ field: parsed.bcc, role: MessageParticipantRole.BCC },
] as const;
return addressFields.flatMap(({ field, role }) =>
formatAddressObjectAsParticipants(this.extractAddresses(field), role),
);
}
private extractAddresses(
address: Address | Address[] | undefined,
): EmailAddress[] {
if (!address) {
return [];
}
const addresses = Array.isArray(address) ? address : [address];
const mailboxes = addresses.flatMap((addr) =>
addr.address ? [addr] : (addr.group ?? []),
);
return mailboxes
.filter((mailbox) => mailbox.address)
.map((mailbox) => ({
address: mailbox.address,
name: sanitizeString(mailbox.name || ''),
}));
}
private extractAttachments(parsed: ParsedMail) {
return (parsed.attachments || []).map((attachment) => ({
filename: attachment.filename || 'unnamed-attachment',
}));
}
}
@@ -0,0 +1,6 @@
export const INBOUND_EMAIL_S3_PREFIXES = {
incoming: 'inbound-email/incoming/',
processed: 'inbound-email/processed/',
unmatched: 'inbound-email/unmatched/',
failed: 'inbound-email/failed/',
} as const;
@@ -0,0 +1 @@
export const X_TWENTY_ORIGIN_HEADER = 'x-twenty-origin';
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider';
import { InboundEmailParserService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service';
import { InboundEmailStorageService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service';
@Module({
imports: [
TwentyConfigModule,
WorkspaceDataSourceModule,
TypeOrmModule.forFeature([MessageChannelEntity, ConnectedAccountEntity]),
],
providers: [
InboundEmailS3ClientProvider,
InboundEmailStorageService,
InboundEmailParserService,
],
exports: [
InboundEmailS3ClientProvider,
InboundEmailStorageService,
InboundEmailParserService,
],
})
export class MessagingInboundEmailDriverModule {}
@@ -0,0 +1,82 @@
import { Injectable, Logger } from '@nestjs/common';
import { S3Client, type S3ClientConfig } from '@aws-sdk/client-s3';
import { isNonEmptyString } from '@sniptt/guards';
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class InboundEmailS3ClientProvider {
private readonly logger = new Logger(InboundEmailS3ClientProvider.name);
private s3Client: S3Client | null = null;
constructor(private readonly twentyConfigService: TwentyConfigService) {}
isConfigured(): boolean {
const storageType = this.twentyConfigService.get('STORAGE_TYPE');
const domain = this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN');
return storageType === StorageDriverType.S_3 && isNonEmptyString(domain);
}
getBucket(): string {
const bucket = this.twentyConfigService.get('STORAGE_S3_NAME');
if (!isNonEmptyString(bucket)) {
throw new Error(
'STORAGE_S3_NAME is not configured; email forwarding requires S3 storage.',
);
}
return bucket;
}
getDomain(): string {
const domain = this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN');
if (!isNonEmptyString(domain)) {
throw new Error(
'INBOUND_EMAIL_DOMAIN is not configured; email forwarding is disabled.',
);
}
return domain;
}
getClient(): S3Client {
if (this.s3Client) {
return this.s3Client;
}
const region = this.twentyConfigService.get('STORAGE_S3_REGION');
if (!isNonEmptyString(region)) {
throw new Error('STORAGE_S3_REGION must be set to use email forwarding.');
}
const config: S3ClientConfig = { region };
const endpoint = this.twentyConfigService.get('STORAGE_S3_ENDPOINT');
if (isNonEmptyString(endpoint)) {
config.endpoint = endpoint;
}
const accessKeyId = this.twentyConfigService.get(
'STORAGE_S3_ACCESS_KEY_ID',
);
const secretAccessKey = this.twentyConfigService.get(
'STORAGE_S3_SECRET_ACCESS_KEY',
);
if (isNonEmptyString(accessKeyId) && isNonEmptyString(secretAccessKey)) {
config.credentials = { accessKeyId, secretAccessKey };
}
this.s3Client = new S3Client(config);
this.logger.log(`Inbound-email S3 client initialized in region ${region}`);
return this.s3Client;
}
}
@@ -0,0 +1,229 @@
import { type Repository } from 'typeorm';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { type GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type InboundEmailParserService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service';
import { type InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider';
import { type InboundEmailStorageService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service';
import { InboundEmailImportService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service';
import { type InboundEmailImportOutcome } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/types/inbound-email-import-outcome.type';
import { type MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
import { MessageChannelType } from 'twenty-shared/types';
const TEST_S3_KEY = 'incoming/test-email-001';
const TEST_DOMAIN = 'in.twenty.com';
const TEST_WORKSPACE_ID = 'ws-123';
const TEST_CHANNEL_ID = 'ch-456';
const TEST_CONNECTED_ACCOUNT_ID = 'ca-789';
const TEST_HANDLE = `ch_abc123@${TEST_DOMAIN}`;
const buildMockChannel = (): Partial<MessageChannelEntity> => ({
id: TEST_CHANNEL_ID,
handle: TEST_HANDLE,
type: MessageChannelType.EMAIL_FORWARDING,
workspaceId: TEST_WORKSPACE_ID,
connectedAccountId: TEST_CONNECTED_ACCOUNT_ID,
});
const buildMockConnectedAccount = (): Partial<ConnectedAccountEntity> => ({
id: TEST_CONNECTED_ACCOUNT_ID,
workspaceId: TEST_WORKSPACE_ID,
});
const buildMockParsedResult = (
overrides: { originWorkspaceId?: string | null } = {},
) => ({
parsed: {
headers: [
{
key: 'delivered-to',
originalKey: 'Delivered-To',
value: TEST_HANDLE,
},
],
to: [{ address: TEST_HANDLE, name: '' }],
from: { address: 'sender@example.com', name: 'Sender' },
subject: 'Test',
text: 'body',
messageId: '<test@example.com>',
date: new Date().toISOString(),
cc: [],
bcc: [],
html: '',
attachments: [],
},
originWorkspaceId: overrides.originWorkspaceId ?? null,
message: {
externalId: `inbound-email:${TEST_S3_KEY}`,
messageThreadExternalId: '<test@example.com>',
headerMessageId: '<test@example.com>',
subject: 'Test',
text: 'body',
receivedAt: new Date(),
direction: 'incoming',
attachments: [],
participants: [],
},
});
describe('InboundEmailImportService', () => {
let service: InboundEmailImportService;
let s3ClientProvider: jest.Mocked<InboundEmailS3ClientProvider>;
let storageService: jest.Mocked<InboundEmailStorageService>;
let parserService: jest.Mocked<InboundEmailParserService>;
let globalWorkspaceOrmManager: jest.Mocked<GlobalWorkspaceOrmManager>;
let saveMessagesService: jest.Mocked<MessagingSaveMessagesAndEnqueueContactCreationService>;
let messageChannelRepo: jest.Mocked<Repository<MessageChannelEntity>>;
let connectedAccountRepo: jest.Mocked<Repository<ConnectedAccountEntity>>;
beforeEach(() => {
s3ClientProvider = {
isConfigured: jest.fn().mockReturnValue(true),
getDomain: jest.fn().mockReturnValue(TEST_DOMAIN),
getBucket: jest.fn(),
getClient: jest.fn(),
} as unknown as jest.Mocked<InboundEmailS3ClientProvider>;
storageService = {
getRawMessage: jest.fn().mockResolvedValue(Buffer.from('raw-email')),
moveToProcessed: jest.fn().mockResolvedValue(undefined),
moveToUnmatched: jest.fn().mockResolvedValue(undefined),
moveToFailed: jest.fn().mockResolvedValue(undefined),
listIncoming: jest.fn(),
} as unknown as jest.Mocked<InboundEmailStorageService>;
parserService = {
parse: jest.fn().mockResolvedValue(buildMockParsedResult()),
} as unknown as jest.Mocked<InboundEmailParserService>;
globalWorkspaceOrmManager = {
executeInWorkspaceContext: jest
.fn()
.mockImplementation(async (fn: () => Promise<void>) => fn()),
} as unknown as jest.Mocked<GlobalWorkspaceOrmManager>;
saveMessagesService = {
saveMessagesAndEnqueueContactCreation: jest
.fn()
.mockResolvedValue(undefined),
} as unknown as jest.Mocked<MessagingSaveMessagesAndEnqueueContactCreationService>;
messageChannelRepo = {
findOne: jest.fn().mockResolvedValue(buildMockChannel()),
} as unknown as jest.Mocked<Repository<MessageChannelEntity>>;
connectedAccountRepo = {
findOne: jest.fn().mockResolvedValue(buildMockConnectedAccount()),
} as unknown as jest.Mocked<Repository<ConnectedAccountEntity>>;
service = new InboundEmailImportService(
s3ClientProvider,
storageService,
parserService,
globalWorkspaceOrmManager,
saveMessagesService,
messageChannelRepo,
connectedAccountRepo,
);
});
it('should return "imported" when everything succeeds', async () => {
const outcome: InboundEmailImportOutcome =
await service.importFromS3Key(TEST_S3_KEY);
expect(outcome).toEqual({
kind: 'imported',
workspaceId: TEST_WORKSPACE_ID,
messageChannelId: TEST_CHANNEL_ID,
});
expect(storageService.moveToProcessed).toHaveBeenCalledWith(TEST_S3_KEY);
expect(
saveMessagesService.saveMessagesAndEnqueueContactCreation,
).toHaveBeenCalled();
});
it('should return "unconfigured" when S3 is not configured', async () => {
s3ClientProvider.isConfigured.mockReturnValue(false);
const outcome = await service.importFromS3Key(TEST_S3_KEY);
expect(outcome).toEqual({ kind: 'unconfigured' });
expect(storageService.getRawMessage).not.toHaveBeenCalled();
});
it('should return "unmatched" when no channel matches the recipient', async () => {
messageChannelRepo.findOne.mockResolvedValue(null);
const outcome = await service.importFromS3Key(TEST_S3_KEY);
expect(outcome.kind).toBe('unmatched');
expect(storageService.moveToUnmatched).toHaveBeenCalledWith(TEST_S3_KEY);
});
it('should return "loop_dropped" when X-Twenty-Origin matches workspace', async () => {
parserService.parse.mockResolvedValue(
buildMockParsedResult({ originWorkspaceId: TEST_WORKSPACE_ID }) as never,
);
const outcome = await service.importFromS3Key(TEST_S3_KEY);
expect(outcome).toEqual({
kind: 'loop_dropped',
workspaceId: TEST_WORKSPACE_ID,
});
expect(storageService.moveToProcessed).toHaveBeenCalledWith(TEST_S3_KEY);
});
it('should return "parse_failed" and move to failed/ when download fails', async () => {
storageService.getRawMessage.mockRejectedValue(
new Error('S3 download error'),
);
const outcome = await service.importFromS3Key(TEST_S3_KEY);
expect(outcome).toEqual({
kind: 'parse_failed',
error: 'S3 download error',
});
expect(storageService.moveToFailed).toHaveBeenCalledWith(TEST_S3_KEY);
});
it('should return "parse_failed" when parsing fails', async () => {
parserService.parse.mockRejectedValue(new Error('Invalid MIME'));
const outcome = await service.importFromS3Key(TEST_S3_KEY);
expect(outcome).toEqual({
kind: 'parse_failed',
error: 'Invalid MIME',
});
expect(storageService.moveToFailed).toHaveBeenCalledWith(TEST_S3_KEY);
});
it('should return "persist_failed" when save throws', async () => {
saveMessagesService.saveMessagesAndEnqueueContactCreation.mockRejectedValue(
new Error('DB error'),
);
const outcome = await service.importFromS3Key(TEST_S3_KEY);
expect(outcome).toEqual({
kind: 'persist_failed',
error: 'DB error',
});
expect(storageService.moveToFailed).toHaveBeenCalledWith(TEST_S3_KEY);
});
it('should return "persist_failed" when connected account is missing', async () => {
connectedAccountRepo.findOne.mockResolvedValue(null);
const outcome = await service.importFromS3Key(TEST_S3_KEY);
expect(outcome).toEqual({
kind: 'persist_failed',
error: 'connected_account_missing',
});
expect(storageService.moveToFailed).toHaveBeenCalledWith(TEST_S3_KEY);
});
});
@@ -0,0 +1,189 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { MessageChannelType } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { InboundEmailParserService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service';
import { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider';
import { InboundEmailStorageService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service';
import { type InboundEmailImportOutcome } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/types/inbound-email-import-outcome.type';
import { extractEnvelopeRecipient } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/utils/extract-envelope-recipient.util';
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
@Injectable()
export class InboundEmailImportService {
private readonly logger = new Logger(InboundEmailImportService.name);
constructor(
private readonly inboundEmailS3ClientProvider: InboundEmailS3ClientProvider,
private readonly inboundEmailStorageService: InboundEmailStorageService,
private readonly inboundEmailParserService: InboundEmailParserService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly messagingSaveMessagesAndEnqueueContactCreationService: MessagingSaveMessagesAndEnqueueContactCreationService,
@InjectRepository(MessageChannelEntity)
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
) {}
async importFromS3Key(s3Key: string): Promise<InboundEmailImportOutcome> {
if (!this.inboundEmailS3ClientProvider.isConfigured()) {
this.logger.warn(
`Skipping inbound email import for ${s3Key}: forwarding is not configured.`,
);
return { kind: 'unconfigured' };
}
const inboundDomain = this.inboundEmailS3ClientProvider.getDomain();
let rawMessage: Buffer;
try {
rawMessage = await this.inboundEmailStorageService.getRawMessage(s3Key);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(
`Failed to download inbound email from S3 key ${s3Key}: ${message}`,
);
await this.safeMove(s3Key, 'failed');
return { kind: 'parse_failed', error: message };
}
let parsedInbound;
try {
parsedInbound = await this.inboundEmailParserService.parse(
rawMessage,
s3Key,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to parse inbound email ${s3Key}: ${message}`);
await this.safeMove(s3Key, 'failed');
return { kind: 'parse_failed', error: message };
}
const recipient = extractEnvelopeRecipient(
parsedInbound.parsed,
inboundDomain,
);
if (!recipient) {
this.logger.warn(
`No recipient at ${inboundDomain} found for inbound email ${s3Key}`,
);
await this.safeMove(s3Key, 'unmatched');
return { kind: 'unmatched', recipient: null };
}
const messageChannel = await this.messageChannelRepository.findOne({
where: {
handle: recipient,
type: MessageChannelType.EMAIL_FORWARDING,
},
});
if (!messageChannel) {
this.logger.warn(
`No forwarding channel matches recipient ${recipient} (key ${s3Key})`,
);
await this.safeMove(s3Key, 'unmatched');
return { kind: 'unmatched', recipient };
}
const workspaceId = messageChannel.workspaceId;
if (
parsedInbound.originWorkspaceId &&
parsedInbound.originWorkspaceId === workspaceId
) {
this.logger.log(
`Dropping loopback email ${s3Key} for workspace ${workspaceId}`,
);
await this.safeMove(s3Key, 'processed');
return { kind: 'loop_dropped', workspaceId };
}
const connectedAccount = await this.connectedAccountRepository.findOne({
where: {
id: messageChannel.connectedAccountId,
workspaceId,
},
});
if (!connectedAccount) {
this.logger.error(
`Forwarding channel ${messageChannel.id} has no connected account`,
);
await this.safeMove(s3Key, 'failed');
return { kind: 'persist_failed', error: 'connected_account_missing' };
}
const authContext = buildSystemAuthContext(workspaceId);
try {
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
await this.messagingSaveMessagesAndEnqueueContactCreationService.saveMessagesAndEnqueueContactCreation(
[parsedInbound.message],
messageChannel,
connectedAccount,
workspaceId,
);
},
authContext,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(
`Failed to persist inbound email ${s3Key} for workspace ${workspaceId}: ${message}`,
);
await this.safeMove(s3Key, 'failed');
return { kind: 'persist_failed', error: message };
}
await this.safeMove(s3Key, 'processed');
return {
kind: 'imported',
workspaceId,
messageChannelId: messageChannel.id,
};
}
private async safeMove(
s3Key: string,
destination: 'processed' | 'unmatched' | 'failed',
): Promise<void> {
try {
if (destination === 'processed') {
await this.inboundEmailStorageService.moveToProcessed(s3Key);
} else if (destination === 'unmatched') {
await this.inboundEmailStorageService.moveToUnmatched(s3Key);
} else {
await this.inboundEmailStorageService.moveToFailed(s3Key);
}
} catch (error) {
this.logger.error(
`Failed to archive inbound email ${s3Key} to ${destination}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
}
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import PostalMime, { type Email as ParsedEmail } from 'postal-mime';
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
import { X_TWENTY_ORIGIN_HEADER } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/x-twenty-origin-header.constant';
import { type ParsedInboundMessage } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/types/parsed-inbound-message.type';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
import { extractParticipantsFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util';
import { extractThreadIdFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-thread-id-from-parsed-email.util';
import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util';
@Injectable()
export class InboundEmailParserService {
async parse(
rawMessage: Buffer,
s3Key: string,
): Promise<ParsedInboundMessage> {
const parsed = await PostalMime.parse(rawMessage);
const originWorkspaceId = this.extractOriginWorkspaceId(parsed);
const message = this.buildMessage(parsed, s3Key);
return { parsed, originWorkspaceId, message };
}
private extractOriginWorkspaceId(parsed: ParsedEmail): string | null {
const header = parsed.headers?.find(
(h) => h.key?.toLowerCase() === X_TWENTY_ORIGIN_HEADER,
);
if (!header?.value) {
return null;
}
return header.value.trim();
}
private buildMessage(
parsed: ParsedEmail,
s3Key: string,
): MessageWithParticipants {
return {
externalId: `inbound-email:${s3Key}`,
messageThreadExternalId: extractThreadIdFromParsedEmail(parsed),
headerMessageId: parsed.messageId?.trim() || `inbound-${s3Key}`,
subject: sanitizeString(parsed.subject || ''),
text: sanitizeString(parsed.text || ''),
receivedAt: parsed.date ? new Date(parsed.date) : new Date(),
direction: MessageDirection.INCOMING,
attachments: [],
participants: extractParticipantsFromParsedEmail(parsed),
};
}
}
@@ -0,0 +1,120 @@
import { Injectable, Logger } from '@nestjs/common';
import {
CopyObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
} from '@aws-sdk/client-s3';
import { Readable } from 'stream';
import { INBOUND_EMAIL_S3_PREFIXES } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email-s3-prefixes.constant';
import { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider';
@Injectable()
export class InboundEmailStorageService {
private readonly logger = new Logger(InboundEmailStorageService.name);
constructor(
private readonly inboundEmailS3ClientProvider: InboundEmailS3ClientProvider,
) {}
async listIncoming(maxKeys: number): Promise<string[]> {
const client = this.inboundEmailS3ClientProvider.getClient();
const Bucket = this.inboundEmailS3ClientProvider.getBucket();
const response = await client.send(
new ListObjectsV2Command({
Bucket,
Prefix: INBOUND_EMAIL_S3_PREFIXES.incoming,
MaxKeys: maxKeys,
}),
);
const keys = (response.Contents ?? [])
.map((object) => object.Key)
.filter((key): key is string => typeof key === 'string')
.filter((key) => key !== INBOUND_EMAIL_S3_PREFIXES.incoming);
return keys;
}
async getRawMessage(key: string): Promise<Buffer> {
const client = this.inboundEmailS3ClientProvider.getClient();
const Bucket = this.inboundEmailS3ClientProvider.getBucket();
const response = await client.send(
new GetObjectCommand({ Bucket, Key: key }),
);
if (!response.Body) {
throw new Error(`S3 object ${key} has no body`);
}
const stream = response.Body as Readable;
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async moveToProcessed(key: string): Promise<void> {
await this.moveToArchive(key, INBOUND_EMAIL_S3_PREFIXES.processed);
}
async moveToUnmatched(key: string): Promise<void> {
await this.moveToArchive(key, INBOUND_EMAIL_S3_PREFIXES.unmatched);
}
async moveToFailed(key: string): Promise<void> {
await this.moveToArchive(key, INBOUND_EMAIL_S3_PREFIXES.failed);
}
private async moveToArchive(
key: string,
destinationPrefix: string,
): Promise<void> {
const client = this.inboundEmailS3ClientProvider.getClient();
const Bucket = this.inboundEmailS3ClientProvider.getBucket();
const destinationKey = this.buildArchiveKey(key, destinationPrefix);
try {
await client.send(
new CopyObjectCommand({
Bucket,
CopySource: `${Bucket}/${encodeURIComponent(key)}`,
Key: destinationKey,
}),
);
await client.send(new DeleteObjectCommand({ Bucket, Key: key }));
} catch (error) {
this.logger.error(
`Failed to move S3 key ${key} to ${destinationKey}: ${
error instanceof Error ? error.message : String(error)
}`,
);
throw error;
}
}
private buildArchiveKey(
sourceKey: string,
destinationPrefix: string,
): string {
const bareKey = sourceKey.startsWith(INBOUND_EMAIL_S3_PREFIXES.incoming)
? sourceKey.slice(INBOUND_EMAIL_S3_PREFIXES.incoming.length)
: sourceKey;
const now = new Date();
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const day = String(now.getUTCDate()).padStart(2, '0');
return `${destinationPrefix}${year}-${month}-${day}/${bareKey}`;
}
}
@@ -0,0 +1,7 @@
export type InboundEmailImportOutcome =
| { kind: 'imported'; workspaceId: string; messageChannelId: string }
| { kind: 'unmatched'; recipient: string | null }
| { kind: 'loop_dropped'; workspaceId: string }
| { kind: 'unconfigured' }
| { kind: 'parse_failed'; error: string }
| { kind: 'persist_failed'; error: string };
@@ -0,0 +1,9 @@
import { type Email as ParsedEmail } from 'postal-mime';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
export type ParsedInboundMessage = {
parsed: ParsedEmail;
originWorkspaceId: string | null;
message: MessageWithParticipants;
};
@@ -0,0 +1,128 @@
import { type Email as ParsedEmail, type Header } from 'postal-mime';
import { extractEnvelopeRecipient } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/utils/extract-envelope-recipient.util';
const INBOUND_DOMAIN = 'in.twenty.com';
const header = (key: string, value: string): Header => ({
key: key.toLowerCase(),
originalKey: key,
value,
});
const buildParsedEmail = (
overrides: Partial<ParsedEmail> = {},
): ParsedEmail => ({
headers: [],
subject: 'test',
from: { address: 'sender@example.com', name: 'Sender' },
to: [],
cc: [],
bcc: [],
date: new Date().toISOString(),
messageId: '<test@example.com>',
html: '',
text: 'test body',
attachments: [],
...overrides,
});
describe('extractEnvelopeRecipient', () => {
it('should extract recipient from Received header "for <addr>" clause', () => {
const parsed = buildParsedEmail({
headers: [
header(
'Received',
'from mx.google.com by 10.0.0.1 for <ch_abc123@in.twenty.com>; Mon, 01 Jan 2024 00:00:00 +0000',
),
],
});
expect(extractEnvelopeRecipient(parsed, INBOUND_DOMAIN)).toBe(
'ch_abc123@in.twenty.com',
);
});
it('should extract from Delivered-To header', () => {
const parsed = buildParsedEmail({
headers: [header('Delivered-To', 'ch_def456@in.twenty.com')],
});
expect(extractEnvelopeRecipient(parsed, INBOUND_DOMAIN)).toBe(
'ch_def456@in.twenty.com',
);
});
it('should extract from X-Original-To header', () => {
const parsed = buildParsedEmail({
headers: [header('X-Original-To', 'ch_ghi789@in.twenty.com')],
});
expect(extractEnvelopeRecipient(parsed, INBOUND_DOMAIN)).toBe(
'ch_ghi789@in.twenty.com',
);
});
it('should fall back to To field when no delivery headers match', () => {
const parsed = buildParsedEmail({
to: [{ address: 'ch_tofield@in.twenty.com', name: 'Channel' }],
});
expect(extractEnvelopeRecipient(parsed, INBOUND_DOMAIN)).toBe(
'ch_tofield@in.twenty.com',
);
});
it('should fall back to Cc field', () => {
const parsed = buildParsedEmail({
to: [{ address: 'someone@other.com', name: '' }],
cc: [{ address: 'ch_ccfield@in.twenty.com', name: '' }],
});
expect(extractEnvelopeRecipient(parsed, INBOUND_DOMAIN)).toBe(
'ch_ccfield@in.twenty.com',
);
});
it('should return null when no address matches the inbound domain', () => {
const parsed = buildParsedEmail({
to: [{ address: 'user@example.com', name: '' }],
headers: [header('Delivered-To', 'user@other-domain.com')],
});
expect(extractEnvelopeRecipient(parsed, INBOUND_DOMAIN)).toBeNull();
});
it('should be case-insensitive on domain match', () => {
const parsed = buildParsedEmail({
headers: [header('Delivered-To', 'ch_upper@IN.TWENTY.COM')],
});
expect(extractEnvelopeRecipient(parsed, INBOUND_DOMAIN)).toBe(
'ch_upper@in.twenty.com',
);
});
it('should prefer Received header over Delivered-To', () => {
const parsed = buildParsedEmail({
headers: [
header('Received', 'from mx for <ch_received@in.twenty.com>; date'),
header('Delivered-To', 'ch_deliveredto@in.twenty.com'),
],
});
expect(extractEnvelopeRecipient(parsed, INBOUND_DOMAIN)).toBe(
'ch_received@in.twenty.com',
);
});
it('should handle empty headers gracefully', () => {
const parsed = buildParsedEmail({
headers: [],
to: [],
cc: [],
});
expect(extractEnvelopeRecipient(parsed, INBOUND_DOMAIN)).toBeNull();
});
});
@@ -0,0 +1,127 @@
import { type Email as ParsedEmail } from 'postal-mime';
// SES inbound action writes the raw MIME to S3 without preserving the SMTP
// envelope ("RCPT TO"). We recover the envelope recipient by walking the
// headers in priority order: delivery headers first (most reliable), then
// To/Cc as a fallback. The first address whose domain matches our inbound
// domain wins, which is what we use to look up the forwarding channel.
type HeaderLike =
| string
| string[]
| { key?: string; value?: string }[]
| undefined;
const EMAIL_REGEX = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+/g;
const extractEmailsFromText = (text: string): string[] => {
const matches = text.match(EMAIL_REGEX);
return matches ? matches.map((email) => email.toLowerCase()) : [];
};
const headerValues = (
headers: ParsedEmail['headers'] | undefined,
name: string,
): string[] => {
if (!headers) {
return [];
}
const target = name.toLowerCase();
return headers
.filter((header) => header.key?.toLowerCase() === target)
.map((header) => header.value ?? '');
};
const addressesFromParsedField = (
field: ParsedEmail['to'] | ParsedEmail['cc'],
): string[] => {
if (!field) {
return [];
}
const list = Array.isArray(field) ? field : [field];
return list
.flatMap((entry) =>
entry.address
? [entry.address]
: (entry.group ?? []).map((g) => g.address ?? ''),
)
.filter((address) => address.length > 0)
.map((address) => address.toLowerCase());
};
export const extractEnvelopeRecipient = (
parsed: ParsedEmail,
inboundDomain: string,
rawHeaders?: HeaderLike,
): string | null => {
const domain = inboundDomain.toLowerCase();
const isMatch = (address: string): boolean => address.endsWith(`@${domain}`);
// SES adds "X-Original-To" and "Delivered-To" on many flows; Received
// headers include "for <addr>" which is the closest thing to the envelope
// recipient that survives MIME parsing.
const receivedValues = headerValues(parsed.headers, 'received');
for (const received of receivedValues) {
const forMatch = received.match(/\bfor\s+<?([^\s>;]+)>?/i);
if (forMatch) {
const candidate = forMatch[1].toLowerCase();
if (isMatch(candidate)) {
return candidate;
}
}
}
const deliveryHeaders = [
...headerValues(parsed.headers, 'delivered-to'),
...headerValues(parsed.headers, 'x-original-to'),
...headerValues(parsed.headers, 'x-forwarded-to'),
...headerValues(parsed.headers, 'x-envelope-to'),
];
for (const raw of deliveryHeaders) {
for (const candidate of extractEmailsFromText(raw)) {
if (isMatch(candidate)) {
return candidate;
}
}
}
const toCandidates = [
...addressesFromParsedField(parsed.to),
...addressesFromParsedField(parsed.cc),
];
for (const candidate of toCandidates) {
if (isMatch(candidate)) {
return candidate;
}
}
// rawHeaders is a last-ditch escape hatch for callers that have the raw
// message available and want to scan any arbitrary header line.
if (rawHeaders) {
const flat = Array.isArray(rawHeaders)
? rawHeaders
.map((h) => (typeof h === 'string' ? h : (h?.value ?? '')))
.join('\n')
: rawHeaders;
if (typeof flat === 'string') {
for (const candidate of extractEmailsFromText(flat)) {
if (isMatch(candidate)) {
return candidate;
}
}
}
}
return null;
};
@@ -0,0 +1,3 @@
export type MessagingInboundEmailImportJobData = {
s3Key: string;
};
@@ -0,0 +1,22 @@
import { Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { InboundEmailImportService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service';
import { type MessagingInboundEmailImportJobData } from 'src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import-job-data.type';
@Processor({
queueName: MessageQueue.messagingQueue,
scope: Scope.REQUEST,
})
export class MessagingInboundEmailImportJob {
constructor(
private readonly inboundEmailImportService: InboundEmailImportService,
) {}
@Process(MessagingInboundEmailImportJob.name)
async handle(data: MessagingInboundEmailImportJobData): Promise<void> {
await this.inboundEmailImportService.importFromS3Key(data.s3Key);
}
}
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
@@ -19,20 +21,25 @@ import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cle
import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module';
import { MessagingSingleMessageImportCommand } from 'src/modules/messaging/message-import-manager/commands/messaging-single-message-import.command';
import { MessagingTriggerMessageListFetchCommand } from 'src/modules/messaging/message-import-manager/commands/messaging-trigger-message-list-fetch.command';
import { MessagingInboundEmailPollCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-inbound-email-poll.cron.command';
import { MessagingMessageListFetchCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-message-list-fetch.cron.command';
import { MessagingMessagesImportCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-messages-import.cron.command';
import { MessagingOngoingStaleCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-ongoing-stale.cron.command';
import { MessagingRelaunchFailedMessageChannelsCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-relaunch-failed-message-channels.cron.command';
import { MessagingInboundEmailPollCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-inbound-email-poll.cron.job';
import { MessagingMessageListFetchCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-message-list-fetch.cron.job';
import { MessagingMessagesImportCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-messages-import.cron.job';
import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-ongoing-stale.cron.job';
import { MessagingRelaunchFailedMessageChannelsCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job';
import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module';
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
import { MessagingInboundEmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/messaging-inbound-email-driver.module';
import { InboundEmailImportService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service';
import { MessagingMicrosoftDriverModule } from 'src/modules/messaging/message-import-manager/drivers/microsoft/messaging-microsoft-driver.module';
import { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module';
import { MessagingAddSingleMessageToCacheForImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-add-single-message-to-cache-for-import.job';
import { MessagingCleanCacheJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-clean-cache';
import { MessagingInboundEmailImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import.job';
import { MessagingMessageListFetchJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
import { MessagingMessagesImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-messages-import.job';
import { MessagingOngoingStaleJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-ongoing-stale.job';
@@ -62,7 +69,9 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingMicrosoftDriverModule,
MessagingIMAPDriverModule,
MessagingSmtpDriverModule,
MessagingInboundEmailDriverModule,
MessagingCommonModule,
TwentyConfigModule,
TypeOrmModule.forFeature([
WorkspaceEntity,
DataSourceEntity,
@@ -70,6 +79,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessageChannelEntity,
MessageFolderEntity,
UserWorkspaceEntity,
ConnectedAccountEntity,
]),
EmailAliasManagerModule,
FeatureFlagModule,
@@ -85,16 +95,19 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingMessagesImportCronCommand,
MessagingOngoingStaleCronCommand,
MessagingRelaunchFailedMessageChannelsCronCommand,
MessagingInboundEmailPollCronCommand,
MessagingSingleMessageImportCommand,
MessagingTriggerMessageListFetchCommand,
MessagingMessageListFetchJob,
MessagingMessagesImportJob,
MessagingOngoingStaleJob,
MessagingRelaunchFailedMessageChannelJob,
MessagingInboundEmailImportJob,
MessagingMessageListFetchCronJob,
MessagingMessagesImportCronJob,
MessagingOngoingStaleCronJob,
MessagingRelaunchFailedMessageChannelsCronJob,
MessagingInboundEmailPollCronJob,
MessagingAddSingleMessageToCacheForImportJob,
MessagingCleanCacheJob,
MessagingMessageService,
@@ -111,6 +124,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingProcessGroupEmailActionsService,
MessagingDeleteFolderMessagesService,
MessagingDeleteGroupEmailMessagesService,
InboundEmailImportService,
],
exports: [
MessagingAccountAuthenticationService,
@@ -118,7 +132,9 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingMessagesImportCronCommand,
MessagingOngoingStaleCronCommand,
MessagingRelaunchFailedMessageChannelsCronCommand,
MessagingInboundEmailPollCronCommand,
MessagingProcessGroupEmailActionsService,
InboundEmailImportService,
],
})
export class MessagingImportManagerModule {}
@@ -0,0 +1,25 @@
import { type Address } from 'postal-mime';
import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address';
import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util';
export const extractAddressesFromParsedEmail = (
address: Address | Address[] | undefined,
): EmailAddress[] => {
if (!address) {
return [];
}
const addresses = Array.isArray(address) ? address : [address];
const mailboxes = addresses.flatMap((addr) =>
addr.address ? [addr] : (addr.group ?? []),
);
return mailboxes
.filter((mailbox) => mailbox.address)
.map((mailbox) => ({
address: mailbox.address,
name: sanitizeString(mailbox.name || ''),
}));
};
@@ -0,0 +1,21 @@
import { type Email as ParsedEmail } from 'postal-mime';
import { MessageParticipantRole } from 'twenty-shared/types';
import { extractAddressesFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util';
import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util';
export const extractParticipantsFromParsedEmail = (parsed: ParsedEmail) => {
const addressFields = [
{ field: parsed.from, role: MessageParticipantRole.FROM },
{ field: parsed.to, role: MessageParticipantRole.TO },
{ field: parsed.cc, role: MessageParticipantRole.CC },
{ field: parsed.bcc, role: MessageParticipantRole.BCC },
] as const;
return addressFields.flatMap(({ field, role }) =>
formatAddressObjectAsParticipants(
extractAddressesFromParsedEmail(field),
role,
),
);
};
@@ -0,0 +1,35 @@
import { type Email as ParsedEmail } from 'postal-mime';
export const extractThreadIdFromParsedEmail = (parsed: ParsedEmail): string => {
const references = parsed.references;
if (typeof references === 'string' && references.trim()) {
const first = references.trim().split(/\s+/)[0];
if (first) {
return first;
}
}
if (Array.isArray(references) && references.length > 0) {
const first = String(references[0]).trim();
if (first) {
return first;
}
}
if (parsed.inReplyTo) {
const inReplyTo = String(parsed.inReplyTo).trim();
if (inReplyTo) {
return inReplyTo;
}
}
if (parsed.messageId?.trim()) {
return parsed.messageId.trim();
}
return `thread-${crypto.randomUUID()}`;
};
@@ -38,6 +38,12 @@ export class MessagingMessageOutboundService {
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.EMAIL_FORWARDING:
// Forwarding channels are inbound-only: replies should go through the
// user's own Gmail/Outlook/IMAP account to avoid masking the sender.
throw new Error(
'Email forwarding channels are inbound-only; reply using your personal account.',
);
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
throw new Error(
@@ -71,6 +77,7 @@ export class MessagingMessageOutboundService {
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.EMAIL_FORWARDING:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
throw new Error(
@@ -26,6 +26,7 @@ export class SendEmailService {
attachments: data.attachments,
inReplyTo: data.inReplyTo,
threadExternalId: data.threadExternalId,
originWorkspaceId: data.connectedAccount.workspaceId,
},
data.connectedAccount,
);
@@ -14,4 +14,7 @@ export type SendMessageInput = {
}[];
inReplyTo?: string;
threadExternalId?: string;
// When set, an X-Twenty-Origin header is stamped on the outbound message.
// Used by the inbound-email forwarding pipeline to drop group echoes.
originWorkspaceId?: string;
};
@@ -1,9 +1,19 @@
import { X_TWENTY_ORIGIN_HEADER } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/x-twenty-origin-header.constant';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
export const toMailComposerOptions = (
from: string,
sendMessageInput: SendMessageInput,
) => {
// Stamp outbound mail with the originating workspace id so the inbound
// forwarding pipeline can drop loopbacks when a reply hits the group
// address that the forwarding channel is a member of.
const headers: Record<string, string> = {};
if (sendMessageInput.originWorkspaceId) {
headers[X_TWENTY_ORIGIN_HEADER] = sendMessageInput.originWorkspaceId;
}
return {
from,
to: sendMessageInput.to,
@@ -12,6 +22,7 @@ export const toMailComposerOptions = (
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(sendMessageInput.attachments && sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
@@ -4,4 +4,5 @@ export enum ConnectedAccountProvider {
IMAP_SMTP_CALDAV = 'imap_smtp_caldav',
OIDC = 'oidc',
SAML = 'saml',
EMAIL_FORWARDING = 'email_forwarding',
}
@@ -1,4 +1,5 @@
export enum MessageChannelType {
EMAIL = 'EMAIL',
SMS = 'SMS',
EMAIL_FORWARDING = 'EMAIL_FORWARDING',
}