Skip to content

Commit 9600512

Browse files
committed
Add support for AWS SES and SMTP email transports
Signed-off-by: Erin Allison <[email protected]>
1 parent 21a4fab commit 9600512

File tree

11 files changed

+1229
-64
lines changed

11 files changed

+1229
-64
lines changed

.env.example

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,32 @@ DEV_OTEL_BATCH_PROCESSING_ENABLED="0"
3131
# AUTH_GITHUB_CLIENT_ID=
3232
# AUTH_GITHUB_CLIENT_SECRET=
3333

34-
# Resend is an email service used for signing in to Trigger.dev via a Magic Link.
35-
# Emails will print to the console if you leave these commented out
34+
# Configure an email transport to allow users to sign in to Trigger.dev via a Magic Link.
35+
# If none are configured, emails will print to the console instead.
36+
# Uncomment one of the following blocks to allow delivery of
37+
38+
# Resend
3639
### Visit https://resend.com, create an account and get your API key. Then insert it below along with your From and Reply To email addresses. Visit https://resend.com/docs for more information.
37-
# RESEND_API_KEY=<api_key>
40+
# EMAIL_TRANSPORT=resend
41+
# FROM_EMAIL=
42+
# REPLY_TO_EMAIL=
43+
# RESEND_API_KEY=
44+
45+
# Generic SMTP
46+
### Enter the configuration provided by your mail provider. Visit https://nodemailer.com/smtp/ for more information
47+
### SMTP_SECURE = false will use STARTTLS when connecting to a server that supports it (usually port 587)
48+
# EMAIL_TRANSPORT=smtp
49+
# FROM_EMAIL=
50+
# REPLY_TO_EMAIL=
51+
# SMTP_HOST=
52+
# SMTP_PORT=587
53+
# SMTP_SECURE=false
54+
# SMTP_USER=
55+
# SMTP_PASSWORD=
56+
57+
# AWS Simple Email Service
58+
### Authentication is configured using the default Node.JS credentials provider chain (https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/#fromnodeproviderchain)
59+
# EMAIL_TRANSPORT=aws-ses
3860
# FROM_EMAIL=
3961
# REPLY_TO_EMAIL=
4062

