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
@@ -10,6 +10,8 @@ import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
import { extractMessageIdFromBuffer } from 'src/modules/messaging/message-outbound-manager/utils/extract-message-id-from-buffer.util';
import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util';
@Injectable()
@@ -21,18 +23,25 @@ export class GmailMessageOutboundService implements MessageOutboundDriver {
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void> {
const { gmailClient, encodedMessage } = await this.composeGmailMessage(
connectedAccount,
sendMessageInput,
);
): Promise<SendMessageResult> {
const { gmailClient, encodedMessage, messageBuffer } =
await this.composeGmailMessage(connectedAccount, sendMessageInput);
await gmailClient.users.messages.send({
const { data } = await gmailClient.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
...(sendMessageInput.threadExternalId
? { threadId: sendMessageInput.threadExternalId }
: {}),
},
});
return {
headerMessageId: extractMessageIdFromBuffer(messageBuffer),
messageExternalId: data.id ?? undefined,
threadExternalId: data.threadId ?? undefined,
};
}
async createDraft(
@@ -60,6 +69,7 @@ export class GmailMessageOutboundService implements MessageOutboundDriver {
): Promise<{
gmailClient: gmail_v1.Gmail;
encodedMessage: string;
messageBuffer: Buffer;
}> {
const oAuth2Client =
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
@@ -104,6 +114,6 @@ export class GmailMessageOutboundService implements MessageOutboundDriver {
const messageBuffer = await compiledMessage.build();
const encodedMessage = Buffer.from(messageBuffer).toString('base64url');
return { gmailClient, encodedMessage };
return { gmailClient, encodedMessage, messageBuffer };
}
}
@@ -14,6 +14,8 @@ import { ImapClientProvider } from 'src/modules/messaging/message-import-manager
import { ImapFindDraftsFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
import { extractMessageIdFromBuffer } from 'src/modules/messaging/message-outbound-manager/utils/extract-message-id-from-buffer.util';
import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util';
@Injectable()
@@ -31,7 +33,7 @@ export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void> {
): Promise<SendMessageResult> {
const { handle, connectionParameters } = connectedAccount;
const smtpClient =
@@ -80,6 +82,10 @@ export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
await this.imapClientProvider.closeClient(imapClient);
}
return {
headerMessageId: extractMessageIdFromBuffer(messageBuffer),
};
}
async createDraft(
@@ -6,6 +6,7 @@ import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { toMicrosoftRecipients } from 'src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
import { type Client as MicrosoftGraphClient } from '@microsoft/microsoft-graph-client';
import { isDefined } from 'twenty-shared/utils';
@@ -18,18 +19,25 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void> {
): Promise<SendMessageResult> {
const microsoftClient =
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
connectedAccount,
);
const messageId = await this.createDraftMessage(
microsoftClient,
sendMessageInput,
);
const {
id: messageId,
internetMessageId,
conversationId,
} = await this.createDraftMessage(microsoftClient, sendMessageInput);
await microsoftClient.api(`/me/messages/${messageId}/send`).post({});
return {
headerMessageId: internetMessageId ?? '',
messageExternalId: messageId,
threadExternalId: conversationId ?? undefined,
};
}
async createDraft(
@@ -47,7 +55,11 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
private async createDraftMessage(
microsoftClient: MicrosoftGraphClient,
sendMessageInput: SendMessageInput,
): Promise<string> {
): Promise<{
id: string;
internetMessageId?: string;
conversationId?: string;
}> {
const parentMessageGraphId = sendMessageInput.inReplyTo
? await this.findMessageByInternetMessageId(
microsoftClient,
@@ -62,14 +74,25 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
.api(`/me/messages/${parentMessageGraphId}/createReply`)
.post({});
await microsoftClient.api(`/me/messages/${reply.id}`).patch(message);
const patched = await microsoftClient
.api(`/me/messages/${reply.id}`)
.patch(message);
return reply.id;
return {
id: reply.id,
internetMessageId:
patched?.internetMessageId ?? reply.internetMessageId,
conversationId: patched?.conversationId ?? reply.conversationId,
};
}
const response = await microsoftClient.api('/me/messages').post(message);
return response.id;
return {
id: response.id,
internetMessageId: response.internetMessageId,
conversationId: response.conversationId,
};
}
private async findMessageByInternetMessageId(
@@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('SendEmailOutput')
export class SendEmailOutputDTO {
@Field(() => Boolean)
success: boolean;
@Field(() => String, { nullable: true })
error?: string;
}
@@ -0,0 +1,25 @@
import { Field, InputType } from '@nestjs/graphql';
@InputType()
export class SendEmailInput {
@Field(() => String)
connectedAccountId: string;
@Field(() => String)
to: string;
@Field(() => String, { nullable: true })
cc?: string;
@Field(() => String, { nullable: true })
bcc?: string;
@Field(() => String)
subject: string;
@Field(() => String)
body: string;
@Field(() => String, { nullable: true })
inReplyTo?: string;
}
@@ -1,11 +1,12 @@
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
export type MessageOutboundDriver = {
sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void>;
): Promise<SendMessageResult>;
createDraft(
sendMessageInput: SendMessageInput,
@@ -10,6 +10,8 @@ import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbo
import { ImapSmtpMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service';
import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service';
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';
import { SentMessagePersistenceService } from 'src/modules/messaging/message-outbound-manager/services/sent-message-persistence.service';
@Module({
imports: [
@@ -23,7 +25,13 @@ import { MessagingMessageOutboundService } from 'src/modules/messaging/message-o
MicrosoftMessageOutboundService,
ImapSmtpMessageOutboundService,
MessagingMessageOutboundService,
SendEmailService,
SentMessagePersistenceService,
],
exports: [
MessagingMessageOutboundService,
SendEmailService,
SentMessagePersistenceService,
],
exports: [MessagingMessageOutboundService],
})
export class MessagingSendManagerModule {}
@@ -0,0 +1,97 @@
import {
ForbiddenException,
Logger,
UseFilters,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailComposerService } from 'src/engine/core-modules/tool/tools/email-tool/email-composer.service';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { SendEmailOutputDTO } from 'src/modules/messaging/message-outbound-manager/dtos/send-email-output.dto';
import { SendEmailInput } from 'src/modules/messaging/message-outbound-manager/dtos/send-email.input';
import { SendEmailService } from 'src/modules/messaging/message-outbound-manager/services/send-email.service';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
@UseFilters(AuthGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
export class SendEmailResolver {
private readonly logger = new Logger(SendEmailResolver.name);
constructor(
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
private readonly emailComposerService: EmailComposerService,
private readonly sendEmailService: SendEmailService,
) {}
@Mutation(() => SendEmailOutputDTO)
async sendEmail(
@Args('input') input: SendEmailInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<SendEmailOutputDTO> {
try {
await this.connectedAccountMetadataService.verifyOwnership({
id: input.connectedAccountId,
userWorkspaceId,
workspaceId: workspace.id,
});
const result = await this.emailComposerService.composeEmail(
{
recipients: {
to: input.to,
cc: input.cc ?? '',
bcc: input.bcc ?? '',
},
subject: input.subject,
body: input.body,
connectedAccountId: input.connectedAccountId,
files: [],
inReplyTo: input.inReplyTo,
},
{ workspaceId: workspace.id },
);
if (!result.success) {
return {
success: false,
error: result.output.error ?? result.output.message,
};
}
const { data } = result;
const sendResult = await this.sendEmailService.sendComposedEmail(data);
await this.sendEmailService.persistSentMessage(
sendResult,
data,
workspace.id,
);
return { success: true };
} catch (error) {
if (error instanceof ForbiddenException) {
throw error;
}
this.logger.error(`Failed to send email: ${error}`);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to send email',
};
}
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { SendEmailResolver } from 'src/modules/messaging/message-outbound-manager/resolvers/send-email.resolver';
import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbound-manager/messaging-send-manager.module';
@Module({
imports: [
ToolModule,
MessagingSendManagerModule,
ConnectedAccountMetadataModule,
],
providers: [SendEmailResolver],
})
export class SendEmailModule {}
@@ -8,6 +8,7 @@ import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbo
import { ImapSmtpMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/imap/services/imap-smtp-message-outbound.service';
import { MicrosoftMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/microsoft/services/microsoft-message-outbound.service';
import { SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
@Injectable()
export class MessagingMessageOutboundService {
@@ -20,7 +21,7 @@ export class MessagingMessageOutboundService {
public async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void> {
): Promise<SendMessageResult> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailMessageOutboundService.sendMessage(
@@ -0,0 +1,56 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
import { SentMessagePersistenceService } from 'src/modules/messaging/message-outbound-manager/services/sent-message-persistence.service';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
@Injectable()
export class SendEmailService {
private readonly logger = new Logger(SendEmailService.name);
constructor(
private readonly messageOutboundService: MessagingMessageOutboundService,
private readonly sentMessagePersistenceService: SentMessagePersistenceService,
) {}
async sendComposedEmail(data: ComposedEmail): Promise<SendMessageResult> {
return 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,
threadExternalId: data.threadExternalId,
},
data.connectedAccount,
);
}
async persistSentMessage(
sendResult: SendMessageResult,
data: ComposedEmail,
workspaceId: string,
): Promise<void> {
try {
await this.sentMessagePersistenceService.persistSentMessage({
sendResult,
subject: data.sanitizedSubject,
body: data.plainTextBody,
recipients: data.recipients,
connectedAccount: data.connectedAccount,
messageChannelId: data.messageChannelId,
inReplyTo: data.inReplyTo,
workspaceId,
});
} catch (persistenceError) {
this.logger.warn(
`Failed to persist sent message (sync will recover): ${persistenceError}`,
);
}
}
}
@@ -0,0 +1,185 @@
import { Injectable, Logger } from '@nestjs/common';
import { MessageParticipantRole } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
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 { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-participant.workspace-entity';
import { type MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { type SendMessageResult } from 'src/modules/messaging/message-outbound-manager/types/send-message-result.type';
type PersistSentMessageInput = {
sendResult: SendMessageResult;
subject: string;
body: string;
recipients: { to: string[]; cc: string[]; bcc: string[] };
connectedAccount: ConnectedAccountEntity;
messageChannelId: string;
inReplyTo?: string;
workspaceId: string;
};
@Injectable()
export class SentMessagePersistenceService {
private readonly logger = new Logger(SentMessagePersistenceService.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async persistSentMessage(input: PersistSentMessageInput): Promise<void> {
const authContext = buildSystemAuthContext(input.workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const messageRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageWorkspaceEntity>(
input.workspaceId,
'message',
);
const messageThreadRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageThreadWorkspaceEntity>(
input.workspaceId,
'messageThread',
);
const messageParticipantRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageParticipantWorkspaceEntity>(
input.workspaceId,
'messageParticipant',
);
const associationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
input.workspaceId,
'messageChannelMessageAssociation',
);
const messageThreadId = await this.findOrCreateThread({
messageRepository,
messageThreadRepository,
inReplyTo: input.inReplyTo,
subject: input.subject,
});
const messageId = v4();
await messageRepository.insert({
id: messageId,
headerMessageId: input.sendResult.headerMessageId,
subject: input.subject,
text: input.body,
receivedAt: new Date(),
messageThreadId,
});
const participants = this.buildParticipants(
messageId,
input.connectedAccount.handle ?? '',
input.recipients,
);
if (participants.length > 0) {
await messageParticipantRepository.insert(participants);
}
await associationRepository.insert({
messageChannelId: input.messageChannelId,
messageId,
messageExternalId: input.sendResult.messageExternalId ?? null,
messageThreadExternalId: input.sendResult.threadExternalId ?? null,
direction: MessageDirection.OUTGOING,
});
}, authContext);
}
private async findOrCreateThread({
messageRepository,
messageThreadRepository,
inReplyTo,
subject,
}: {
messageRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>;
messageThreadRepository: Awaited<
ReturnType<GlobalWorkspaceOrmManager['getRepository']>
>;
inReplyTo?: string;
subject: string;
}): Promise<string> {
if (inReplyTo) {
const parentMessage = await messageRepository.findOne({
where: { headerMessageId: inReplyTo },
});
if (parentMessage?.messageThreadId) {
return parentMessage.messageThreadId;
}
}
const threadId = v4();
await messageThreadRepository.insert({
id: threadId,
subject,
});
return threadId;
}
private buildParticipants(
messageId: string,
senderHandle: string,
recipients: { to: string[]; cc: string[]; bcc: string[] },
): Pick<
MessageParticipantWorkspaceEntity,
'messageId' | 'handle' | 'displayName' | 'role'
>[] {
const participants: Pick<
MessageParticipantWorkspaceEntity,
'messageId' | 'handle' | 'displayName' | 'role'
>[] = [];
participants.push({
messageId,
handle: senderHandle,
displayName: senderHandle,
role: MessageParticipantRole.FROM,
});
for (const email of recipients.to) {
participants.push({
messageId,
handle: email,
displayName: email,
role: MessageParticipantRole.TO,
});
}
for (const email of recipients.cc) {
participants.push({
messageId,
handle: email,
displayName: email,
role: MessageParticipantRole.CC,
});
}
for (const email of recipients.bcc) {
participants.push({
messageId,
handle: email,
displayName: email,
role: MessageParticipantRole.BCC,
});
}
return participants;
}
}
@@ -13,4 +13,5 @@ export type SendMessageInput = {
contentType: string;
}[];
inReplyTo?: string;
threadExternalId?: string;
};
@@ -0,0 +1,5 @@
export type SendMessageResult = {
headerMessageId: string;
messageExternalId?: string;
threadExternalId?: string;
};
@@ -0,0 +1,17 @@
// Extracts the RFC 2822 Message-ID header from a raw email buffer.
// Handles folded headers (continuation lines starting with whitespace).
// MailComposer always generates this header; if missing, falls back to empty string.
export const extractMessageIdFromBuffer = (messageBuffer: Buffer): string => {
const headerSection = messageBuffer.toString('utf-8').split('\r\n\r\n')[0];
if (!headerSection) {
return '';
}
// Unfold continuation lines (RFC 2822: lines starting with whitespace are continuations)
const unfolded = headerSection.replace(/\r\n([ \t])/g, '$1');
const match = unfolded.match(/^Message-ID:\s*(.+)$/im);
return match?.[1]?.trim() ?? '';
};