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:
@@ -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,
|
||||
|
||||
+58
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+9
-20
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -9,5 +9,7 @@ export type ComposedEmail = {
|
||||
sanitizedHtmlBody: string;
|
||||
attachments: MessageAttachment[];
|
||||
connectedAccount: ConnectedAccountEntity;
|
||||
messageChannelId: string;
|
||||
inReplyTo?: string;
|
||||
threadExternalId?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user