apps/webapp/app/env.server.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,16 @@ const EnvironmentSchema = z.object({
4444
HIGHLIGHT_PROJECT_ID: z.string().optional(),
4545
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
4646
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
47+
EMAIL_SERVICE: z.enum(["resend", "smtp", "aws-ses"]).default("resend"),
4748
FROM_EMAIL: z.string().optional(),
4849
REPLY_TO_EMAIL: z.string().optional(),
4950
RESEND_API_KEY: z.string().optional(),
51+
SMTP_HOST: z.string().optional(),
52+
SMTP_PORT: z.coerce.number().optional(),
53+
SMTP_SECURE: z.coerce.boolean().optional(),
54+
SMTP_USER: z.string().optional(),
55+
SMTP_PASSWORD: z.string().optional(),
56+
5057
PLAIN_API_KEY: z.string().optional(),
5158
RUNTIME_PLATFORM: z.enum(["docker-compose", "ecs", "local"]).default("local"),
5259
WORKER_SCHEMA: z.string().default("graphile_worker"),
@@ -195,8 +202,16 @@ const EnvironmentSchema = z.object({
195202
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
196203

197204
/** These enable the alerts feature in v3 */
205+
ALERT_EMAIL_SERVICE: z.union([z.literal("resend"), z.literal("smtp"), z.literal("aws-ses")]).default("resend"),
198206
ALERT_FROM_EMAIL: z.string().optional(),
207+
ALERT_REPLY_TO_EMAIL: z.string().optional(),
199208
ALERT_RESEND_API_KEY: z.string().optional(),
209+
ALERT_SMTP_HOST: z.string().optional(),
210+
ALERT_SMTP_PORT: z.coerce.number().optional(),
211+
ALERT_SMTP_SECURE: z.coerce.boolean().optional(),
212+
ALERT_SMTP_USER: z.string().optional(),
213+
ALERT_SMTP_PASSWORD: z.string().optional(),
214+
200215

201216
MAX_SEQUENTIAL_INDEX_FAILURE_COUNT: z.coerce.number().default(96),
202217

apps/webapp/app/services/email.server.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { DeliverEmail, SendPlainTextOptions } from "emails";
22
import { EmailClient } from "emails";
3+
import { MailTransportOptions } from "emails/transports";
34
import type { SendEmailOptions } from "remix-auth-email-link";
45
import { redirect } from "remix-typedjson";
56
import { env } from "~/env.server";
@@ -13,7 +14,7 @@ const client = singleton(
1314
"email-client",
1415
() =>
1516
new EmailClient({
16-
apikey: env.RESEND_API_KEY,
17+
transport: buildTransportOptions(false),
1718
imagesBaseUrl: env.APP_ORIGIN,
1819
from: env.FROM_EMAIL ?? "[email protected]",
1920
replyTo: env.REPLY_TO_EMAIL ?? "[email protected]",
@@ -24,13 +25,42 @@ const alertsClient = singleton(
2425
"alerts-email-client",
2526
() =>
2627
new EmailClient({
27-
apikey: env.ALERT_RESEND_API_KEY,
28+
transport: buildTransportOptions(true),
2829
imagesBaseUrl: env.APP_ORIGIN,
2930
from: env.ALERT_FROM_EMAIL ?? "[email protected]",
3031
replyTo: env.REPLY_TO_EMAIL ?? "[email protected]",
3132
})
3233
);
3334

35+
function buildTransportOptions(alerts?: boolean): MailTransportOptions {
36+
switch (alerts ? env.ALERT_EMAIL_SERVICE : env.EMAIL_SERVICE) {
37+
case "aws-ses":
38+
return { type: "aws-ses" };
39+
case "resend":
40+
return {
41+
type: "resend",
42+
config: {
43+
apiKey: alerts ? env.ALERT_RESEND_API_KEY : env.RESEND_API_KEY,
44+
}
45+
}
46+
case "smtp":
47+
return {
48+
type: "smtp",
49+
config: {
50+
host: alerts ? env.ALERT_SMTP_HOST : env.SMTP_HOST,
51+
port: alerts ? env.ALERT_SMTP_PORT : env.SMTP_PORT,
52+
secure: alerts ? env.ALERT_SMTP_SECURE : env.ALERT_SMTP_SECURE,
53+
auth: {
54+
user: alerts ? env.ALERT_SMTP_USER : env.SMTP_USER,
55+
password: alerts ? env.ALERT_SMTP_PASSWORD : env.SMTP_PASSWORD
56+
}
57+
}
58+
};
59+
default:
60+
return { type: undefined };
61+
}
62+
}
63+
3464
export async function sendMagicLinkEmail(options: SendEmailOptions<AuthUser>): Promise<void> {
3565
// Auto redirect when in development mode
3666
if (env.NODE_ENV === "development") {

internal-packages/emails/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
"dev": "PORT=3080 email dev"
1010
},
1111
"dependencies": {
12+
"@aws-sdk/client-ses": "^3.716.0",
1213
"@react-email/components": "0.0.16",
1314
"@react-email/render": "^0.0.12",
15+
"@types/nodemailer": "^6.4.17",
16+
"nodemailer": "^6.9.16",
1417
"react": "^18.2.0",
1518
"react-email": "^2.1.1",
1619
"resend": "^3.2.0",
@@ -25,4 +28,4 @@
2528
"engines": {
2629
"node": ">=18.0.0"
2730
}
28-
}
31+
}

internal-packages/emails/src/index.tsx

Lines changed: 22 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { render } from "@react-email/render";
21
import { ReactElement } from "react";
3-
import AlertRunFailureEmail, { AlertRunEmailSchema } from "../emails/alert-run-failure";
2+
3+
import { z } from "zod";
44
import AlertAttemptFailureEmail, { AlertAttemptEmailSchema } from "../emails/alert-attempt-failure";
5+
import AlertRunFailureEmail, { AlertRunEmailSchema } from "../emails/alert-run-failure";
56
import { setGlobalBasePath } from "../emails/components/BasePath";
67
import AlertDeploymentFailureEmail, {
78
AlertDeploymentFailureEmailSchema,
@@ -12,9 +13,7 @@ import AlertDeploymentSuccessEmail, {
1213
import InviteEmail, { InviteEmailSchema } from "../emails/invite";
1314
import MagicLinkEmail from "../emails/magic-link";
1415
import WelcomeEmail from "../emails/welcome";
15-
16-
import { Resend } from "resend";
17-
import { z } from "zod";
16+
import { constructMailTransport, MailTransport, MailTransportOptions } from "./transports";
1817

1918
export const DeliverEmailSchema = z
2019
.discriminatedUnion("email", [
@@ -39,14 +38,20 @@ export type DeliverEmail = z.infer<typeof DeliverEmailSchema>;
3938
export type SendPlainTextOptions = { to: string; subject: string; text: string };
4039

4140
export class EmailClient {
42-
#client?: Resend;
41+
#transport: MailTransport;
42+
4343
#imagesBaseUrl: string;
4444
#from: string;
4545
#replyTo: string;
4646

47-
constructor(config: { apikey?: string; imagesBaseUrl: string; from: string; replyTo: string }) {
48-
this.#client =
49-
config.apikey && config.apikey.startsWith("re_") ? new Resend(config.apikey) : undefined;
47+
constructor(config: {
48+
transport?: MailTransportOptions;
49+
imagesBaseUrl: string;
50+
from: string;
51+
replyTo: string;
52+
}) {
53+
this.#transport = constructMailTransport(config.transport ?? { type: undefined });
54+
5055
this.#imagesBaseUrl = config.imagesBaseUrl;
5156
this.#from = config.from;
5257
this.#replyTo = config.replyTo;
@@ -57,25 +62,21 @@ export class EmailClient {
5762

5863
setGlobalBasePath(this.#imagesBaseUrl);
5964

60-
return this.#sendEmail({
65+
return await this.#transport.send({
6166
to: data.to,
6267
subject,
6368
react: component,
69+
from: this.#from,
70+
replyTo: this.#replyTo,
6471
});
6572
}
6673

6774
async sendPlainText(options: SendPlainTextOptions) {
68-
if (this.#client) {
69-
await this.#client.emails.send({
70-
from: this.#from,
71-
to: options.to,
72-
reply_to: this.#replyTo,
73-
subject: options.subject,
74-
text: options.text,
75-
});
76-
77-
return;
78-
}
75+
await this.#transport.sendPlainText({
76+
...options,
77+
from: this.#from,
78+
replyTo: this.#replyTo,
79+
});
7980
}
8081

8182
#getTemplate(data: DeliverEmail): {
@@ -124,41 +125,4 @@ export class EmailClient {
124125
}
125126
}
126127
}
127-
128-
async #sendEmail({ to, subject, react }: { to: string; subject: string; react: ReactElement }) {
129-
if (this.#client) {
130-
const result = await this.#client.emails.send({
131-
from: this.#from,
132-
to,
133-
reply_to: this.#replyTo,
134-
subject,
135-
react,
136-
});
137-
138-
if (result.error) {
139-
console.error(
140-
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
141-
);
142-
throw new EmailError(result.error);
143-
}
144-
145-
return;
146-
}
147-
148-
console.log(`
149-
##### sendEmail to ${to}, subject: ${subject}
150-
151-
${render(react, {
152-
plainText: true,
153-
})}
154-
`);
155-
}
156-
}
157-
158-
//EmailError type where you can set the name and message
159-
export class EmailError extends Error {
160-
constructor({ name, message }: { name: string; message: string }) {
161-
super(message);
162-
this.name = name;
163-
}
164128
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { render } from "@react-email/render";
2+
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
3+
import nodemailer from "nodemailer"
4+
import awsSes from "@aws-sdk/client-ses"
5+
6+
export type AwsSesMailTransportOptions = {
7+
type: 'aws-ses',
8+
}
9+
10+
export class AwsSesMailTransport implements MailTransport {
11+
#client: nodemailer.Transporter;
12+
13+
constructor(options: AwsSesMailTransportOptions) {
14+
const ses = new awsSes.SESClient()
15+
16+
this.#client = nodemailer.createTransport({
17+
SES: {
18+
aws: awsSes,
19+
ses
20+
}
21+
})
22+
}
23+
24+
async send({to, from, replyTo, subject, react}: MailMessage): Promise<void> {
25+
try {
26+
await this.#client.sendMail({
27+
from: from,
28+
to,
29+
replyTo: replyTo,
30+
subject,
31+
html: render(react),
32+
});
33+
}
34+
catch (error: Error) {
35+
console.error(
36+
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
37+
);
38+
throw new EmailError(error);
39+
}
40+
}
41+
42+
async sendPlainText({to, from, replyTo, subject, text}: PlainTextMailMessage): Promise<void> {
43+
try {
44+
await this.#client.sendMail({
45+
from: from,
46+
to,
47+
replyTo: replyTo,
48+
subject,
49+
text: text,
50+
});
51+
}
52+
catch (error: Error) {
53+
console.error(
54+
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
55+
);
56+
throw new EmailError(error);
57+
}
58+
}
59+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ReactElement } from "react";
2+
import { AwsSesMailTransport, AwsSesMailTransportOptions } from "./aws-ses";
3+
import { NullMailTransport, NullMailTransportOptions } from "./null";
4+
import { ResendMailTransport, ResendMailTransportOptions } from "./resend";
5+
import { SmtpMailTransport, SmtpMailTransportOptions } from "./smtp";
6+
7+
export type MailMessage = {
8+
to: string;
9+
from: string;
10+
replyTo: string;
11+
subject: string;
12+
react: ReactElement;
13+
};
14+
15+
export type PlainTextMailMessage = {
16+
to: string;
17+
from: string;
18+
replyTo: string;
19+
subject: string;
20+
text: string;
21+
}
22+
23+
export interface MailTransport {
24+
send(message: MailMessage): Promise<void>;
25+
sendPlainText(message: PlainTextMailMessage): Promise<void>;
26+
}
27+
28+
export class EmailError extends Error {
29+
constructor({ name, message }: { name: string; message: string }) {
30+
super(message);
31+
this.name = name;
32+
}
33+
}
34+
35+
export type MailTransportOptions =
36+
AwsSesMailTransportOptions |
37+
ResendMailTransportOptions |
38+
NullMailTransportOptions |
39+
SmtpMailTransportOptions
40+
41+
export function constructMailTransport(options: MailTransportOptions): MailTransport {
42+
switch(options.type) {
43+
case "aws-ses":
44+
return new AwsSesMailTransport(options);
45+
case "resend":
46+
return new ResendMailTransport(options);
47+
case "smtp":
48+
return new SmtpMailTransport(options);
49+
case undefined:
50+
return new NullMailTransport(options);
51+
}
52+
}

0 commit comments

Comments
 (0)