Skip to content
This repository has been archived by the owner on Oct 21, 2024. It is now read-only.

Commit

Permalink
Email: support event destinations
Browse files Browse the repository at this point in the history
  • Loading branch information
fwang committed Oct 16, 2024
1 parent 9be2840 commit ce169d8
Show file tree
Hide file tree
Showing 5 changed files with 219 additions and 5 deletions.
40 changes: 40 additions & 0 deletions examples/internal/playground/functions/email/index.ts
Original file line number Diff line number Diff line change
@@ -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!",
};
};
3 changes: 2 additions & 1 deletion examples/internal/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"sst": "3.0.1-37"
"@aws-sdk/client-sesv2": "^3.515.0",
"sst": "3.2.31"
}
}
28 changes: 28 additions & 0 deletions examples/internal/playground/sst.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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: "[email protected]",
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)",
Expand Down
151 changes: 147 additions & 4 deletions platform/src/components/aws/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/**
* 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<string>;
/**
* The ARN of the EventBridge bus to send events to.
*/
bus?: Input<string>;
}

export interface EmailArgs {
/**
Expand Down Expand Up @@ -105,6 +140,27 @@ export interface EmailArgs {
* ```
*/
dmarc?: Input<string>;
/**
* 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<Prettify<Events>[]>;
/**
* [Transform](/docs/components#transform) how this component creates its underlying
* resources.
Expand All @@ -114,12 +170,17 @@ export interface EmailArgs {
* Transform the SES identity resource.
*/
identity?: Transform<sesv2.EmailIdentityArgs>;
/**
* Transform the SES configuration set resource.
*/
configurationSet?: Transform<sesv2.ConfigurationSetArgs>;
};
}

interface EmailRef {
ref: boolean;
identity: sesv2.EmailIdentity;
configurationSet: sesv2.ConfigurationSet;
}

/**
Expand Down Expand Up @@ -204,6 +265,7 @@ interface EmailRef {
export class Email extends Component implements Link.Linkable {
private _sender: Output<string>;
private identity: sesv2.EmailIdentity;
private configurationSet: sesv2.ConfigurationSet;

constructor(name: string, args: EmailArgs, opts?: ComponentResourceOptions) {
super(__pulumiType, name, args, opts);
Expand All @@ -212,14 +274,17 @@ 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;
}

const parent = this;
const isDomain = checkIsDomain();
const dns = normalizeDns();
const dmarc = normalizeDmarc();
const configurationSet = createConfigurationSet();
const identity = createIdentity();
createEvents();
isDomain.apply((isDomain) => {
if (!isDomain) return;
createDkimRecords();
Expand All @@ -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("@"));
Expand Down Expand Up @@ -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]) => {
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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,
};
}

Expand All @@ -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],
}),
],
};
Expand Down Expand Up @@ -370,7 +504,16 @@ export class Email extends Component implements Link.Linkable {
*/
public static get(name: string, sender: Input<string>) {
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);
}
}

Expand Down
2 changes: 2 additions & 0 deletions platform/src/components/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
[
Expand Down Expand Up @@ -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",
Expand Down

0 comments on commit ce169d8

Please sign in to comment.