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
@@ -14,14 +14,28 @@ import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { Trans } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { styled } from '@linaria/react';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { UndecoratedLink } from 'twenty-ui/navigation';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { SidePanelPageInfoLayout } from './SidePanelPageInfoLayout';
const StyledClickableTitle = styled.div`
cursor: pointer;
a {
color: inherit;
text-decoration: none;
}
`;
export const SidePanelRecordInfo = ({
sidePanelPageInstanceId,
}: {
@@ -85,6 +99,11 @@ export const SidePanelRecordInfo = ({
objectNameSingular,
});
const recordShowPagePath = getAppPath(AppPath.RecordShowPage, {
objectNameSingular,
objectRecordId,
});
const fieldDefinition = {
type: labelIdentifierFieldMetadataItem?.type ?? FieldMetadataType.TEXT,
iconName: '',
@@ -97,6 +116,25 @@ export const SidePanelRecordInfo = ({
defaultValue: labelIdentifierFieldMetadataItem?.defaultValue,
};
const titleContent = (
<FieldContext.Provider
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isCentered: false,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isTitleReadOnly,
}}
>
<RecordTitleCell
sizeVariant="sm"
containerType={RecordTitleCellContainerType.PageHeader}
/>
</FieldContext.Provider>
);
return (
<SidePanelPageInfoLayout
icon={
@@ -111,22 +149,15 @@ export const SidePanelRecordInfo = ({
) : undefined
}
title={
<FieldContext.Provider
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isCentered: false,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isTitleReadOnly,
}}
>
<RecordTitleCell
sizeVariant="sm"
containerType={RecordTitleCellContainerType.PageHeader}
/>
</FieldContext.Provider>
isTitleReadOnly ? (
<StyledClickableTitle>
<UndecoratedLink to={recordShowPagePath}>
{titleContent}
</UndecoratedLink>
</StyledClickableTitle>
) : (
titleContent
)
}
label={
beautifiedCreatedAt ? (