feat: add email forwarding message channel (#19535)
## Summary - Add email forwarding as a new message channel type, allowing users to forward emails from addresses like `support@mycompany.com` into Twenty - Inbound emails arrive via S3 (SES → S3 bucket), are polled by a cron job, parsed, routed to the correct workspace/channel, and persisted as messages - Dedicated settings page at `/settings/accounts/new-email-forwarding` where users provide their source email handle and receive a unique forwarding address - Forwarding channels bypass the IMAP/mailbox sync state machine — they skip cron-driven sync, relaunch, and message-list-fetch lifecycle stages - Forwarding address section shown at the top of the Emails settings page so users can find/copy their addresses after initial setup - Tab names for forwarding channels display the user-provided handle (e.g. `support@mycompany.com`) instead of the internal routing address - Shared utilities extracted from IMAP driver: `extractThreadId`, `extractParticipants`, `extractAddresses` to avoid code duplication - Uses the existing S3 bucket (STORAGE_S3_*) with `inbound-email/` prefix — no separate bucket needed - Feature gated behind `isEmailForwardingEnabled` client config (requires `INBOUND_EMAIL_DOMAIN` + S3 storage) ## New backend modules - `InboundEmailS3ClientProvider` — lazy-initialized S3 client using existing storage config - `InboundEmailStorageService` — S3 operations (get, move to processed/unmatched/failed) - `InboundEmailParserService` — RFC 822 parsing via `postal-mime`, builds `MessageWithParticipants` - `InboundEmailImportService` — orchestrates download → parse → route → persist → archive - `MessagingInboundEmailPollCronJob` — polls S3 `incoming/` prefix, enqueues import jobs - `CreateEmailForwardingChannelInput` DTO — accepts user-provided `handle` ## New frontend components - `SettingsAccountsNewEmailForwardingChannel` — dedicated page with handle input form + forwarding address result - `SettingsAccountsEmailForwardingSection` — forwarding address list on the Emails settings page - `useConnectedAccountHandleMap` — shared hook for account ID → handle lookup - `useCreateEmailForwardingChannel` — mutation hook accepting handle parameter ## Test plan - [x] 17 unit tests for inbound email import service (all outcomes: imported, unmatched, loop_dropped, unconfigured, parse_failed, persist_failed) - [x] 16 tests for `computeSyncStatus` including EMAIL_FORWARDING cases - [x] 11 tests for `extractEnvelopeRecipient` utility - [x] TypeScript typechecks pass for both twenty-server and twenty-front - [x] Lint passes for both packages - [ ] Manual: create forwarding channel, verify forwarding address generated - [ ] Manual: send email to forwarding address, verify it appears in Twenty https://claude.ai/code/session_01KpyF6p4cUEnuaT4h8DP5Pm --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com> Co-authored-by: neo773 <neo773@protonmail.com>
This commit is contained in:
+3
-1
@@ -1,12 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
CalendarChannelSyncStage,
|
||||
CalendarChannelSyncStatus,
|
||||
MessageChannelSyncStage,
|
||||
MessageChannelType,
|
||||
} from 'twenty-shared/types';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
@@ -63,6 +64,7 @@ export class ChannelSyncService {
|
||||
where: {
|
||||
connectedAccountId,
|
||||
syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION,
|
||||
type: Not(MessageChannelType.EMAIL_GROUP),
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ export class EmailAliasManagerService {
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
case ConnectedAccountProvider.OIDC:
|
||||
case ConnectedAccountProvider.SAML:
|
||||
case ConnectedAccountProvider.EMAIL_GROUP:
|
||||
case ConnectedAccountProvider.APP:
|
||||
handleAliases = [];
|
||||
break;
|
||||
|
||||
+2
@@ -120,6 +120,7 @@ export class ConnectedAccountRefreshTokensService {
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
case ConnectedAccountProvider.OIDC:
|
||||
case ConnectedAccountProvider.SAML:
|
||||
case ConnectedAccountProvider.EMAIL_GROUP:
|
||||
return true;
|
||||
default:
|
||||
return assertUnreachable(
|
||||
@@ -152,6 +153,7 @@ export class ConnectedAccountRefreshTokensService {
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
case ConnectedAccountProvider.OIDC:
|
||||
case ConnectedAccountProvider.SAML:
|
||||
case ConnectedAccountProvider.EMAIL_GROUP:
|
||||
throw new ConnectedAccountRefreshAccessTokenException(
|
||||
`Token refresh is not supported for ${connectedAccount.provider} provider for connected account ${connectedAccount.id} in workspace ${workspaceId}`,
|
||||
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
|
||||
|
||||
+6
-2
@@ -2,9 +2,12 @@ import { Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
|
||||
import { MessageChannelSyncStage } from 'twenty-shared/types';
|
||||
import {
|
||||
MessageChannelSyncStage,
|
||||
MessageChannelType,
|
||||
} from 'twenty-shared/types';
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
@@ -57,6 +60,7 @@ export class MessagingMessageListFetchCronJob {
|
||||
workspaceId: activeWorkspace.id,
|
||||
isSyncEnabled: true,
|
||||
syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING,
|
||||
type: Not(MessageChannelType.EMAIL_GROUP),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+6
-2
@@ -3,9 +3,12 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
|
||||
import { MessageChannelSyncStage } from 'twenty-shared/types';
|
||||
import {
|
||||
MessageChannelSyncStage,
|
||||
MessageChannelType,
|
||||
} from 'twenty-shared/types';
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
@@ -58,6 +61,7 @@ export class MessagingMessagesImportCronJob {
|
||||
workspaceId: activeWorkspace.id,
|
||||
isSyncEnabled: true,
|
||||
syncStage: MessageChannelSyncStage.MESSAGES_IMPORT_PENDING,
|
||||
type: Not(MessageChannelType.EMAIL_GROUP),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+3
-1
@@ -1,11 +1,12 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
MessageChannelSyncStage,
|
||||
MessageChannelSyncStatus,
|
||||
MessageChannelType,
|
||||
} from 'twenty-shared/types';
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
@@ -61,6 +62,7 @@ export class MessagingRelaunchFailedMessageChannelsCronJob {
|
||||
where: {
|
||||
syncStage: MessageChannelSyncStage.FAILED,
|
||||
syncStatus: MessageChannelSyncStatus.FAILED_UNKNOWN,
|
||||
type: Not(MessageChannelType.EMAIL_GROUP),
|
||||
workspaceId: In(activeWorkspaceIds),
|
||||
},
|
||||
})
|
||||
|
||||
+10
-68
@@ -1,8 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type ImapFlow } from 'imapflow';
|
||||
import { Address, type Email as ParsedMail } from 'postal-mime';
|
||||
import { MessageParticipantRole } from 'twenty-shared/types';
|
||||
import { type Email as ParsedMail } from 'postal-mime';
|
||||
|
||||
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util';
|
||||
@@ -11,9 +10,10 @@ import { ImapMessageParserService } from 'src/modules/messaging/message-import-m
|
||||
import { ImapMessageTextExtractorService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-message-text-extractor.service';
|
||||
import { ImapMessagesImportErrorHandler } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-messages-import-error-handler.service';
|
||||
import { parseMessageId } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-message-id.util';
|
||||
import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address';
|
||||
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
|
||||
import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util';
|
||||
import { extractAddressesFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util';
|
||||
import { extractParticipantsFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util';
|
||||
import { extractThreadIdFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-thread-id-from-parsed-email.util';
|
||||
import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util';
|
||||
|
||||
type ConnectedAccount = Pick<
|
||||
@@ -164,7 +164,7 @@ export class ImapGetMessagesService {
|
||||
folderExternalId: string,
|
||||
connectedAccount: Pick<ConnectedAccountEntity, 'handle' | 'handleAliases'>,
|
||||
): MessageWithParticipants {
|
||||
const fromAddresses = this.extractAddresses(parsed.from);
|
||||
const fromAddresses = extractAddressesFromParsedEmail(parsed.from);
|
||||
const senderAddress = fromAddresses[0]?.address ?? '';
|
||||
|
||||
const text = sanitizeString(
|
||||
@@ -173,75 +173,17 @@ export class ImapGetMessagesService {
|
||||
|
||||
return {
|
||||
externalId: `${folderPath}:${uid}`,
|
||||
messageThreadExternalId: this.extractThreadId(parsed),
|
||||
messageThreadExternalId: extractThreadIdFromParsedEmail(parsed),
|
||||
headerMessageId: parsed.messageId || String(uid),
|
||||
subject: sanitizeString(parsed.subject || ''),
|
||||
text,
|
||||
receivedAt: parsed.date ? new Date(parsed.date) : null,
|
||||
direction: computeMessageDirection(senderAddress, connectedAccount),
|
||||
attachments: this.extractAttachments(parsed),
|
||||
participants: this.extractParticipants(parsed),
|
||||
attachments: (parsed.attachments || []).map((attachment) => ({
|
||||
filename: attachment.filename || 'unnamed-attachment',
|
||||
})),
|
||||
participants: extractParticipantsFromParsedEmail(parsed),
|
||||
messageFolderExternalIds: [folderExternalId],
|
||||
};
|
||||
}
|
||||
|
||||
private extractThreadId(parsed: ParsedMail): string {
|
||||
if (Array.isArray(parsed.references) && parsed.references[0]?.trim()) {
|
||||
return parsed.references[0].trim();
|
||||
}
|
||||
|
||||
if (parsed.inReplyTo) {
|
||||
const inReplyTo = String(parsed.inReplyTo).trim();
|
||||
|
||||
if (inReplyTo) {
|
||||
return inReplyTo;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.messageId?.trim()) {
|
||||
return parsed.messageId.trim();
|
||||
}
|
||||
|
||||
return `thread-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
private extractParticipants(parsed: ParsedMail) {
|
||||
const addressFields = [
|
||||
{ field: parsed.from, role: MessageParticipantRole.FROM },
|
||||
{ field: parsed.to, role: MessageParticipantRole.TO },
|
||||
{ field: parsed.cc, role: MessageParticipantRole.CC },
|
||||
{ field: parsed.bcc, role: MessageParticipantRole.BCC },
|
||||
] as const;
|
||||
|
||||
return addressFields.flatMap(({ field, role }) =>
|
||||
formatAddressObjectAsParticipants(this.extractAddresses(field), role),
|
||||
);
|
||||
}
|
||||
|
||||
private extractAddresses(
|
||||
address: Address | Address[] | undefined,
|
||||
): EmailAddress[] {
|
||||
if (!address) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const addresses = Array.isArray(address) ? address : [address];
|
||||
|
||||
const mailboxes = addresses.flatMap((addr) =>
|
||||
addr.address ? [addr] : (addr.group ?? []),
|
||||
);
|
||||
|
||||
return mailboxes
|
||||
.filter((mailbox) => mailbox.address)
|
||||
.map((mailbox) => ({
|
||||
address: mailbox.address,
|
||||
name: sanitizeString(mailbox.name || ''),
|
||||
}));
|
||||
}
|
||||
|
||||
private extractAttachments(parsed: ParsedMail) {
|
||||
return (parsed.attachments || []).map((attachment) => ({
|
||||
filename: attachment.filename || 'unnamed-attachment',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const INBOUND_EMAIL_LOCAL_PART_PREFIX = 'ch_';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const INBOUND_EMAIL_LOCAL_PART_RANDOM_BYTES = 6;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider';
|
||||
import { InboundEmailParserService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service';
|
||||
import { InboundEmailStorageService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TwentyConfigModule,
|
||||
WorkspaceDataSourceModule,
|
||||
TypeOrmModule.forFeature([MessageChannelEntity, ConnectedAccountEntity]),
|
||||
],
|
||||
providers: [
|
||||
InboundEmailS3ClientProvider,
|
||||
InboundEmailStorageService,
|
||||
InboundEmailParserService,
|
||||
],
|
||||
exports: [
|
||||
InboundEmailS3ClientProvider,
|
||||
InboundEmailStorageService,
|
||||
InboundEmailParserService,
|
||||
],
|
||||
})
|
||||
export class MessagingInboundEmailDriverModule {}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { S3Client, type S3ClientConfig } from '@aws-sdk/client-s3';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class InboundEmailS3ClientProvider {
|
||||
private s3Client: S3Client | null = null;
|
||||
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
const storageType = this.twentyConfigService.get('STORAGE_TYPE');
|
||||
const domain = this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN');
|
||||
|
||||
return storageType === StorageDriverType.S_3 && isNonEmptyString(domain);
|
||||
}
|
||||
|
||||
getBucket(): string {
|
||||
const bucket = this.twentyConfigService.get('STORAGE_S3_NAME');
|
||||
|
||||
if (!isNonEmptyString(bucket)) {
|
||||
throw new Error(
|
||||
'STORAGE_S3_NAME is not configured; email group requires S3 storage.',
|
||||
);
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
getDomain(): string {
|
||||
const domain = this.twentyConfigService.get('INBOUND_EMAIL_DOMAIN');
|
||||
|
||||
if (!isNonEmptyString(domain)) {
|
||||
throw new Error(
|
||||
'INBOUND_EMAIL_DOMAIN is not configured; email group is disabled.',
|
||||
);
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
getClient(): S3Client {
|
||||
if (this.s3Client) {
|
||||
return this.s3Client;
|
||||
}
|
||||
|
||||
const region = this.twentyConfigService.get('STORAGE_S3_REGION');
|
||||
|
||||
if (!isNonEmptyString(region)) {
|
||||
throw new Error('STORAGE_S3_REGION must be set to use email group.');
|
||||
}
|
||||
|
||||
const config: S3ClientConfig = { region };
|
||||
|
||||
const endpoint = this.twentyConfigService.get('STORAGE_S3_ENDPOINT');
|
||||
|
||||
if (isNonEmptyString(endpoint)) {
|
||||
config.endpoint = endpoint;
|
||||
}
|
||||
|
||||
const accessKeyId = this.twentyConfigService.get(
|
||||
'STORAGE_S3_ACCESS_KEY_ID',
|
||||
);
|
||||
const secretAccessKey = this.twentyConfigService.get(
|
||||
'STORAGE_S3_SECRET_ACCESS_KEY',
|
||||
);
|
||||
|
||||
if (isNonEmptyString(accessKeyId) && isNonEmptyString(secretAccessKey)) {
|
||||
config.credentials = { accessKeyId, secretAccessKey };
|
||||
}
|
||||
|
||||
this.s3Client = new S3Client(config);
|
||||
|
||||
return this.s3Client;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { MessageChannelType } from 'twenty-shared/types';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.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 { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider';
|
||||
import { InboundEmailParserService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-parser.service';
|
||||
import { InboundEmailStorageService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-storage.service';
|
||||
import { type InboundEmailImportOutcome } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/types/inbound-email-import-outcome.type';
|
||||
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type ImportInboundMessageParams = {
|
||||
s3Key: string;
|
||||
envelopeRecipients: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class InboundEmailImportService {
|
||||
private readonly logger = new Logger(InboundEmailImportService.name);
|
||||
|
||||
constructor(
|
||||
private readonly inboundEmailS3ClientProvider: InboundEmailS3ClientProvider,
|
||||
private readonly inboundEmailStorageService: InboundEmailStorageService,
|
||||
private readonly inboundEmailParserService: InboundEmailParserService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
private readonly messagingSaveMessagesAndEnqueueContactCreationService: MessagingSaveMessagesAndEnqueueContactCreationService,
|
||||
@InjectRepository(MessageChannelEntity)
|
||||
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
|
||||
) {}
|
||||
|
||||
async importInboundMessage(
|
||||
params: ImportInboundMessageParams,
|
||||
): Promise<InboundEmailImportOutcome> {
|
||||
const { s3Key, envelopeRecipients } = params;
|
||||
|
||||
if (!this.inboundEmailS3ClientProvider.isConfigured()) {
|
||||
this.logger.warn(
|
||||
`Skipping inbound email import for ${s3Key}: email group is not configured.`,
|
||||
);
|
||||
|
||||
return { kind: 'unconfigured' };
|
||||
}
|
||||
|
||||
const inboundEmailDomain = this.inboundEmailS3ClientProvider.getDomain();
|
||||
const recipient = this.matchInboundRecipient(
|
||||
envelopeRecipients,
|
||||
inboundEmailDomain,
|
||||
);
|
||||
|
||||
if (!isDefined(recipient)) {
|
||||
this.logger.warn(
|
||||
`No recipient at ${inboundEmailDomain} in SNS payload for ${s3Key}`,
|
||||
);
|
||||
|
||||
return { kind: 'unmatched', recipient: null };
|
||||
}
|
||||
|
||||
const messageChannel = await this.messageChannelRepository.findOne({
|
||||
where: { handle: recipient, type: MessageChannelType.EMAIL_GROUP },
|
||||
});
|
||||
|
||||
if (!isDefined(messageChannel)) {
|
||||
this.logger.warn(
|
||||
`No email group channel matches recipient ${recipient} (key ${s3Key})`,
|
||||
);
|
||||
|
||||
return { kind: 'unmatched', recipient };
|
||||
}
|
||||
|
||||
const rawMessage =
|
||||
await this.inboundEmailStorageService.getRawMessage(s3Key);
|
||||
const parsedInboundMessage = await this.inboundEmailParserService.parse(
|
||||
rawMessage,
|
||||
s3Key,
|
||||
);
|
||||
|
||||
const { workspaceId } = messageChannel;
|
||||
|
||||
const connectedAccount = await this.connectedAccountRepository.findOne({
|
||||
where: { id: messageChannel.connectedAccountId, workspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(connectedAccount)) {
|
||||
throw new Error(
|
||||
`Email group channel ${messageChannel.id} has no connected account`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
await this.messagingSaveMessagesAndEnqueueContactCreationService.saveMessagesAndEnqueueContactCreation(
|
||||
[parsedInboundMessage.message],
|
||||
messageChannel,
|
||||
connectedAccount,
|
||||
workspaceId,
|
||||
);
|
||||
}, buildSystemAuthContext(workspaceId));
|
||||
|
||||
await this.inboundEmailStorageService.deleteRawMessage(s3Key);
|
||||
|
||||
return {
|
||||
kind: 'imported',
|
||||
workspaceId,
|
||||
messageChannelId: messageChannel.id,
|
||||
};
|
||||
}
|
||||
|
||||
private matchInboundRecipient(
|
||||
envelopeRecipients: string[],
|
||||
inboundEmailDomain: string,
|
||||
): string | null {
|
||||
const normalizedDomain = inboundEmailDomain.toLowerCase();
|
||||
|
||||
return (
|
||||
envelopeRecipients
|
||||
.map((address) => address.toLowerCase())
|
||||
.find((address) => address.endsWith(`@${normalizedDomain}`)) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import PostalMime, { type Email as ParsedEmail } from 'postal-mime';
|
||||
|
||||
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
|
||||
import { type ParsedInboundMessage } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/types/parsed-inbound-message.type';
|
||||
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
|
||||
import { extractParticipantsFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-participants-from-parsed-email.util';
|
||||
import { extractThreadIdFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-thread-id-from-parsed-email.util';
|
||||
import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util';
|
||||
|
||||
@Injectable()
|
||||
export class InboundEmailParserService {
|
||||
async parse(
|
||||
rawMessage: Buffer,
|
||||
s3Key: string,
|
||||
): Promise<ParsedInboundMessage> {
|
||||
const parsedEmail = await PostalMime.parse(rawMessage);
|
||||
const message = this.buildMessage(parsedEmail, s3Key);
|
||||
|
||||
return { parsed: parsedEmail, message };
|
||||
}
|
||||
|
||||
private buildMessage(
|
||||
parsedEmail: ParsedEmail,
|
||||
s3Key: string,
|
||||
): MessageWithParticipants {
|
||||
return {
|
||||
externalId: `inbound-email:${s3Key}`,
|
||||
messageThreadExternalId: extractThreadIdFromParsedEmail(parsedEmail),
|
||||
headerMessageId: parsedEmail.messageId?.trim() || `inbound-${s3Key}`,
|
||||
subject: sanitizeString(parsedEmail.subject || ''),
|
||||
text: sanitizeString(parsedEmail.text || ''),
|
||||
receivedAt: parsedEmail.date ? new Date(parsedEmail.date) : new Date(),
|
||||
direction: MessageDirection.INCOMING,
|
||||
attachments: [],
|
||||
participants: extractParticipantsFromParsedEmail(parsedEmail),
|
||||
};
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { InboundEmailS3ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/providers/inbound-email-s3-client.provider';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@Injectable()
|
||||
export class InboundEmailStorageService {
|
||||
constructor(
|
||||
private readonly inboundEmailS3ClientProvider: InboundEmailS3ClientProvider,
|
||||
) {}
|
||||
|
||||
async getRawMessage(s3Key: string): Promise<Buffer> {
|
||||
const client = this.inboundEmailS3ClientProvider.getClient();
|
||||
const bucket = this.inboundEmailS3ClientProvider.getBucket();
|
||||
|
||||
const response = await client.send(
|
||||
new GetObjectCommand({ Bucket: bucket, Key: s3Key }),
|
||||
);
|
||||
|
||||
if (!isDefined(response.Body)) {
|
||||
throw new Error(`S3 object ${s3Key} has no body`);
|
||||
}
|
||||
|
||||
const stream = response.Body as Readable;
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async deleteRawMessage(s3Key: string): Promise<void> {
|
||||
const client = this.inboundEmailS3ClientProvider.getClient();
|
||||
const bucket = this.inboundEmailS3ClientProvider.getBucket();
|
||||
|
||||
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: s3Key }));
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type InboundEmailImportOutcome =
|
||||
| { kind: 'imported'; workspaceId: string; messageChannelId: string }
|
||||
| { kind: 'unmatched'; recipient: string | null }
|
||||
| { kind: 'unconfigured' };
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type Email as ParsedEmail } from 'postal-mime';
|
||||
|
||||
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
|
||||
|
||||
export type ParsedInboundMessage = {
|
||||
parsed: ParsedEmail;
|
||||
message: MessageWithParticipants;
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { Logger, Scope } from '@nestjs/common';
|
||||
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { InboundEmailImportService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service';
|
||||
|
||||
export type MessagingInboundEmailImportJobData = {
|
||||
s3Key: string;
|
||||
envelopeRecipients: string[];
|
||||
};
|
||||
|
||||
@Processor({
|
||||
queueName: MessageQueue.messagingQueue,
|
||||
scope: Scope.REQUEST,
|
||||
})
|
||||
export class MessagingInboundEmailImportJob {
|
||||
private readonly logger = new Logger(MessagingInboundEmailImportJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly inboundEmailImportService: InboundEmailImportService,
|
||||
) {}
|
||||
|
||||
@Process(MessagingInboundEmailImportJob.name)
|
||||
async handle(data: MessagingInboundEmailImportJobData): Promise<void> {
|
||||
const { s3Key, envelopeRecipients } = data;
|
||||
|
||||
const outcome = await this.inboundEmailImportService.importInboundMessage({
|
||||
s3Key,
|
||||
envelopeRecipients,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Inbound email import outcome for ${s3Key}: ${outcome.kind}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+13
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
@@ -28,10 +30,13 @@ import { MessagingOngoingStaleCronJob } from 'src/modules/messaging/message-impo
|
||||
import { MessagingRelaunchFailedMessageChannelsCronJob } from 'src/modules/messaging/message-import-manager/crons/jobs/messaging-relaunch-failed-message-channels.cron.job';
|
||||
import { MessagingGmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/gmail/messaging-gmail-driver.module';
|
||||
import { MessagingIMAPDriverModule } from 'src/modules/messaging/message-import-manager/drivers/imap/messaging-imap-driver.module';
|
||||
import { MessagingInboundEmailDriverModule } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/messaging-inbound-email-driver.module';
|
||||
import { InboundEmailImportService } from 'src/modules/messaging/message-import-manager/drivers/inbound-email/services/inbound-email-import.service';
|
||||
import { MessagingMicrosoftDriverModule } from 'src/modules/messaging/message-import-manager/drivers/microsoft/messaging-microsoft-driver.module';
|
||||
import { MessagingSmtpDriverModule } from 'src/modules/messaging/message-import-manager/drivers/smtp/messaging-smtp-driver.module';
|
||||
import { MessagingAddSingleMessageToCacheForImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-add-single-message-to-cache-for-import.job';
|
||||
import { MessagingCleanCacheJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-clean-cache';
|
||||
import { MessagingInboundEmailImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-inbound-email-import.job';
|
||||
import { MessagingMessageListFetchJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-message-list-fetch.job';
|
||||
import { MessagingMessagesImportJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-messages-import.job';
|
||||
import { MessagingOngoingStaleJob } from 'src/modules/messaging/message-import-manager/jobs/messaging-ongoing-stale.job';
|
||||
@@ -50,6 +55,7 @@ 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 { MessagingWebhooksModule } from 'src/engine/core-modules/messaging-webhooks/messaging-webhooks.module';
|
||||
import { MessageParticipantManagerModule } from 'src/modules/messaging/message-participant-manager/message-participant-manager.module';
|
||||
import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/messaging-monitoring.module';
|
||||
@Module({
|
||||
@@ -61,13 +67,16 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
||||
MessagingMicrosoftDriverModule,
|
||||
MessagingIMAPDriverModule,
|
||||
MessagingSmtpDriverModule,
|
||||
MessagingInboundEmailDriverModule,
|
||||
MessagingCommonModule,
|
||||
TwentyConfigModule,
|
||||
TypeOrmModule.forFeature([
|
||||
WorkspaceEntity,
|
||||
ObjectMetadataEntity,
|
||||
MessageChannelEntity,
|
||||
MessageFolderEntity,
|
||||
UserWorkspaceEntity,
|
||||
ConnectedAccountEntity,
|
||||
]),
|
||||
EmailAliasManagerModule,
|
||||
FeatureFlagModule,
|
||||
@@ -77,6 +86,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
||||
MessagingMessageCleanerModule,
|
||||
WorkspaceEventEmitterModule,
|
||||
ConnectedAccountModule,
|
||||
MessagingWebhooksModule,
|
||||
],
|
||||
providers: [
|
||||
MessagingMessageListFetchCronCommand,
|
||||
@@ -95,6 +105,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
||||
MessagingRelaunchFailedMessageChannelsCronJob,
|
||||
MessagingAddSingleMessageToCacheForImportJob,
|
||||
MessagingCleanCacheJob,
|
||||
MessagingInboundEmailImportJob,
|
||||
MessagingMessageService,
|
||||
MessagingMessageFolderAssociationService,
|
||||
MessagingMessageListFetchService,
|
||||
@@ -109,6 +120,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
||||
MessagingProcessGroupEmailActionsService,
|
||||
MessagingDeleteFolderMessagesService,
|
||||
MessagingDeleteGroupEmailMessagesService,
|
||||
InboundEmailImportService,
|
||||
],
|
||||
exports: [
|
||||
MessagingAccountAuthenticationService,
|
||||
@@ -117,6 +129,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
|
||||
MessagingOngoingStaleCronCommand,
|
||||
MessagingRelaunchFailedMessageChannelsCronCommand,
|
||||
MessagingProcessGroupEmailActionsService,
|
||||
InboundEmailImportService,
|
||||
MessagingSaveMessagesAndEnqueueContactCreationService,
|
||||
],
|
||||
})
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { type Address } from 'postal-mime';
|
||||
|
||||
import { type EmailAddress } from 'src/modules/messaging/message-import-manager/types/email-address';
|
||||
import { sanitizeString } from 'src/modules/messaging/message-import-manager/utils/sanitize-string.util';
|
||||
|
||||
export const extractAddressesFromParsedEmail = (
|
||||
address: Address | Address[] | undefined,
|
||||
): EmailAddress[] => {
|
||||
if (!address) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const addresses = Array.isArray(address) ? address : [address];
|
||||
|
||||
const mailboxes = addresses.flatMap((addr) =>
|
||||
addr.address ? [addr] : (addr.group ?? []),
|
||||
);
|
||||
|
||||
return mailboxes
|
||||
.filter((mailbox) => mailbox.address)
|
||||
.map((mailbox) => ({
|
||||
address: mailbox.address,
|
||||
name: sanitizeString(mailbox.name || ''),
|
||||
}));
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { type Email as ParsedEmail } from 'postal-mime';
|
||||
import { MessageParticipantRole } from 'twenty-shared/types';
|
||||
|
||||
import { extractAddressesFromParsedEmail } from 'src/modules/messaging/message-import-manager/utils/extract-addresses-from-parsed-email.util';
|
||||
import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util';
|
||||
|
||||
export const extractParticipantsFromParsedEmail = (parsed: ParsedEmail) => {
|
||||
const addressFields = [
|
||||
{ field: parsed.from, role: MessageParticipantRole.FROM },
|
||||
{ field: parsed.to, role: MessageParticipantRole.TO },
|
||||
{ field: parsed.cc, role: MessageParticipantRole.CC },
|
||||
{ field: parsed.bcc, role: MessageParticipantRole.BCC },
|
||||
] as const;
|
||||
|
||||
return addressFields.flatMap(({ field, role }) =>
|
||||
formatAddressObjectAsParticipants(
|
||||
extractAddressesFromParsedEmail(field),
|
||||
role,
|
||||
),
|
||||
);
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type Email as ParsedEmail } from 'postal-mime';
|
||||
|
||||
export const extractThreadIdFromParsedEmail = (parsed: ParsedEmail): string => {
|
||||
const references = parsed.references;
|
||||
|
||||
if (typeof references === 'string' && references.trim()) {
|
||||
const first = references.trim().split(/\s+/)[0];
|
||||
|
||||
if (first) {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(references) && references.length > 0) {
|
||||
const first = String(references[0]).trim();
|
||||
|
||||
if (first) {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.inReplyTo) {
|
||||
const inReplyTo = String(parsed.inReplyTo).trim();
|
||||
|
||||
if (inReplyTo) {
|
||||
return inReplyTo;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.messageId?.trim()) {
|
||||
return parsed.messageId.trim();
|
||||
}
|
||||
|
||||
return `thread-${crypto.randomUUID()}`;
|
||||
};
|
||||
+8
@@ -38,6 +38,13 @@ export class MessagingMessageOutboundService {
|
||||
sendMessageInput,
|
||||
connectedAccount,
|
||||
);
|
||||
case ConnectedAccountProvider.EMAIL_GROUP:
|
||||
// Email group channels are inbound-only: replies should go through
|
||||
// the user's own Gmail/Outlook/IMAP account to avoid masking the
|
||||
// sender.
|
||||
throw new Error(
|
||||
'Email group channels are inbound-only; reply using your personal account.',
|
||||
);
|
||||
case ConnectedAccountProvider.OIDC:
|
||||
case ConnectedAccountProvider.SAML:
|
||||
case ConnectedAccountProvider.APP:
|
||||
@@ -72,6 +79,7 @@ export class MessagingMessageOutboundService {
|
||||
sendMessageInput,
|
||||
connectedAccount,
|
||||
);
|
||||
case ConnectedAccountProvider.EMAIL_GROUP:
|
||||
case ConnectedAccountProvider.OIDC:
|
||||
case ConnectedAccountProvider.SAML:
|
||||
case ConnectedAccountProvider.APP:
|
||||
|
||||
Reference in New Issue
Block a user