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:
+17
-7
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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(
|
||||
|
||||
+32
-9
@@ -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(
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+9
-1
@@ -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 {}
|
||||
|
||||
+97
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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 {}
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+56
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+185
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
@@ -13,4 +13,5 @@ export type SendMessageInput = {
|
||||
contentType: string;
|
||||
}[];
|
||||
inReplyTo?: string;
|
||||
threadExternalId?: string;
|
||||
};
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type SendMessageResult = {
|
||||
headerMessageId: string;
|
||||
messageExternalId?: string;
|
||||
threadExternalId?: string;
|
||||
};
|
||||
+17
@@ -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() ?? '';
|
||||
};
|
||||
Reference in New Issue
Block a user