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
@@ -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 />,
@@ -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}
/>
);
};
@@ -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}
/>
);
};
@@ -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}
/>
);
};
@@ -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