From ce169d8fdc29927989bb59e29461fed8cf835f2a Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 16 Oct 2024 16:15:07 -0400 Subject: [PATCH] Email: support event destinations --- .../playground/functions/email/index.ts | 40 +++++ examples/internal/playground/package.json | 3 +- examples/internal/playground/sst.config.ts | 28 ++++ platform/src/components/aws/email.ts | 151 +++++++++++++++++- platform/src/components/component.ts | 2 + 5 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 examples/internal/playground/functions/email/index.ts diff --git a/examples/internal/playground/functions/email/index.ts b/examples/internal/playground/functions/email/index.ts new file mode 100644 index 000000000..6c8e5f442 --- /dev/null +++ b/examples/internal/playground/functions/email/index.ts @@ -0,0 +1,40 @@ +import { Resource } from "sst"; +import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; + +const client = new SESv2Client(); + +export const sender = async () => { + await client.send( + new SendEmailCommand({ + FromEmailAddress: Resource.MyEmail.sender, + Destination: { + ToAddresses: [Resource.MyEmail.sender], + }, + Content: { + Simple: { + Subject: { + Data: "Hello World!", + }, + Body: { + Text: { + Data: "Sent from my SST app.", + }, + }, + }, + }, + }) + ); + + return { + statusCode: 200, + body: "Sent!", + }; +}; + +export const notification = async (event: any) => { + console.log(JSON.stringify(event, null, 2)); + return { + statusCode: 200, + body: "Received!", + }; +}; diff --git a/examples/internal/playground/package.json b/examples/internal/playground/package.json index 180da816c..da7b52262 100644 --- a/examples/internal/playground/package.json +++ b/examples/internal/playground/package.json @@ -10,6 +10,7 @@ "author": "", "license": "ISC", "dependencies": { - "sst": "3.0.1-37" + "@aws-sdk/client-sesv2": "^3.515.0", + "sst": "3.2.31" } } diff --git a/examples/internal/playground/sst.config.ts b/examples/internal/playground/sst.config.ts index e2142fe1e..584a4201b 100644 --- a/examples/internal/playground/sst.config.ts +++ b/examples/internal/playground/sst.config.ts @@ -13,6 +13,7 @@ export default $config({ const vpc = addVpc(); const bucket = addBucket(); + //const email = addEmail(); //const apiv1 = addApiV1(); //const apiv2 = addApiV2(); //const app = addFunction(); @@ -32,6 +33,33 @@ export default $config({ return bucket; } + function addEmail() { + const topic = new sst.aws.SnsTopic("MyTopic"); + topic.subscribe("functions/email/index.notification"); + + const email = new sst.aws.Email("MyEmail", { + sender: "wangfanjie@gmail.com", + events: [ + { + name: "notif", + types: ["delivery"], + topic: topic.arn, + }, + ], + }); + + const sender = new sst.aws.Function("MyApi", { + handler: "functions/email/index.sender", + link: [email], + url: true, + }); + + ret.emailSend = sender.url; + ret.email = email.sender; + ret.emailConfig = email.configSet; + return ret; + } + function addCron() { const cron = new sst.aws.Cron("MyCron", { schedule: "rate(1 minute)", diff --git a/platform/src/components/aws/email.ts b/platform/src/components/aws/email.ts index ff0a4f571..726e4a5fa 100644 --- a/platform/src/components/aws/email.ts +++ b/platform/src/components/aws/email.ts @@ -5,13 +5,48 @@ import { interpolate, output, } from "@pulumi/pulumi"; -import { Component, Transform, transform } from "../component"; +import { Component, Prettify, Transform, transform } from "../component"; import { Link } from "../link"; import { Input } from "../input"; import { Dns } from "../dns"; import { dns as awsDns } from "./dns.js"; import { ses, sesv2 } from "@pulumi/aws"; import { permission } from "./permission"; +import { RandomId } from "@pulumi/random"; +import { hashNumberToPrettyString, physicalName } from "../naming"; +import { VisibleError } from "../error"; + +interface Events { + /** + * The name of the event. + */ + name: Input; + /** + * The types of events to send. + */ + types: Input< + Input< + | "send" + | "reject" + | "bounce" + | "complaint" + | "delivery" + | "delivery-delay" + | "rendering-failure" + | "subscription" + | "open" + | "click" + >[] + >; + /** + * The ARN of the SNS topic to send events to. + */ + topic?: Input; + /** + * The ARN of the EventBridge bus to send events to. + */ + bus?: Input; +} export interface EmailArgs { /** @@ -105,6 +140,27 @@ export interface EmailArgs { * ``` */ dmarc?: Input; + /** + * Configure event notifications for this Email component. + * + * :::tip + * You don't need to use a Lambda layer to use FFmpeg. + * ::: + * + * @default No event notifications + * @example + * + * ```js + * { + * events: { + * name: "OnBounce", + * types: ["bounce"], + * topic: "arn:aws:sns:us-east-1:123456789012:MyTopic" + * } + * } + * ``` + */ + events?: Input[]>; /** * [Transform](/docs/components#transform) how this component creates its underlying * resources. @@ -114,12 +170,17 @@ export interface EmailArgs { * Transform the SES identity resource. */ identity?: Transform; + /** + * Transform the SES configuration set resource. + */ + configurationSet?: Transform; }; } interface EmailRef { ref: boolean; identity: sesv2.EmailIdentity; + configurationSet: sesv2.ConfigurationSet; } /** @@ -204,6 +265,7 @@ interface EmailRef { export class Email extends Component implements Link.Linkable { private _sender: Output; private identity: sesv2.EmailIdentity; + private configurationSet: sesv2.ConfigurationSet; constructor(name: string, args: EmailArgs, opts?: ComponentResourceOptions) { super(__pulumiType, name, args, opts); @@ -212,6 +274,7 @@ export class Email extends Component implements Link.Linkable { const ref = args as unknown as EmailRef; this._sender = ref.identity.emailIdentity; this.identity = ref.identity; + this.configurationSet = ref.configurationSet; return; } @@ -219,7 +282,9 @@ export class Email extends Component implements Link.Linkable { const isDomain = checkIsDomain(); const dns = normalizeDns(); const dmarc = normalizeDmarc(); + const configurationSet = createConfigurationSet(); const identity = createIdentity(); + createEvents(); isDomain.apply((isDomain) => { if (!isDomain) return; createDkimRecords(); @@ -229,6 +294,7 @@ export class Email extends Component implements Link.Linkable { this._sender = output(args.sender); this.identity = identity; + this.configurationSet = configurationSet; function checkIsDomain() { return output(args.sender).apply((sender) => !sender.includes("@")); @@ -256,17 +322,73 @@ export class Email extends Component implements Link.Linkable { return args.dmarc ?? `v=DMARC1; p=none;`; } + function createConfigurationSet() { + const transformed = transform( + args.transform?.configurationSet, + `${name}Config`, + {} as sesv2.ConfigurationSetArgs, + { parent }, + ); + + if (!transformed[1].configurationSetName) { + const randomId = new RandomId( + `${name}Id`, + { byteLength: 6 }, + { parent }, + ); + transformed[1].configurationSetName = randomId.dec.apply((dec) => + physicalName( + 64, + name, + `-${hashNumberToPrettyString(parseInt(dec), 8)}`, + ).toLowerCase(), + ); + } + + return new sesv2.ConfigurationSet(...transformed); + } + function createIdentity() { return new sesv2.EmailIdentity( ...transform( args.transform?.identity, `${name}Identity`, - { emailIdentity: args.sender }, + { + emailIdentity: args.sender, + configurationSetName: configurationSet.configurationSetName, + }, { parent }, ), ); } + function createEvents() { + output(args.events ?? []).apply((events) => + events.forEach((event) => { + new sesv2.ConfigurationSetEventDestination( + `${name}Event${event.name}`, + { + configurationSetName: configurationSet.configurationSetName, + eventDestinationName: event.name, + eventDestination: { + matchingEventTypes: event.types.map((t) => + t.toUpperCase().replaceAll("-", "_"), + ), + ...(event.bus + ? { eventBridgeDestination: { eventBusArn: event.bus } } + : {}), + ...(event.topic + ? { snsDestination: { topicArn: event.topic } } + : {}), + enabled: true, + }, + }, + { parent }, + ); + }), + ); + } + function createDkimRecords() { all([dns, identity?.dkimSigningAttributes.tokens]).apply( ([dns, tokens]) => { @@ -321,6 +443,13 @@ export class Email extends Component implements Link.Linkable { return this._sender; } + /** + * The name of the configuration set. + */ + public get configSet() { + return this.configurationSet.configurationSetName; + } + /** * The underlying [resources](/docs/components/#nodes) this component creates. */ @@ -330,6 +459,10 @@ export class Email extends Component implements Link.Linkable { * The Amazon SES identity. */ identity: this.identity, + /** + * The Amazon SES configuration set. + */ + configurationSet: this.configurationSet, }; } @@ -338,11 +471,12 @@ export class Email extends Component implements Link.Linkable { return { properties: { sender: this._sender, + configSet: this.configSet, }, include: [ permission({ actions: ["ses:*"], - resources: [this.identity.arn], + resources: [this.identity.arn, this.configurationSet.arn], }), ], }; @@ -370,7 +504,16 @@ export class Email extends Component implements Link.Linkable { */ public static get(name: string, sender: Input) { const identity = sesv2.EmailIdentity.get(`${name}Identity`, sender); - return new Email(name, { ref: true, identity } as unknown as EmailArgs); + const configSet = sesv2.ConfigurationSet.get( + `${name}Config`, + identity.configurationSetName.apply((v) => v!), + ); + + return new Email(name, { + ref: true, + identity, + configurationSet: configSet, + } as unknown as EmailArgs); } } diff --git a/platform/src/components/component.ts b/platform/src/components/component.ts index 00f6ac630..b54f44707 100644 --- a/platform/src/components/component.ts +++ b/platform/src/components/component.ts @@ -107,6 +107,7 @@ export class Component extends ComponentResource { "aws:s3/bucketV2:BucketV2", "aws:servicediscovery/privateDnsNamespace:PrivateDnsNamespace", "aws:servicediscovery/service:Service", + "aws:sesv2/configurationSet:ConfigurationSet", ].includes(args.type) || // resources not prefixed [ @@ -163,6 +164,7 @@ export class Component extends ComponentResource { "aws:s3/bucketWebsiteConfigurationV2:BucketWebsiteConfigurationV2", "aws:secretsmanager/secretVersion:SecretVersion", "aws:ses/domainIdentityVerification:DomainIdentityVerification", + "aws:sesv2/configurationSetEventDestination:ConfigurationSetEventDestination", "aws:sesv2/emailIdentity:EmailIdentity", "aws:sns/topicSubscription:TopicSubscription", "cloudflare:index/record:Record",