83d30f8b76
## 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>
64 lines
1.4 KiB
TypeScript
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,
|
|
]);
|
|
};
|