feat(messaging): sync draft emails and edit them in the thread composer (#22178)

Stop excluding drafts from sync across all three providers (Gmail DRAFT
label, Microsoft/IMAP Drafts folder) and add an isDraft boolean field on
Message so drafts are queryable by the API and AI agents.

Drafts render in the thread with a Draft tag; clicking one opens the
existing reply composer pre-filled with the draft's recipients, subject
and body, and Send reuses the existing send-email flow.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22178?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-06-30 16:37:53 +05:30
committed by GitHub
parent c4a6446757
commit 9f3ebaaf22
60 changed files with 2057 additions and 1100 deletions
@@ -4,11 +4,15 @@ import { useCallback, useMemo } from 'react';
import { EmailComposerFields } from '@/activities/emails/components/EmailComposerFields';
import { useEmailComposerState } from '@/activities/emails/hooks/useEmailComposerState';
import { type ReplyContextReady } from '@/activities/emails/hooks/useReplyContext';
import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
import { EmailThreadComposerFooterEffect } from '@/page-layout/widgets/email-thread/components/EmailThreadComposerFooterEffect';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useOpenRecordInSidePanel } from '@/side-panel/hooks/useOpenRecordInSidePanel';
import { type SidePanelFooterCommandMenuItem } from '@/ui/layout/side-panel/types/SidePanelFooterCommandMenuItem';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { t } from '@lingui/core/macro';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconArrowBackUp, IconSend, IconX } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getOsControlSymbol } from 'twenty-ui/utilities';
@@ -34,6 +38,7 @@ type EmailThreadComposerProps = {
isInSidePanel: boolean;
isComposerOpen: boolean;
setIsComposerOpen: (open: boolean) => void;
draftPrefill?: EmailDraftPrefill | null;
};
export const EmailThreadComposer = ({
@@ -41,13 +46,27 @@ export const EmailThreadComposer = ({
isInSidePanel,
isComposerOpen,
setIsComposerOpen,
draftPrefill,
}: EmailThreadComposerProps) => {
const handleReplySent = useCallback(() => {
setIsComposerOpen(false);
}, [setIsComposerOpen]);
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
const handleReplySent = useCallback(
(messageThreadId: string | null) => {
setIsComposerOpen(false);
if (isDefined(messageThreadId)) {
openRecordInSidePanel({
recordId: messageThreadId,
objectNameSingular: CoreObjectNameSingular.MessageThread,
});
}
},
[setIsComposerOpen, openRecordInSidePanel],
);
const composerState = useEmailComposerState({
connectedAccountId: replyContext.connectedAccountId,
draftPrefill,
defaultTo: replyContext.to,
defaultSubject: replyContext.subject,
defaultInReplyTo: replyContext.inReplyTo,
@@ -15,8 +15,10 @@ const StyledButtonContainer = styled.div`
export const EmailThreadIntermediaryMessages = ({
messages,
onDraftClick,
}: {
messages: EmailThreadMessageWithSender[];
onDraftClick: (message: EmailThreadMessageWithSender) => void;
}) => {
const [areMessagesOpen, setAreMessagesOpen] = useState(false);
const messagesLength = messages.length;
@@ -29,10 +31,8 @@ export const EmailThreadIntermediaryMessages = ({
messages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
message={message}
onDraftClick={onDraftClick}
/>
))
) : (
@@ -1,11 +1,14 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { useReplyContext } from '@/activities/emails/hooks/useReplyContext';
import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { getEmailDraftPrefillFromMessage } from '@/activities/emails/utils/getEmailDraftPrefillFromMessage';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { EmailThreadComposer } from '@/page-layout/widgets/email-thread/components/EmailThreadComposer';
import { EmailThreadIntermediaryMessages } from '@/page-layout/widgets/email-thread/components/EmailThreadIntermediaryMessages';
@@ -43,7 +46,36 @@ export const EmailThreadWidget = ({
const replyContext = useReplyContext(targetRecord.id);
const [isComposerOpen, setIsComposerOpen] = useState(false);
const [composerIntent, setComposerIntent] = useState<
'opened' | 'closed' | null
>(null);
const [clickedDraftPrefill, setClickedDraftPrefill] =
useState<EmailDraftPrefill | null>(null);
const [previousTargetRecordId, setPreviousTargetRecordId] = useState(
targetRecord.id,
);
if (previousTargetRecordId !== targetRecord.id) {
setPreviousTargetRecordId(targetRecord.id);
setComposerIntent(null);
setClickedDraftPrefill(null);
}
const handleComposerOpenChange = useCallback((open: boolean) => {
setComposerIntent(open ? 'opened' : 'closed');
if (!open) {
setClickedDraftPrefill(null);
}
}, []);
const handleDraftClick = useCallback(
(message: EmailThreadMessageWithSender) => {
setClickedDraftPrefill(getEmailDraftPrefillFromMessage(message));
setComposerIntent('opened');
},
[],
);
const canReply = isDefined(replyContext) && !replyContext.loading;
@@ -58,6 +90,16 @@ export const EmailThreadWidget = ({
: [];
const lastMessage = messages[messagesCount - 1];
const trailingDraft = lastMessage?.isDraft ? lastMessage : undefined;
const draftPrefill =
clickedDraftPrefill ??
(isDefined(trailingDraft)
? getEmailDraftPrefillFromMessage(trailingDraft)
: null);
const isComposerOpen =
composerIntent === 'opened' ||
(composerIntent === null && isDefined(trailingDraft));
if (threadLoading || !thread || !messages.length) {
return (
<StyledWrapper>
@@ -74,21 +116,20 @@ export const EmailThreadWidget = ({
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
message={message}
onDraftClick={handleDraftClick}
/>
))}
<EmailThreadIntermediaryMessages messages={intermediaryMessages} />
<EmailThreadIntermediaryMessages
messages={intermediaryMessages}
onDraftClick={handleDraftClick}
/>
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
message={lastMessage}
isExpanded
hideBottomBorder={!isComposerOpen}
onDraftClick={handleDraftClick}
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
@@ -97,10 +138,12 @@ export const EmailThreadWidget = ({
</StyledContainer>
{canReply && (
<EmailThreadComposer
key={draftPrefill?.messageId ?? 'reply'}
replyContext={replyContext}
isInSidePanel={isInSidePanel}
isComposerOpen={isComposerOpen}
setIsComposerOpen={setIsComposerOpen}
setIsComposerOpen={handleComposerOpenChange}
draftPrefill={draftPrefill}
/>
)}
</StyledWrapper>