feat(messaging): sync draft emails and edit them in the thread composer (#22178)

Stop excluding drafts from sync across all three providers (Gmail DRAFT
label, Microsoft/IMAP Drafts folder) and add an isDraft boolean field on
Message so drafts are queryable by the API and AI agents.

Drafts render in the thread with a Draft tag; clicking one opens the
existing reply composer pre-filled with the draft's recipients, subject
and body, and Send reuses the existing send-email flow.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22178?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-06-30 16:37:53 +05:30
committed by GitHub
parent c4a6446757
commit 9f3ebaaf22
60 changed files with 2057 additions and 1100 deletions
@@ -29,6 +29,7 @@ const createMockMessage = (
messageCampaign: null,
messageCampaignId: null,
deliveryStatus: null,
isDraft: false,
deletedAt: null,
createdAt: '2024-03-20T09:00:00Z',
updatedAt: '2024-03-20T09:00:00Z',
@@ -19,4 +19,5 @@ export class MessageWorkspaceEntity extends BaseWorkspaceEntity {
messageCampaign: EntityRelation<MessageCampaignWorkspaceEntity> | null;
messageCampaignId: string | null;
deliveryStatus: string | null;
isDraft: boolean;
}
@@ -1,7 +1,6 @@
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
export const MESSAGING_FOLDER_MANAGER_ALWAYS_EXCLUDED_FOLDERS = [
StandardFolder.DRAFTS,
StandardFolder.TRASH,
StandardFolder.JUNK,
];
@@ -5,6 +5,7 @@ describe('shouldCreateFolderByDefault', () => {
it('should allow creating user folders', () => {
expect(shouldCreateFolderByDefault(StandardFolder.INBOX)).toBe(true);
expect(shouldCreateFolderByDefault(StandardFolder.SENT)).toBe(true);
expect(shouldCreateFolderByDefault(StandardFolder.DRAFTS)).toBe(true);
});
it('should allow creating custom folders', () => {
@@ -13,7 +14,6 @@ describe('shouldCreateFolderByDefault', () => {
});
it('should prevent creating system-excluded folders', () => {
expect(shouldCreateFolderByDefault(StandardFolder.DRAFTS)).toBe(false);
expect(shouldCreateFolderByDefault(StandardFolder.TRASH)).toBe(false);
expect(shouldCreateFolderByDefault(StandardFolder.JUNK)).toBe(false);
});
@@ -1,6 +1 @@
export const MESSAGING_GMAIL_EXCLUDED_SYSTEM_LABELS = [
'TRASH',
'SPAM',
'DRAFT',
'CHAT',
];
export const MESSAGING_GMAIL_EXCLUDED_SYSTEM_LABELS = ['TRASH', 'SPAM', 'CHAT'];
@@ -190,8 +190,8 @@ describe('computeGmailExcludeSearchFilter', () => {
expect(result).toContain('-label:trash');
expect(result).toContain('-label:spam');
expect(result).toContain('-label:draft');
expect(result).toContain('-label:chat');
expect(result).not.toContain('-label:draft');
});
it('uses -category: syntax for category exclusions in ALL_FOLDERS mode', () => {
@@ -152,4 +152,32 @@ describe('parseAndFormatGmailMessage', () => {
expect(result).toBeNull();
});
it('should keep a draft missing a Message-ID header by synthesizing a fallback id', () => {
const result = parseAndFormatGmailMessage(
buildMessage(
[
{ name: 'From', value: 'me@example.com' },
{ name: 'To', value: 'alice@example.com' },
],
{ labelIds: ['DRAFT'] },
),
connectedAccount,
);
expect(result?.isDraft).toBe(true);
expect(result?.headerMessageId).toBe('draft-msg-1');
});
it('should still drop a non-draft message missing a Message-ID header', () => {
const result = parseAndFormatGmailMessage(
buildMessage([
{ name: 'From', value: 'sender@example.com' },
{ name: 'To', value: 'alice@example.com' },
]),
connectedAccount,
);
expect(result).toBeNull();
});
});
@@ -33,7 +33,18 @@ export const parseAndFormatGmailMessage = (
labelIds,
} = parseGmailMessage(message);
if (!isDefined(from) || !isDefined(headerMessageId) || !isDefined(threadId)) {
const isDraft = (labelIds ?? []).includes('DRAFT');
// Gmail may omit the Message-ID header on drafts; synthesize a stable id from
// the message id so drafts aren't dropped.
const resolvedHeaderMessageId =
headerMessageId ?? (isDraft ? `draft-${id}` : undefined);
if (
!isDefined(from) ||
!isDefined(resolvedHeaderMessageId) ||
!isDefined(threadId)
) {
return null;
}
@@ -58,13 +69,13 @@ export const parseAndFormatGmailMessage = (
(participant) => participant.role !== MessageParticipantRole.FROM,
);
if (!hasRecipientParticipant) {
if (!hasRecipientParticipant && !isDraft) {
return null;
}
return {
externalId: id,
headerMessageId,
headerMessageId: resolvedHeaderMessageId,
subject: subject || '',
messageThreadExternalId: threadId,
receivedAt: new Date(parseInt(internalDate)),
@@ -74,5 +85,6 @@ export const parseAndFormatGmailMessage = (
attachments,
messageFolderExternalIds: labelIds,
labelIds,
isDraft,
};
};
@@ -145,6 +145,7 @@ export class ImapGetMessagesService {
folderPath,
folderExternalId,
connectedAccount,
result.flags,
),
);
}
@@ -162,6 +163,7 @@ export class ImapGetMessagesService {
folderPath: string,
folderExternalId: string,
connectedAccount: Pick<ConnectedAccountEntity, 'handle' | 'handleAliases'>,
flags?: Set<string>,
): MessageWithParticipants {
const fromAddresses = extractAddressesFromParsedEmail(parsed.from);
const senderAddress = fromAddresses[0]?.address ?? '';
@@ -184,6 +186,7 @@ export class ImapGetMessagesService {
})),
participants: extractParticipantsFromParsedEmail(parsed),
messageFolderExternalIds: [folderExternalId],
isDraft: flags?.has('\\Draft') ?? false,
};
}
}
@@ -6,6 +6,7 @@ import PostalMime, { type Email as ParsedEmail } from 'postal-mime';
export type MessageParseResult = {
uid: number;
parsed: ParsedEmail | null;
flags?: Set<string>;
error?: Error;
};
@@ -40,7 +41,7 @@ export class ImapMessageParserService {
const messages = await client.fetchAll(
uidSet,
{ uid: true, source: true },
{ uid: true, source: true, flags: true },
{ uid: true },
);
@@ -80,22 +81,22 @@ export class ImapMessageParserService {
private async parseMessage(
message: FetchMessageObject,
): Promise<MessageParseResult> {
const { uid, source } = message;
const { uid, source, flags } = message;
if (!source) {
this.logger.debug(`No source content for message UID ${uid}`);
return { uid, parsed: null };
return { uid, parsed: null, flags };
}
try {
const parsed = await PostalMime.parse(source);
return { uid, parsed };
return { uid, parsed, flags };
} catch (error) {
this.logger.error(`Failed to parse message UID ${uid}: ${error.message}`);
return { uid, parsed: null, error: error as Error };
return { uid, parsed: null, flags, error: error as Error };
}
}
@@ -35,6 +35,7 @@ export class InboundEmailParserService {
direction: MessageDirection.INCOMING,
attachments: [],
participants: extractParticipantsFromParsedEmail(parsedEmail),
isDraft: false,
};
}
}
@@ -99,6 +99,7 @@ describe('Microsoft get messages service', () => {
role: MessageParticipantRole.TO,
},
],
isDraft: false,
attachments: [],
messageFolderExternalIds: responseExample1.body.parentFolderId
? [responseExample1.body.parentFolderId]
@@ -145,6 +146,7 @@ describe('Microsoft get messages service', () => {
role: MessageParticipantRole.CC,
},
],
isDraft: false,
attachments: [],
messageFolderExternalIds: responseExample2.body.parentFolderId
? [responseExample2.body.parentFolderId]
@@ -188,6 +190,7 @@ describe('Microsoft get messages service', () => {
role: MessageParticipantRole.FROM,
},
],
isDraft: false,
attachments: [],
messageFolderExternalIds: responseExample.body.parentFolderId
? [responseExample.body.parentFolderId]
@@ -163,6 +163,7 @@ export class MicrosoftGetMessagesService {
messageFolderExternalIds: response.parentFolderId
? [response.parentFolderId]
: [],
isDraft: response.isDraft ?? false,
};
});
@@ -24,6 +24,7 @@ type MessageAccumulator = {
| 'receivedAt'
| 'text'
| 'messageThreadId'
| 'isDraft'
>;
threadToCreate?: Pick<MessageThreadWorkspaceEntity, 'id' | 'subject'>;
messageChannelMessageAssociationToCreate?: Pick<
@@ -168,6 +169,7 @@ export class MessagingMessageService {
receivedAt: message.receivedAt,
text: message.text,
messageThreadId,
isDraft: message.isDraft,
};
messageAccumulator.messageToCreate = messageToCreate;
@@ -55,6 +55,7 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
text: 'Test content 1',
receivedAt: new Date(),
attachments: [],
isDraft: false,
messageThreadExternalId: 'thread-1',
direction: MessageDirection.OUTGOING,
participants: [
@@ -77,6 +78,7 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
text: 'Test content 2',
receivedAt: new Date(),
attachments: [],
isDraft: false,
messageThreadExternalId: 'thread-1',
direction: MessageDirection.INCOMING,
participants: [
@@ -329,4 +331,40 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
},
);
});
it('should not create contacts for unsent drafts', async () => {
await service.saveMessagesAndEnqueueContactCreation(
[
{
...mockMessages[0],
isDraft: true,
participants: [
{
role: MessageParticipantRole.FROM,
handle: 'test@example.com',
displayName: 'Test User',
},
{
role: MessageParticipantRole.TO,
handle: 'prospect@company.com',
displayName: 'Prospect',
},
],
},
],
mockMessageChannel,
mockConnectedAccount,
workspaceId,
);
expect(messageQueueService.add).toHaveBeenCalledWith(
CreateCompanyAndContactJob.name,
{
workspaceId,
connectedAccount: mockConnectedAccount,
source: FieldActorSource.EMAIL,
contactsToCreate: [],
},
);
});
});
@@ -96,7 +96,10 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
messageChannel.excludeNonProfessionalEmails &&
!isWorkEmail(participant.handle);
// Drafts are outgoing, so don't turn recipients of an
// unsent email into CRM contacts.
const shouldCreateContact =
!message.isDraft &&
!!participant.handle &&
!isParticipantConnectedAccount &&
!isExcludedByNonProfessionalEmails &&
@@ -25,6 +25,7 @@ export const messagingGetMessagesServiceGetMessages = [
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'AA-work-emails-external',
@@ -47,6 +48,7 @@ export const messagingGetMessagesServiceGetMessages = [
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'AA-personal-emails',
@@ -69,5 +71,6 @@ export const messagingGetMessagesServiceGetMessages = [
},
],
attachments: [],
isDraft: false,
},
] satisfies MessageWithParticipants[];
@@ -93,6 +93,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'support-message',
@@ -110,6 +111,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'regular-message',
@@ -127,6 +129,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
];
@@ -155,6 +158,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
];
@@ -176,6 +180,7 @@ describe('filterEmails', () => {
direction: MessageDirection.INCOMING,
participants: undefined as any,
attachments: [],
isDraft: false,
},
];
@@ -203,6 +208,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
];
@@ -236,6 +242,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'alias-sent-message',
@@ -258,6 +265,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'reply-from-john',
@@ -280,6 +288,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
{
externalId: 'incoming-from-noreply',
@@ -302,6 +311,7 @@ describe('filterEmails', () => {
},
],
attachments: [],
isDraft: false,
},
];
@@ -71,6 +71,13 @@ export class EmailGroupMessageOutboundService implements MessageOutboundDriver {
);
}
async sendDraft(): Promise<SendMessageResult> {
throw new MessageChannelException(
'Email handle channels do not support drafts.',
MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT,
);
}
private async resolveEmailingDomain(
connectedAccount: ConnectedAccountEntity,
): Promise<EmailingDomainEntity> {
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { type gmail_v1, google } from 'googleapis';
@@ -17,6 +17,8 @@ import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-ma
@Injectable()
export class GmailMessageOutboundService implements MessageOutboundDriver {
private readonly logger = new Logger(GmailMessageOutboundService.name);
constructor(
private readonly googleOAuth2ClientProvider: GoogleOAuth2ClientProvider,
) {}
@@ -67,6 +69,78 @@ export class GmailMessageOutboundService implements MessageOutboundDriver {
});
}
async sendDraft(
draftExternalId: string,
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<SendMessageResult> {
const sendResult = await this.sendMessage(
sendMessageInput,
connectedAccount,
);
try {
await this.deleteDraftByMessageId(connectedAccount, draftExternalId);
} catch (error) {
this.logger.warn(
`Failed to delete Gmail draft for message ${draftExternalId} after send: ${error}`,
);
}
return sendResult;
}
private async deleteDraftByMessageId(
connectedAccount: ConnectedAccountEntity,
messageId: string,
): Promise<void> {
const oAuth2Client = await this.googleOAuth2ClientProvider.getClient(
connectedAccount.id,
);
const gmailClient = google.gmail({ version: 'v1', auth: oAuth2Client });
const draftId = await this.findDraftIdByMessageId(gmailClient, messageId);
if (isDefined(draftId)) {
await gmailClient.users.drafts.delete({ userId: 'me', id: draftId });
return;
}
this.logger.warn(
`No Gmail draft found for message ${messageId}; skipping delete`,
);
}
private async findDraftIdByMessageId(
gmailClient: gmail_v1.Gmail,
messageId: string,
): Promise<string | undefined> {
let pageToken: string | undefined = undefined;
do {
const { data }: { data: gmail_v1.Schema$ListDraftsResponse } =
await gmailClient.users.drafts.list({
userId: 'me',
maxResults: 500,
pageToken,
});
const draft = (data.drafts ?? []).find(
(currentDraft) => currentDraft.message?.id === messageId,
);
if (isDefined(draft?.id)) {
return draft.id;
}
pageToken = data.nextPageToken ?? undefined;
} while (isDefined(pageToken));
return undefined;
}
private async composeGmailMessage(
connectedAccount: ConnectedAccountEntity,
sendMessageInput: SendMessageInput,
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import MailComposer from 'nodemailer/lib/mail-composer';
@@ -13,6 +13,7 @@ import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connect
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { ImapFindDraftsFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-drafts-folder.service';
import { getImapFolderPath } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/get-imap-folder-path.util';
import { parseMessageId } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-message-id.util';
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';
@@ -21,6 +22,8 @@ import { toMailComposerOptions } from 'src/modules/messaging/message-outbound-ma
@Injectable()
export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
private readonly logger = new Logger(ImapSmtpMessageOutboundService.name);
constructor(
private readonly smtpClientProvider: SmtpClientProvider,
private readonly imapClientProvider: ImapClientProvider,
@@ -131,6 +134,56 @@ export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
}
}
async sendDraft(
draftExternalId: string,
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<SendMessageResult> {
const sendResult = await this.sendMessage(
sendMessageInput,
connectedAccount,
);
try {
await this.deleteDraft(draftExternalId, connectedAccount);
} catch (error) {
this.logger.warn(
`Failed to delete IMAP draft ${draftExternalId} after send: ${error}`,
);
}
return sendResult;
}
async deleteDraft(
externalId: string,
connectedAccount: ConnectedAccountEntity,
): Promise<void> {
const parsedMessageId = parseMessageId(externalId);
if (!isDefined(parsedMessageId)) {
throw new Error(
`Could not resolve IMAP drafts folder and uid from external id ${externalId}`,
);
}
const imapClient = await this.imapClientProvider.getClient(
connectedAccount.id,
);
try {
const lock = await imapClient.getMailboxLock(parsedMessageId.folder);
try {
await imapClient.messageDelete(`${parsedMessageId.uid}`, { uid: true });
} finally {
lock.release();
}
} finally {
await this.imapClientProvider.closeClient(imapClient);
}
}
private async compileRawMessage(
from: string,
sendMessageInput: SendMessageInput,
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
@@ -12,6 +12,8 @@ import { isDefined } from 'twenty-shared/utils';
@Injectable()
export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
private readonly logger = new Logger(MicrosoftMessageOutboundService.name);
constructor(
private readonly microsoftOAuth2ClientProvider: MicrosoftOAuth2ClientProvider,
) {}
@@ -50,6 +52,32 @@ export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
await this.createDraftMessage(microsoftClient, sendMessageInput);
}
async sendDraft(
draftExternalId: string,
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<SendMessageResult> {
const sendResult = await this.sendMessage(
sendMessageInput,
connectedAccount,
);
const microsoftClient = await this.microsoftOAuth2ClientProvider.getClient(
connectedAccount.id,
);
await microsoftClient
.api(`/me/messages/${draftExternalId}`)
.delete()
.catch((error) =>
this.logger.warn(
`Failed to delete Microsoft draft ${draftExternalId} after send: ${error}`,
),
);
return sendResult;
}
private async createDraftMessage(
microsoftClient: MicrosoftGraphClient,
sendMessageInput: SendMessageInput,
@@ -7,4 +7,7 @@ export class SendEmailOutputDTO {
@Field(() => String, { nullable: true })
error?: string;
@Field(() => String, { nullable: true })
messageThreadId?: string;
}
@@ -32,6 +32,9 @@ export class SendEmailInput {
@Field(() => String, { nullable: true })
inReplyTo?: string;
@Field(() => String, { nullable: true })
draftMessageId?: string;
@Field(() => [SendEmailAttachmentInput], { nullable: true })
files?: SendEmailAttachmentInput[];
}
@@ -12,4 +12,10 @@ export type MessageOutboundDriver = {
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<void>;
sendDraft(
draftExternalId: string,
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<SendMessageResult>;
};
@@ -10,10 +10,12 @@ import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
import { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.module';
import { EmailGroupMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/email-group/services/email-group-message-outbound.service';
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
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 { MessagingDraftSendService } from 'src/modules/messaging/message-outbound-manager/services/messaging-draft-send.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';
@@ -24,6 +26,7 @@ import { SentMessagePersistenceService } from 'src/modules/messaging/message-out
MessagingIMAPDriverModule,
MessagingSmtpDriverModule,
MessagingImportManagerModule,
MessagingMessageCleanerModule,
EmailingModule,
TypeOrmModule.forFeature([
MessageChannelEntity,
@@ -37,12 +40,14 @@ import { SentMessagePersistenceService } from 'src/modules/messaging/message-out
ImapSmtpMessageOutboundService,
EmailGroupMessageOutboundService,
MessagingMessageOutboundService,
MessagingDraftSendService,
SendEmailService,
SentMessagePersistenceService,
provideWorkspaceScopedRepository(EmailingDomainEntity),
],
exports: [
MessagingMessageOutboundService,
MessagingDraftSendService,
SendEmailService,
SentMessagePersistenceService,
],
@@ -23,6 +23,8 @@ import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/con
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';
import { isDefined } from 'twenty-shared/utils';
import { isNonEmptyString } from '@sniptt/guards';
@MetadataResolver()
@UsePipes(ResolverValidationPipe)
@@ -79,26 +81,60 @@ export class SendEmailResolver {
const { data } = result;
const sendResult = await this.sendEmailService.sendComposedEmail(data);
const sendResult = isDefined(input.draftMessageId)
? await this.sendEmailService.sendComposedDraft(
data,
input.draftMessageId,
workspace.id,
)
: await this.sendEmailService.sendComposedEmail(data);
if (data.shouldPersistMessage) {
await this.sendEmailService.persistSentMessage(
sendResult,
data,
workspace.id,
let messageThreadId: string | undefined;
try {
if (data.shouldPersistMessage) {
await this.sendEmailService.persistSentMessage(
sendResult,
data,
workspace.id,
);
}
if (isDefined(input.draftMessageId)) {
await this.sendEmailService.deleteSentDraft(
input.draftMessageId,
input.connectedAccountId,
workspace.id,
);
}
const sentMessageExternalId =
sendResult.messageExternalId ?? sendResult.headerMessageId;
messageThreadId =
isDefined(input.draftMessageId) &&
isNonEmptyString(sentMessageExternalId)
? await this.sendEmailService.getSentMessageThreadId(
sentMessageExternalId,
workspace.id,
)
: undefined;
const attachmentFileIds = (input.files ?? []).map((file) => file.id);
if (attachmentFileIds.length > 0) {
await this.fileEmailAttachmentService.deleteFiles({
fileIds: attachmentFileIds,
workspaceId: workspace.id,
});
}
} catch (postSendError) {
this.logger.warn(
`Email sent but post-send cleanup failed (sync will recover): ${postSendError}`,
);
}
const attachmentFileIds = (input.files ?? []).map((file) => file.id);
if (attachmentFileIds.length > 0) {
await this.fileEmailAttachmentService.deleteFiles({
fileIds: attachmentFileIds,
workspaceId: workspace.id,
});
}
return { success: true };
return { success: true, messageThreadId };
} catch (error) {
if (error instanceof ForbiddenException) {
throw error;
@@ -0,0 +1,171 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
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 { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessagingMessageCleanerService } from 'src/modules/messaging/message-cleaner/services/messaging-message-cleaner.service';
import { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
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';
@Injectable()
export class MessagingDraftSendService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly messageOutboundService: MessagingMessageOutboundService,
private readonly messageCleanerService: MessagingMessageCleanerService,
) {}
async sendDraftMessage({
draftMessageId,
sendMessageInput,
connectedAccount,
workspaceId,
}: {
draftMessageId: string;
sendMessageInput: SendMessageInput;
connectedAccount: ConnectedAccountEntity;
workspaceId: string;
}): Promise<SendMessageResult> {
const draftAssociation = await this.resolveDraftAssociation(
draftMessageId,
connectedAccount.id,
workspaceId,
);
if (!isDefined(draftAssociation)) {
throw new Error(
`Could not find a synced draft to send for message ${draftMessageId}`,
);
}
return this.messageOutboundService.sendDraft(
draftAssociation.messageExternalId,
sendMessageInput,
connectedAccount,
);
}
async getSentMessageThreadId({
messageExternalId,
workspaceId,
}: {
messageExternalId: string;
workspaceId: string;
}): Promise<string | undefined> {
const authContext = buildSystemAuthContext(workspaceId);
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageChannelMessageAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
);
const association =
await messageChannelMessageAssociationRepository.findOne({
where: { messageExternalId },
relations: ['message'],
});
return association?.message?.messageThreadId ?? undefined;
},
authContext,
{ lite: true },
);
}
async deleteSentDraft({
draftMessageId,
connectedAccountId,
workspaceId,
}: {
draftMessageId: string;
connectedAccountId: string;
workspaceId: string;
}): Promise<void> {
const draftAssociation = await this.resolveDraftAssociation(
draftMessageId,
connectedAccountId,
workspaceId,
);
if (!isDefined(draftAssociation)) {
return;
}
await this.messageCleanerService.deleteMessagesChannelMessageAssociationsAndRelatedOrphans(
{
workspaceId,
messageExternalIds: [draftAssociation.messageExternalId],
messageChannelId: draftAssociation.messageChannelId,
},
);
}
// Scoped to the caller's own channels so a member cannot act on another
// member's draft by passing its message id.
private async resolveDraftAssociation(
draftMessageId: string,
connectedAccountId: string,
workspaceId: string,
): Promise<{ messageExternalId: string; messageChannelId: string } | null> {
const authContext = buildSystemAuthContext(workspaceId);
const associations =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const channels = await messageChannelRepository.find({
where: { connectedAccountId },
});
const channelIds = channels.map((channel) => channel.id);
if (channelIds.length === 0) {
return [];
}
const messageChannelMessageAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
);
return messageChannelMessageAssociationRepository.find({
where: {
messageId: draftMessageId,
messageChannelId: In(channelIds),
},
});
},
authContext,
{ lite: true },
);
const association = associations.find((currentAssociation) =>
isNonEmptyString(currentAssociation.messageExternalId),
);
if (!association || !isNonEmptyString(association.messageExternalId)) {
return null;
}
return {
messageExternalId: association.messageExternalId,
messageChannelId: association.messageChannelId,
};
}
}
@@ -93,4 +93,43 @@ export class MessagingMessageOutboundService {
);
}
}
public async sendDraft(
draftExternalId: string,
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountEntity,
): Promise<SendMessageResult> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailMessageOutboundService.sendDraft(
draftExternalId,
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftMessageOutboundService.sendDraft(
draftExternalId,
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.imapSmtpMessageOutboundService.sendDraft(
draftExternalId,
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.EMAIL_GROUP:
case ConnectedAccountProvider.OIDC:
case ConnectedAccountProvider.SAML:
case ConnectedAccountProvider.APP:
throw new Error(
`Provider ${connectedAccount.provider} does not support sending drafts`,
);
default:
assertUnreachable(
connectedAccount.provider,
`Provider ${connectedAccount.provider} not supported for sending drafts`,
);
}
}
}
@@ -1,8 +1,10 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ComposedEmail } from 'src/engine/core-modules/tool/tools/email-tool/types/composed-email.type';
import { MessagingDraftSendService } from 'src/modules/messaging/message-outbound-manager/services/messaging-draft-send.service';
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 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()
@@ -11,27 +13,67 @@ export class SendEmailService {
constructor(
private readonly messageOutboundService: MessagingMessageOutboundService,
private readonly messagingDraftSendService: MessagingDraftSendService,
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,
references: data.references,
},
this.toSendMessageInput(data),
data.connectedAccount,
);
}
async sendComposedDraft(
data: ComposedEmail,
draftMessageId: string,
workspaceId: string,
): Promise<SendMessageResult> {
return this.messagingDraftSendService.sendDraftMessage({
draftMessageId,
sendMessageInput: this.toSendMessageInput(data),
connectedAccount: data.connectedAccount,
workspaceId,
});
}
async deleteSentDraft(
draftMessageId: string,
connectedAccountId: string,
workspaceId: string,
): Promise<void> {
await this.messagingDraftSendService.deleteSentDraft({
draftMessageId,
connectedAccountId,
workspaceId,
});
}
async getSentMessageThreadId(
messageExternalId: string,
workspaceId: string,
): Promise<string | undefined> {
return this.messagingDraftSendService.getSentMessageThreadId({
messageExternalId,
workspaceId,
});
}
private toSendMessageInput(data: ComposedEmail): SendMessageInput {
return {
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,
references: data.references,
};
}
async persistSentMessage(
sendResult: SendMessageResult,
data: ComposedEmail,
@@ -55,5 +55,6 @@ export const formatSentMessage = (
direction: MessageDirection.OUTGOING,
attachments: [],
participants,
isDraft: false,
};
};