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:
+10
-4
@@ -72,6 +72,7 @@ type ParticipantData = {
|
||||
workspaceMemberId: string;
|
||||
personId: string;
|
||||
displayName: string;
|
||||
handle: string;
|
||||
};
|
||||
|
||||
const GET_RANDOM_FAKE_PARTICIPANT = () => {
|
||||
@@ -120,6 +121,7 @@ const CREATE_PERSON_PARTICIPANT = (
|
||||
workspaceMemberId: defaultWorkspaceMemberId,
|
||||
personId: PERSON_ID,
|
||||
displayName: `Person ${PERSON_INDEX}`,
|
||||
handle: `person${PERSON_INDEX}@example.com`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -141,26 +143,30 @@ const CREATE_WORKSPACE_MEMBER_PARTICIPANT = (
|
||||
case WORKSPACE_MEMBER_DATA_SEED_IDS.TIM:
|
||||
return {
|
||||
workspaceMemberId: WORKSPACE_MEMBER_ID,
|
||||
personId: personIds[0] || personIds[0],
|
||||
personId: personIds[0],
|
||||
displayName: 'Tim Apple',
|
||||
handle: 'tim@apple.dev',
|
||||
};
|
||||
case WORKSPACE_MEMBER_DATA_SEED_IDS.JONY:
|
||||
return {
|
||||
workspaceMemberId: WORKSPACE_MEMBER_ID,
|
||||
personId: personIds[1] || personIds[0],
|
||||
displayName: 'Jony Ive',
|
||||
handle: 'jony@apple.dev',
|
||||
};
|
||||
case WORKSPACE_MEMBER_DATA_SEED_IDS.PHIL:
|
||||
return {
|
||||
workspaceMemberId: WORKSPACE_MEMBER_ID,
|
||||
personId: personIds[2] || personIds[0],
|
||||
displayName: 'Phil Schiller',
|
||||
handle: 'phil@apple.dev',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
workspaceMemberId: WORKSPACE_MEMBER_ID,
|
||||
personId: personIds[0] || personIds[0],
|
||||
personId: personIds[0],
|
||||
displayName: 'Workspace Member',
|
||||
handle: 'member@apple.dev',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -176,6 +182,7 @@ const CREATE_FAKE_PARTICIPANT = (
|
||||
personId:
|
||||
personIds[Math.floor(Math.random() * Math.min(10, personIds.length))],
|
||||
displayName: FAKE.name,
|
||||
handle: FAKE.email,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -231,7 +238,6 @@ const CREATE_MESSAGE_PARTICIPANTS = (
|
||||
const ROLE = IS_SENDER
|
||||
? MessageParticipantRole.FROM
|
||||
: MessageParticipantRole.TO;
|
||||
const HANDLE = IS_SENDER ? 'outgoing' : 'incoming';
|
||||
|
||||
// Random date within the last 3 months
|
||||
const NOW = new Date();
|
||||
@@ -255,7 +261,7 @@ const CREATE_MESSAGE_PARTICIPANTS = (
|
||||
workspaceMemberId: PARTICIPANT_DATA.workspaceMemberId,
|
||||
personId: PARTICIPANT_DATA.personId,
|
||||
displayName: PARTICIPANT_DATA.displayName,
|
||||
handle: HANDLE,
|
||||
handle: PARTICIPANT_DATA.handle,
|
||||
role: ROLE,
|
||||
messageId,
|
||||
});
|
||||
|
||||
+19
-1
@@ -44,6 +44,14 @@ import {
|
||||
EMPLOYMENT_HISTORY_DATA_SEED_COLUMNS,
|
||||
EMPLOYMENT_HISTORY_DATA_SEEDS,
|
||||
} from 'src/engine/workspace-manager/dev-seeder/data/constants/employment-history-data-seeds.constant';
|
||||
import {
|
||||
CONNECTED_ACCOUNT_DATA_SEED_COLUMNS,
|
||||
CONNECTED_ACCOUNT_DATA_SEEDS,
|
||||
} from 'src/engine/workspace-manager/dev-seeder/data/constants/connected-account-data-seeds.constant';
|
||||
import {
|
||||
MESSAGE_CHANNEL_DATA_SEED_COLUMNS,
|
||||
MESSAGE_CHANNEL_DATA_SEEDS,
|
||||
} from 'src/engine/workspace-manager/dev-seeder/data/constants/message-channel-data-seeds.constant';
|
||||
import {
|
||||
MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_DATA_SEED_COLUMNS,
|
||||
MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_DATA_SEEDS,
|
||||
@@ -157,9 +165,14 @@ const getRecordSeedsBatches = (
|
||||
pgColumns: DASHBOARD_DATA_SEED_COLUMNS,
|
||||
recordSeeds: getDashboardDataSeeds(workspaceId),
|
||||
},
|
||||
{
|
||||
tableName: 'connectedAccount',
|
||||
pgColumns: CONNECTED_ACCOUNT_DATA_SEED_COLUMNS,
|
||||
recordSeeds: CONNECTED_ACCOUNT_DATA_SEEDS,
|
||||
},
|
||||
];
|
||||
|
||||
// Batch 3: Depends on company and connectedAccount
|
||||
// Batch 3: Depends on company, connectedAccount
|
||||
const batch3: RecordSeedConfig[] = [
|
||||
{
|
||||
tableName: 'person',
|
||||
@@ -171,6 +184,11 @@ const getRecordSeedsBatches = (
|
||||
pgColumns: PET_DATA_SEED_COLUMNS,
|
||||
recordSeeds: PET_DATA_SEEDS,
|
||||
},
|
||||
{
|
||||
tableName: 'messageChannel',
|
||||
pgColumns: MESSAGE_CHANNEL_DATA_SEED_COLUMNS,
|
||||
recordSeeds: MESSAGE_CHANNEL_DATA_SEEDS,
|
||||
},
|
||||
];
|
||||
|
||||
// Batch 4: Depends on person/company/messageChannel or independent
|
||||
|
||||
+14
@@ -802,4 +802,18 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
|
||||
engineComponentKey: EngineComponentKey.REPLY_TO_EMAIL_THREAD,
|
||||
hotKeys: null,
|
||||
},
|
||||
composeEmail: {
|
||||
universalIdentifier: '96457c5a-b028-4d48-94e3-27f4c41296b8',
|
||||
label: 'Compose Email',
|
||||
icon: 'IconMail',
|
||||
isPinned: false,
|
||||
position: 71,
|
||||
shortLabel: 'Compose',
|
||||
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
|
||||
conditionalAvailabilityExpression: null,
|
||||
availabilityObjectMetadataUniversalIdentifier: null,
|
||||
frontComponentUniversalIdentifier: null,
|
||||
engineComponentKey: EngineComponentKey.COMPOSE_EMAIL,
|
||||
hotKeys: null,
|
||||
},
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user