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:
Félix Malfait
2026-04-07 08:43:48 +02:00
committed by GitHub
parent aec43da1e2
commit 83d30f8b76
68 changed files with 2466 additions and 579 deletions
@@ -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} />
))}