feat: Send email from UI — inline reply composer & SendEmail mutation (#19363)
## Summary - **Inline email reply**: Replace external email client redirects (Gmail/Outlook deeplinks) with an in-app email composer. Users can reply to email threads directly from the email thread widget or via the command menu. - **SendEmail GraphQL mutation**: New backend mutation that reuses `EmailComposerService` for body sanitization, recipient validation, and SMTP dispatch via the existing outbound messaging infrastructure. - **Side panel compose page**: Command menu "Reply" action now opens a side-panel compose email page with pre-filled To, Subject, and In-Reply-To fields. ### Backend - `SendEmailResolver` with `SendEmailInput` / `SendEmailOutputDTO` - `SendEmailModule` wired into `CoreEngineModule` - Reuses `EmailComposerService` + `MessagingMessageOutboundService` ### Frontend - `EmailComposer` / `EmailComposerFields` components - `useSendEmail`, `useReplyContext`, `useEmailComposerState` hooks - `useOpenComposeEmailInSidePanel` + `SidePanelComposeEmailPage` - `EmailThreadWidget` inline Reply bar with toggle composer - `ReplyToEmailThreadCommand` now opens side-panel instead of external links ### Seeds - Added `handle` field to message participant seeds for realistic email addresses - Seed `connectedAccount` and `messageChannel` in correct batch order ## Test plan - [ ] Open an email thread on a person/company record → verify "Reply..." bar appears below the last message - [ ] Click "Reply..." → composer opens inline with pre-filled To and Subject - [ ] Type a message and click Send → email is sent via SMTP, composer closes - [ ] Use command menu Reply action → side panel opens with compose email page - [ ] Verify Send/Cancel buttons work correctly in side panel - [ ] Test with Cc/Bcc toggle in composer fields - [ ] Verify error handling: invalid recipients, missing connected account Made with [Cursor](https://cursor.com) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+32
-3
@@ -4,8 +4,13 @@ import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuCont
|
||||
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
|
||||
import { getSidePanelCommandMenuDropdownIdFromCommandMenuId } from '@/command-menu-item/utils/getSidePanelCommandMenuDropdownIdFromCommandMenuId';
|
||||
import { OptionsDropdownMenu } from '@/ui/layout/dropdown/components/OptionsDropdownMenu';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useContext } from 'react';
|
||||
import { HorizontalSeparator } from 'twenty-ui/display';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
|
||||
export const RecordPageSidePanelCommandMenuDropdown = () => {
|
||||
const { commandMenuItems } = useContext(CommandMenuContext);
|
||||
@@ -17,13 +22,24 @@ export const RecordPageSidePanelCommandMenuDropdown = () => {
|
||||
const dropdownId =
|
||||
getSidePanelCommandMenuDropdownIdFromCommandMenuId(commandMenuId);
|
||||
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const sidePanelWidgetFooterActions = useAtomStateValue(
|
||||
sidePanelWidgetFooterActionsState,
|
||||
);
|
||||
|
||||
const dropdownWidgetActions = sidePanelWidgetFooterActions.filter(
|
||||
(action) => action.isPinned === false,
|
||||
);
|
||||
|
||||
const recordSelectionActions = commandMenuItems.filter(
|
||||
(action) => action.scope === CommandMenuItemScope.RecordSelection,
|
||||
);
|
||||
|
||||
const selectableItemIdArray = recordSelectionActions.map(
|
||||
(action) => action.key,
|
||||
);
|
||||
const selectableItemIdArray = [
|
||||
...dropdownWidgetActions.map((action) => action.key),
|
||||
...recordSelectionActions.map((action) => action.key),
|
||||
];
|
||||
|
||||
return (
|
||||
<OptionsDropdownMenu
|
||||
@@ -31,6 +47,19 @@ export const RecordPageSidePanelCommandMenuDropdown = () => {
|
||||
selectableListId={commandMenuId}
|
||||
selectableItemIdArray={selectableItemIdArray}
|
||||
>
|
||||
{dropdownWidgetActions.map((action) => (
|
||||
<MenuItem
|
||||
key={action.key}
|
||||
text={action.label}
|
||||
LeftIcon={action.Icon}
|
||||
onClick={() => {
|
||||
closeDropdown(dropdownId);
|
||||
action.onClick();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{dropdownWidgetActions.length > 0 &&
|
||||
recordSelectionActions.length > 0 && <HorizontalSeparator noMargin />}
|
||||
{recordSelectionActions.map((action) => (
|
||||
<CommandMenuItemComponent action={action} key={action.key} />
|
||||
))}
|
||||
|
||||
+2
@@ -40,6 +40,7 @@ import { TestWorkflowSingleRecordCommand } from '@/command-menu-item/engine-comm
|
||||
import { TidyUpWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand';
|
||||
import { HeadlessFrontComponentRendererEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessFrontComponentRendererEngineCommand';
|
||||
import { TriggerWorkflowVersionEngineCommand } from '@/command-menu-item/engine-command/record/components/TriggerWorkflowVersionEngineCommand';
|
||||
import { ComposeEmailCommand } from '@/command-menu-item/engine-command/global/components/ComposeEmailCommand';
|
||||
import { ReplyToEmailThreadCommand } from '@/command-menu-item/engine-command/record/single-record/message-thread/components/ReplyToEmailThreadCommand';
|
||||
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -242,6 +243,7 @@ export const ENGINE_COMPONENT_KEY_COMPONENT_MAP: Record<
|
||||
<HeadlessFrontComponentRendererEngineCommand />
|
||||
),
|
||||
[EngineComponentKey.REPLY_TO_EMAIL_THREAD]: <ReplyToEmailThreadCommand />,
|
||||
[EngineComponentKey.COMPOSE_EMAIL]: <ComposeEmailCommand />,
|
||||
|
||||
// Deprecated keys kept for backward compatibility until migration runs
|
||||
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteRecordsCommand />,
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { useFirstConnectedAccount } from '@/activities/emails/hooks/useFirstConnectedAccount';
|
||||
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
|
||||
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
export const ComposeEmailCommand = () => {
|
||||
const { connectedAccountId, loading } = useFirstConnectedAccount();
|
||||
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
|
||||
const navigateSettings = useNavigateSettings();
|
||||
|
||||
const handleExecute = () => {
|
||||
if (!isDefined(connectedAccountId)) {
|
||||
navigateSettings(SettingsPath.NewAccount);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openComposeEmailInSidePanel({
|
||||
connectedAccountId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<HeadlessEngineCommandWrapperEffect
|
||||
execute={handleExecute}
|
||||
ready={!loading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+8
-5
@@ -15,10 +15,13 @@ export const NavigateToNextRecordSingleRecordCommand = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const { navigateToNextRecord } = useRecordShowPagePagination(
|
||||
objectMetadataItem.nameSingular,
|
||||
recordId,
|
||||
);
|
||||
const { navigateToNextRecord, isLoadingPagination } =
|
||||
useRecordShowPagePagination(objectMetadataItem.nameSingular, recordId);
|
||||
|
||||
return <HeadlessEngineCommandWrapperEffect execute={navigateToNextRecord} />;
|
||||
return (
|
||||
<HeadlessEngineCommandWrapperEffect
|
||||
execute={navigateToNextRecord}
|
||||
ready={!isLoadingPagination}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-5
@@ -15,12 +15,13 @@ export const NavigateToPreviousRecordSingleRecordCommand = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const { navigateToPreviousRecord } = useRecordShowPagePagination(
|
||||
objectMetadataItem.nameSingular,
|
||||
recordId,
|
||||
);
|
||||
const { navigateToPreviousRecord, isLoadingPagination } =
|
||||
useRecordShowPagePagination(objectMetadataItem.nameSingular, recordId);
|
||||
|
||||
return (
|
||||
<HeadlessEngineCommandWrapperEffect execute={navigateToPreviousRecord} />
|
||||
<HeadlessEngineCommandWrapperEffect
|
||||
execute={navigateToPreviousRecord}
|
||||
ready={!isLoadingPagination}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+13
-55
@@ -1,73 +1,31 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
|
||||
import { useReplyContext } from '@/activities/emails/hooks/useReplyContext';
|
||||
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
|
||||
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { useOpenComposeEmailInSidePanel } from '@/side-panel/hooks/useOpenComposeEmailInSidePanel';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const ALLOWED_REPLY_PROVIDERS = [
|
||||
ConnectedAccountProvider.GOOGLE,
|
||||
ConnectedAccountProvider.MICROSOFT,
|
||||
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
|
||||
];
|
||||
|
||||
export const ReplyToEmailThreadCommand = () => {
|
||||
const { selectedRecords } = useHeadlessCommandContextApi();
|
||||
const threadId = selectedRecords[0]?.id ?? null;
|
||||
|
||||
const {
|
||||
messageThreadExternalId,
|
||||
connectedAccountHandle,
|
||||
connectedAccountProvider,
|
||||
lastMessageExternalId,
|
||||
connectedAccountConnectionParameters,
|
||||
messageChannelLoading,
|
||||
} = useEmailThread(threadId);
|
||||
|
||||
const canReply = useMemo(() => {
|
||||
return (
|
||||
isDefined(connectedAccountHandle) &&
|
||||
isDefined(connectedAccountProvider) &&
|
||||
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
|
||||
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
|
||||
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
|
||||
isDefined(messageThreadExternalId)
|
||||
);
|
||||
}, [
|
||||
connectedAccountConnectionParameters,
|
||||
connectedAccountHandle,
|
||||
connectedAccountProvider,
|
||||
messageThreadExternalId,
|
||||
]);
|
||||
const replyContext = useReplyContext(threadId);
|
||||
const { openComposeEmailInSidePanel } = useOpenComposeEmailInSidePanel();
|
||||
|
||||
const handleExecute = () => {
|
||||
if (!canReply) {
|
||||
if (!isDefined(replyContext) || replyContext.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (connectedAccountProvider) {
|
||||
case ConnectedAccountProvider.MICROSOFT: {
|
||||
const url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
|
||||
window.open(url, '_blank');
|
||||
break;
|
||||
}
|
||||
case ConnectedAccountProvider.GOOGLE: {
|
||||
const url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
|
||||
window.open(url, '_blank');
|
||||
break;
|
||||
}
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
case ConnectedAccountProvider.OIDC:
|
||||
case ConnectedAccountProvider.SAML:
|
||||
case null:
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
openComposeEmailInSidePanel({
|
||||
threadId: threadId ?? undefined,
|
||||
connectedAccountId: replyContext.connectedAccountId,
|
||||
defaultTo: replyContext.to,
|
||||
defaultSubject: replyContext.subject,
|
||||
defaultInReplyTo: replyContext.inReplyTo,
|
||||
});
|
||||
};
|
||||
|
||||
const isReady = !messageChannelLoading && canReply;
|
||||
const isReady = isDefined(replyContext) && !replyContext.loading;
|
||||
|
||||
return (
|
||||
<HeadlessEngineCommandWrapperEffect
|
||||
|
||||
+8
-1
@@ -20,7 +20,14 @@ export const CommandMenuContextProviderServerItems = ({
|
||||
containerType,
|
||||
children,
|
||||
}: CommandMenuContextProviderServerItemsProps) => {
|
||||
const commandMenuContextApi = useCommandMenuContextApi();
|
||||
const commandMenuContextApiFromHook = useCommandMenuContextApi();
|
||||
|
||||
// SidePanelRecordPage shadows the outer ContextStore provider with a
|
||||
// per-page instance ID, so useCommandMenuContextApi derives isInSidePanel
|
||||
// as false. The explicit prop from the caller is the source of truth.
|
||||
const commandMenuContextApi = isInSidePanel
|
||||
? { ...commandMenuContextApiFromHook, isInSidePanel: true as const }
|
||||
: commandMenuContextApiFromHook;
|
||||
|
||||
const currentObjectNameSingular =
|
||||
commandMenuContextApi.objectMetadataItem.nameSingular;
|
||||
|
||||
Reference in New Issue
Block a user