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:
Félix Malfait
2026-04-07 08:43:48 +02:00
committed by GitHub
parent aec43da1e2
commit 83d30f8b76
68 changed files with 2466 additions and 579 deletions
@@ -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,
});
@@ -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
@@ -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;