packages/aws-cdk-lib/aws-ses/lib/email-identity.ts (228 lines of code) (raw):

import { Construct } from 'constructs'; import { IConfigurationSet } from './configuration-set'; import { undefinedIfNoKeys } from './private/utils'; import { CfnEmailIdentity } from './ses.generated'; import { Grant, IGrantable } from '../../aws-iam'; import { IPublicHostedZone } from '../../aws-route53'; import * as route53 from '../../aws-route53'; import { ArnFormat, IResource, Lazy, Resource, SecretValue, Stack } from '../../core'; import { ValidationError } from '../../core/lib/errors'; import { addConstructMetadata } from '../../core/lib/metadata-resource'; /** * An email identity */ export interface IEmailIdentity extends IResource { /** * The name of the email identity * * @attribute */ readonly emailIdentityName: string; /** * The ARN of the email identity * * @attribute */ readonly emailIdentityArn: string; /** * Adds an IAM policy statement associated with this email identity to an IAM principal's policy. * * @param grantee the principal (no-op if undefined) * @param actions the set of actions to allow */ grant(grantee: IGrantable, ...actions: string[]): Grant; /** * Permits an IAM principal the send email action. * * Actions: SendEmail. * * @param grantee the principal to grant access to */ grantSendEmail(grantee: IGrantable): Grant; } /** * Properties for an email identity */ export interface EmailIdentityProps { /** * The email address or domain to verify. */ readonly identity: Identity; /** * The configuration set to associate with the email identity * * @default - do not use a specific configuration set */ readonly configurationSet?: IConfigurationSet; /** * Whether the messages that are sent from the identity are signed using DKIM * * @default true */ readonly dkimSigning?: boolean; /** * The type of DKIM identity to use * * @default - Easy DKIM with a key length of 2048-bit */ readonly dkimIdentity?: DkimIdentity; /** * Whether to receive email notifications when bounce or complaint events occur. * These notifications are sent to the address that you specified in the `Return-Path` * header of the original email. * * You're required to have a method of tracking bounces and complaints. If you haven't set * up another mechanism for receiving bounce or complaint notifications (for example, by * setting up an event destination), you receive an email notification when these events * occur (even if this setting is disabled). * * @default true */ readonly feedbackForwarding?: boolean; /** * The custom MAIL FROM domain that you want the verified identity to use. The MAIL FROM domain * must meet the following criteria: * - It has to be a subdomain of the verified identity * - It can't be used to receive email * - It can't be used in a "From" address if the MAIL FROM domain is a destination for feedback * forwarding emails * * @default - use amazonses.com */ readonly mailFromDomain?: string; /** * The action to take if the required MX record for the MAIL FROM domain isn't * found when you send an email * * @default MailFromBehaviorOnMxFailure.USE_DEFAULT_VALUE */ readonly mailFromBehaviorOnMxFailure?: MailFromBehaviorOnMxFailure; } /** * Identity */ export abstract class Identity { /** * Verify an email address * * To complete the verification process look for an email from * no-reply-aws@amazon.com, open it and click the link. */ public static email(email: string): Identity { return { value: email }; } /** * Verify a domain name * * DKIM records will have to be added manually to complete the verification * process */ public static domain(domain: string): Identity { return { value: domain }; } /** * Verify a public hosted zone * * DKIM and MAIL FROM records will be added automatically to the hosted * zone */ public static publicHostedZone(hostedZone: IPublicHostedZone): Identity { return { value: hostedZone.zoneName, hostedZone, }; } /** * The value of the identity */ public abstract readonly value: string; /** * The hosted zone associated with this identity * * @default - no hosted zone is associated and no records are created */ public abstract readonly hostedZone?: IPublicHostedZone; } /** * The action to take if the required MX record for the MAIL FROM domain isn't * found */ export enum MailFromBehaviorOnMxFailure { /** * The mail is sent using amazonses.com as the MAIL FROM domain */ USE_DEFAULT_VALUE = 'USE_DEFAULT_VALUE', /** * The Amazon SES API v2 returns a `MailFromDomainNotVerified` error and doesn't * attempt to deliver the email */ REJECT_MESSAGE = 'REJECT_MESSAGE', } /** * Configuration for DKIM identity */ export interface DkimIdentityConfig { /** * A private key that's used to generate a DKIM signature * * @default - use Easy DKIM */ readonly domainSigningPrivateKey?: string; /** * A string that's used to identify a public key in the DNS configuration for * a domain * * @default - use Easy DKIM */ readonly domainSigningSelector?: string; /** * The key length of the future DKIM key pair to be generated. This can be changed * at most once per day. * * @default EasyDkimSigningKeyLength.RSA_2048_BIT */ readonly nextSigningKeyLength?: EasyDkimSigningKeyLength; } /** * The identity to use for DKIM */ export abstract class DkimIdentity { /** * Easy DKIM * * @param signingKeyLength The length of the signing key. This can be changed at * most once per day. * * @see https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dkim-easy.html */ public static easyDkim(signingKeyLength?: EasyDkimSigningKeyLength): DkimIdentity { return new EasyDkim(signingKeyLength); } /** * Bring Your Own DKIM * * @param options Options for BYO DKIM * * @see https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dkim-bring-your-own.html */ public static byoDkim(options: ByoDkimOptions): DkimIdentity { return new ByoDkim(options); } /** * Binds this DKIM identity to the email identity */ public abstract bind(emailIdentity: EmailIdentity, hostedZone?: route53.IPublicHostedZone): DkimIdentityConfig | undefined; } class EasyDkim extends DkimIdentity { constructor(private readonly signingKeyLength?: EasyDkimSigningKeyLength) { super(); } public bind(emailIdentity: EmailIdentity, hostedZone?: route53.IPublicHostedZone): DkimIdentityConfig | undefined { if (hostedZone) { // Use CfnRecordSet instead of CnameRecord to avoid current bad handling of // tokens in route53.determineFullyQualifiedDomainName() at https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/aws-route53/lib/util.ts new route53.CfnRecordSet(emailIdentity, 'DkimDnsToken1', { hostedZoneId: hostedZone.hostedZoneId, name: Lazy.string({ produce: () => emailIdentity.dkimDnsTokenName1 }), type: 'CNAME', resourceRecords: [Lazy.string({ produce: () => emailIdentity.dkimDnsTokenValue1 })], ttl: '1800', }); new route53.CfnRecordSet(emailIdentity, 'DkimDnsToken2', { hostedZoneId: hostedZone.hostedZoneId, name: Lazy.string({ produce: () => emailIdentity.dkimDnsTokenName2 }), type: 'CNAME', resourceRecords: [Lazy.string({ produce: () => emailIdentity.dkimDnsTokenValue2 })], ttl: '1800', }); new route53.CfnRecordSet(emailIdentity, 'DkimDnsToken3', { hostedZoneId: hostedZone.hostedZoneId, name: Lazy.string({ produce: () => emailIdentity.dkimDnsTokenName3 }), type: 'CNAME', resourceRecords: [Lazy.string({ produce: () => emailIdentity.dkimDnsTokenValue3 })], ttl: '1800', }); } return this.signingKeyLength ? { nextSigningKeyLength: this.signingKeyLength } : undefined; } } /** * Options for BYO DKIM */ export interface ByoDkimOptions { /** * The private key that's used to generate a DKIM signature */ readonly privateKey: SecretValue; /** * A string that's used to identify a public key in the DNS configuration for * a domain */ readonly selector: string; /** * The public key. If specified, a TXT record with the public key is created. * * @default - the validation TXT record with the public key is not created */ readonly publicKey?: string; } class ByoDkim extends DkimIdentity { constructor(private readonly options: ByoDkimOptions) { super(); } public bind(emailIdentity: EmailIdentity, hostedZone?: route53.IPublicHostedZone): DkimIdentityConfig | undefined { if (hostedZone && this.options.publicKey) { new route53.TxtRecord(emailIdentity, 'DkimTxt', { zone: hostedZone, recordName: `${this.options.selector}._domainkey`, values: [`p=${this.options.publicKey}`], }); } return { domainSigningPrivateKey: this.options.privateKey.unsafeUnwrap(), // safe usage domainSigningSelector: this.options.selector, }; } } /** * The signing key length for Easy DKIM */ export enum EasyDkimSigningKeyLength { /** * RSA 1024-bit */ RSA_1024_BIT = 'RSA_1024_BIT', /** * RSA 2048-bit */ RSA_2048_BIT = 'RSA_2048_BIT', } abstract class EmailIdentityBase extends Resource implements IEmailIdentity { /** * The name of the email identity * * @attribute */ public abstract readonly emailIdentityName: string; /** * The ARN of the email identity * * @attribute */ public abstract readonly emailIdentityArn: string; /** * Adds an IAM policy statement associated with this email identity to an IAM principal's policy. * * @param grantee the principal (no-op if undefined) * @param actions the set of actions to allow */ public grant(grantee: IGrantable, ...actions: string[]): Grant { const resourceArns = [this.emailIdentityArn]; return Grant.addToPrincipal({ grantee, actions, resourceArns, scope: this, }); } /** * Permits an IAM principal the send email action. * * Actions: SendEmail, SendRawEmail. * * @param grantee the principal to grant access to */ public grantSendEmail(grantee: IGrantable): Grant { return this.grant(grantee, 'ses:SendEmail', 'ses:SendRawEmail'); } } /** * An email identity */ export class EmailIdentity extends EmailIdentityBase { /** * Use an existing email identity */ public static fromEmailIdentityName(scope: Construct, id: string, emailIdentityName: string): IEmailIdentity { class Import extends EmailIdentityBase { public readonly emailIdentityName = emailIdentityName; public readonly emailIdentityArn = this.stack.formatArn({ service: 'ses', resource: 'identity', resourceName: this.emailIdentityName, }); } return new Import(scope, id); } /** * Import an email identity by ARN */ public static fromEmailIdentityArn(scope: Construct, id: string, emailIdentityArn: string): IEmailIdentity { // emailIdentityArn is in the format 'arn:aws:ses:{region}:{account}:identity/{name}' const stack = Stack.of(scope); const parsedArn = stack.splitArn(emailIdentityArn, ArnFormat.SLASH_RESOURCE_NAME); if (parsedArn.service !== 'ses' || parsedArn.resource !== 'identity' || !parsedArn.resourceName) { throw new ValidationError(`Invalid email identity ARN: ${emailIdentityArn}`, scope); } const emailIdentityName = parsedArn.resourceName; class Import extends EmailIdentityBase { public readonly emailIdentityName = emailIdentityName; public readonly emailIdentityArn = emailIdentityArn; } return new Import(scope, id); } public readonly emailIdentityName: string; public readonly emailIdentityArn: string; /** * The host name for the first token that you have to add to the * DNS configurationfor your domain * * @attribute */ public readonly dkimDnsTokenName1: string; /** * The host name for the second token that you have to add to the * DNS configuration for your domain * * @attribute */ public readonly dkimDnsTokenName2: string; /** * The host name for the third token that you have to add to the * DNS configuration for your domain * * @attribute */ public readonly dkimDnsTokenName3: string; /** * The record value for the first token that you have to add to the * DNS configuration for your domain * * @attribute */ public readonly dkimDnsTokenValue1: string; /** * The record value for the second token that you have to add to the * DNS configuration for your domain * * @attribute */ public readonly dkimDnsTokenValue2: string; /** * The record value for the third token that you have to add to the * DNS configuration for your domain * * @attribute */ public readonly dkimDnsTokenValue3: string; /** * DKIM records for this identity */ public readonly dkimRecords: DkimRecord[]; constructor(scope: Construct, id: string, props: EmailIdentityProps) { super(scope, id); // Enhanced CDK Analytics Telemetry addConstructMetadata(this, props); const dkimIdentity = props.dkimIdentity ?? DkimIdentity.easyDkim(); const identity = new CfnEmailIdentity(this, 'Resource', { emailIdentity: props.identity.value, configurationSetAttributes: undefinedIfNoKeys({ configurationSetName: props.configurationSet?.configurationSetName, }), dkimAttributes: undefinedIfNoKeys({ signingEnabled: props.dkimSigning, }), dkimSigningAttributes: dkimIdentity.bind(this, props.identity.hostedZone), feedbackAttributes: undefinedIfNoKeys({ emailForwardingEnabled: props.feedbackForwarding, }), mailFromAttributes: undefinedIfNoKeys({ mailFromDomain: props.mailFromDomain, behaviorOnMxFailure: props.mailFromBehaviorOnMxFailure, }), }); if (props.mailFromDomain && props.identity.hostedZone) { new route53.MxRecord(this, 'MailFromMxRecord', { zone: props.identity.hostedZone, recordName: props.mailFromDomain, values: [{ priority: 10, hostName: `feedback-smtp.${Stack.of(this).region}.amazonses.com`, }], }); new route53.TxtRecord(this, 'MailFromTxtRecord', { zone: props.identity.hostedZone, recordName: props.mailFromDomain, values: ['v=spf1 include:amazonses.com ~all'], }); } this.emailIdentityName = identity.ref; this.emailIdentityArn = this.stack.formatArn({ service: 'ses', resource: 'identity', resourceName: this.emailIdentityName, }); this.dkimDnsTokenName1 = identity.attrDkimDnsTokenName1; this.dkimDnsTokenName2 = identity.attrDkimDnsTokenName2; this.dkimDnsTokenName3 = identity.attrDkimDnsTokenName3; this.dkimDnsTokenValue1 = identity.attrDkimDnsTokenValue1; this.dkimDnsTokenValue2 = identity.attrDkimDnsTokenValue2; this.dkimDnsTokenValue3 = identity.attrDkimDnsTokenValue3; this.dkimRecords = [ { name: this.dkimDnsTokenName1, value: this.dkimDnsTokenValue1 }, { name: this.dkimDnsTokenName2, value: this.dkimDnsTokenValue2 }, { name: this.dkimDnsTokenName3, value: this.dkimDnsTokenValue3 }, ]; } } /** * A DKIM record */ export interface DkimRecord { /** * The name of the record */ readonly name: string; /** * The value of the record */ readonly value: string; }