Skip to content

Commit 4d2412a

Browse files
authored
Add Support for Alternate Email Transports (#1580)
* Add support for AWS SES and SMTP email transports Signed-off-by: Erin Allison <eallison@andrettikarting.com> * Correct environment variable names for new email settings Signed-off-by: Erin Allison <eallison@andrettikarting.com> * Correct option names being passed to nodemailer for SMTP Signed-off-by: Erin Allison <eallison@andrettikarting.com> * Remove use of AWS SDK synthetic default export It apparently causes issues when transpiled/bundled Signed-off-by: Erin Allison <eallison@andrettikarting.com> * Add documentation for new email settings Signed-off-by: Erin Allison <eallison@andrettikarting.com> * Move nodemailer types to devDependencies Signed-off-by: Erin Allison <eallison@andrettikarting.com> * Adjust mail transport error handling Gotta keep the linter happy :) Signed-off-by: Erin Allison <eallison@andrettikarting.com> * Fix typecheck error on MailTransportOptions Signed-off-by: Erin Allison <eallison@andrettikarting.com> * Correct environment variable usage for SMTP email transport Signed-off-by: Erin Allison <eallison@andrettikarting.com> --------- Signed-off-by: Erin Allison <eallison@andrettikarting.com>
1 parent 21a4fab commit 4d2412a

File tree

12 files changed

+1286
-65
lines changed

12 files changed

