feat: add draft email workflow action (#17793)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
neo773
2026-02-13 22:50:20 +05:30
committed by GitHub
parent ebfaff0a53
commit 84afbb4d2c
64 changed files with 1451 additions and 614 deletions
@@ -10,6 +10,7 @@ import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects
import { EmailAliasManagerModule } from 'src/modules/connected-account/email-alias-manager/email-alias-manager.module';
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
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 { ImapFindSentFolderService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service';
import { ImapGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service';
import { ImapGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-messages.service';
@@ -39,6 +40,7 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
ImapMessagesImportErrorHandler,
ImapSyncService,
ImapMessageParserService,
ImapFindDraftsFolderService,
ImapFindSentFolderService,
ImapMessageTextExtractorService,
],
@@ -46,6 +48,7 @@ import { MessageParticipantManagerModule } from 'src/modules/messaging/message-p
ImapGetMessagesService,
ImapGetMessageListService,
ImapClientProvider,
ImapFindDraftsFolderService,
ImapFindSentFolderService,
],
})
@@ -0,0 +1,101 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ImapFlow, type ListResponse } from 'imapflow';
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
import { getStandardFolderByRegex } from 'src/modules/messaging/message-import-manager/drivers/utils/get-standard-folder-by-regex';
type DraftsFolderResult = {
name: string;
path: string;
} | null;
@Injectable()
export class ImapFindDraftsFolderService {
private readonly logger = new Logger(ImapFindDraftsFolderService.name);
public async findOrCreateDraftsFolder(
client: ImapFlow,
): Promise<DraftsFolderResult> {
try {
const list = await client.list();
const specialUseDraftsFolder = this.findDraftsFolderBySpecialUse(list);
if (specialUseDraftsFolder) {
return specialUseDraftsFolder;
}
const regexDraftsFolder = this.findDraftsFolderByRegex(list);
if (regexDraftsFolder) {
return regexDraftsFolder;
}
return await this.createDraftsFolder(client);
} catch (error) {
this.logger.error(
`Error finding drafts folder: ${error instanceof Error ? error.message : error}`,
);
return null;
}
}
private findDraftsFolderBySpecialUse(
list: ListResponse[],
): DraftsFolderResult {
for (const folder of list) {
if (folder.specialUse && folder.specialUse.includes('\\Drafts')) {
this.logger.debug(
`Found drafts folder via special-use flag: ${folder.path}`,
);
return {
name: folder.name,
path: folder.path,
};
}
}
return null;
}
private findDraftsFolderByRegex(list: ListResponse[]): DraftsFolderResult {
for (const folder of list) {
if (getStandardFolderByRegex(folder.name) === StandardFolder.DRAFTS) {
this.logger.debug(
`Found drafts folder via pattern match: ${folder.path}`,
);
return {
name: folder.name,
path: folder.path,
};
}
}
return null;
}
private async createDraftsFolder(
client: ImapFlow,
): Promise<DraftsFolderResult> {
try {
await client.mailboxCreate('Drafts');
this.logger.debug('Created drafts folder: Drafts');
return {
name: 'Drafts',
path: 'Drafts',
};
} catch (error) {
this.logger.error(
`Failed to create drafts folder: ${error instanceof Error ? error.message : error}`,
);
return null;
}
}
}
@@ -68,7 +68,7 @@ export class ImapFindSentFolderService {
): Promise<SentFolderResult> {
for (const folder of list) {
if (folder.specialUse && folder.specialUse.includes('\\Sent')) {
this.logger.log(
this.logger.debug(
`Found sent folder via special-use flag: ${folder.path}`,
);
@@ -108,7 +108,7 @@ export class ImapFindSentFolderService {
);
if (messageCount > 0) {
this.logger.log(
this.logger.debug(
`Selected sent folder via pattern match: ${folder.path}`,
);
@@ -120,7 +120,7 @@ export class ImapFindSentFolderService {
}
if (regexCandidateFolders.length > 0) {
this.logger.log(
this.logger.debug(
`Using first regex candidate sent folder: ${regexCandidateFolders[0].path} (no messages found in any regex candidate)`,
);
@@ -48,7 +48,6 @@ import { MessagingMessagesImportService } from 'src/modules/messaging/message-im
import { MessagingProcessFolderActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-folder-actions.service';
import { MessagingProcessGroupEmailActionsService } from 'src/modules/messaging/message-import-manager/services/messaging-process-group-email-actions.service';
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
@Module({
@@ -101,7 +100,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingGetMessagesService,
MessageImportExceptionHandlerService,
MessagingCursorService,
MessagingSendMessageService,
MessagingAccountAuthenticationService,
MessagingProcessFolderActionsService,
MessagingProcessGroupEmailActionsService,
@@ -109,7 +107,6 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingDeleteGroupEmailMessagesService,
],
exports: [
MessagingSendMessageService,
MessagingAccountAuthenticationService,
MessagingMessageListFetchCronCommand,
MessagingMessagesImportCronCommand,
@@ -1,225 +0,0 @@
import { Injectable } from '@nestjs/common';
import { google } from 'googleapis';
import MailComposer from 'nodemailer/lib/mail-composer';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import {
MessageImportDriverException,
MessageImportDriverExceptionCode,
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
import { toMicrosoftRecipients } from 'src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util';
type EmailAddress = string | string[];
type SendMessageInput = {
body: string;
subject: string;
to: EmailAddress;
cc?: EmailAddress;
bcc?: EmailAddress;
html: string;
attachments?: {
filename: string;
content: Buffer;
contentType: string;
}[];
};
@Injectable()
export class MessagingSendMessageService {
constructor(
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
private readonly smtpClientProvider: SmtpClientProvider,
private readonly imapClientProvider: ImapClientProvider,
) {}
public async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE: {
const oAuth2Client =
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
connectedAccount,
);
const gmailClient = google.gmail({
version: 'v1',
auth: oAuth2Client,
});
const peopleClient = google.people({
version: 'v1',
auth: oAuth2Client,
});
const { data: gmailData } = await gmailClient.users.getProfile({
userId: 'me',
});
const fromEmail = gmailData.emailAddress;
const { data: peopleData } = await peopleClient.people.get({
resourceName: 'people/me',
personFields: 'names',
});
const fromName = peopleData?.names?.[0]?.displayName;
const mail = new MailComposer({
from: isDefined(fromName)
? `"${mimeEncode(fromName)}" <${fromEmail}>`
: `${fromEmail}`,
to: sendMessageInput.to,
cc: sendMessageInput.cc,
bcc: sendMessageInput.bcc,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
...(sendMessageInput.attachments &&
sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
filename: attachment.filename,
content: attachment.content,
contentType: attachment.contentType,
})),
}
: {}),
});
const compiledMessage = mail.compile();
compiledMessage.keepBcc = true;
const messageBuffer = await compiledMessage.build();
const encodedMessage = Buffer.from(messageBuffer).toString('base64');
await gmailClient.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
},
});
break;
}
case ConnectedAccountProvider.MICROSOFT: {
const microsoftClient =
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
connectedAccount,
);
const message = {
subject: sendMessageInput.subject,
body: {
contentType: 'HTML',
content: sendMessageInput.html,
},
toRecipients: toMicrosoftRecipients(sendMessageInput.to),
ccRecipients: toMicrosoftRecipients(sendMessageInput.cc),
bccRecipients: toMicrosoftRecipients(sendMessageInput.bcc),
...(sendMessageInput.attachments &&
sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
'@odata.type': '#microsoft.graph.fileAttachment',
name: attachment.filename,
contentType: attachment.contentType,
contentBytes: attachment.content.toString('base64'),
})),
}
: {}),
};
const response = await microsoftClient
.api(`/me/messages`)
.post(message);
z.string().parse(response.id);
await microsoftClient.api(`/me/messages/${response.id}/send`).post({});
break;
}
case ConnectedAccountProvider.IMAP_SMTP_CALDAV: {
const { handle, connectionParameters, messageChannels } =
connectedAccount;
const smtpClient =
await this.smtpClientProvider.getSmtpClient(connectedAccount);
if (!isDefined(handle)) {
throw new MessageImportDriverException(
'Handle is required',
MessageImportDriverExceptionCode.CHANNEL_MISCONFIGURED,
);
}
const mail = new MailComposer({
from: handle,
to: sendMessageInput.to,
cc: sendMessageInput.cc,
bcc: sendMessageInput.bcc,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
...(sendMessageInput.attachments &&
sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
filename: attachment.filename,
content: attachment.content,
contentType: attachment.contentType,
})),
}
: {}),
});
const messageBuffer = await mail.compile().build();
await smtpClient.sendMail({
from: handle,
to: sendMessageInput.to,
cc: sendMessageInput.cc,
bcc: sendMessageInput.bcc,
raw: messageBuffer,
});
if (isDefined(connectionParameters?.IMAP)) {
const imapClient =
await this.imapClientProvider.getClient(connectedAccount);
const messageChannel = messageChannels.find(
(channel) => channel.handle === handle,
);
const sentFolder = messageChannel?.messageFolders.find(
(messageFolder) => messageFolder.isSentFolder,
);
if (isDefined(sentFolder) && isDefined(sentFolder.name)) {
await imapClient.append(sentFolder.name, messageBuffer);
}
await this.imapClientProvider.closeClient(imapClient);
}
break;
}
default:
assertUnreachable(
connectedAccount.provider,
`Provider ${connectedAccount.provider} not supported for sending messages`,
);
}
}
}
@@ -4,9 +4,7 @@ import { google } from 'googleapis';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { GmailMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/drivers/gmail/services/gmail-message-outbound.service';
jest.mock('nodemailer/lib/mail-composer', () => {
return jest.fn().mockImplementation(() => ({
@@ -16,8 +14,8 @@ jest.mock('nodemailer/lib/mail-composer', () => {
}));
});
describe('MessagingSendMessageService - Gmail HTML Support', () => {
let service: MessagingSendMessageService;
describe('GmailMessageOutboundService', () => {
let service: GmailMessageOutboundService;
const mockSend = jest.fn().mockResolvedValue({ data: { id: 'message-id' } });
@@ -54,7 +52,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
MessagingSendMessageService,
GmailMessageOutboundService,
{
provide: OAuth2ClientManagerService,
useValue: {
@@ -63,19 +61,11 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
.mockResolvedValue(mockOAuth2Client),
},
},
{
provide: SmtpClientProvider,
useValue: {},
},
{
provide: ImapClientProvider,
useValue: {},
},
],
}).compile();
service = module.get<MessagingSendMessageService>(
MessagingSendMessageService,
service = module.get<GmailMessageOutboundService>(
GmailMessageOutboundService,
);
});
@@ -105,7 +95,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
expect(mockSend).toHaveBeenCalledWith({
userId: 'me',
requestBody: {
raw: Buffer.from('mocked-email-content').toString('base64'),
raw: Buffer.from('mocked-email-content').toString('base64url'),
},
});
});
@@ -137,7 +127,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
expect(mockSend).toHaveBeenCalledWith({
userId: 'me',
requestBody: {
raw: Buffer.from('mocked-email-content').toString('base64'),
raw: Buffer.from('mocked-email-content').toString('base64url'),
},
});
});
@@ -0,0 +1,109 @@
import { Injectable } from '@nestjs/common';
import { type gmail_v1, google } from 'googleapis';
import MailComposer from 'nodemailer/lib/mail-composer';
import { isDefined } from 'twenty-shared/utils';
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-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 { toMailComposerOptions } from 'src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util';
@Injectable()
export class GmailMessageOutboundService implements MessageOutboundDriver {
constructor(
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
) {}
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const { gmailClient, encodedMessage } = await this.composeGmailMessage(
connectedAccount,
sendMessageInput,
);
await gmailClient.users.messages.send({
userId: 'me',
requestBody: {
raw: encodedMessage,
},
});
}
async createDraft(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const { gmailClient, encodedMessage } = await this.composeGmailMessage(
connectedAccount,
sendMessageInput,
);
await gmailClient.users.drafts.create({
userId: 'me',
requestBody: {
message: {
raw: encodedMessage,
},
},
});
}
private async composeGmailMessage(
connectedAccount: ConnectedAccountWorkspaceEntity,
sendMessageInput: SendMessageInput,
): Promise<{
gmailClient: gmail_v1.Gmail;
encodedMessage: string;
}> {
const oAuth2Client =
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
connectedAccount,
);
const gmailClient = google.gmail({
version: 'v1',
auth: oAuth2Client,
});
const peopleClient = google.people({
version: 'v1',
auth: oAuth2Client,
});
const { data: gmailData } = await gmailClient.users.getProfile({
userId: 'me',
});
const fromEmail = gmailData.emailAddress;
const { data: peopleData } = await peopleClient.people.get({
resourceName: 'people/me',
personFields: 'names',
});
const fromName = peopleData?.names?.[0]?.displayName;
const from = isDefined(fromName)
? `"${mimeEncode(fromName)}" <${fromEmail}>`
: `${fromEmail}`;
const mail = new MailComposer(
toMailComposerOptions(from, sendMessageInput),
);
const compiledMessage = mail.compile();
compiledMessage.keepBcc = true;
const messageBuffer = await compiledMessage.build();
const encodedMessage = Buffer.from(messageBuffer).toString('base64url');
return { gmailClient, encodedMessage };
}
}
@@ -0,0 +1,122 @@
import { Injectable } from '@nestjs/common';
import MailComposer from 'nodemailer/lib/mail-composer';
import { isDefined } from 'twenty-shared/utils';
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
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 { 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 { toMailComposerOptions } from 'src/modules/messaging/message-outbound-manager/utils/to-mail-composer-options.util';
@Injectable()
export class ImapSmtpMessageOutboundService implements MessageOutboundDriver {
constructor(
private readonly smtpClientProvider: SmtpClientProvider,
private readonly imapClientProvider: ImapClientProvider,
private readonly imapFindDraftsFolderService: ImapFindDraftsFolderService,
) {}
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const { handle, connectionParameters, messageChannels } = connectedAccount;
const smtpClient =
await this.smtpClientProvider.getSmtpClient(connectedAccount);
this.assertHandleIsDefined(handle);
const messageBuffer = await this.compileRawMessage(
handle,
sendMessageInput,
);
await smtpClient.sendMail({
from: handle,
to: sendMessageInput.to,
cc: sendMessageInput.cc,
bcc: sendMessageInput.bcc,
raw: messageBuffer,
});
if (isDefined(connectionParameters?.IMAP)) {
const imapClient =
await this.imapClientProvider.getClient(connectedAccount);
const messageChannel = messageChannels.find(
(channel) => channel.handle === handle,
);
const sentFolder = messageChannel?.messageFolders.find(
(messageFolder) => messageFolder.isSentFolder,
);
if (isDefined(sentFolder) && isDefined(sentFolder.name)) {
await imapClient.append(sentFolder.name, messageBuffer);
}
await this.imapClientProvider.closeClient(imapClient);
}
}
async createDraft(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const { handle, connectionParameters } = connectedAccount;
this.assertHandleIsDefined(handle);
if (!isDefined(connectionParameters?.IMAP)) {
throw new Error('IMAP connection is required to create drafts');
}
const messageBuffer = await this.compileRawMessage(
handle,
sendMessageInput,
);
const imapClient =
await this.imapClientProvider.getClient(connectedAccount);
try {
const draftsFolder =
await this.imapFindDraftsFolderService.findOrCreateDraftsFolder(
imapClient,
);
if (!isDefined(draftsFolder)) {
throw new Error('No drafts folder found and could not create one');
}
const DRAFT_FLAG = '\\Draft';
await imapClient.append(draftsFolder.path, messageBuffer, [DRAFT_FLAG]);
} finally {
await this.imapClientProvider.closeClient(imapClient);
}
}
private async compileRawMessage(
from: string,
sendMessageInput: SendMessageInput,
): Promise<Buffer> {
const mail = new MailComposer(
toMailComposerOptions(from, sendMessageInput),
);
return mail.compile().build();
}
private assertHandleIsDefined(
handle: string | null,
): asserts handle is string {
if (!isDefined(handle)) {
throw new Error('Handle is required');
}
}
}
@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
import { type MessageOutboundDriver } from 'src/modules/messaging/message-outbound-manager/interfaces/message-outbound-driver.interface';
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-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';
@Injectable()
export class MicrosoftMessageOutboundService implements MessageOutboundDriver {
constructor(
private readonly oAuth2ClientManagerService: OAuth2ClientManagerService,
) {}
async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const microsoftClient =
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
connectedAccount,
);
const message = this.composeMicrosoftMessage(sendMessageInput);
const response = await microsoftClient.api(`/me/messages`).post(message);
z.string().parse(response.id);
await microsoftClient.api(`/me/messages/${response.id}/send`).post({});
}
async createDraft(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
const microsoftClient =
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
connectedAccount,
);
const message = this.composeMicrosoftMessage(sendMessageInput);
await microsoftClient.api(`/me/messages`).post(message);
}
private composeMicrosoftMessage(
sendMessageInput: SendMessageInput,
): Record<string, unknown> {
return {
subject: sendMessageInput.subject,
body: {
contentType: 'HTML',
content: sendMessageInput.html,
},
toRecipients: toMicrosoftRecipients(sendMessageInput.to),
ccRecipients: toMicrosoftRecipients(sendMessageInput.cc),
bccRecipients: toMicrosoftRecipients(sendMessageInput.bcc),
...(sendMessageInput.attachments &&
sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
'@odata.type': '#microsoft.graph.fileAttachment',
name: attachment.filename,
contentType: attachment.contentType,
contentBytes: attachment.content.toString('base64'),
})),
}
: {}),
};
}
}
@@ -0,0 +1,14 @@
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
export type MessageOutboundDriver = {
sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void>;
createDraft(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void>;
};
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
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 { 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 { MessagingMessageOutboundService } from 'src/modules/messaging/message-outbound-manager/services/messaging-message-outbound.service';
@Module({
imports: [
OAuth2ClientManagerModule,
MessagingIMAPDriverModule,
MessagingSmtpDriverModule,
],
providers: [
GmailMessageOutboundService,
MicrosoftMessageOutboundService,
ImapSmtpMessageOutboundService,
MessagingMessageOutboundService,
],
exports: [MessagingMessageOutboundService],
})
export class MessagingSendManagerModule {}
@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
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 { SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
@Injectable()
export class MessagingMessageOutboundService {
constructor(
private readonly gmailMessageOutboundService: GmailMessageOutboundService,
private readonly microsoftMessageOutboundService: MicrosoftMessageOutboundService,
private readonly imapSmtpMessageOutboundService: ImapSmtpMessageOutboundService,
) {}
public async sendMessage(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailMessageOutboundService.sendMessage(
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftMessageOutboundService.sendMessage(
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.imapSmtpMessageOutboundService.sendMessage(
sendMessageInput,
connectedAccount,
);
default:
assertUnreachable(
connectedAccount.provider,
`Provider ${connectedAccount.provider} not supported for sending messages`,
);
}
}
public async createDraft(
sendMessageInput: SendMessageInput,
connectedAccount: ConnectedAccountWorkspaceEntity,
): Promise<void> {
switch (connectedAccount.provider) {
case ConnectedAccountProvider.GOOGLE:
return this.gmailMessageOutboundService.createDraft(
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.MICROSOFT:
return this.microsoftMessageOutboundService.createDraft(
sendMessageInput,
connectedAccount,
);
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
return this.imapSmtpMessageOutboundService.createDraft(
sendMessageInput,
connectedAccount,
);
default:
assertUnreachable(
connectedAccount.provider,
`Provider ${connectedAccount.provider} not supported for creating drafts`,
);
}
}
}
@@ -0,0 +1,15 @@
type EmailAddress = string | string[];
export type SendMessageInput = {
body: string;
subject: string;
to: EmailAddress;
cc?: EmailAddress;
bcc?: EmailAddress;
html: string;
attachments?: {
filename: string;
content: Buffer;
contentType: string;
}[];
};
@@ -0,0 +1,25 @@
import { type SendMessageInput } from 'src/modules/messaging/message-outbound-manager/types/send-message-input.type';
export const toMailComposerOptions = (
from: string,
sendMessageInput: SendMessageInput,
) => {
return {
from,
to: sendMessageInput.to,
cc: sendMessageInput.cc,
bcc: sendMessageInput.bcc,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
...(sendMessageInput.attachments && sendMessageInput.attachments.length > 0
? {
attachments: sendMessageInput.attachments.map((attachment) => ({
filename: attachment.filename,
content: attachment.content,
contentType: attachment.contentType,
})),
}
: {}),
};
};