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;
|
||||
};
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ export enum EngineComponentKey {
|
||||
TRIGGER_WORKFLOW_VERSION = 'TRIGGER_WORKFLOW_VERSION',
|
||||
FRONT_COMPONENT_RENDERER = 'FRONT_COMPONENT_RENDERER',
|
||||
REPLY_TO_EMAIL_THREAD = 'REPLY_TO_EMAIL_THREAD',
|
||||
COMPOSE_EMAIL = 'COMPOSE_EMAIL',
|
||||
|
||||
// Deprecated keys kept for backward compatibility until migration runs
|
||||
DELETE_SINGLE_RECORD = 'DELETE_SINGLE_RECORD',
|
||||
|
||||
+10
-4
@@ -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,
|
||||
});
|
||||
|
||||
+19
-1
@@ -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
|
||||
|
||||
+14
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user