Ses outbound followup (#20610)

This pull request unifies outbound with inbound under the new feature
and the new email groups feature.

These are workspace level shared inboxes that are shared between all
workspace members.

outbound sending with SES works, we only listen for tenant status
events, rest is managed by AWS

PR refactors old code and webhook to be split for outbound and inbound
for proper separation


| Area | Change |
|---|---|
| AWS SES driver | Split into `AwsSesRegisterDomainService` (tenant +
identity + DKIM + MAIL FROM + configuration-set + EventBridge dest +
contact list) and `AwsSesSendEmailService` (SendEmail). |
| Reputation webhook | New `/webhooks/messaging/ses/outbound` route. SES
→ EventBridge (`Sending Status Enabled/Disabled` on default bus) → SNS →
router → `SesOutboundSendingStateHandlerService` updates
`emailing_domain.tenantStatus`. |
| Inbound webhook | Refactored into `SesInboundWebhookRouterService` +
`SesInboundMailHandlerService`. Shared `SnsSignatureVerifierService` +
`SnsSubscriptionConfirmerService` across both routes. |
| Global uniqueness | New migration + instance command:
`emailing_domain.domain` is now globally unique (one tenant per domain
across workspaces). |
| Tenant status | New `emailing_domain.tenantStatus` column (`ACTIVE` /
`PAUSED`) + `EmailingDomainTenantStatusService`. |
| Send-email mutation | New `sendEmailViaDomain` GraphQL mutation +
DTOs. |
| Cleanup | `EmailingDomainWorkspaceCleanupJob` wired into
`WorkspaceService.deleteWorkspace` — tears down SES tenant association +
identity on workspace delete. |
| Settings UI | Rewritten around reusable `SettingsTableListSection`.
"Email Group" → "Email Handle" rename. New cells for
status/source/forwarding. Outbound domains surfaced on workspace
settings page. |

### Env vars (new)

All in `config-variables.ts`, group `AWS_SES_SETTINGS`, all optional:

- `AWS_SES_REGION` — `@IsAWSRegion`, consumed by `AwsSesClientProvider`
+ driver factory
- `AWS_SES_ACCOUNT_ID` — used for ARN construction in driver factory
- `SES_SNS_TOPIC_ARN_ALLOWLIST` — **shared** by inbound + outbound
webhook routers, comma-separated list of accepted SNS topic ARNs
(verified via `sns-payload-validator`)

### Migrations

- `1778862608620-add-emailing-domain-tenant-status` (fast) — adds
`tenantStatus` column.
- `1778865501791-unique-emailing-domain-globally` (slow, idempotent) —
enforces global uniqueness on `domain`.
- Instance commands bumped to `2.5`.

### Infra dependency

Two coupled twenty-infra PRs:

- `ses-inbound-email` — receipt-rule + inbound SNS topic + S3 bucket
policy + KMS grant + `email_group_*` outputs.
- `ses-outbound-tf` — EventBridge rule + outbound SNS topic + SES IAM
policy + outbound `webhook_url` subscription. **Based on
`ses-inbound-email`.**

Merge order: inbound first, then outbound. Outbound PR's chart edit owns
the comma-joined `SES_SNS_TOPIC_ARN_ALLOWLIST` value (both ARNs).


Features lives under `/settings/general`

<img width="1496" height="845" alt="SCR-20260519-ofhi-2"
src="https://github.com/user-attachments/assets/a025485a-09f7-4131-91cd-0067690ff18d"
/>

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
This commit is contained in:
neo773
2026-05-27 23:08:44 +05:30
committed by GitHub
parent 0702e72e3f
commit c5606212f2
80 changed files with 3155 additions and 1341 deletions
@@ -82,7 +82,10 @@ export class ConnectedAccountMetadataService {
);
}
if (connectedAccount.userWorkspaceId !== userWorkspaceId) {
if (
connectedAccount.visibility !== 'workspace' &&
connectedAccount.userWorkspaceId !== userWorkspaceId
) {
throw new ConnectedAccountException(
`Connected account ${id} does not belong to user workspace ${userWorkspaceId}`,
ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_OWNERSHIP_VIOLATION,
@@ -107,6 +110,19 @@ export class ConnectedAccountMetadataService {
return accounts.map((account) => account.id);
}
async getWorkspaceSharedConnectedAccountIds({
workspaceId,
}: {
workspaceId: string;
}): Promise<string[]> {
const accounts = await this.repository.find({
where: { workspaceId, visibility: 'workspace' },
select: ['id'],
});
return accounts.map((account) => account.id);
}
async create(
data: Partial<ConnectedAccountEntity> & {
workspaceId: string;
@@ -55,8 +55,15 @@ export class MessageChannelMetadataService {
workspaceId,
});
const sharedAccountIds =
await this.connectedAccountMetadataService.getWorkspaceSharedConnectedAccountIds(
{ workspaceId },
);
return this.findByConnectedAccountIds({
connectedAccountIds: userAccountIds,
connectedAccountIds: [
...new Set([...userAccountIds, ...sharedAccountIds]),
],
workspaceId,
});
}
@@ -137,6 +144,16 @@ export class MessageChannelMetadataService {
);
}
const connectedAccount =
await this.connectedAccountMetadataService.findById({
id: messageChannel.connectedAccountId,
workspaceId,
});
if (connectedAccount?.visibility === 'workspace') {
return messageChannel;
}
const userAccountIds =
await this.connectedAccountMetadataService.getUserConnectedAccountIds({
userWorkspaceId,
@@ -204,7 +221,7 @@ export class MessageChannelMetadataService {
storageType !== StorageDriverType.S_3
) {
throw new MessageChannelException(
'Email group is not configured: INBOUND_EMAIL_DOMAIN must be set and STORAGE_TYPE must be S3',
'Email handles are not configured: INBOUND_EMAIL_DOMAIN must be set and STORAGE_TYPE must be S3',
MessageChannelExceptionCode.EMAIL_GROUP_NOT_CONFIGURED,
);
}
@@ -222,6 +239,7 @@ export class MessageChannelMetadataService {
userWorkspaceId,
accessToken: null,
refreshToken: null,
visibility: 'workspace',
});
const messageChannel = await this.create({