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
@@ -70,6 +70,7 @@ import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.modul
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
import { SendEmailModule } from 'src/modules/messaging/message-outbound-manager/send-email.module';
import { AuditModule } from './audit/audit.module';
import { ClientConfigModule } from './client-config/client-config.module';
import { EventLogsModule } from './event-logs/event-logs.module';
@@ -123,6 +124,7 @@ import { FileModule } from './file/file.module';
SubscriptionsModule,
ImapSmtpCaldavModule,
ChannelSyncModule,
SendEmailModule,
FileStorageModule.forRoot(),
LoggerModule.forRootAsync({
useFactory: loggerModuleFactory,
@@ -24,6 +24,8 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { parseEmailBody } from 'src/utils/parse-email-body';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@@ -209,6 +211,50 @@ export class EmailComposerService {
return attachments;
}
// Look up the provider-specific thread ID (e.g. Gmail threadId) from the
// parent message so replies can be explicitly threaded in the provider API.
private async getThreadExternalId(
workspaceId: string,
inReplyTo: string,
messageChannelId: string,
): Promise<string | undefined> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageWorkspaceEntity>(
workspaceId,
'message',
);
const parentMessage = await messageRepository.findOne({
where: { headerMessageId: inReplyTo },
});
if (!parentMessage) {
return undefined;
}
const associationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
);
const association = await associationRepository.findOne({
where: {
messageId: parentMessage.id,
messageChannelId,
},
});
return association?.messageThreadExternalId ?? undefined;
},
authContext,
);
}
async composeEmail(
parameters: EmailToolInput,
context: ToolExecutionContext,
@@ -301,6 +347,16 @@ export class EmailComposerService {
const sanitizedHtmlBody = purify.sanitize(htmlBody || '');
const sanitizedSubject = purify.sanitize(subject || '');
let threadExternalId: string | undefined;
if (inReplyTo) {
threadExternalId = await this.getThreadExternalId(
workspaceId,
inReplyTo,
messageChannel.id,
);
}
return {
success: true,
data: {
@@ -311,7 +367,9 @@ export class EmailComposerService {
sanitizedHtmlBody,
attachments,
connectedAccount: connectedAccountWithFreshTokens,
messageChannelId: messageChannel.id,
inReplyTo,
threadExternalId,
},
};
}
@@ -4,12 +4,11 @@ import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-t
import { EmailToolInputZodSchema } from 'src/engine/core-modules/tool/tools/email-tool/email-tool.schema';
import { EmailToolException } from 'src/engine/core-modules/tool/tools/email-tool/exceptions/email-tool.exception';
import { isInsufficientPermissionsError } from 'src/engine/core-modules/tool/tools/email-tool/utils/is-insufficient-permissions-error.util';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { type EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
import { SendEmailService } from 'src/modules/messaging/message-outbound-manager/services/send-email.service';
@Injectable()
export class SendEmailTool implements Tool {
@@ -21,7 +20,7 @@ export class SendEmailTool implements Tool {
constructor(
private readonly emailComposerService: EmailComposerService,
private readonly messageOutboundService: MessagingMessageOutboundService,
private readonly sendEmailService: SendEmailService,
) {}
async execute(
@@ -40,7 +39,13 @@ export class SendEmailTool implements Tool {
const { data } = result;
await this.sendEmail(data);
const sendResult = await this.sendEmailService.sendComposedEmail(data);
await this.sendEmailService.persistSentMessage(
sendResult,
data,
context.workspaceId,
);
this.logger.log(
`Email sent successfully to ${data.toRecipientsDisplay}${data.attachments.length > 0 ? ` with ${data.attachments.length} attachments` : ''}`,
@@ -86,20 +91,4 @@ export class SendEmailTool implements Tool {
};
}
}
private async sendEmail(data: ComposedEmail): Promise<void> {
await this.messageOutboundService.sendMessage(
{
to: data.recipients.to,
cc: data.recipients.cc.length > 0 ? data.recipients.cc : undefined,
bcc: data.recipients.bcc.length > 0 ? data.recipients.bcc : undefined,
subject: data.sanitizedSubject,
body: data.plainTextBody,
html: data.sanitizedHtmlBody,
attachments: data.attachments,
inReplyTo: data.inReplyTo,
},
data.connectedAccount,
);
}
}
@@ -9,5 +9,7 @@ export type ComposedEmail = {
sanitizedHtmlBody: string;
attachments: MessageAttachment[];
connectedAccount: ConnectedAccountEntity;
messageChannelId: string;
inReplyTo?: string;
threadExternalId?: string;
};