-
-
Notifications
You must be signed in to change notification settings - Fork 419
fix: reuse an existing SES identity when adding a domain #452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
|
Comment on lines
+211
to
+215
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🌐 Web query:
💡 Result: In AWS SESv2, when you use Bring Your Own DKIM (BYODKIM), the Citations:
🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- target function and nearby definitions ---'
sed -n '1,280p' apps/web/src/server/aws/ses.ts
printf '%s\n' '--- relevant symbols and call sites ---'
rg -n -C 4 'getExistingDkimAttributes|dkimSelector|Tokens|addDomain|DkimAttributes|DomainSigningSelector' apps/web/src/server/aws apps/web/src --glob '*.ts' --glob '*.tsx'Repository: usesend/useSend Length of output: 33901 🤖 get_repo_knowledge executed:
Length of output: 3055 🏁 Script executed: #!/bin/bash
set -e
sed -n '180,245p' apps/web/src/server/aws/ses.ts
rg -n -C 8 'getExistingDkimAttributes|addDomain|PutEmailIdentityDkimSigningAttributes|DomainSigningSelector' apps/web/src/server/aws/ses.tsRepository: usesend/useSend Length of output: 5500 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- target source ---'
sed -n '1,280p' apps/web/src/server/aws/ses.ts
printf '%s\n' '--- bound symbols and call flow ---'
rg -n -C 6 'getExistingDkimAttributes|dkimSelector|Tokens|addDomain|PutEmailIdentityDkimSigningAttributes|DomainSigningSelector' apps/web/src/server/aws/ses.tsRepository: usesend/useSend Length of output: 12096 Handle BYODKIM selectors separately For 🤖 Prompt for AI Agents |
||
|
|
||
| const publicKey = await getPublishedDkimPublicKey(domain, dkimSelector); | ||
|
|
||
| return publicKey ? { publicKey, dkimSelector } : null; | ||
|
Comment on lines
+217
to
+219
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win A transient DNS failure now triggers a DKIM key rotation.
Distinguish "no 🛠️ Proposed direction- const publicKey = await getPublishedDkimPublicKey(domain, dkimSelector);
-
- return publicKey ? { publicKey, dkimSelector } : null;
+ // Distinguish a missing record from a failed lookup, so a transient resolver
+ // error does not rotate the key of an identity that signs correctly.
+ const { publicKey, resolutionFailed } = await getPublishedDkimPublicKey(
+ domain,
+ dkimSelector
+ );
+
+ if (resolutionFailed) {
+ throw new Error(
+ `Couldn't read the DKIM record of ${domain}. Retry once DNS resolution succeeds.`
+ );
+ }
+
+ return publicKey ? { publicKey, dkimSelector } : null;🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Compare the MAIL FROM domains without case sensitivity.
domaincomes from the user-supplied domain name and is not lowercased increateDomain. SES returnsMailFromDomainin lowercase. If a user entersExample.com,existingMailFromismail.example.comandmailFromDomainismail.Example.com, so this check rejects a reusable identity and blocks onboarding.🛠️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents