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:
+37
-7
@@ -10,9 +10,12 @@ import { usePageLayoutIdForRecord } from '@/page-layout/hooks/usePageLayoutIdFor
|
||||
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
|
||||
import { type TargetRecordIdentifier } from '@/ui/layout/contexts/TargetRecordIdentifier';
|
||||
import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter';
|
||||
import { sidePanelWidgetFooterActionsState } from '@/ui/layout/side-panel/states/sidePanelWidgetFooterActionsState';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { PageLayoutType } from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -55,6 +58,16 @@ export const PageLayoutRecordPageRenderer = ({
|
||||
targetObjectNameSingular: targetRecordIdentifier.targetObjectNameSingular,
|
||||
});
|
||||
|
||||
const sidePanelWidgetFooterActions = useAtomStateValue(
|
||||
sidePanelWidgetFooterActionsState,
|
||||
);
|
||||
|
||||
const pinnedWidgetActions = sidePanelWidgetFooterActions.filter(
|
||||
(action) => action.isPinned !== false,
|
||||
);
|
||||
|
||||
const hasPinnedWidgetActions = pinnedWidgetActions.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<RecordShowEffect
|
||||
@@ -101,13 +114,30 @@ export const PageLayoutRecordPageRenderer = ({
|
||||
{isInSidePanel && (
|
||||
<SidePanelFooter
|
||||
actions={[
|
||||
<RecordPageSidePanelCommandMenu />,
|
||||
<RecordShowSidePanelOpenRecordButton
|
||||
objectNameSingular={
|
||||
targetRecordIdentifier.targetObjectNameSingular
|
||||
}
|
||||
recordId={targetRecordIdentifier.id}
|
||||
/>,
|
||||
<RecordPageSidePanelCommandMenu key="options" />,
|
||||
...(hasPinnedWidgetActions
|
||||
? pinnedWidgetActions.map((action) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
size="small"
|
||||
variant={action.isPrimaryCTA ? 'primary' : 'secondary'}
|
||||
accent={action.isPrimaryCTA ? 'blue' : 'default'}
|
||||
title={action.label}
|
||||
Icon={action.Icon}
|
||||
hotkeys={action.hotkeys}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
/>
|
||||
))
|
||||
: [
|
||||
<RecordShowSidePanelOpenRecordButton
|
||||
key="open"
|
||||
objectNameSingular={
|
||||
targetRecordIdentifier.targetObjectNameSingular
|
||||
}
|
||||
recordId={targetRecordIdentifier.id}
|
||||
/>,
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user