Files
twenty/packages/twenty-front/src/modules/side-panel/pages/message-thread/components/SidePanelMessageThreadPage.tsx
T
Sonarly Claude Code 2586059ace Reply button throws unhandled error for IMAP/SMTP email accounts
https://sonarly.com/issue/14482?type=bug

Clicking "Reply" on an email thread in the side panel throws an unhandled "Account provider not supported" error for users with IMAP/SMTP connected accounts, crashing the UI.

Fix: **Two bugs fixed in `SidePanelMessageThreadPage.tsx`:**

**1. IMAP Reply crash (commit 7c8d362772):** The IMAP Driver Integration PR added `IMAP_SMTP_CALDAV` to `ALLOWED_REPLY_PROVIDERS` and added `canReply` logic for IMAP accounts with SMTP params, but left `handleReplyClick` with a `throw new Error('Account provider not supported')` placeholder for the IMAP case. Unlike Google (Gmail URL) and Microsoft (Outlook URL), IMAP has no webmail URL to deep-link to for replies.

**Fix:** Removed `IMAP_SMTP_CALDAV` from `ALLOWED_REPLY_PROVIDERS` so the Reply button is hidden for IMAP users. The switch statement keeps `IMAP_SMTP_CALDAV` as a no-op `break` to satisfy TypeScript exhaustiveness checking via `assertUnreachable`. Removed the now-unused `connectedAccountConnectionParameters` destructuring and IMAP-specific `canReply` condition.

**2. `isDefined(canReply)` regression (commit 9d57bc39e5):** The ESLint-to-OxLint migration mechanically replaced `canReply` truthiness checks with `isDefined(canReply)`. Since `canReply` is a boolean (from `useMemo`), `isDefined(false)` returns `true`, which meant: the Reply button was always rendered (line 169), never disabled (line 176), and the guard in `handleReplyClick` (line 106) never triggered.

**Fix:** Reverted `isDefined(canReply)` back to `canReply` in the three affected locations: the render condition, the disabled prop, and the click handler guard.
2026-03-13 16:24:35 +00:00

177 lines
5.4 KiB
TypeScript

import { styled } from '@linaria/react';
import { useEffect, useMemo } from 'react';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadHeader } from '@/activities/emails/components/EmailThreadHeader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { SidePanelMessageThreadIntermediaryMessages } from '@/side-panel/pages/message-thread/components/SidePanelMessageThreadIntermediaryMessages';
import { useEmailThreadInSidePanel } from '@/side-panel/pages/message-thread/hooks/useEmailThreadInSidePanel';
import { messageThreadComponentState } from '@/side-panel/pages/message-thread/states/messageThreadComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { t } from '@lingui/core/macro';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { IconArrowBackUp } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
height: 85%;
overflow-y: auto;
`;
const StyledButtonContainer = styled.div`
background: ${themeCssVariables.background.secondary};
border-top: 1px solid ${themeCssVariables.border.color.light};
box-sizing: border-box;
display: flex;
justify-content: flex-end;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const ALLOWED_REPLY_PROVIDERS = [
ConnectedAccountProvider.GOOGLE,
ConnectedAccountProvider.MICROSOFT,
];
export const SidePanelMessageThreadPage = () => {
const setMessageThread = useSetAtomComponentState(
messageThreadComponentState,
);
const {
thread,
messages,
fetchMoreMessages,
threadLoading,
messageThreadExternalId,
connectedAccountHandle,
messageChannelLoading,
connectedAccountProvider,
lastMessageExternalId,
} = useEmailThreadInSidePanel();
useEffect(() => {
if (!isDefined(messages[0]?.messageThread)) {
return;
}
setMessageThread(messages[0]?.messageThread);
}, [messages, setMessageThread]);
const messagesCount = messages.length;
const is5OrMoreMessages = messagesCount >= 5;
const firstMessages = messages.slice(
0,
is5OrMoreMessages ? 2 : messagesCount - 1,
);
const intermediaryMessages = is5OrMoreMessages
? messages.slice(2, messagesCount - 1)
: [];
const lastMessage = messages[messagesCount - 1];
const subject = messages[0]?.subject;
const canReply = useMemo(() => {
return (
connectedAccountHandle &&
connectedAccountProvider &&
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
isDefined(lastMessage) &&
messageThreadExternalId != null
);
}, [
connectedAccountHandle,
connectedAccountProvider,
lastMessage,
messageThreadExternalId,
]);
const handleReplyClick = () => {
if (!canReply) {
return;
}
let url: string;
switch (connectedAccountProvider) {
case ConnectedAccountProvider.MICROSOFT:
url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.GOOGLE:
url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
case null:
break;
default:
assertUnreachable(connectedAccountProvider);
}
};
if (!thread || !messages.length) {
return null;
}
return (
<StyledWrapper>
<StyledContainer>
{threadLoading ? (
<EmailLoader loadingText={t`Loading thread`} />
) : (
<>
<EmailThreadHeader
subject={subject}
lastMessageSentAt={lastMessage.receivedAt}
/>
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<SidePanelMessageThreadIntermediaryMessages
messages={intermediaryMessages}
/>
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</>
)}
</StyledContainer>
{canReply && !messageChannelLoading && (
<StyledButtonContainer>
<Button
size="small"
onClick={handleReplyClick}
title={t`Reply`}
Icon={IconArrowBackUp}
disabled={!canReply}
/>
</StyledButtonContainer>
)}
</StyledWrapper>
);
};