Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c38b7c6d71 | ||
|
|
c83fe36e43 | ||
|
|
ba8e1d6650 | ||
|
|
854c9f2b16 | ||
|
|
e7b4ea49cc | ||
|
|
11b39d980c | ||
|
|
a2ace9ce49 | ||
|
|
b36859055b |
@@ -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,
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
+127
@@ -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>
|
||||
);
|
||||
};
|
||||
+81
-27
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+20
-14
@@ -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`}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const EMAIL_FORWARDING_MODAL_ID = 'email-forwarding-address-modal';
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+35
@@ -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 };
|
||||
};
|
||||
+27
@@ -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: {
|
||||
|
||||
+36
@@ -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;
|
||||
|
||||
|
||||
+8
@@ -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',
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
+70
@@ -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,
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+13
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+3
-1
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
+1
@@ -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:
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+33
@@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+65
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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) {
|
||||
|
||||
+5
-2
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+10
-68
@@ -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',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const INBOUND_EMAIL_LOCAL_PART_PREFIX = 'ch_';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES = 12;
|
||||
+6
@@ -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;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const X_TWENTY_ORIGIN_HEADER = 'x-twenty-origin';
|
||||
+29
@@ -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 {}
|
||||
+82
@@ -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;
|
||||
}
|
||||
}
|
||||
+229
@@ -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);
|
||||
});
|
||||
});
|
||||
+189
@@ -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)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
+120
@@ -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}`;
|
||||
}
|
||||
}
|
||||
+7
@@ -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 };
|
||||
+9
@@ -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;
|
||||
};
|
||||
+128
@@ -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();
|
||||
});
|
||||
});
|
||||
+127
@@ -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;
|
||||
};
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export type MessagingInboundEmailImportJobData = {
|
||||
s3Key: string;
|
||||
};
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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 {}
|
||||
|
||||
+25
@@ -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 || ''),
|
||||
}));
|
||||
};
|
||||
+21
@@ -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,
|
||||
),
|
||||
);
|
||||
};
|
||||
+35
@@ -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()}`;
|
||||
};
|
||||
+7
@@ -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(
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ export class SendEmailService {
|
||||
attachments: data.attachments,
|
||||
inReplyTo: data.inReplyTo,
|
||||
threadExternalId: data.threadExternalId,
|
||||
originWorkspaceId: data.connectedAccount.workspaceId,
|
||||
},
|
||||
data.connectedAccount,
|
||||
);
|
||||
|
||||
+3
@@ -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;
|
||||
};
|
||||
|
||||
+11
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user