From c9e9bb4ea76605971a46b201426d47f9bb1696e1 Mon Sep 17 00:00:00 2001 From: David Vallejo Date: Wed, 9 Sep 2026 20:55:26 +0200 Subject: [PATCH] fix: reuse an existing SES identity when adding a domain Adding a domain whose SES identity already existed failed outright with AlreadyExistsException, and there was no way back: addDomain is not atomic, so a create that failed after the identity was made left it orphaned in SES with no domain row, and deleteDomain works from a row. Every retry hit the error again. Adopt the identity instead. When it already signs with a recoverable BYODKIM key, take the selector from DkimAttributes.Tokens and read the public key back out of the record it publishes, so DNS that is already in place keeps working. Otherwise rotate in a fresh keypair, which needs the DKIM record republished but does complete. Adopting an identity also rewrites its MAIL FROM domain, so refuse when one is already set to something else rather than silently redirecting mail from an existing setup. Domains now store the selector they actually sign with rather than assuming "usesend". --- apps/web/src/server/aws/ses.ts | 237 +++++++++++++++--- apps/web/src/server/service/domain-service.ts | 7 +- 2 files changed, 213 insertions(+), 31 deletions(-) diff --git a/apps/web/src/server/aws/ses.ts b/apps/web/src/server/aws/ses.ts index 5b6e9ab1..644ee257 100644 --- a/apps/web/src/server/aws/ses.ts +++ b/apps/web/src/server/aws/ses.ts @@ -1,8 +1,13 @@ import { SESv2Client, + AlreadyExistsException, CreateEmailIdentityCommand, + type CreateEmailIdentityCommandOutput, DeleteEmailIdentityCommand, + PutEmailIdentityDkimSigningAttributesCommand, + type PutEmailIdentityDkimSigningAttributesCommandOutput, GetEmailIdentityCommand, + type GetEmailIdentityCommandOutput, PutEmailIdentityMailFromAttributesCommand, SendEmailCommand, CreateConfigurationSetEventDestinationCommand, @@ -15,6 +20,8 @@ import { } from "@aws-sdk/client-sesv2"; import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts"; import { generateKeyPairSync } from "crypto"; +import dns from "dns"; +import util from "util"; import nodemailer from "nodemailer"; import { env } from "~/env"; import { getAwsCredentialOptions } from "~/server/aws/credentials"; @@ -78,38 +85,59 @@ function generateKeyPair() { return { privateKey: base64PrivateKey, publicKey: base64PublicKey }; } -export async function addDomain( - domain: string, - region: string, - sesTenantId?: string, - dkimSelector: string = "usesend" -) { - const sesClient = getSesClient(region); +const dnsResolveTxt = util.promisify(dns.resolveTxt); - const { privateKey, publicKey } = generateKeyPair(); - const command = new CreateEmailIdentityCommand({ - EmailIdentity: domain, - DkimSigningAttributes: { - DomainSigningSelector: dkimSelector, - DomainSigningPrivateKey: privateKey, - }, - }); - const response = await sesClient.send(command); +/** + * Reads the DKIM public key a domain already publishes. DKIM public keys live in + * DNS by definition, so an identity that was set up elsewhere can be adopted + * without rotating its keypair. + */ +async function getPublishedDkimPublicKey(domain: string, selector: string) { + try { + const records = await dnsResolveTxt(`${selector}._domainkey.${domain}`); + + for (const record of records) { + // Long TXT records are split into 255 byte chunks. + const value = record.join(""); + const publicKey = value + .split(";") + .map((tag) => tag.trim()) + .find((tag) => tag.startsWith("p=")) + ?.slice(2) + .trim(); + + if (publicKey) { + return publicKey; + } + } - const emailIdentityCommand = new PutEmailIdentityMailFromAttributesCommand({ - EmailIdentity: domain, - MailFromDomain: `mail.${domain}`, - }); + return null; + } catch (error) { + logger.warn( + { err: error, domain, selector }, + "Couldn't read the published DKIM record" + ); + return null; + } +} - const emailIdentityResponse = await sesClient.send(emailIdentityCommand); +async function associateTenant( + sesClient: SESv2Client, + domain: string, + region: string, + sesTenantId?: string +) { + if (!sesTenantId) { + return; + } - if (sesTenantId) { - const tenantResourceAssociationCommand = - new CreateTenantResourceAssociationCommand({ - TenantName: sesTenantId, - ResourceArn: await getIdentityArn(domain, region), - }); + const tenantResourceAssociationCommand = + new CreateTenantResourceAssociationCommand({ + TenantName: sesTenantId, + ResourceArn: await getIdentityArn(domain, region), + }); + try { const tenantResourceAssociationResponse = await sesClient.send( tenantResourceAssociationCommand ); @@ -121,8 +149,161 @@ export async function addDomain( ); throw new Error("Failed to associate domain with tenant"); } + } catch (error) { + if (!(error instanceof AlreadyExistsException)) { + throw error; + } + + logger.info( + { domain, region, sesTenantId }, + "Domain already associated with tenant, reusing it" + ); + } +} + +/** + * Adopting an identity rewrites its MAIL FROM domain, which would silently + * break mail flowing through an existing setup. Refuse instead. + */ +function assertMailFromCanBeReused( + domain: string, + identity: GetEmailIdentityCommandOutput +) { + const existingMailFrom = identity.MailFromAttributes?.MailFromDomain; + const mailFromDomain = `mail.${domain}`; + + if (existingMailFrom && existingMailFrom !== mailFromDomain) { + logger.error( + { domain, existingMailFrom, mailFromDomain }, + "Refusing to reuse an identity configured with another MAIL FROM domain" + ); + throw new Error( + `${domain} already exists in SES with the MAIL FROM domain ${existingMailFrom}. ` + + `Adding it here would change that to ${mailFromDomain} and break mail sent ` + + `through the existing setup. Clear the MAIL FROM domain in SES, or delete the ` + + `identity, and try again.` + ); + } +} + +/** + * Returns the DKIM key and selector of an identity that already exists in SES, + * or null when they can't be recovered and the keypair has to be rotated. + */ +async function getExistingDkimAttributes( + domain: string, + identity: GetEmailIdentityCommandOutput +) { + const dkimAttributes = identity.DkimAttributes; + + // Easy DKIM keys are generated and held by SES, so the public key is never + // exposed, in DNS or otherwise. + if (dkimAttributes?.SigningAttributesOrigin !== "EXTERNAL") { + return null; + } + + // Only a verified identity proves the published record still matches the + // private key SES holds. Anything else could be a stale record. + if (dkimAttributes.Status !== "SUCCESS") { + return null; + } + + const dkimSelector = dkimAttributes.Tokens?.[0]; + + if (!dkimSelector) { + return null; + } + + const publicKey = await getPublishedDkimPublicKey(domain, dkimSelector); + + return publicKey ? { publicKey, dkimSelector } : null; +} + +export async function addDomain( + domain: string, + region: string, + sesTenantId?: string, + dkimSelector: string = "usesend" +) { + const sesClient = getSesClient(region); + + const { privateKey, publicKey } = generateKeyPair(); + + let response: + | CreateEmailIdentityCommandOutput + | PutEmailIdentityDkimSigningAttributesCommandOutput; + + try { + response = await sesClient.send( + new CreateEmailIdentityCommand({ + EmailIdentity: domain, + DkimSigningAttributes: { + DomainSigningSelector: dkimSelector, + DomainSigningPrivateKey: privateKey, + }, + }) + ); + } catch (error) { + if (!(error instanceof AlreadyExistsException)) { + throw error; + } + + // The identity is already registered in SES, either from an earlier attempt + // that failed before the domain row was stored, or because something else + // set it up. Reuse its existing DKIM key when we can still recover it, so + // that the records already in DNS keep working. + const identity = await sesClient.send( + new GetEmailIdentityCommand({ EmailIdentity: domain }) + ); + + assertMailFromCanBeReused(domain, identity); + + const existingDkim = await getExistingDkimAttributes(domain, identity); + + if (existingDkim) { + logger.info( + { domain, region, dkimSelector: existingDkim.dkimSelector }, + "Email identity already exists, reusing its DKIM key" + ); + + await sesClient.send( + new PutEmailIdentityMailFromAttributesCommand({ + EmailIdentity: domain, + MailFromDomain: `mail.${domain}`, + }) + ); + + await associateTenant(sesClient, domain, region, sesTenantId); + + return existingDkim; + } + + logger.info( + { domain, region }, + "Email identity already exists, rotating its DKIM key" + ); + + response = await sesClient.send( + new PutEmailIdentityDkimSigningAttributesCommand({ + EmailIdentity: domain, + SigningAttributesOrigin: "EXTERNAL", + SigningAttributes: { + DomainSigningSelector: dkimSelector, + DomainSigningPrivateKey: privateKey, + }, + }) + ); } + const emailIdentityCommand = new PutEmailIdentityMailFromAttributesCommand({ + EmailIdentity: domain, + MailFromDomain: `mail.${domain}`, + }); + + const emailIdentityResponse = await sesClient.send(emailIdentityCommand); + + await associateTenant(sesClient, domain, region, sesTenantId); + if ( response.$metadata.httpStatusCode !== 200 || emailIdentityResponse.$metadata.httpStatusCode !== 200 @@ -134,7 +315,7 @@ export async function addDomain( throw new Error("Failed to create domain identity"); } - return publicKey; + return { publicKey, dkimSelector }; } export async function deleteDomain( diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts index da9c325e..04b3476c 100644 --- a/apps/web/src/server/service/domain-service.ts +++ b/apps/web/src/server/service/domain-service.ts @@ -422,12 +422,13 @@ export async function createDomain( } const subdomain = tldts.getSubdomain(name); - const dkimSelector = "usesend"; - const publicKey = await ses.addDomain( + // An identity that already exists keeps its own selector, so take back + // whichever one the domain is actually signing with. + const { publicKey, dkimSelector } = await ses.addDomain( name, region, sesTenantId, - dkimSelector, + "usesend", ); const domain = await db.domain.create({