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:
Félix Malfait
2026-05-09 11:00:57 +02:00
committed by GitHub
parent 23aa859502
commit 4da8878697
81 changed files with 1967 additions and 309 deletions
@@ -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,
},
});
@@ -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;
@@ -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,
@@ -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),
},
},
);
@@ -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),
},
},
);
@@ -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),
},
})
@@ -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',
}));
}
}
@@ -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 {}
@@ -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;
}
}
@@ -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
);
}
}
@@ -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),
};
}
}
@@ -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 }));
}
}
@@ -0,0 +1,4 @@
export type InboundEmailImportOutcome =
| { kind: 'imported'; workspaceId: string; messageChannelId: string }
| { kind: 'unmatched'; recipient: string | null }
| { kind: 'unconfigured' };
@@ -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;
};
@@ -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}`,
);
}
}
@@ -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,
],
})
@@ -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 || ''),
}));
};
@@ -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,
),
);
};
@@ -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()}`;
};
@@ -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: