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:
+1
@@ -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',
|
||||
|
||||
+1
@@ -19,4 +19,5 @@ export class MessageWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
messageCampaign: EntityRelation<MessageCampaignWorkspaceEntity> | null;
|
||||
messageCampaignId: string | null;
|
||||
deliveryStatus: string | null;
|
||||
isDraft: boolean;
|
||||
}
|
||||
|
||||
-1
@@ -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,
|
||||
];
|
||||
|
||||
+1
-1
@@ -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,6 +1 @@
|
||||
export const MESSAGING_GMAIL_EXCLUDED_SYSTEM_LABELS = [
|
||||
'TRASH',
|
||||
'SPAM',
|
||||
'DRAFT',
|
||||
'CHAT',
|
||||
];
|
||||
export const MESSAGING_GMAIL_EXCLUDED_SYSTEM_LABELS = ['TRASH', 'SPAM', 'CHAT'];
|
||||
|
||||
+1
-1
@@ -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', () => {
|
||||
|
||||
+28
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+15
-3
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+3
@@ -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
-5
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -35,6 +35,7 @@ export class InboundEmailParserService {
|
||||
direction: MessageDirection.INCOMING,
|
||||
attachments: [],
|
||||
participants: extractParticipantsFromParsedEmail(parsedEmail),
|
||||
isDraft: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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]
|
||||
|
||||
+1
@@ -163,6 +163,7 @@ export class MicrosoftGetMessagesService {
|
||||
messageFolderExternalIds: response.parentFolderId
|
||||
? [response.parentFolderId]
|
||||
: [],
|
||||
isDraft: response.isDraft ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+38
@@ -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: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+3
@@ -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 &&
|
||||
|
||||
+3
@@ -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[];
|
||||
|
||||
+10
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
+7
@@ -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> {
|
||||
|
||||
+75
-1
@@ -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,
|
||||
|
||||
+54
-1
@@ -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,
|
||||
|
||||
+29
-1
@@ -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,
|
||||
|
||||
+3
@@ -7,4 +7,7 @@ export class SendEmailOutputDTO {
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
error?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
messageThreadId?: string;
|
||||
}
|
||||
|
||||
+3
@@ -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[];
|
||||
}
|
||||
|
||||
+6
@@ -12,4 +12,10 @@ export type MessageOutboundDriver = {
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
): Promise<void>;
|
||||
|
||||
sendDraft(
|
||||
draftExternalId: string,
|
||||
sendMessageInput: SendMessageInput,
|
||||
connectedAccount: ConnectedAccountEntity,
|
||||
): Promise<SendMessageResult>;
|
||||
};
|
||||
|
||||
+5
@@ -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,
|
||||
],
|
||||
|
||||
+52
-16
@@ -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;
|
||||
|
||||
+171
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+39
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
-12
@@ -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,
|
||||
|
||||
+1
@@ -55,5 +55,6 @@ export const formatSentMessage = (
|
||||
direction: MessageDirection.OUTGOING,
|
||||
attachments: [],
|
||||
participants,
|
||||
isDraft: false,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user