4da8878697
## 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>
47 lines
1.5 KiB
TypeScript
47 lines
1.5 KiB
TypeScript
import { assertUnreachable } from 'twenty-shared/utils';
|
|
|
|
import {
|
|
ForbiddenError,
|
|
InternalServerError,
|
|
NotFoundError,
|
|
UserInputError,
|
|
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
|
import {
|
|
ConnectedAccountException,
|
|
ConnectedAccountExceptionCode,
|
|
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
|
|
import {
|
|
MessageChannelException,
|
|
MessageChannelExceptionCode,
|
|
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
|
|
|
|
export const messageChannelGraphqlApiExceptionHandler = (error: Error) => {
|
|
if (error instanceof MessageChannelException) {
|
|
switch (error.code) {
|
|
case MessageChannelExceptionCode.MESSAGE_CHANNEL_NOT_FOUND:
|
|
throw new NotFoundError(error);
|
|
case MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT:
|
|
throw new UserInputError(error);
|
|
case MessageChannelExceptionCode.MESSAGE_CHANNEL_OWNERSHIP_VIOLATION:
|
|
throw new ForbiddenError(error);
|
|
case MessageChannelExceptionCode.EMAIL_GROUP_NOT_CONFIGURED:
|
|
throw new InternalServerError(error);
|
|
default: {
|
|
return assertUnreachable(error.code);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (error instanceof ConnectedAccountException) {
|
|
switch (error.code) {
|
|
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION:
|
|
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
|
|
throw new ForbiddenError(error);
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
};
|