+1286
-65
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_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
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_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
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: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { DeliverEmail, SendPlainTextOptions } from "emails";
2-
import { EmailClient } from "emails";
2+
import { EmailClient, MailTransportOptions } from "emails";
33
import type { SendEmailOptions } from "remix-auth-email-link";
44
import { redirect } from "remix-typedjson";
55
import { env } from "~/env.server";
@@ -13,7 +13,7 @@ const client = singleton(
1313
"email-client",
1414
() =>
1515
new EmailClient({
16-
apikey: env.RESEND_API_KEY,
16+
transport: buildTransportOptions(),
1717
imagesBaseUrl: env.APP_ORIGIN,
1818
from: env.FROM_EMAIL ?? "team@email.trigger.dev",
1919
replyTo: env.REPLY_TO_EMAIL ?? "help@email.trigger.dev",
@@ -24,13 +24,45 @@ const alertsClient = singleton(
2424
"alerts-email-client",
2525
() =>
2626
new EmailClient({
27-
apikey: env.ALERT_RESEND_API_KEY,
27+
transport: buildTransportOptions(true),
2828
imagesBaseUrl: env.APP_ORIGIN,
2929
from: env.ALERT_FROM_EMAIL ?? "noreply@alerts.trigger.dev",
3030
replyTo: env.REPLY_TO_EMAIL ?? "help@email.trigger.dev",
3131
})
3232
);
3333

34+
function buildTransportOptions(alerts?: boolean): MailTransportOptions {
35+
const transportType = alerts ? env.ALERT_EMAIL_TRANSPORT : env.EMAIL_TRANSPORT
36+
logger.debug(`Constructing email transport '${transportType}' for usage '${alerts?'alerts':'general'}'`)
37+
38+
switch (transportType) {
39+
case "aws-ses":
40+
return { type: "aws-ses" };
41+
case "resend":
42+
return {
43+
type: "resend",
44+
config: {
45+
apiKey: alerts ? env.ALERT_RESEND_API_KEY : env.RESEND_API_KEY,
46+
}
47+
}
48+
case "smtp":
49+
return {
50+
type: "smtp",
51+
config: {
52+
host: alerts ? env.ALERT_SMTP_HOST : env.SMTP_HOST,
53+
port: alerts ? env.ALERT_SMTP_PORT : env.SMTP_PORT,
54+
secure: alerts ? env.ALERT_SMTP_SECURE : env.SMTP_SECURE,
55+
auth: {
56+
user: alerts ? env.ALERT_SMTP_USER : env.SMTP_USER,
57+
pass: alerts ? env.ALERT_SMTP_PASSWORD : env.SMTP_PASSWORD
58+
}
59+
}
60+
};
61+
default:
62+
return { type: undefined };
63+
}
64+
}
65+
3466
export async function sendMagicLinkEmail(options: SendEmailOptions<AuthUser>): Promise<void> {
3567
// Auto redirect when in development mode
3668
if (env.NODE_ENV === "development") {

docs/open-source-self-hosting.mdx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,45 @@ TRIGGER_IMAGE_TAG=v3.0.4
269269

270270
### Auth options
271271

272-
By default, magic link auth is the only login option. If the `RESEND_API_KEY` env var is not set, the magic links will be logged by the webapp container and not sent via email.
272+
By default, magic link auth is the only login option. If the `EMAIL_TRANSPORT` env var is not set, the magic links will be logged by the webapp container and not sent via email.
273+
274+
Depending on your choice of mail provider/transport, you will want to configure a set of variables like one of the following:
275+
276+
##### Resend:
277+
```bash
278+
EMAIL_TRANSPORT=resend
279+
FROM_EMAIL=
280+
REPLY_TO_EMAIL=
281+
RESEND_API_KEY=<your_resend_api_key>
282+
```
283+
284+
##### SMTP
285+
286+
Note that setting `SMTP_SECURE=false` does _not_ mean the email is sent insecurely.
287+
This simply means that the connection is secured using the modern STARTTLS protocol command instead of implicit TLS.
288+
You should only set this to true when the SMTP server host directs you to do so (generally when using port 465)
289+
290+
```bash
291+
EMAIL_TRANSPORT=smtp
292+
FROM_EMAIL=
293+
REPLY_TO_EMAIL=
294+
SMTP_HOST=<your_smtp_server>
295+
SMTP_PORT=587
296+
SMTP_SECURE=false
297+
SMTP_USER=<your_smtp_username>
298+
SMTP_PASSWORD=<your_smtp_password>
299+
```
300+
301+
##### AWS Simple Email Service
302+
303+
Credentials are to be supplied as with any other program using the AWS SDK.
304+
In this scenario, you would likely either supply the additional environment variables `AWS_REGION`, `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` or, when running on AWS, use credentials supplied by the EC2 IMDS.
305+
306+
```bash
307+
EMAIL_TRANSPORT=aws-ses
308+
FROM_EMAIL=
309+
REPLY_TO_EMAIL=
310+
```
273311

274312
All email addresses can sign up and log in this way. If you would like to restrict this, you can use the `WHITELISTED_EMAILS` env var. For example:
275313

internal-packages/emails/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
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+
"nodemailer": "^6.9.16",
1416
"react": "^18.2.0",
1517
"react-email": "^2.1.1",
1618
"resend": "^3.2.0",
@@ -19,10 +21,11 @@
1921
},
2022
"devDependencies": {
2123
"@types/node": "^18",
24+
"@types/nodemailer": "^6.4.17",
2225
"@types/react": "18.2.69",
2326
"typescript": "^4.9.4"
2427
},
2528
"engines": {
2629
"node": ">=18.0.0"
2730
}
28-
}
31+
}

internal-packages/emails/src/index.tsx

Lines changed: 23 additions & 57 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,9 @@ import AlertDeploymentSuccessEmail, {
1213
import InviteEmail, { InviteEmailSchema } from "../emails/invite";
1314
import MagicLinkEmail from "../emails/magic-link";
1415
import WelcomeEmail from "../emails/welcome";
16+
import { constructMailTransport, MailTransport, MailTransportOptions } from "./transports";
1517

16-
import { Resend } from "resend";
17-
import { z } from "zod";
18+
export { type MailTransportOptions }
1819

1920
export const DeliverEmailSchema = z
2021
.discriminatedUnion("email", [
@@ -39,14 +40,20 @@ export type DeliverEmail = z.infer<typeof DeliverEmailSchema>;
3940
export type SendPlainTextOptions = { to: string; subject: string; text: string };
4041

4142
export class EmailClient {
42-
#client?: Resend;
43+
#transport: MailTransport;
44+
4345
#imagesBaseUrl: string;
4446
#from: string;
4547
#replyTo: string;
4648

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;
49+
constructor(config: {
50+
transport?: MailTransportOptions;
51+
imagesBaseUrl: string;
52+
from: string;
53+
replyTo: string;
54+
}) {
55+
this.#transport = constructMailTransport(config.transport ?? { type: undefined });
56+
5057
this.#imagesBaseUrl = config.imagesBaseUrl;
5158
this.#from = config.from;
5259
this.#replyTo = config.replyTo;
@@ -57,25 +64,21 @@ export class EmailClient {
5764

5865
setGlobalBasePath(this.#imagesBaseUrl);
5966

60-
return this.#sendEmail({
67+
return await this.#transport.send({
6168
to: data.to,
6269
subject,
6370
react: component,
71+
from: this.#from,
72+
replyTo: this.#replyTo,
6473
});
6574
}
6675

6776
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-
}
77+
await this.#transport.sendPlainText({
78+
...options,
79+
from: this.#from,
80+
replyTo: this.#replyTo,
81+
});
7982
}
8083

8184
#getTemplate(data: DeliverEmail): {
@@ -124,41 +127,4 @@ export class EmailClient {
124127
}
125128
}
126129
}
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-
}
164130
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { render } from "@react-email/render";
2+
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
3+
import nodemailer from "nodemailer"
4+
import * as 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) {
35+
if (error instanceof Error) {
36+
console.error(
37+
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
38+
);
39+
throw new EmailError(error);
40+
} else {
41+
throw error;
42+
}
43+
}
44+
}
45+
46+
async sendPlainText({to, from, replyTo, subject, text}: PlainTextMailMessage): Promise<void> {
47+
try {
48+
await this.#client.sendMail({
49+
from: from,
50+
to,
51+
replyTo: replyTo,
52+
subject,
53+
text: text,
54+
});
55+
}
56+
catch (error) {
57+
if (error instanceof Error) {
58+
console.error(
59+
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
60+
);
61+
throw new EmailError(error);
62+
} else {
63+
throw error;
64+
}
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)