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:
+6
-1
@@ -1,16 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { AddMessageIsDraftFieldCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1810000005000-add-message-is-draft-field.command';
|
||||
import { NormalizeLegacyIndexNamesCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-workspace-command-1799200000000-normalize-legacy-index-names.command';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApplicationModule,
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceIteratorModule,
|
||||
WorkspaceMigrationModule,
|
||||
WorkspaceSchemaManagerModule,
|
||||
],
|
||||
providers: [NormalizeLegacyIndexNamesCommand],
|
||||
providers: [AddMessageIsDraftFieldCommand, NormalizeLegacyIndexNamesCommand],
|
||||
})
|
||||
export class V2_18_UpgradeVersionCommandModule {}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { Command } from 'nest-commander';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
const MESSAGE_OBJECT_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-3f6b-4425-80ab-e468899ab4b2';
|
||||
const MESSAGE_IS_DRAFT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'20202020-4d3a-4b6e-9c1f-2a5e7b9d0c34';
|
||||
|
||||
@RegisteredWorkspaceCommand('2.18.0', 1810000005000)
|
||||
@Command({
|
||||
name: 'upgrade:2-18:add-message-is-draft-field',
|
||||
description:
|
||||
'Add the Message isDraft field metadata and column to existing workspaces',
|
||||
})
|
||||
export class AddMessageIsDraftFieldCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatFieldMetadataMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
]);
|
||||
|
||||
const messageObjectMetadata =
|
||||
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
universalIdentifier: MESSAGE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
|
||||
if (!isDefined(messageObjectMetadata)) {
|
||||
this.logger.log(
|
||||
`Message object does not exist for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIsDraftFieldMetadata =
|
||||
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
universalIdentifier: MESSAGE_IS_DRAFT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
|
||||
if (isDefined(existingIsDraftFieldMetadata)) {
|
||||
this.logger.log(
|
||||
`Message isDraft field already present for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { twentyStandardFlatApplication } =
|
||||
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
|
||||
computeTwentyStandardApplicationAllFlatEntityMaps({
|
||||
now: new Date().toISOString(),
|
||||
workspaceId,
|
||||
twentyStandardApplicationId: twentyStandardFlatApplication.id,
|
||||
});
|
||||
|
||||
const isDraftFlatFieldMetadata =
|
||||
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
|
||||
flatEntityMaps: standardAllFlatEntityMaps.flatFieldMetadataMaps,
|
||||
universalIdentifier: MESSAGE_IS_DRAFT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
|
||||
if (!isDefined(isDraftFlatFieldMetadata)) {
|
||||
throw new Error(
|
||||
'Standard application is missing the Message isDraft field metadata',
|
||||
);
|
||||
}
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would create Message isDraft field for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
isSystemBuild: true,
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
fieldMetadata: {
|
||||
flatEntityToCreate: [isDraftFlatFieldMetadata],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [],
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier:
|
||||
twentyStandardFlatApplication.universalIdentifier,
|
||||
},
|
||||
);
|
||||
|
||||
if (validateAndBuildResult.status === 'fail') {
|
||||
this.logger.error(
|
||||
`Failed to create Message isDraft field:\n${JSON.stringify(
|
||||
validateAndBuildResult,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Failed to create Message isDraft field for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Created Message isDraft field for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,7 @@ export class TimelineThreadDTO {
|
||||
|
||||
@Field()
|
||||
participantCount: number;
|
||||
|
||||
@Field()
|
||||
lastMessageIsDraft: boolean;
|
||||
}
|
||||
|
||||
+1
@@ -106,6 +106,7 @@ export class TimelineMessagingService {
|
||||
lastMessageBody: lastMessage.text ?? '',
|
||||
lastMessageReceivedAt: lastMessage.receivedAt ?? new Date(),
|
||||
numberOfMessagesInThread: messageThread.messages.length,
|
||||
lastMessageIsDraft: lastMessage.isDraft ?? false,
|
||||
};
|
||||
}),
|
||||
totalNumberOfThreads,
|
||||
|
||||
+940
-937
File diff suppressed because it is too large
Load Diff
+20
@@ -415,4 +415,24 @@ export const buildMessageStandardFlatFieldMetadatas = ({
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
isDraft: createStandardFieldFlatMetadata({
|
||||
objectName,
|
||||
workspaceId,
|
||||
context: {
|
||||
fieldName: 'isDraft',
|
||||
type: FieldMetadataType.BOOLEAN,
|
||||
label: i18nLabel(msg`Is draft`),
|
||||
description: i18nLabel(
|
||||
msg`Whether this message is an unsent draft synced from the provider`,
|
||||
),
|
||||
icon: 'IconPencil',
|
||||
isNullable: false,
|
||||
isUIEditable: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
standardObjectMetadataRelatedEntityIds,
|
||||
dependencyFlatEntityMaps,
|
||||
twentyStandardApplicationId,
|
||||
now,
|
||||
}),
|
||||
});
|
||||
|
||||
+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