diff --git a/packages/@aws-cdk/aws-ses/README.md b/packages/@aws-cdk/aws-ses/README.md index 77cf9101c97ba..7dc46f9b29c8c 100644 --- a/packages/@aws-cdk/aws-ses/README.md +++ b/packages/@aws-cdk/aws-ses/README.md @@ -1,2 +1,90 @@ ## The CDK Construct Library for AWS Simple Email Service This module is part of the [AWS Cloud Development Kit](https://github.com/awslabs/aws-cdk) project. + +### Email receiving +Create a receipt rule set with rules and actions: +[example of setting up a receipt rule set](test/example.receiving.lit.ts) + +Alternatively, rules can be added to a rule set: +```ts +const ruleSet = new ses.ReceiptRuleSet(this, 'RuleSet'): + +const awsRule = ruleSet.addRule('Aws', { + recipients: ['aws.com'] +}); +``` + +And actions to rules: +```ts +awsRule.addAction( + new ses.ReceiptRuleSnsAction({ + topic + }); +); +``` +When using `addRule`, the new rule is added after the last added rule unless `after` is specified. + +[More actions](test/integ.receipt.ts) + +#### Drop spams +A rule to drop spam can be added by setting `dropSpam` to `true`: + +```ts +new ses.ReceiptRuleSet(this, 'RuleSet', { + dropSpam: true +}); +``` + +This will add a rule at the top of the rule set with a Lambda action that stops processing messages that have at least one spam indicator. See [Lambda Function Examples](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-action-lambda-example-functions.html). + +### Import and export receipt rule set and receipt rules +Receipt rule sets and receipt rules can be exported: + +```ts +const ruleSet = new ReceiptRuleSet(this, 'RuleSet'); +const rule = ruleSet.addRule(this, 'Rule', { + recipients: ['hello@mydomain.com'] +}); + +const ruleSetRef = ruleSet.export(); +const ruleRef = rule.export(); +``` + +And imported: +```ts +const importedRuleSet = ses.ReceiptRuleSet.import(this, 'ImportedRuleSet', ruleSetRef); + +const importedRule = ses.ReceiptRule.import(this, 'ImportedRule', ruleRef); + +const otherRule = ses.ReceiptRule.import(this, 'OtherRule', { + name: 'other-rule' +}); + +importedRuleSet.addRule('New', { // This rule is added after the imported rule + after: importedRule, + recipients: ['mydomain.com'] +}); + +importedRuleSet.addRule('Extra', { // Added after the 'New' rule + recipients: ['extra.com'] +}); +``` + +### Receipt filter +Create a receipt filter: +```ts +new ses.ReceiptFilter(this, 'Filter', { + ip: '1.2.3.4/16' // Will be blocked +}) +``` + +A white list filter is also available: +```ts +new ses.WhiteListReceiptFilter(this, 'WhiteList', { + ips: [ + '10.0.0.0/16', + '1.2.3.4/16', + ] +}); +``` +This will first create a block all filter and then create allow filters for the listed ip addresses. diff --git a/packages/@aws-cdk/aws-ses/lib/index.ts b/packages/@aws-cdk/aws-ses/lib/index.ts index 725bd4c040640..078887862180c 100644 --- a/packages/@aws-cdk/aws-ses/lib/index.ts +++ b/packages/@aws-cdk/aws-ses/lib/index.ts @@ -1,2 +1,7 @@ +export * from './receipt-rule-set'; +export * from './receipt-rule'; +export * from './receipt-rule-action'; +export * from './receipt-filter'; + // AWS::SES CloudFormation Resources: export * from './ses.generated'; diff --git a/packages/@aws-cdk/aws-ses/lib/receipt-filter.ts b/packages/@aws-cdk/aws-ses/lib/receipt-filter.ts new file mode 100644 index 0000000000000..f715454906ae1 --- /dev/null +++ b/packages/@aws-cdk/aws-ses/lib/receipt-filter.ts @@ -0,0 +1,91 @@ +import cdk = require('@aws-cdk/cdk'); +import { CfnReceiptFilter } from './ses.generated'; + +/** + * The policy for the receipt filter. + */ +export enum ReceiptFilterPolicy { + /** + * Allow the ip address or range. + */ + Allow = 'Allow', + + /** + * Block the ip address or range. + */ + Block = 'Block' +} + +/** + * Construction properties for a ReceiptFilter. + */ +export interface ReceiptFilterProps { + /** + * The name for the receipt filter. + * + * @default a CloudFormation generated name + */ + name?: string; + + /** + * The ip address or range to filter. + * + * @default 0.0.0.0/0 + */ + ip?: string; + + /** + * The policy for the filter. + * + * @default Block + */ + policy?: ReceiptFilterPolicy; +} + +/** + * A receipt filter. When instantiated without props, it creates a + * block all receipt filter. + */ +export class ReceiptFilter extends cdk.Construct { + constructor(scope: cdk.Construct, id: string, props?: ReceiptFilterProps) { + super(scope, id); + + new CfnReceiptFilter(this, 'Resource', { + filter: { + ipFilter: { + cidr: (props && props.ip) || '0.0.0.0/0', + policy: (props && props.policy) || ReceiptFilterPolicy.Block + }, + name: props ? props.name : undefined + } + }); + } +} + +/** + * Construction properties for a WhiteListReceiptFilter. + */ +export interface WhiteListReceiptFilterProps { + /** + * A list of ip addresses or ranges to white list. + */ + ips: string[]; +} + +/** + * A white list receipt filter. + */ +export class WhiteListReceiptFilter extends cdk.Construct { + constructor(scope: cdk.Construct, id: string, props: WhiteListReceiptFilterProps) { + super(scope, id); + + new ReceiptFilter(this, 'BlockAll'); + + props.ips.forEach(ip => { + new ReceiptFilter(this, `Allow${ip.replace(/[^\d]/g, '')}`, { + ip, + policy: ReceiptFilterPolicy.Allow + }); + }); + } +} diff --git a/packages/@aws-cdk/aws-ses/lib/receipt-rule-action.ts b/packages/@aws-cdk/aws-ses/lib/receipt-rule-action.ts new file mode 100644 index 0000000000000..36c8a7892e105 --- /dev/null +++ b/packages/@aws-cdk/aws-ses/lib/receipt-rule-action.ts @@ -0,0 +1,458 @@ +import iam = require('@aws-cdk/aws-iam'); +import kms = require('@aws-cdk/aws-kms'); +import lambda = require('@aws-cdk/aws-lambda'); +import s3 = require('@aws-cdk/aws-s3'); +import sns = require('@aws-cdk/aws-sns'); +import cdk = require('@aws-cdk/cdk'); +import { CfnReceiptRule } from './ses.generated'; + +/** + * Properties for a receipt rule action. + */ +export interface ReceiptRuleActionProps { + /** + * Adds a header to the received email. + */ + addHeaderAction?: CfnReceiptRule.AddHeaderActionProperty + + /** + * Rejects the received email by returning a bounce response to the sender and, + * optionally, publishes a notification to Amazon SNS. + */ + bounceAction?: CfnReceiptRule.BounceActionProperty; + + /** + * Calls an AWS Lambda function, and optionally, publishes a notification to + * Amazon SNS. + */ + lambdaAction?: CfnReceiptRule.LambdaActionProperty; + + /** + * Saves the received message to an Amazon S3 bucket and, optionally, publishes + * a notification to Amazon SNS. + */ + s3Action?: CfnReceiptRule.S3ActionProperty; + + /** + * Publishes the email content within a notification to Amazon SNS. + */ + snsAction?: CfnReceiptRule.SNSActionProperty; + + /** + * Terminates the evaluation of the receipt rule set and optionally publishes a + * notification to Amazon SNS. + */ + stopAction?: CfnReceiptRule.StopActionProperty; + + /** + * Calls Amazon WorkMail and, optionally, publishes a notification to Amazon SNS. + */ + workmailAction?: CfnReceiptRule.WorkmailActionProperty; +} + +/** + * An abstract action for a receipt rule. + */ +export interface IReceiptRuleAction { + /** + * Renders the action specification + */ + render(): ReceiptRuleActionProps; +} + +/** + * Construction properties for a ReceiptRuleAddHeaderAction. + */ +export interface ReceiptRuleAddHeaderActionProps { + /** + * The name of the header to add. Must be between 1 and 50 characters, + * inclusive, and consist of alphanumeric (a-z, A-Z, 0-9) characters + * and dashes only. + */ + name: string; + + /** + * The value of the header to add. Must be less than 2048 characters, + * and must not contain newline characters ("\r" or "\n"). + */ + value: string; +} + +/** + * Adds a header to the received email + */ +export class ReceiptRuleAddHeaderAction implements IReceiptRuleAction { + private readonly name: string; + private readonly value: string; + + constructor(props: ReceiptRuleAddHeaderActionProps) { + if (!/^[a-zA-Z0-9-]{1,50}$/.test(props.name)) { + // tslint:disable:max-line-length + throw new Error('Header `name` must be between 1 and 50 characters, inclusive, and consist of alphanumeric (a-z, A-Z, 0-9) characters and dashes only.'); + // tslint:enable:max-line-length + } + + if (!/^[^\n\r]{0,2047}$/.test(props.value)) { + throw new Error('Header `value` must be less than 2048 characters, and must not contain newline characters ("\r" or "\n").'); + } + + this.name = props.name; + this.value = props.value; + } + + public render(): ReceiptRuleActionProps { + return { + addHeaderAction: { + headerName: this.name, + headerValue: this.value + } + }; + } +} + +/** + * Construction properties for a ReceiptRuleBounceActionTemplate. + */ +export interface ReceiptRuleBounceActionTemplateProps { + /** + * Human-readable text to include in the bounce message. + */ + message: string; + + /** + * The SMTP reply code, as defined by RFC 5321. + * + * @see https://tools.ietf.org/html/rfc5321 + */ + smtpReplyCode: string; + + /** + * The SMTP enhanced status code, as defined by RFC 3463. + * + * @see https://tools.ietf.org/html/rfc3463 + */ + statusCode?: string; +} + +/** + * A bounce action template. + */ +export class ReceiptRuleBounceActionTemplate { + public static readonly MailboxDoesNotExist = new ReceiptRuleBounceActionTemplate({ + message: 'Mailbox does not exist', + smtpReplyCode: '550', + statusCode: '5.1.1' + }); + + public static readonly MessageTooLarge = new ReceiptRuleBounceActionTemplate({ + message: 'Message too large', + smtpReplyCode: '552', + statusCode: '5.3.4' + }); + + public static readonly MailboxFull = new ReceiptRuleBounceActionTemplate({ + message: 'Mailbox full', + smtpReplyCode: '552', + statusCode: '5.2.2' + }); + + public static readonly MessageContentRejected = new ReceiptRuleBounceActionTemplate({ + message: 'Message content rejected', + smtpReplyCode: '500', + statusCode: '5.6.1' + }); + + public static readonly TemporaryFailure = new ReceiptRuleBounceActionTemplate({ + message: 'Temporary failure', + smtpReplyCode: '450', + statusCode: '4.0.0' + }); + + public readonly message: string; + public readonly smtpReplyCode: string; + public readonly statusCode?: string; + + constructor(props: ReceiptRuleBounceActionTemplateProps) { + this.message = props.message; + this.smtpReplyCode = props.smtpReplyCode; + this.statusCode = props.statusCode; + } +} + +/** + * Construction properties for a ReceiptRuleBounceAction. + */ +export interface ReceiptRuleBounceActionProps { + /** + * The template containing the message, reply code and status code. + */ + template: ReceiptRuleBounceActionTemplate; + + /** + * The email address of the sender of the bounced email. This is the address + * from which the bounce message will be sent. + */ + sender: string; + + /** + * The SNS topic to notify when the bounce action is taken. + * + * @default no notification + */ + topic?: sns.ITopic; +} + +/** + * Rejects the received email by returning a bounce response to the sender and, + * optionally, publishes a notification to Amazon SNS. + */ +export class ReceiptRuleBounceAction implements IReceiptRuleAction { + constructor(private readonly props: ReceiptRuleBounceActionProps) { + } + + public render(): ReceiptRuleActionProps { + return { + bounceAction: { + sender: this.props.sender, + smtpReplyCode: this.props.template.smtpReplyCode, + message: this.props.template.message, + topicArn: this.props.topic ? this.props.topic.topicArn : undefined, + statusCode: this.props.template.statusCode + } + }; + } +} + +/** + * The type of invocation to use for a Lambda Action. + */ +export enum LambdaInvocationType { + /** + * The function will be invoked asynchronously. + */ + Event = 'Event', + + /** + * The function will be invoked sychronously. Use RequestResponse only when + * you want to make a mail flow decision, such as whether to stop the receipt + * rule or the receipt rule set. + */ + RequestResponse = 'RequestResponse', +} + +/** + * Construction properties for a ReceiptRuleLambdaAction. + */ +export interface ReceiptRuleLambdaActionProps { + /** + * The Lambda function to invoke. + */ + function: lambda.IFunction + + /** + * The invocation type of the Lambda function. + * + * @default Event + */ + invocationType?: LambdaInvocationType; + + /** + * The SNS topic to notify when the Lambda action is taken. + * + * @default no notification + */ + topic?: sns.ITopic; +} + +/** + * Calls an AWS Lambda function, and optionally, publishes a notification to + * Amazon SNS. + */ +export class ReceiptRuleLambdaAction implements IReceiptRuleAction { + constructor(private readonly props: ReceiptRuleLambdaActionProps) { + } + + public render(): ReceiptRuleActionProps { + // Allow SES to invoke Lambda function + // See https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-permissions.html#receiving-email-permissions-lambda + const permissionId = 'AllowSes'; + if (!this.props.function.node.tryFindChild(permissionId)) { + this.props.function.addPermission(permissionId, { + action: 'lambda:InvokeFunction', + principal: new iam.ServicePrincipal('ses.amazonaws.com'), + sourceAccount: new cdk.ScopedAws().accountId + }); + } + + return { + lambdaAction: { + functionArn: this.props.function.functionArn, + invocationType: this.props.invocationType, + topicArn: this.props.topic ? this.props.topic.topicArn : undefined + } + }; + } +} + +/** + * Construction properties for a ReceiptRuleS3Action. + */ +export interface ReceiptRuleS3ActionProps { + /** + * The S3 bucket that incoming email will be saved to. + */ + bucket: s3.IBucket; + + /** + * The master key that SES should use to encrypt your emails before saving + * them to the S3 bucket. + * + * @default no encryption + */ + kmsKey?: kms.IEncryptionKey; + + /** + * The key prefix of the S3 bucket. + * + * @default no prefix + */ + objectKeyPrefix?: string; + + /** + * The SNS topic to notify when the S3 action is taken. + * + * @default no notification + */ + topic?: sns.ITopic; +} + +/** + * Saves the received message to an Amazon S3 bucket and, optionally, publishes + * a notification to Amazon SNS. + */ +export class ReceiptRuleS3Action implements IReceiptRuleAction { + constructor(private readonly props: ReceiptRuleS3ActionProps) { + } + + public render(): ReceiptRuleActionProps { + // Allow SES to write to S3 bucket + // See https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-permissions.html#receiving-email-permissions-s3 + const keyPattern = this.props.objectKeyPrefix || ''; + + const s3Statement = new iam.PolicyStatement() + .addAction('s3:PutObject') + .addServicePrincipal('ses.amazonaws.com') + .addResource(this.props.bucket.arnForObjects(`${keyPattern}*`)) + .addCondition('StringEquals', { + 'aws:Referer': new cdk.ScopedAws().accountId + }); + + this.props.bucket.addToResourcePolicy(s3Statement); + + // Allow SES to use KMS master key + // See https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-permissions.html#receiving-email-permissions-kms + if (this.props.kmsKey && !/alias\/aws\/ses$/.test(this.props.kmsKey.keyArn)) { + const kmsStatement = new iam.PolicyStatement() + .addActions('km:Encrypt', 'kms:GenerateDataKey') + .addServicePrincipal('ses.amazonaws.com') + .addAllResources() + .addConditions({ + Null: { + 'kms:EncryptionContext:aws:ses:rule-name': 'false', + 'kms:EncryptionContext:aws:ses:message-id': 'false' + }, + StringEquals: { + 'kms:EncryptionContext:aws:ses:source-account': new cdk.ScopedAws().accountId + } + }); + + this.props.kmsKey.addToResourcePolicy(kmsStatement); + } + + return { + s3Action: { + bucketName: this.props.bucket.bucketName, + kmsKeyArn: this.props.kmsKey ? this.props.kmsKey.keyArn : undefined, + objectKeyPrefix: this.props.objectKeyPrefix, + topicArn: this.props.topic ? this.props.topic.topicArn : undefined + } + }; + } +} + +/** + * The type of email encoding to use for a SNS action. + */ +export enum EmailEncoding { + /** + * Base 64 + */ + Base64 = 'Base64', + + /** + * UTF-8 + */ + UTF8 = 'UTF-8', +} + +/** + * Construction properties for a ReceiptRuleSnsAction. + */ +export interface ReceiptRuleSnsActionProps { + /** + * The encoding to use for the email within the Amazon SNS notification. + * + * @default UTF-8 + */ + encoding?: EmailEncoding; + + /** + * The SNS topic to notify. + */ + topic: sns.ITopic; +} + +/** + * Publishes the email content within a notification to Amazon SNS. + */ +export class ReceiptRuleSnsAction implements IReceiptRuleAction { + constructor(private readonly props: ReceiptRuleSnsActionProps) { + } + + public render(): ReceiptRuleActionProps { + return { + snsAction: { + encoding: this.props.encoding, + topicArn: this.props.topic.topicArn + } + }; + } +} + +/** + * Construction properties for a ReceiptRuleStopAction. + */ +export interface ReceiptRuleStopActionProps { + /** + * The SNS topic to notify when the stop action is taken. + */ + topic?: sns.ITopic; +} + +/** + * Terminates the evaluation of the receipt rule set and optionally publishes a + * notification to Amazon SNS. + */ +export class ReceiptRuleStopAction implements IReceiptRuleAction { + constructor(private readonly props?: ReceiptRuleStopActionProps) { + } + + public render(): ReceiptRuleActionProps { + return { + stopAction: { + scope: 'RuleSet', + topicArn: this.props && this.props.topic ? this.props.topic.topicArn : undefined + } + }; + } +} diff --git a/packages/@aws-cdk/aws-ses/lib/receipt-rule-set.ts b/packages/@aws-cdk/aws-ses/lib/receipt-rule-set.ts new file mode 100644 index 0000000000000..54122a7a2ed62 --- /dev/null +++ b/packages/@aws-cdk/aws-ses/lib/receipt-rule-set.ts @@ -0,0 +1,157 @@ +import cdk = require('@aws-cdk/cdk'); +import { DropSpamReceiptRule, ReceiptRule, ReceiptRuleOptions } from './receipt-rule'; +import { CfnReceiptRuleSet } from './ses.generated'; + +/** + * A receipt rule set. + */ +export interface IReceiptRuleSet extends cdk.IConstruct { + /** + * The receipt rule set name. + */ + readonly name: string; + + /** + * Adds a new receipt rule in this rule set. The new rule is added after + * the last added rule unless `after` is specified. + */ + addRule(id: string, options?: ReceiptRuleOptions): ReceiptRule; + + /** + * Exports this receipt rule set from the stack. + */ + export(): ReceiptRuleSetImportProps; +} + +/** + * Construction properties for a ReceiptRuleSet. + */ +export interface ReceiptRuleSetProps { + /** + * The name for the receipt rule set. + * + * @default a CloudFormation generated name + */ + name?: string; + + /** + * The list of rules to add to this rule set. Rules are added in the same + * order as they appear in the list. + */ + rules?: ReceiptRuleOptions[] + + /** + * Whether to add a first rule to stop processing messages + * that have at least one spam indicator. + * + * @default false + */ + dropSpam?: boolean; +} + +/** + * A new or imported receipt rule set. + */ +export abstract class ReceiptRuleSetBase extends cdk.Construct implements IReceiptRuleSet { + public abstract readonly name: string; + + private lastAddedRule?: ReceiptRule; + + /** + * Adds a new receipt rule in this rule set. The new rule is added after + * the last added rule unless `after` is specified. + */ + public addRule(id: string, options?: ReceiptRuleOptions): ReceiptRule { + this.lastAddedRule = new ReceiptRule(this, id, { + after: this.lastAddedRule ? this.lastAddedRule : undefined, + ruleSet: this, + ...options + }); + + return this.lastAddedRule; + } + + public abstract export(): ReceiptRuleSetImportProps; + + /** + * Adds a drop spam rule + */ + protected addDropSpamRule(): void { + const dropSpam = new DropSpamReceiptRule(this, 'DropSpam', { + ruleSet: this + }); + this.lastAddedRule = dropSpam.rule; + } +} + +/** + * A new receipt rule set. + */ +export class ReceiptRuleSet extends ReceiptRuleSetBase implements IReceiptRuleSet { + /** + * Import an exported receipt rule set. + */ + public static import(scope: cdk.Construct, id: string, props: ReceiptRuleSetImportProps): IReceiptRuleSet { + return new ImportedReceiptRuleSet(scope, id, props); + } + + public readonly name: string; + + constructor(scope: cdk.Construct, id: string, props?: ReceiptRuleSetProps) { + super(scope, id); + + const resource = new CfnReceiptRuleSet(this, 'Resource', { + ruleSetName: props ? props.name : undefined + }); + + this.name = resource.receiptRuleSetName; + + if (props) { + const rules = props.rules || []; + rules.forEach((ruleOption, idx) => this.addRule(`Rule${idx}`, ruleOption)); + + if (props.dropSpam) { + this.addDropSpamRule(); + } + } + } + + /** + * Exports this receipt rule set from the stack. + */ + public export(): ReceiptRuleSetImportProps { + return { + name: new cdk.CfnOutput(this, 'ReceiptRuleSetName', { value: this.name }).makeImportValue().toString() + }; + } +} + +/** + * Construction properties for an ImportedReceiptRuleSet. + */ +export interface ReceiptRuleSetImportProps { + /** + * The receipt rule set name. + */ + name: string; +} + +/** + * An imported receipt rule set. + */ +class ImportedReceiptRuleSet extends ReceiptRuleSetBase implements IReceiptRuleSet { + public readonly name: string; + + constructor(scope: cdk.Construct, id: string, private readonly props: ReceiptRuleSetImportProps) { + super(scope, id); + + this.name = props.name; + } + + /** + * Exports this receipt rule set from the stack. + */ + public export() { + return this.props; + } +} diff --git a/packages/@aws-cdk/aws-ses/lib/receipt-rule.ts b/packages/@aws-cdk/aws-ses/lib/receipt-rule.ts new file mode 100644 index 0000000000000..05956125bcc56 --- /dev/null +++ b/packages/@aws-cdk/aws-ses/lib/receipt-rule.ts @@ -0,0 +1,243 @@ +import lambda = require('@aws-cdk/aws-lambda'); +import cdk = require('@aws-cdk/cdk'); +import { IReceiptRuleAction, LambdaInvocationType, ReceiptRuleActionProps, ReceiptRuleLambdaAction } from './receipt-rule-action'; +import { IReceiptRuleSet } from './receipt-rule-set'; +import { CfnReceiptRule } from './ses.generated'; + +/** + * A receipt rule. + */ +export interface IReceiptRule extends cdk.IConstruct { + /** + * The name of the receipt rule. + */ + readonly name: string; + + /** + * Exports this receipt rule from the stack. + */ + export(): ReceiptRuleImportProps; +} + +/** + * The type of TLS policy for a receipt rule. + */ +export enum TlsPolicy { + /** + * Do not check for TLS. + */ + Optional = 'Optional', + + /** + * Bounce emails that are not received over TLS. + */ + Require = 'Require' +} + +/** + * Options to add a receipt rule to a receipt rule set. + */ +export interface ReceiptRuleOptions { + /** + * An ordered list of actions to perform on messages that match at least + * one of the recipient email addresses or domains specified in the + * receipt rule. + */ + actions?: IReceiptRuleAction[]; + + /** + * An existing rule after which the new rule will be placed. + * + * @default the new rule is inserted at the beginning of the rule list + */ + after?: IReceiptRule; + + /** + * Whether the rule is active. + * + * @default true + */ + enabled?: boolean; + + /** + * The name for the rule + * + * @default a CloudFormation generated name + */ + name?: string; + + /** + * The recipient domains and email addresses that the receipt rule applies to. + * + * @default match all recipients under all verified domains. + */ + recipients?: string[]; + + /** + * Wheter to scan for spam and viruses. + * + * @default false + */ + scanEnabled?: boolean; + + /** + * The TLS policy + * + * @default Optional + */ + tlsPolicy?: TlsPolicy; +} + +/** + * Construction properties for a ReceiptRule. + */ +export interface ReceiptRuleProps extends ReceiptRuleOptions { + /** + * The name of the rule set that the receipt rule will be added to. + */ + ruleSet: IReceiptRuleSet; +} + +/** + * A new receipt rule. + */ +export class ReceiptRule extends cdk.Construct implements IReceiptRule { + /** + * Import an exported receipt rule. + */ + public static import(scope: cdk.Construct, id: string, props: ReceiptRuleImportProps): IReceiptRule { + return new ImportedReceiptRule(scope, id, props); + } + + public readonly name: string; + private readonly renderedActions = new Array(); + + constructor(scope: cdk.Construct, id: string, props: ReceiptRuleProps) { + super(scope, id); + + const resource = new CfnReceiptRule(this, 'Resource', { + after: props.after ? props.after.name : undefined, + rule: { + actions: new cdk.Token(() => this.getRenderedActions()), + enabled: props.enabled === undefined ? true : props.enabled, + name: props.name, + recipients: props.recipients, + scanEnabled: props.scanEnabled, + tlsPolicy: props.tlsPolicy + }, + ruleSetName: props.ruleSet.name + }); + + this.name = resource.receiptRuleName; + + if (props.actions) { + props.actions.forEach(action => this.addAction(action)); + } + } + + /** + * Adds an action to this receipt rule. + */ + public addAction(action: IReceiptRuleAction) { + const renderedAction = action.render(); + + this.renderedActions.push(renderedAction); + } + + /** + * Exports this receipt rule from the stack. + */ + public export(): ReceiptRuleImportProps { + return { + name: new cdk.CfnOutput(this, 'ReceiptRuleName', { value: this.name }).makeImportValue().toString() + }; + } + + private getRenderedActions() { + if (this.renderedActions.length === 0) { + return undefined; + } + + return this.renderedActions; + } +} + +export interface ReceiptRuleImportProps { + /** + * The name of the receipt rule. + */ + name: string; +} + +/** + * An imported receipt rule. + */ +class ImportedReceiptRule extends cdk.Construct implements IReceiptRule { + public readonly name: string; + + constructor(scope: cdk.Construct, id: string, private readonly props: ReceiptRuleImportProps) { + super(scope, id); + + this.name = props.name; + } + + /** + * Exports this receipt rule from the stack. + */ + public export() { + return this.props; + } +} + +/** + * A rule added at the top of the rule set to drop spam/virus. + * + * @see https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-action-lambda-example-functions.html + */ +export class DropSpamReceiptRule extends cdk.Construct { + public readonly rule: ReceiptRule; + + constructor(scope: cdk.Construct, id: string, props: ReceiptRuleProps) { + super(scope, id); + + const fn = new lambda.SingletonFunction(this, 'Function', { + runtime: lambda.Runtime.NodeJS810, + handler: 'index.handler', + code: lambda.Code.inline(`exports.handler = ${dropSpamCode}`), + uuid: '224e77f9-a32e-4b4d-ac32-983477abba16' + }); + + this.rule = new ReceiptRule(this, 'Rule', { + actions: [ + new ReceiptRuleLambdaAction({ + function: fn, + invocationType: LambdaInvocationType.RequestResponse + }) + ], + scanEnabled: true, + ruleSet: props.ruleSet + }); + } +} + +// Adapted from https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-action-lambda-example-functions.html +// tslint:disable:no-console +function dropSpamCode(event: any, _: any, callback: any) { + console.log('Spam filter'); + + const sesNotification = event.Records[0].ses; + console.log("SES Notification:\n", JSON.stringify(sesNotification, null, 2)); + + // Check if any spam check failed + if (sesNotification.receipt.spfVerdict.status === 'FAIL' + || sesNotification.receipt.dkimVerdict.status === 'FAIL' + || sesNotification.receipt.spamVerdict.status === 'FAIL' + || sesNotification.receipt.virusVerdict.status === 'FAIL') { + console.log('Dropping spam'); + + // Stop processing rule set, dropping message + callback(null, { disposition : 'STOP_RULE_SET' }); + } else { + callback(null, null); + } +} diff --git a/packages/@aws-cdk/aws-ses/package.json b/packages/@aws-cdk/aws-ses/package.json index 5648c95c06063..0f1675168eabb 100644 --- a/packages/@aws-cdk/aws-ses/package.json +++ b/packages/@aws-cdk/aws-ses/package.json @@ -56,17 +56,28 @@ "devDependencies": { "@aws-cdk/assert": "^0.25.3", "cdk-build-tools": "^0.25.3", + "cdk-integ-tools": "^0.25.3", "cfn2ts": "^0.25.3", "pkglint": "^0.25.3" }, "dependencies": { + "@aws-cdk/aws-iam": "^0.25.3", + "@aws-cdk/aws-kms": "^0.25.3", + "@aws-cdk/aws-lambda": "^0.25.3", + "@aws-cdk/aws-s3": "^0.25.3", + "@aws-cdk/aws-sns": "^0.25.3", "@aws-cdk/cdk": "^0.25.3" }, "homepage": "https://github.com/awslabs/aws-cdk", "peerDependencies": { + "@aws-cdk/aws-iam": "^0.25.3", + "@aws-cdk/aws-kms": "^0.25.3", + "@aws-cdk/aws-lambda": "^0.25.3", + "@aws-cdk/aws-s3": "^0.25.3", + "@aws-cdk/aws-sns": "^0.25.3", "@aws-cdk/cdk": "^0.25.3" }, "engines": { "node": ">= 8.10.0" } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-ses/test/example.receiving.lit.ts b/packages/@aws-cdk/aws-ses/test/example.receiving.lit.ts new file mode 100644 index 0000000000000..f14cfa46d10b6 --- /dev/null +++ b/packages/@aws-cdk/aws-ses/test/example.receiving.lit.ts @@ -0,0 +1,38 @@ +import s3 = require('@aws-cdk/aws-s3'); +import sns = require('@aws-cdk/aws-sns'); +import cdk = require('@aws-cdk/cdk'); +import ses = require('../lib'); + +const stack = new cdk.Stack(); + +/// !show +const bucket = new s3.Bucket(stack, 'Bucket'); +const topic = new sns.Topic(stack, 'Topic'); + +new ses.ReceiptRuleSet(stack, 'RuleSet', { + rules: [ + { + recipients: ['hello@aws.com'], + actions: [ + new ses.ReceiptRuleAddHeaderAction({ + name: 'X-Special-Header', + value: 'aws' + }), + new ses.ReceiptRuleS3Action({ + bucket, + objectKeyPrefix: 'emails/', + topic + }) + ], + }, + { + recipients: ['aws.com'], + actions: [ + new ses.ReceiptRuleSnsAction({ + topic + }) + ] + } + ] +}); +/// !hide diff --git a/packages/@aws-cdk/aws-ses/test/integ.receipt.expected.json b/packages/@aws-cdk/aws-ses/test/integ.receipt.expected.json new file mode 100644 index 0000000000000..e74ddbee8d0b3 --- /dev/null +++ b/packages/@aws-cdk/aws-ses/test/integ.receipt.expected.json @@ -0,0 +1,439 @@ +{ + "Resources": { + "TopicBFC7AF6E": { + "Type": "AWS::SNS::Topic" + }, + "FunctionServiceRole675BB04A": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "lambda.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "Function76856677": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async (event) => event;" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "FunctionServiceRole675BB04A", + "Arn" + ] + }, + "Runtime": "nodejs8.10" + }, + "DependsOn": [ + "FunctionServiceRole675BB04A" + ] + }, + "FunctionAllowSes1829904A": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "Function76856677" + }, + "Principal": "ses.amazonaws.com", + "SourceAccount": { + "Ref": "AWS::AccountId" + } + } + }, + "Bucket83908E77": { + "Type": "AWS::S3::Bucket", + "DeletionPolicy": "Retain" + }, + "BucketPolicyE9A3008A": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { + "Ref": "Bucket83908E77" + }, + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:PutObject", + "Condition": { + "StringEquals": { + "aws:Referer": { + "Ref": "AWS::AccountId" + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "ses.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + }, + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "Bucket83908E77", + "Arn" + ] + }, + "/emails/*" + ] + ] + } + } + ], + "Version": "2012-10-17" + } + } + }, + "Key961B73FD": { + "Type": "AWS::KMS::Key", + "Properties": { + "KeyPolicy": { + "Statement": [ + { + "Action": [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + }, + "Resource": "*" + }, + { + "Action": [ + "km:Encrypt", + "kms:GenerateDataKey" + ], + "Condition": { + "Null": { + "kms:EncryptionContext:aws:ses:rule-name": "false", + "kms:EncryptionContext:aws:ses:message-id": "false" + }, + "StringEquals": { + "kms:EncryptionContext:aws:ses:source-account": { + "Ref": "AWS::AccountId" + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "ses.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + } + }, + "DeletionPolicy": "Retain" + }, + "RuleSetE30C6C48": { + "Type": "AWS::SES::ReceiptRuleSet" + }, + "RuleSetDropSpamRule5809F51B": { + "Type": "AWS::SES::ReceiptRule", + "Properties": { + "Rule": { + "Actions": [ + { + "LambdaAction": { + "FunctionArn": { + "Fn::GetAtt": [ + "SingletonLambda224e77f9a32e4b4dac32983477abba164533EA15", + "Arn" + ] + }, + "InvocationType": "RequestResponse" + } + } + ], + "Enabled": true, + "ScanEnabled": true + }, + "RuleSetName": { + "Ref": "RuleSetE30C6C48" + } + } + }, + "RuleSetFirstRule0A27C8CC": { + "Type": "AWS::SES::ReceiptRule", + "Properties": { + "Rule": { + "Actions": [ + { + "AddHeaderAction": { + "HeaderName": "X-My-Header", + "HeaderValue": "value" + } + }, + { + "LambdaAction": { + "FunctionArn": { + "Fn::GetAtt": [ + "Function76856677", + "Arn" + ] + }, + "InvocationType": "RequestResponse", + "TopicArn": { + "Ref": "TopicBFC7AF6E" + } + } + }, + { + "S3Action": { + "BucketName": { + "Ref": "Bucket83908E77" + }, + "KmsKeyArn": { + "Fn::GetAtt": [ + "Key961B73FD", + "Arn" + ] + }, + "ObjectKeyPrefix": "emails/", + "TopicArn": { + "Ref": "TopicBFC7AF6E" + } + } + }, + { + "SNSAction": { + "Encoding": "Base64", + "TopicArn": { + "Ref": "TopicBFC7AF6E" + } + } + }, + { + "BounceAction": { + "Message": "Message content rejected", + "Sender": "cdk-ses-receipt-test@yopmail.com", + "SmtpReplyCode": "500", + "StatusCode": "5.6.1", + "TopicArn": { + "Ref": "TopicBFC7AF6E" + } + } + } + ], + "Enabled": true, + "Name": "FirstRule", + "Recipients": [ + "cdk-ses-receipt-test@yopmail.com" + ], + "ScanEnabled": true, + "TlsPolicy": "Require" + }, + "RuleSetName": { + "Ref": "RuleSetE30C6C48" + }, + "After": { + "Ref": "RuleSetDropSpamRule5809F51B" + } + } + }, + "RuleSetSecondRule03178AD4": { + "Type": "AWS::SES::ReceiptRule", + "Properties": { + "Rule": { + "Actions": [ + { + "StopAction": { + "Scope": "RuleSet", + "TopicArn": { + "Ref": "TopicBFC7AF6E" + } + } + } + ], + "Enabled": true + }, + "RuleSetName": { + "Ref": "RuleSetE30C6C48" + }, + "After": { + "Ref": "RuleSetFirstRule0A27C8CC" + } + } + }, + "SingletonLambda224e77f9a32e4b4dac32983477abba16ServiceRole3037F5B4": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "lambda.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "SingletonLambda224e77f9a32e4b4dac32983477abba164533EA15": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = function dropSpamCode(event, _, callback) {\n console.log('Spam filter');\n const sesNotification = event.Records[0].ses;\n console.log(\"SES Notification:\\n\", JSON.stringify(sesNotification, null, 2));\n // Check if any spam check failed\n if (sesNotification.receipt.spfVerdict.status === 'FAIL'\n || sesNotification.receipt.dkimVerdict.status === 'FAIL'\n || sesNotification.receipt.spamVerdict.status === 'FAIL'\n || sesNotification.receipt.virusVerdict.status === 'FAIL') {\n console.log('Dropping spam');\n // Stop processing rule set, dropping message\n callback(null, { disposition: 'STOP_RULE_SET' });\n }\n else {\n callback(null, null);\n }\n}" + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "SingletonLambda224e77f9a32e4b4dac32983477abba16ServiceRole3037F5B4", + "Arn" + ] + }, + "Runtime": "nodejs8.10" + }, + "DependsOn": [ + "SingletonLambda224e77f9a32e4b4dac32983477abba16ServiceRole3037F5B4" + ] + }, + "SingletonLambda224e77f9a32e4b4dac32983477abba16AllowSesB42DF904": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Ref": "SingletonLambda224e77f9a32e4b4dac32983477abba164533EA15" + }, + "Principal": "ses.amazonaws.com", + "SourceAccount": { + "Ref": "AWS::AccountId" + } + } + }, + "WhiteListBlockAllAE2CDDFF": { + "Type": "AWS::SES::ReceiptFilter", + "Properties": { + "Filter": { + "IpFilter": { + "Cidr": "0.0.0.0/0", + "Policy": "Block" + } + } + } + }, + "WhiteListAllow1000016F396A7F2": { + "Type": "AWS::SES::ReceiptFilter", + "Properties": { + "Filter": { + "IpFilter": { + "Cidr": "10.0.0.0/16", + "Policy": "Allow" + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ses/test/integ.receipt.ts b/packages/@aws-cdk/aws-ses/test/integ.receipt.ts new file mode 100644 index 0000000000000..2994acd4e2f60 --- /dev/null +++ b/packages/@aws-cdk/aws-ses/test/integ.receipt.ts @@ -0,0 +1,76 @@ +import kms = require('@aws-cdk/aws-kms'); +import lambda = require('@aws-cdk/aws-lambda'); +import s3 = require('@aws-cdk/aws-s3'); +import sns = require('@aws-cdk/aws-sns'); +import cdk = require('@aws-cdk/cdk'); +import ses = require('../lib'); + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-ses-receipt'); + +const topic = new sns.Topic(stack, 'Topic'); + +const fn = new lambda.Function(stack, 'Function', { + code: lambda.Code.inline('exports.handler = async (event) => event;'), + handler: 'index.handler', + runtime: lambda.Runtime.NodeJS810 +}); + +const bucket = new s3.Bucket(stack, 'Bucket'); + +const kmsKey = new kms.EncryptionKey(stack, 'Key'); + +const ruleSet = new ses.ReceiptRuleSet(stack, 'RuleSet', { + dropSpam: true +}); + +const firstRule = ruleSet.addRule('FirstRule', { + actions: [ + new ses.ReceiptRuleAddHeaderAction({ + name: 'X-My-Header', + value: 'value' + }), + new ses.ReceiptRuleLambdaAction({ + function: fn, + invocationType: ses.LambdaInvocationType.RequestResponse, + topic + }), + new ses.ReceiptRuleS3Action({ + bucket, + kmsKey, + objectKeyPrefix: 'emails/', + topic + }), + new ses.ReceiptRuleSnsAction({ + encoding: ses.EmailEncoding.Base64, + topic + }) + ], + name: 'FirstRule', + recipients: ['cdk-ses-receipt-test@yopmail.com'], + scanEnabled: true, + tlsPolicy: ses.TlsPolicy.Require, +}); + +firstRule.addAction( + new ses.ReceiptRuleBounceAction({ + sender: 'cdk-ses-receipt-test@yopmail.com', + template: ses.ReceiptRuleBounceActionTemplate.MessageContentRejected, + topic + }) +); + +const secondRule = ruleSet.addRule('SecondRule'); + +secondRule.addAction(new ses.ReceiptRuleStopAction({ + topic +})); + +new ses.WhiteListReceiptFilter(stack, 'WhiteList', { + ips: [ + '10.0.0.0/16' + ] +}); + +app.run(); diff --git a/packages/@aws-cdk/aws-ses/test/test.receipt-filter.ts b/packages/@aws-cdk/aws-ses/test/test.receipt-filter.ts new file mode 100644 index 0000000000000..b0d40052a3534 --- /dev/null +++ b/packages/@aws-cdk/aws-ses/test/test.receipt-filter.ts @@ -0,0 +1,94 @@ +import { expect } from '@aws-cdk/assert'; +import { Stack } from '@aws-cdk/cdk'; +import { Test } from 'nodeunit'; +import { ReceiptFilter, ReceiptFilterPolicy, WhiteListReceiptFilter } from '../lib'; + +// tslint:disable:object-literal-key-quotes + +export = { + 'can create a receipt filter'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + new ReceiptFilter(stack, 'Filter', { + ip: '1.2.3.4/16', + name: 'MyFilter', + policy: ReceiptFilterPolicy.Block + }); + + // THEN + expect(stack).toMatch({ + "Resources": { + "FilterC907D6DA": { + "Type": "AWS::SES::ReceiptFilter", + "Properties": { + "Filter": { + "IpFilter": { + "Cidr": "1.2.3.4/16", + "Policy": "Block" + }, + "Name": "MyFilter" + } + } + } + } + }); + + test.done(); + }, + + 'can create a white list filter'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + new WhiteListReceiptFilter(stack, 'WhiteList', { + ips: [ + '10.0.0.0/16', + '1.2.3.4' + ] + }); + + // THEN + expect(stack).toMatch({ + "Resources": { + "WhiteListBlockAllAE2CDDFF": { + "Type": "AWS::SES::ReceiptFilter", + "Properties": { + "Filter": { + "IpFilter": { + "Cidr": "0.0.0.0/0", + "Policy": "Block" + } + } + } + }, + "WhiteListAllow1000016F396A7F2": { + "Type": "AWS::SES::ReceiptFilter", + "Properties": { + "Filter": { + "IpFilter": { + "Cidr": "10.0.0.0/16", + "Policy": "Allow" + } + } + } + }, + "WhiteListAllow1234A4DDAD4E": { + "Type": "AWS::SES::ReceiptFilter", + "Properties": { + "Filter": { + "IpFilter": { + "Cidr": "1.2.3.4", + "Policy": "Allow" + } + } + } + } + } + }); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-ses/test/test.receipt-rule-action.ts b/packages/@aws-cdk/aws-ses/test/test.receipt-rule-action.ts new file mode 100644 index 0000000000000..4ac0e349169cb --- /dev/null +++ b/packages/@aws-cdk/aws-ses/test/test.receipt-rule-action.ts @@ -0,0 +1,451 @@ +import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; +import kms = require('@aws-cdk/aws-kms'); +import lambda = require('@aws-cdk/aws-lambda'); +import s3 = require('@aws-cdk/aws-s3'); +import sns = require('@aws-cdk/aws-sns'); +import { Stack } from '@aws-cdk/cdk'; +import { Test } from 'nodeunit'; +// tslint:disable:max-line-length +import { EmailEncoding, LambdaInvocationType, ReceiptRuleAddHeaderAction, ReceiptRuleBounceAction, ReceiptRuleBounceActionTemplate, ReceiptRuleLambdaAction, ReceiptRuleS3Action, ReceiptRuleSet, ReceiptRuleSnsAction, ReceiptRuleStopAction } from '../lib'; +// tslint:enable:max-line-length + +export = { + 'can add an add header action'(test: Test) { + // GIVEN + const stack = new Stack(); + + new ReceiptRuleSet(stack, 'RuleSet', { + rules: [ + { + actions: [ + new ReceiptRuleAddHeaderAction({ + name: 'X-My-Header', + value: 'value' + }) + ] + } + ] + }); + + // THEN + expect(stack).to(haveResource('AWS::SES::ReceiptRule', { + Rule: { + Actions: [ + { + AddHeaderAction: { + HeaderName: 'X-My-Header', + HeaderValue: 'value' + } + } + ], + Enabled: true + } + })); + + test.done(); + }, + + 'fails when header name is invalid'(test: Test) { + const stack = new Stack(); + + test.throws(() => new ReceiptRuleSet(stack, 'RuleSet', { + rules: [ + { + actions: [ + new ReceiptRuleAddHeaderAction({ + name: 'He@der', + value: 'value' + }) + ] + } + ] + }), /`name`/); + + test.done(); + }, + + 'fails when header value is invalid'(test: Test) { + const stack = new Stack(); + + const ruleSet = new ReceiptRuleSet(stack, 'RuleSet'); + + test.throws(() => ruleSet.addRule('Rule', { + actions: [ + new ReceiptRuleAddHeaderAction({ + name: 'Header', + value: `va + lu` + }) + ] + }), /`value`/); + + test.done(); + }, + + 'can add a bounce action'(test: Test) { + // GIVEN + const stack = new Stack(); + + const topic = new sns.Topic(stack, 'Topic'); + + // WHEN + new ReceiptRuleSet(stack, 'RuleSet', { + rules: [ + { + actions: [ + new ReceiptRuleBounceAction({ + sender: 'noreply@aws.com', + template: ReceiptRuleBounceActionTemplate.MessageContentRejected, + topic + }) + ] + } + ] + }); + + // THEN + expect(stack).to(haveResource('AWS::SES::ReceiptRule', { + Rule: { + Actions: [ + { + BounceAction: { + Message: 'Message content rejected', + Sender: 'noreply@aws.com', + SmtpReplyCode: '500', + TopicArn: { + Ref: 'TopicBFC7AF6E' + }, + StatusCode: '5.6.1', + } + } + ], + Enabled: true + } + })); + + test.done(); + }, + + 'can add a lambda action'(test: Test) { + // GIVEN + const stack = new Stack(); + + const topic = new sns.Topic(stack, 'Topic'); + + const fn = new lambda.Function(stack, 'Function', { + code: lambda.Code.inline(''), + handler: 'index.handler', + runtime: lambda.Runtime.NodeJS810 + }); + + // WHEN + new ReceiptRuleSet(stack, 'RuleSet', { + rules: [ + { + actions: [ + new ReceiptRuleLambdaAction({ + function: fn, + invocationType: LambdaInvocationType.RequestResponse, + topic + }) + ] + } + ] + }); + + // THEN + expect(stack).to(haveResource('AWS::SES::ReceiptRule', { + Rule: { + Actions: [ + { + LambdaAction: { + FunctionArn: { + 'Fn::GetAtt': [ + 'Function76856677', + 'Arn' + ] + }, + InvocationType: 'RequestResponse', + TopicArn: { + Ref: 'TopicBFC7AF6E' + } + } + }, + ], + Enabled: true + } + })); + + expect(stack).to(haveResource('AWS::Lambda::Permission', { + Action: 'lambda:InvokeFunction', + FunctionName: { + Ref: 'Function76856677' + }, + Principal: 'ses.amazonaws.com', + SourceAccount: { + Ref: 'AWS::AccountId' + } + })); + + test.done(); + }, + + 'can add a s3 action'(test: Test) { + // GIVEN + const stack = new Stack(); + + const topic = new sns.Topic(stack, 'Topic'); + + const bucket = new s3.Bucket(stack, 'Bucket'); + + const kmsKey = new kms.EncryptionKey(stack, 'Key'); + + // WHEN + new ReceiptRuleSet(stack, 'RuleSet', { + rules: [ + { + actions: [ + new ReceiptRuleS3Action({ + bucket, + kmsKey, + objectKeyPrefix: 'emails/', + topic + }) + ] + } + ] + }); + + // THEN + expect(stack).to(haveResource('AWS::SES::ReceiptRule', { + Rule: { + Actions: [ + { + S3Action: { + BucketName: { + Ref: 'Bucket83908E77' + }, + KmsKeyArn: { + 'Fn::GetAtt': [ + 'Key961B73FD', + 'Arn' + ] + }, + TopicArn: { + Ref: 'TopicBFC7AF6E' + }, + ObjectKeyPrefix: 'emails/' + } + } + ], + Enabled: true + } + })); + + expect(stack).to(haveResource('AWS::S3::BucketPolicy', { + Bucket: { + Ref: 'Bucket83908E77' + }, + PolicyDocument: { + Statement: [ + { + Action: 's3:PutObject', + Condition: { + StringEquals: { + 'aws:Referer': { + Ref: 'AWS::AccountId' + } + } + }, + Effect: 'Allow', + Principal: { + Service: { + 'Fn::Join': [ + '', + [ + 'ses.', + { + Ref: 'AWS::URLSuffix' + } + ] + ] + } + }, + Resource: { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [ + 'Bucket83908E77', + 'Arn' + ] + }, + '/emails/*' + ] + ] + } + } + ], + Version: '2012-10-17' + } + })); + + expect(stack).to(haveResourceLike('AWS::KMS::Key', { + KeyPolicy: { + Statement: [ + { + Action: [ + 'kms:Create*', + 'kms:Describe*', + 'kms:Enable*', + 'kms:List*', + 'kms:Put*', + 'kms:Update*', + 'kms:Revoke*', + 'kms:Disable*', + 'kms:Get*', + 'kms:Delete*', + 'kms:ScheduleKeyDeletion', + 'kms:CancelKeyDeletion' + ], + Effect: 'Allow', + Principal: { + AWS: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition' + }, + ':iam::', + { + Ref: 'AWS::AccountId' + }, + ':root' + ] + ] + } + }, + Resource: '*' + }, + { + Action: [ + 'km:Encrypt', + 'kms:GenerateDataKey' + ], + Condition: { + Null: { + 'kms:EncryptionContext:aws:ses:rule-name': 'false', + 'kms:EncryptionContext:aws:ses:message-id': 'false' + }, + StringEquals: { + 'kms:EncryptionContext:aws:ses:source-account': { + Ref: 'AWS::AccountId' + } + } + }, + Effect: 'Allow', + Principal: { + Service: { + 'Fn::Join': [ + '', + [ + 'ses.', + { + Ref: 'AWS::URLSuffix' + } + ] + ] + } + }, + Resource: '*' + } + ], + Version: '2012-10-17' + } + })); + + test.done(); + }, + + 'can add a sns action'(test: Test) { + // GIVEN + const stack = new Stack(); + + const topic = new sns.Topic(stack, 'Topic'); + + // WHEN + new ReceiptRuleSet(stack, 'RuleSet', { + rules: [ + { + actions: [ + new ReceiptRuleSnsAction({ + encoding: EmailEncoding.Base64, + topic + }) + ] + } + ] + }); + + // THEN + expect(stack).to(haveResource('AWS::SES::ReceiptRule', { + Rule: { + Actions: [ + { + SNSAction: { + Encoding: 'Base64', + TopicArn: { + Ref: 'TopicBFC7AF6E' + } + } + } + ], + Enabled: true + } + })); + + test.done(); + }, + + 'can add a stop action'(test: Test) { + // GIVEN + const stack = new Stack(); + + const topic = new sns.Topic(stack, 'Topic'); + + // WHEN + new ReceiptRuleSet(stack, 'RuleSet', { + rules: [ + { + actions: [ + new ReceiptRuleStopAction({ + topic + }) + ] + } + ] + }); + + // THEN + expect(stack).to(haveResource('AWS::SES::ReceiptRule', { + Rule: { + Actions: [ + { + StopAction: { + Scope: 'RuleSet', + TopicArn: { + Ref: 'TopicBFC7AF6E' + } + } + } + ], + Enabled: true + } + })); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-ses/test/test.receipt-rule-set.ts b/packages/@aws-cdk/aws-ses/test/test.receipt-rule-set.ts new file mode 100644 index 0000000000000..6ccca2d1b0be6 --- /dev/null +++ b/packages/@aws-cdk/aws-ses/test/test.receipt-rule-set.ts @@ -0,0 +1,119 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import { Stack } from '@aws-cdk/cdk'; +import { Test } from 'nodeunit'; +import { ReceiptRuleSet } from '../lib'; + +// tslint:disable:object-literal-key-quotes + +export = { + 'can create a receipt rule set'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + new ReceiptRuleSet(stack, 'RuleSet', { + name: 'MyRuleSet' + }); + + // THEN + expect(stack).to(haveResource('AWS::SES::ReceiptRuleSet', { + RuleSetName: 'MyRuleSet' + })); + + test.done(); + }, + + 'can create a receipt rule set with drop spam'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + new ReceiptRuleSet(stack, 'RuleSet', { + dropSpam: true + }); + + // THEN + expect(stack).to(haveResource('AWS::SES::ReceiptRule', { + Rule: { + Actions: [ + { + LambdaAction: { + FunctionArn: { + 'Fn::GetAtt': [ + 'SingletonLambda224e77f9a32e4b4dac32983477abba164533EA15', + 'Arn' + ] + }, + InvocationType: 'RequestResponse' + } + } + ], + Enabled: true, + ScanEnabled: true + } + })); + + expect(stack).to(haveResource('AWS::Lambda::Function')); + + test.done(); + }, + + 'export receipt rule set'(test: Test) { + // GIVEN + const stack = new Stack(); + const receiptRuleSet = new ReceiptRuleSet(stack, 'RuleSet'); + + // WHEN + receiptRuleSet.export(); + + // THEN + expect(stack).toMatch({ + "Resources": { + "RuleSetE30C6C48": { + "Type": "AWS::SES::ReceiptRuleSet" + } + }, + "Outputs": { + "RuleSetReceiptRuleSetNameBA4266DD": { + "Value": { + "Ref": "RuleSetE30C6C48" + }, + "Export": { + "Name": "RuleSetReceiptRuleSetNameBA4266DD" + } + } + } + }); + + test.done(); + }, + + 'import receipt rule set'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const receiptRuleSet = ReceiptRuleSet.import(stack, 'ImportedRuleSet', { + name: 'MyRuleSet' + }); + + receiptRuleSet.addRule('MyRule'); + + // THEN + expect(stack).toMatch({ + "Resources": { + "ImportedRuleSetMyRule53EE2F7F": { + "Type": "AWS::SES::ReceiptRule", + "Properties": { + "Rule": { + "Enabled": true + }, + "RuleSetName": "MyRuleSet" + } + } + }, + }); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-ses/test/test.receipt-rule.ts b/packages/@aws-cdk/aws-ses/test/test.receipt-rule.ts new file mode 100644 index 0000000000000..dbd369a16be08 --- /dev/null +++ b/packages/@aws-cdk/aws-ses/test/test.receipt-rule.ts @@ -0,0 +1,153 @@ +import { expect } from '@aws-cdk/assert'; +import { Stack } from '@aws-cdk/cdk'; +import { Test } from 'nodeunit'; +import { ReceiptRule, ReceiptRuleSet, TlsPolicy } from '../lib'; + +// tslint:disable:object-literal-key-quotes + +export = { + 'can create receipt rules with second after first'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + new ReceiptRuleSet(stack, 'RuleSet', { + rules: [ + { + name: 'FirstRule', + }, + { + enabled: false, + name: 'SecondRule', + recipients: ['hello@aws.com'], + scanEnabled: true, + tlsPolicy: TlsPolicy.Require + } + ] + }); + + // THEN + expect(stack).toMatch({ + "Resources": { + "RuleSetE30C6C48": { + "Type": "AWS::SES::ReceiptRuleSet" + }, + "RuleSetRule023C3B8E1": { + "Type": "AWS::SES::ReceiptRule", + "Properties": { + "Rule": { + "Name": "FirstRule", + "Enabled": true + }, + "RuleSetName": { + "Ref": "RuleSetE30C6C48" + } + } + }, + "RuleSetRule117041B57": { + "Type": "AWS::SES::ReceiptRule", + "Properties": { + "Rule": { + "Enabled": false, + "Name": "SecondRule", + "Recipients": [ + "hello@aws.com" + ], + "ScanEnabled": true, + "TlsPolicy": "Require" + }, + "RuleSetName": { + "Ref": "RuleSetE30C6C48" + }, + "After": { + "Ref": "RuleSetRule023C3B8E1" + } + } + } + } + }); + + test.done(); + }, + + 'export receipt rule'(test: Test) { + // GIVEN + const stack = new Stack(); + const receiptRuleSet = new ReceiptRuleSet(stack, 'RuleSet'); + const receiptRule = receiptRuleSet.addRule('Rule'); + + // WHEN + receiptRule.export(); + + // THEN + expect(stack).toMatch({ + "Resources": { + "RuleSetE30C6C48": { + "Type": "AWS::SES::ReceiptRuleSet" + }, + "RuleSetRule0B1D6BCA": { + "Type": "AWS::SES::ReceiptRule", + "Properties": { + "Rule": { + "Enabled": true + }, + "RuleSetName": { + "Ref": "RuleSetE30C6C48" + } + } + } + }, + "Outputs": { + "RuleSetRuleReceiptRuleName5620D98F": { + "Value": { + "Ref": "RuleSetRule0B1D6BCA" + }, + "Export": { + "Name": "RuleSetRuleReceiptRuleName5620D98F" + } + } + } + }); + + test.done(); + }, + + 'import receipt rule'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const receiptRule = ReceiptRule.import(stack, 'ImportedRule', { + name: 'MyRule' + }); + + const receiptRuleSet = new ReceiptRuleSet(stack, 'RuleSet'); + + receiptRuleSet.addRule('MyRule', { + after: receiptRule + }); + + // THEN + expect(stack).toMatch({ + "Resources": { + "RuleSetE30C6C48": { + "Type": "AWS::SES::ReceiptRuleSet" + }, + "RuleSetMyRule60B1D107": { + "Type": "AWS::SES::ReceiptRule", + "Properties": { + "Rule": { + "Enabled": true + }, + "RuleSetName": { + "Ref": "RuleSetE30C6C48" + }, + "After": "MyRule" + } + } + }, + }); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-ses/test/test.ses.ts b/packages/@aws-cdk/aws-ses/test/test.ses.ts deleted file mode 100644 index 820f6b467f38f..0000000000000 --- a/packages/@aws-cdk/aws-ses/test/test.ses.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Test, testCase } from 'nodeunit'; - -export = testCase({ - notTested(test: Test) { - test.ok(true, 'No tests are specified for this package.'); - test.done(); - } -});