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
This commit is contained in:
Claude
2026-04-09 09:51:40 +00:00
parent ffb6029c2d
commit b36859055b
36 changed files with 1661 additions and 29 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(
@@ -0,0 +1,123 @@
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
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';
export const EMAIL_FORWARDING_MODAL_ID = 'email-forwarding-address-modal';
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;
};
export const SettingsAccountsEmailForwardingModal = ({
forwardingAddress,
}: 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)}
/>
</StyledButtonContainer>
</StyledModalContent>
</ModalStatefulWrapper>
);
};
@@ -3,16 +3,23 @@ import { isGoogleMessagingEnabledState } from '@/client-config/states/isGoogleMe
import { isImapSmtpCaldavEnabledState } from '@/client-config/states/isImapSmtpCaldavEnabledState';
import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicrosoftCalendarEnabledState';
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
import {
EMAIL_FORWARDING_MODAL_ID,
SettingsAccountsEmailForwardingModal,
} from '@/settings/accounts/components/SettingsAccountsEmailForwardingModal';
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 +33,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 +60,68 @@ export const SettingsAccountsListEmptyStateCard = () => {
isImapSmtpCaldavEnabledState,
);
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 Account`}
/>
</UndecoratedLink>
)}
<SettingsCard
Icon={<IconMail size={theme.icon.size.md} />}
title={t`Add Email Forwarding`}
description={t`Receive emails from a shared mailbox or Google Group`}
disabled={isCreatingForwarding}
onClick={handleCreateEmailForwardingChannel}
/>
</StyledCardsContainer>
{forwardingAddress && (
<SettingsAccountsEmailForwardingModal
forwardingAddress={forwardingAddress}
/>
)}
</StyledCardsContainer>
</>
);
};
@@ -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,27 @@
import { useMutation } from '@apollo/client/react';
import { CREATE_EMAIL_FORWARDING_CHANNEL } from '@/settings/accounts/graphql/mutations/createEmailForwardingChannel';
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,
);
return { createEmailForwardingChannel, loading, error };
};
@@ -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: {
@@ -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}`,
@@ -1622,6 +1622,43 @@ 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:
'S3 bucket where SES inbound action writes raw messages. Required to enable email forwarding channels.',
type: ConfigVariableType.STRING,
})
@IsOptional()
INBOUND_EMAIL_S3_BUCKET: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AWS_SES_SETTINGS,
description:
'Region for the inbound-email S3 bucket. Defaults to AWS_SES_REGION when unset.',
type: ConfigVariableType.STRING,
})
@IsOptional()
INBOUND_EMAIL_S3_REGION: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AWS_SES_SETTINGS,
description:
'Maximum number of S3 keys listed per poll of the inbound-email bucket.',
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,
MessageChannelType,
MessageChannelVisibility,
} from 'twenty-shared/types';
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,
INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES,
} from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email.constants';
@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,60 @@ 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',
);
if (!isNonEmptyString(inboundEmailDomain)) {
throw new MessageChannelException(
'Email forwarding is not configured: INBOUND_EMAIL_DOMAIN is not set',
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.PENDING_CONFIGURATION,
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,
});
}
}
@@ -23,6 +23,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 UserInputError(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,
@@ -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,69 @@
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 {
MessagingInboundEmailImportJob,
type MessagingInboundEmailImportJobData,
} from 'src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import.job';
// BullMQ cron patterns are minute-based at the finest; every minute is
// fine for a forwarding inbox since SES writes are eventually consistent
// anyway. Single-leader election comes from BullMQ's repeatable-job dedupe,
// so multiple workers can run this cron safely.
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 },
);
}
} catch (error) {
this.exceptionHandlerService.captureExceptions([error]);
}
}
}
@@ -3,7 +3,7 @@ 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';
@@ -47,9 +47,12 @@ export class MessagingMessageListFetchCronJob {
try {
const now = new Date().toISOString();
// Email forwarding channels are driven by the S3 poll cron, not by
// this "fetch from provider" path — skip them explicitly so their
// syncStage never transitions into MESSAGE_LIST_FETCH_SCHEDULED.
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) {
@@ -0,0 +1,20 @@
// S3 key prefixes used to shard incoming/processed/unmatched/failed mail.
// The bucket itself IS the work queue: presence of a key in "incoming/"
// means the object is pending import. Moving an object to any other prefix
// removes it from the queue, which gives us at-least-once semantics without
// needing a separate dedupe store.
export const INBOUND_EMAIL_S3_PREFIXES = {
incoming: 'incoming/',
processed: 'processed/',
unmatched: 'unmatched/',
failed: 'failed/',
} as const;
// Custom header stamped on outbound mail so we can drop echoes when group
// replies hit the inbound pipeline. The value is the workspace id that sent
// the message, which lets us scope the loop check to a single workspace.
export const X_TWENTY_ORIGIN_HEADER = 'x-twenty-origin';
// Local part prefix for forwarding-channel addresses: "ch_" + 24 hex chars.
export const INBOUND_EMAIL_LOCAL_PART_PREFIX = 'ch_';
export const INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES = 12;
@@ -0,0 +1,33 @@
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';
// Thin driver module that provides the low-level primitives for inbound
// email: S3 client, storage (list/get/move), and MIME parser. The import
// orchestration service lives in MessagingImportManagerModule so it can
// reuse the save-messages pipeline without a circular import.
@Module({
imports: [
TwentyConfigModule,
WorkspaceDataSourceModule,
TypeOrmModule.forFeature([MessageChannelEntity, ConnectedAccountEntity]),
],
providers: [
InboundEmailS3ClientProvider,
InboundEmailStorageService,
InboundEmailParserService,
],
exports: [
InboundEmailS3ClientProvider,
InboundEmailStorageService,
InboundEmailParserService,
],
})
export class MessagingInboundEmailDriverModule {}
@@ -0,0 +1,91 @@
import { Injectable, Logger } from '@nestjs/common';
import { S3Client, type S3ClientConfig } from '@aws-sdk/client-s3';
import { isNonEmptyString } from '@sniptt/guards';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
// Lazy S3 client: constructed on first call so the server boots cleanly in
// workspaces that don't enable email forwarding. Credentials are reused from
// the AWS_SES_* config block — SES inbound action already writes to this
// bucket, so the same principal can read it.
@Injectable()
export class InboundEmailS3ClientProvider {
private readonly logger = new Logger(InboundEmailS3ClientProvider.name);
private s3Client: S3Client | null = null;
constructor(private readonly twentyConfigService: TwentyConfigService) {}
isConfigured(): boolean {
const bucket = this.twentyConfigService.get('INBOUND_EMAIL_S3_BUCKET');
const domain = this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN');
return isNonEmptyString(bucket) && isNonEmptyString(domain);
}
getBucket(): string {
const bucket = this.twentyConfigService.get('INBOUND_EMAIL_S3_BUCKET');
if (!isNonEmptyString(bucket)) {
throw new Error(
'INBOUND_EMAIL_S3_BUCKET is not configured; email forwarding is disabled.',
);
}
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('INBOUND_EMAIL_S3_REGION') ||
this.twentyConfigService.get('AWS_SES_REGION');
if (!isNonEmptyString(region)) {
throw new Error(
'INBOUND_EMAIL_S3_REGION or AWS_SES_REGION must be set to use email forwarding.',
);
}
const config: S3ClientConfig = { region };
const accessKeyId = this.twentyConfigService.get('AWS_SES_ACCESS_KEY_ID');
const secretAccessKey = this.twentyConfigService.get(
'AWS_SES_SECRET_ACCESS_KEY',
);
const sessionToken = this.twentyConfigService.get('AWS_SES_SESSION_TOKEN');
if (
isNonEmptyString(accessKeyId) &&
isNonEmptyString(secretAccessKey) &&
isNonEmptyString(sessionToken)
) {
config.credentials = { accessKeyId, secretAccessKey, sessionToken };
} else 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,230 @@
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,
type InboundEmailImportOutcome,
} from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service';
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" 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',
});
});
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,197 @@
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 { 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';
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 };
// Orchestrates the "pull one S3 object, import it" cycle. Kept independent
// of BullMQ so it can be unit-tested directly; the job is a thin shell.
@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}`,
);
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,137 @@
import { Injectable, Logger } from '@nestjs/common';
import PostalMime, { type Email as ParsedEmail, type Address } from 'postal-mime';
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
import { MessageParticipantRole } from 'twenty-shared/types';
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 { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util';
import { X_TWENTY_ORIGIN_HEADER } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/constants/inbound-email.constants';
export type ParsedInboundMessage = {
parsed: ParsedEmail;
originWorkspaceId: string | null;
message: MessageWithParticipants;
};
@Injectable()
export class InboundEmailParserService {
private readonly logger = new Logger(InboundEmailParserService.name);
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 {
// The S3 key is the natural external id: it uniquely identifies the
// object that produced this message and survives retries.
externalId: `inbound-email:${s3Key}`,
messageThreadExternalId: this.extractThreadId(parsed),
headerMessageId:
parsed.messageId?.trim() || `inbound-${s3Key}`,
subject: sanitizeString(parsed.subject || ''),
text: sanitizeString(parsed.text || ''),
receivedAt: parsed.date ? new Date(parsed.date) : new Date(),
// Forwarding channels are inbound-only — every message is incoming.
direction: MessageDirection.INCOMING,
attachments: (parsed.attachments || []).map((attachment) => ({
filename: attachment.filename || 'unnamed-attachment',
})),
participants: this.extractParticipants(parsed),
};
}
private extractThreadId(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-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
private extractParticipants(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(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 || ''),
}));
}
}
@@ -0,0 +1,125 @@
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.constants';
import { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider';
// Thin wrapper around S3 that implements the "bucket-as-queue" pattern:
// listIncoming => pull work, getRawMessage => download, moveTo* => ack.
// Archive destinations are date-partitioned so operators can browse the
// bucket by day when triaging failures.
@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`);
}
// AWS SDK v3 returns Body as a stream in Node; collect it to a Buffer so
// postal-mime can parse it in one shot.
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;
}
}
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,136 @@
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,125 @@
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,28 @@
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';
export type MessagingInboundEmailImportJobData = {
s3Key: string;
};
// One job per S3 object. Keeping the unit of work small means a single bad
// message can be retried or moved to `failed/` without blocking the rest
// of the queue, and lets BullMQ do the per-message dedupe/backoff we want.
@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 {}
@@ -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/inbound-email.constants';
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',
}