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:
@@ -14,14 +14,28 @@ import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { getAppPath } from 'twenty-shared/utils';
|
||||
import { Avatar } from 'twenty-ui/display';
|
||||
import { UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
|
||||
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
|
||||
import { SidePanelPageInfoLayout } from './SidePanelPageInfoLayout';
|
||||
|
||||
const StyledClickableTitle = styled.div`
|
||||
cursor: pointer;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SidePanelRecordInfo = ({
|
||||
sidePanelPageInstanceId,
|
||||
}: {
|
||||
@@ -85,6 +99,11 @@ export const SidePanelRecordInfo = ({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const recordShowPagePath = getAppPath(AppPath.RecordShowPage, {
|
||||
objectNameSingular,
|
||||
objectRecordId,
|
||||
});
|
||||
|
||||
const fieldDefinition = {
|
||||
type: labelIdentifierFieldMetadataItem?.type ?? FieldMetadataType.TEXT,
|
||||
iconName: '',
|
||||
@@ -97,6 +116,25 @@ export const SidePanelRecordInfo = ({
|
||||
defaultValue: labelIdentifierFieldMetadataItem?.defaultValue,
|
||||
};
|
||||
|
||||
const titleContent = (
|
||||
<FieldContext.Provider
|
||||
value={{
|
||||
recordId: objectRecordId,
|
||||
isLabelIdentifier: false,
|
||||
fieldDefinition,
|
||||
useUpdateRecord: useUpdateOneObjectRecordMutation,
|
||||
isCentered: false,
|
||||
isDisplayModeFixHeight: true,
|
||||
isRecordFieldReadOnly: isTitleReadOnly,
|
||||
}}
|
||||
>
|
||||
<RecordTitleCell
|
||||
sizeVariant="sm"
|
||||
containerType={RecordTitleCellContainerType.PageHeader}
|
||||
/>
|
||||
</FieldContext.Provider>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidePanelPageInfoLayout
|
||||
icon={
|
||||
@@ -111,22 +149,15 @@ export const SidePanelRecordInfo = ({
|
||||
) : undefined
|
||||
}
|
||||
title={
|
||||
<FieldContext.Provider
|
||||
value={{
|
||||
recordId: objectRecordId,
|
||||
isLabelIdentifier: false,
|
||||
fieldDefinition,
|
||||
useUpdateRecord: useUpdateOneObjectRecordMutation,
|
||||
isCentered: false,
|
||||
isDisplayModeFixHeight: true,
|
||||
isRecordFieldReadOnly: isTitleReadOnly,
|
||||
}}
|
||||
>
|
||||
<RecordTitleCell
|
||||
sizeVariant="sm"
|
||||
containerType={RecordTitleCellContainerType.PageHeader}
|
||||
/>
|
||||
</FieldContext.Provider>
|
||||
isTitleReadOnly ? (
|
||||
<StyledClickableTitle>
|
||||
<UndecoratedLink to={recordShowPagePath}>
|
||||
{titleContent}
|
||||
</UndecoratedLink>
|
||||
</StyledClickableTitle>
|
||||
) : (
|
||||
titleContent
|
||||
)
|
||||
}
|
||||
label={
|
||||
beautifiedCreatedAt ? (
|
||||
|
||||
Reference in New Issue
Block a user