Files
twenty/packages/twenty-front/src/modules/activities/emails/hooks/useReplyContext.ts
T
Félix Malfait 83d30f8b76 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>
2026-04-07 08:43:48 +02:00

64 lines
1.4 KiB
TypeScript

import { useMemo } from 'react';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import {
type ReplyContext,
type ReplyContextReady,
} from '@/activities/emails/types/ReplyContext';
import { isDefined } from 'twenty-shared/utils';
export type { ReplyContext, ReplyContextReady };
export const useReplyContext = (
threadId: string | null,
): ReplyContext | null => {
const {
messages,
connectedAccountId,
connectedAccountProvider,
messageChannelLoading,
threadLoading,
} = useEmailThread(threadId);
return useMemo(() => {
if (
!isDefined(connectedAccountId) ||
!isDefined(connectedAccountProvider)
) {
if (messageChannelLoading || threadLoading) {
return { loading: true };
}
return null;
}
const lastMessage = messages[messages.length - 1];
if (!isDefined(lastMessage)) {
return null;
}
const senderHandle = lastMessage.sender?.handle ?? '';
const rawSubject = lastMessage.subject ?? '';
const subject = rawSubject.startsWith('Re: ')
? rawSubject
: `Re: ${rawSubject}`;
return {
loading: false,
to: senderHandle,
subject,
inReplyTo: lastMessage.headerMessageId ?? '',
connectedAccountId,
connectedAccountProvider,
};
}, [
messages,
connectedAccountId,
connectedAccountProvider,
messageChannelLoading,
threadLoading,
]);
};