diff --git a/packages/@aws-cdk/aws-cognito/README.md b/packages/@aws-cdk/aws-cognito/README.md index 2315662f49d10..906480586b769 100644 --- a/packages/@aws-cdk/aws-cognito/README.md +++ b/packages/@aws-cdk/aws-cognito/README.md @@ -314,29 +314,55 @@ new cognito.UserPool(this, 'UserPool', { The default for account recovery is by phone if available and by email otherwise. A user will not be allowed to reset their password via phone if they are also using it for MFA. + ### Emails Cognito sends emails to users in the user pool, when particular actions take place, such as welcome emails, invitation emails, password resets, etc. The address from which these emails are sent can be configured on the user pool. -Read more about [email settings here](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-email.html). +Read more at [Email settings for User Pools](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-email.html). + +By default, user pools are configured to use Cognito's built in email capability, which will send emails +from `no-reply@verificationemail.com`. If you want to use a custom email address you can configure +Cognito to send emails through Amazon SES, which is detailed below. ```ts new cognito.UserPool(this, 'myuserpool', { - // ... - emailSettings: { - from: 'noreply@myawesomeapp.com', + email: UserPoolEmail.withCognito('support@myawesomeapp.com'), +}); +``` + +For typical production environments, the default email limit is below the required delivery volume. +To enable a higher delivery volume, you can configure the UserPool to send emails through Amazon SES. To do +so, follow the steps in the [Cognito Developer Guide](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-email.html#user-pool-email-developer) +to verify an email address, move the account out of the SES sandbox, and grant Cognito email permissions via an +authorization policy. + +Once the SES setup is complete, the UserPool can be configured to use the SES email. + +```ts +new cognito.UserPool(this, 'myuserpool', { + email: UserPoolEmail.withSES({ + fromEmail: 'noreply@myawesomeapp.com', + fromName: 'Awesome App', replyTo: 'support@myawesomeapp.com', - }, + }), }); ``` -By default, user pools are configured to use Cognito's built-in email capability, but it can also be configured to use -Amazon SES, however, support for Amazon SES is not available in the CDK yet. If you would like this to be implemented, -give [this issue](https://github.com/aws/aws-cdk/issues/6768) a +1. Until then, you can use the [cfn -layer](https://docs.aws.amazon.com/cdk/latest/guide/cfn_layer.html) to configure this. +Sending emails through SES requires that SES be configured (as described above) in one of the regions - `us-east-1`, `us-west-1`, or `eu-west-1`. +If the UserPool is being created in a different region, `sesRegion` must be used to specify the correct SES region. + +```ts +new cognito.UserPool(this, 'myuserpool', { + email: UserPoolEmail.withSES({ + sesRegion: 'us-east-1', + fromEmail: 'noreply@myawesomeapp.com', + fromName: 'Awesome App', + replyTo: 'support@myawesomeapp.com', + }), +}); -If an email address contains non-ASCII characters, it will be encoded using the [punycode -encoding](https://en.wikipedia.org/wiki/Punycode) when generating the template for Cloudformation. +``` ### Device Tracking diff --git a/packages/@aws-cdk/aws-cognito/lib/index.ts b/packages/@aws-cdk/aws-cognito/lib/index.ts index cab56671c2b9e..7d5ce97fc2c76 100644 --- a/packages/@aws-cdk/aws-cognito/lib/index.ts +++ b/packages/@aws-cdk/aws-cognito/lib/index.ts @@ -4,6 +4,7 @@ export * from './user-pool'; export * from './user-pool-attr'; export * from './user-pool-client'; export * from './user-pool-domain'; +export * from './user-pool-email'; export * from './user-pool-idp'; export * from './user-pool-idps'; export * from './user-pool-resource-server'; diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool-email.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool-email.ts new file mode 100644 index 0000000000000..2d5b8af06447f --- /dev/null +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool-email.ts @@ -0,0 +1,203 @@ +import { Stack, Token } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { toASCII as punycodeEncode } from 'punycode/'; + +/** + * The valid Amazon SES configuration regions + */ +const REGIONS = ['us-east-1', 'us-west-2', 'eu-west-1']; + +/** + * Configuration for Cognito sending emails via Amazon SES + */ +export interface UserPoolSESOptions { + /** + * The verified Amazon SES email address that Cognito should + * use to send emails. + * + * The email address used must be a verified email address + * in Amazon SES and must be configured to allow Cognito to + * send emails. + * + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-email.html + */ + readonly fromEmail: string; + + /** + * An optional name that should be used as the sender's name + * along with the email. + * + * @default - no name + */ + readonly fromName?: string; + + /** + * The destination to which the receiver of the email should reploy to. + * + * @default - same as the fromEmail + */ + readonly replyTo?: string; + + /** + * The name of a configuration set in Amazon SES that should + * be applied to emails sent via Cognito. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cognito-userpool-emailconfiguration.html#cfn-cognito-userpool-emailconfiguration-configurationset + * + * @default - no configuration set + */ + readonly configurationSetName?: string; + + /** + * Required if the UserPool region is different than the SES region. + * + * If sending emails with a Amazon SES verified email address, + * and the region that SES is configured is different than the + * region in which the UserPool is deployed, you must specify that + * region here. + * + * Must be 'us-east-1', 'us-west-2', or 'eu-west-1' + * + * @default - The same region as the Cognito UserPool + */ + readonly sesRegion?: string; +} + +/** + * Result of binding email settings with a user pool + */ +interface UserPoolEmailConfig { + /** + * The name of the configuration set in SES. + * + * @default - none + */ + readonly configurationSet?: string; + + /** + * Specifies whether to use Cognito's built in email functionality + * or SES. + * + * @default - Cognito built in email functionality + */ + readonly emailSendingAccount?: string; + + /** + * Identifies either the sender's email address or the sender's + * name with their email address. + * + * If emailSendingAccount is DEVELOPER then this cannot be specified. + * + * @default 'no-reply@verificationemail.com' + */ + readonly from?: string; + + /** + * The destination to which the receiver of the email should reply to. + * + * @default - same as `from` + */ + readonly replyToEmailAddress?: string; + + /** + * The ARN of a verified email address in Amazon SES. + * + * required if emailSendingAccount is DEVELOPER or if + * 'from' is provided. + * + * @default - none + */ + readonly sourceArn?: string; +} + +/** + * Configure how Cognito sends emails + */ +export abstract class UserPoolEmail { + /** + * Send email using Cognito + */ + public static withCognito(replyTo?: string): UserPoolEmail { + return new CognitoEmail(replyTo); + } + + /** + * Send email using SES + */ + public static withSES(options: UserPoolSESOptions): UserPoolEmail { + return new SESEmail(options); + } + + + /** + * Returns the email configuration for a Cognito UserPool + * that controls how Cognito will send emails + * @internal + */ + public abstract _bind(scope: Construct): UserPoolEmailConfig; + +} + +class CognitoEmail extends UserPoolEmail { + constructor(private readonly replyTo?: string) { + super(); + } + + public _bind(_scope: Construct): UserPoolEmailConfig { + return { + replyToEmailAddress: encodeAndTest(this.replyTo), + emailSendingAccount: 'COGNITO_DEFAULT', + }; + + } +} + +class SESEmail extends UserPoolEmail { + constructor(private readonly options: UserPoolSESOptions) { + super(); + } + + public _bind(scope: Construct): UserPoolEmailConfig { + const region = Stack.of(scope).region; + + if (Token.isUnresolved(region) && !this.options.sesRegion) { + throw new Error('Your stack region cannot be determined so "sesRegion" is required in SESOptions'); + } + + if (this.options.sesRegion && !REGIONS.includes(this.options.sesRegion)) { + throw new Error(`sesRegion must be one of 'us-east-1', 'us-west-2', 'eu-west-1'. received ${this.options.sesRegion}`); + } else if (!this.options.sesRegion && !REGIONS.includes(region)) { + throw new Error(`Your stack is in ${region}, which is not a SES Region. Please provide a valid value for 'sesRegion'`); + } + + let from = this.options.fromEmail; + if (this.options.fromName) { + from = `${this.options.fromName} <${this.options.fromEmail}>`; + } + + return { + from: encodeAndTest(from), + replyToEmailAddress: encodeAndTest(this.options.replyTo), + configurationSet: this.options.configurationSetName, + emailSendingAccount: 'DEVELOPER', + sourceArn: Stack.of(scope).formatArn({ + service: 'ses', + resource: 'identity', + resourceName: encodeAndTest(this.options.fromEmail), + region: this.options.sesRegion ?? region, + }), + }; + } +} + +function encodeAndTest(input: string | undefined): string | undefined { + if (input) { + const local = input.split('@')[0]; + if (!/[\p{ASCII}]+/u.test(local)) { + throw new Error('the local part of the email address must use ASCII characters only'); + } + return punycodeEncode(input); + } else { + return undefined; + } +} diff --git a/packages/@aws-cdk/aws-cognito/lib/user-pool.ts b/packages/@aws-cdk/aws-cognito/lib/user-pool.ts index adc3f51bf37a2..836537ef6c465 100644 --- a/packages/@aws-cdk/aws-cognito/lib/user-pool.ts +++ b/packages/@aws-cdk/aws-cognito/lib/user-pool.ts @@ -8,6 +8,7 @@ import { StandardAttributeNames } from './private/attr-names'; import { ICustomAttribute, StandardAttribute, StandardAttributes } from './user-pool-attr'; import { UserPoolClient, UserPoolClientOptions } from './user-pool-client'; import { UserPoolDomain, UserPoolDomainOptions } from './user-pool-domain'; +import { UserPoolEmail } from './user-pool-email'; import { IUserPoolIdentityProvider } from './user-pool-idp'; import { UserPoolResourceServer, UserPoolResourceServerOptions } from './user-pool-resource-server'; @@ -570,10 +571,18 @@ export interface UserPoolProps { /** * Email settings for a user pool. + * * @default - see defaults on each property of EmailSettings. + * @deprecated Use 'email' instead. */ readonly emailSettings?: EmailSettings; + /** + * Email settings for a user pool. + * @default - cognito will use the default email configuration + */ + readonly email?: UserPoolEmail; + /** * Lambda functions to use for supported Cognito triggers. * @see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html @@ -788,6 +797,14 @@ export class UserPool extends UserPoolBase { const passwordPolicy = this.configurePasswordPolicy(props); + if (props.email && props.emailSettings) { + throw new Error('you must either provide "email" or "emailSettings", but not both'); + } + const emailConfiguration = props.email ? props.email._bind(this) : undefinedIfNoKeys({ + from: encodePuny(props.emailSettings?.from), + replyToEmailAddress: encodePuny(props.emailSettings?.replyTo), + }); + const userPool = new CfnUserPool(this, 'Resource', { userPoolName: props.userPoolName, usernameAttributes: signIn.usernameAttrs, @@ -805,10 +822,7 @@ export class UserPool extends UserPoolBase { mfaConfiguration: props.mfa, enabledMfas: this.mfaConfiguration(props), policies: passwordPolicy !== undefined ? { passwordPolicy } : undefined, - emailConfiguration: undefinedIfNoKeys({ - from: encodePuny(props.emailSettings?.from), - replyToEmailAddress: encodePuny(props.emailSettings?.replyTo), - }), + emailConfiguration, usernameConfiguration: undefinedIfNoKeys({ caseSensitive: props.signInCaseSensitive, }), diff --git a/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts b/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts index 5aa6c48ee99fc..c499335c7ba9c 100644 --- a/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts +++ b/packages/@aws-cdk/aws-cognito/test/user-pool.test.ts @@ -3,7 +3,7 @@ import { Role, ServicePrincipal } from '@aws-cdk/aws-iam'; import * as lambda from '@aws-cdk/aws-lambda'; import { CfnParameter, Duration, Stack, Tags } from '@aws-cdk/core'; import { Construct } from 'constructs'; -import { AccountRecovery, Mfa, NumberAttribute, StringAttribute, UserPool, UserPoolIdentityProvider, UserPoolOperation, VerificationEmailStyle } from '../lib'; +import { AccountRecovery, Mfa, NumberAttribute, StringAttribute, UserPool, UserPoolIdentityProvider, UserPoolOperation, VerificationEmailStyle, UserPoolEmail } from '../lib'; describe('User Pool', () => { test('default setup', () => { @@ -1388,6 +1388,285 @@ describe('User Pool', () => { }, }); }); + + test('email transmission with cyrillic characters in the domain are encoded', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new UserPool(stack, 'Pool', { + email: UserPoolEmail.withSES({ + sesRegion: 'us-east-1', + fromEmail: 'user@домен.рф', + replyTo: 'user@домен.рф', + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Cognito::UserPool', { + EmailConfiguration: { + From: 'user@xn--d1acufc.xn--p1ai', + ReplyToEmailAddress: 'user@xn--d1acufc.xn--p1ai', + }, + }); + }); + + test('email transmission with cyrillic characters in the local part throw error', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + expect(() => new UserPool(stack, 'Pool', { + email: UserPoolEmail.withSES({ + sesRegion: 'us-east-1', + fromEmail: 'от@домен.рф', + replyTo: 'user@домен.рф', + }), + })).toThrow(/the local part of the email address must use ASCII characters only/); + }); + + test('email transmission with cyrillic characters in the local part of replyTo throw error', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + expect(() => new UserPool(stack, 'Pool', { + email: UserPoolEmail.withSES({ + sesRegion: 'us-east-1', + fromEmail: 'user@домен.рф', + replyTo: 'от@домен.рф', + }), + })).toThrow(/the local part of the email address must use ASCII characters only/); + }); + + test('email withCognito transmission with cyrillic characters in the local part of replyTo throw error', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + expect(() => new UserPool(stack, 'Pool', { + email: UserPoolEmail.withCognito('от@домен.рф'), + })).toThrow(/the local part of the email address must use ASCII characters only/); + }); + + test('email withCognito', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new UserPool(stack, 'Pool', { + email: UserPoolEmail.withCognito(), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Cognito::UserPool', { + EmailConfiguration: { + EmailSendingAccount: 'COGNITO_DEFAULT', + }, + }); + }); + + test('email withCognito and replyTo', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new UserPool(stack, 'Pool', { + email: UserPoolEmail.withCognito('reply@example.com'), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Cognito::UserPool', { + EmailConfiguration: { + EmailSendingAccount: 'COGNITO_DEFAULT', + ReplyToEmailAddress: 'reply@example.com', + }, + }); + }); + + test('email withSES with custom email and no region', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + expect(() => new UserPool(stack, 'Pool', { + email: UserPoolEmail.withSES({ + fromEmail: 'mycustomemail@example.com', + replyTo: 'reply@example.com', + }), + })).toThrow(/Your stack region cannot be determined/); + + }); + + test('email withSES with no name', () => { + // GIVEN + const stack = new Stack(undefined, undefined, { + env: { + region: 'us-east-1', + account: '11111111111', + }, + }); + + // WHEN + new UserPool(stack, 'Pool', { + email: UserPoolEmail.withSES({ + fromEmail: 'mycustomemail@example.com', + replyTo: 'reply@example.com', + configurationSetName: 'default', + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Cognito::UserPool', { + EmailConfiguration: { + EmailSendingAccount: 'DEVELOPER', + From: 'mycustomemail@example.com', + ReplyToEmailAddress: 'reply@example.com', + ConfigurationSet: 'default', + SourceArn: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ses:us-east-1:11111111111:identity/mycustomemail@example.com', + ], + ], + }, + }, + }); + + }); + + test('email withSES', () => { + // GIVEN + const stack = new Stack(undefined, undefined, { + env: { + region: 'us-east-1', + account: '11111111111', + }, + }); + + // WHEN + new UserPool(stack, 'Pool', { + email: UserPoolEmail.withSES({ + fromEmail: 'mycustomemail@example.com', + fromName: 'My Custom Email', + replyTo: 'reply@example.com', + configurationSetName: 'default', + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Cognito::UserPool', { + EmailConfiguration: { + EmailSendingAccount: 'DEVELOPER', + From: 'My Custom Email ', + ReplyToEmailAddress: 'reply@example.com', + ConfigurationSet: 'default', + SourceArn: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ses:us-east-1:11111111111:identity/mycustomemail@example.com', + ], + ], + }, + }, + }); + + }); + + test('email withSES with valid region', () => { + // GIVEN + const stack = new Stack(undefined, undefined, { + env: { + region: 'us-east-2', + account: '11111111111', + }, + }); + + // WHEN + new UserPool(stack, 'Pool', { + email: UserPoolEmail.withSES({ + fromEmail: 'mycustomemail@example.com', + fromName: 'My Custom Email', + sesRegion: 'us-east-1', + replyTo: 'reply@example.com', + configurationSetName: 'default', + }), + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('AWS::Cognito::UserPool', { + EmailConfiguration: { + EmailSendingAccount: 'DEVELOPER', + From: 'My Custom Email ', + ReplyToEmailAddress: 'reply@example.com', + ConfigurationSet: 'default', + SourceArn: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':ses:us-east-1:11111111111:identity/mycustomemail@example.com', + ], + ], + }, + }, + }); + + }); + test('email withSES invalid region throws error', () => { + // GIVEN + const stack = new Stack(undefined, undefined, { + env: { + region: 'us-east-2', + account: '11111111111', + }, + }); + + // WHEN + expect(() => new UserPool(stack, 'Pool', { + email: UserPoolEmail.withSES({ + fromEmail: 'mycustomemail@example.com', + fromName: 'My Custom Email', + replyTo: 'reply@example.com', + configurationSetName: 'default', + }), + })).toThrow(/Please provide a valid value/); + + }); + + test('email withSES invalid sesRegion throws error', () => { + // GIVEN + const stack = new Stack(undefined, undefined, { + env: { + account: '11111111111', + }, + }); + + // WHEN + expect(() => new UserPool(stack, 'Pool', { + email: UserPoolEmail.withSES({ + sesRegion: 'us-east-2', + fromEmail: 'mycustomemail@example.com', + fromName: 'My Custom Email', + replyTo: 'reply@example.com', + configurationSetName: 'default', + }), + })).toThrow(/sesRegion must be one of/); + + }); }); test('device tracking is configured correctly', () => {