Skip to content
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

feat(cognito): user pool: send emails using Amazon SES #17117

Merged
merged 10 commits into from Nov 15, 2021
54 changes: 43 additions & 11 deletions packages/@aws-cdk/aws-cognito/README.md
Expand Up @@ -314,29 +314,61 @@ 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 by default will send emails
from `no-reply@verificationemail.com`. To customize the from address, while still using the Cognito built-in
email capability, specify a custom email address that has been configured in Amazon SES.

```ts
new cognito.UserPool(this, 'myuserpool', {
// ...
emailSettings: {
from: 'noreply@myawesomeapp.com',
email: Email.withCognito({
fromEmail: 'noreply@myawesomeapp.com',
replyTo: 'support@myawesomeapp.com',
},
}),
});
```

The custom email address specified must first be verified in Amazon SES, and associated with an authorization policy
that allows Cognito to send emails. Read more at [Configuring Email for your User Pool](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-email.html#user-pool-email-configure).


For production applications it is recommended to configure the UserPool to send emails through Amazon SES. To do
corymhall marked this conversation as resolved.
Show resolved Hide resolved
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.
nija-at marked this conversation as resolved.
Show resolved Hide resolved

Once the SES setup is complete, the UserPool can be configured to use the SES email.

```ts
new cognito.UserPool(this, 'myuserpool', {
email: Email.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 in either `us-east-1`, `us-west-1`, or `eu-west-1`.
corymhall marked this conversation as resolved.
Show resolved Hide resolved
corymhall marked this conversation as resolved.
Show resolved Hide resolved
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: Email.withSES({
sesRegion: SESRegion.US_EAST_1,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need an enum for this. Region is just a string in the cdk. We can just add a validation for the list of valid regions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still show as an enum. README needs to be updated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah sorry, should be fixed now.

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

Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-cognito/lib/index.ts
Expand Up @@ -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';
267 changes: 267 additions & 0 deletions packages/@aws-cdk/aws-cognito/lib/user-pool-email.ts
@@ -0,0 +1,267 @@
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 SESOptions {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this module contains both user pool and identity pool, perhaps rename this as UserPoolSESOptions.

Same for other names as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated all the exported names.

/**
* 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;
}

/**
* Configuration settings for Cognito default email
*/
export interface CognitoEmailOptions {
/**
* The verified email address in Amazon SES that
* Cognito will use to send emails. You must have already
* configured Amazon SES to allow Cognito to send Emails
* through this address.
corymhall marked this conversation as resolved.
Show resolved Hide resolved
*
* @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-email.html
*
* @default - Cognito default email address will be used
* 'no-reply@verificationemail.com'
corymhall marked this conversation as resolved.
Show resolved Hide resolved
*/
readonly fromEmail?: string;

/**
* The destination to which the receiver of the email should reploy to.
*
* @default - same as the fromEmail
*/
readonly replyTo?: 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;
corymhall marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Configuration for Cognito email settings
corymhall marked this conversation as resolved.
Show resolved Hide resolved
*/
export interface EmailConfiguration {
corymhall marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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_DEFAULT
corymhall marked this conversation as resolved.
Show resolved Hide resolved
*/
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
corymhall marked this conversation as resolved.
Show resolved Hide resolved
*/
readonly from?: string;

/**
* The destination to which the receiver of the email should reply to.
*
* @default - none
corymhall marked this conversation as resolved.
Show resolved Hide resolved
*/
readonly replyToEmailAddress?: string;
corymhall marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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 Email {
/**
* Send email using Cognito
*/
public static withCognito(options?: CognitoEmailOptions): Email {
return new CognitoEmail(options);
}

/**
* Send email using SES
*/
public static withSES(options: SESOptions): Email {
return new SESEmail(options);
}


/**
* Returns the email configuration for a Cognito UserPool
* that controls how Cognito will send emails
*/
public abstract bind(scope: Construct): EmailConfiguration;

}

class CognitoEmail extends Email {
constructor(private readonly options?: CognitoEmailOptions) {
super();
}

public bind(scope: Construct): EmailConfiguration {
const region = Stack.of(scope).region;

// if a custom email is provided that means that cognito is going to use an SES email
// and we need to provide the sourceArn which requires a valid region
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused. Cognito email is not SES email right? Are we mixing these up?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a little confusing, maybe I can add some more explanation to the README. There are technically two modes to Cognito default email.

  1. Using the default no-reply@verificationemail.com
    In this case Cognito is handling sending the email because it is using an email address owned by AWS.

  2. Using a custom email address.
    In this case even though it is still using the Cognito default email functionality, it is actually using SES to send the email, so it does require you to have the email configured in SES.

Copy link
Contributor

@nija-at nija-at Nov 5, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we then roll #2 into Email.withSES() instead? That would keep this simpler/ easier to explain.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm that is a good point. I can't think of a reason why you would want to use Cognito default through SES instead of just SES directly. I'll update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I updated withCognito to only take the replyTo email address. You now how two options

  1. Cognito default with the default no-reply@verificationemail.com email and optionally a replyTo email address.
  2. Sending emails through SES.

I completely removed option 2 from my previous response since you don't gain anything over just configuring Cognito to use SES.

let sourceArn: string | undefined = undefined;
if (this.options?.fromEmail) {
if (this.options.fromEmail !== 'no-reply@verificationemail.com') {
if (Token.isUnresolved(region) && !this.options.sesRegion) {
throw new Error('Your stack region cannot be determined so "sesRegion" is required in CognitoEmailOptions');
}
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'`);
}
corymhall marked this conversation as resolved.
Show resolved Hide resolved
sourceArn = Stack.of(scope).formatArn({
service: 'ses',
resource: 'identity',
resourceName: encodeAndTest(this.options.fromEmail),
region: this.options.sesRegion ?? region,
});
}
}


return {
replyToEmailAddress: encodeAndTest(this.options?.replyTo),
emailSendingAccount: 'COGNITO_DEFAULT',
sourceArn,
};

}
}

class SESEmail extends Email {
constructor(private readonly options: SESOptions) {
super();
}

public bind(scope: Construct): EmailConfiguration {
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;
}
}