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>
This commit is contained in:
Erin Allison
2024-12-30 12:38:17 -05:00
committed by GitHub
parent 21a4fabc28
commit 4d2412a0fe
12 changed files with 1286 additions and 65 deletions
+25 -3
View File
@@ -31,10 +31,32 @@ DEV_OTEL_BATCH_PROCESSING_ENABLED="0"
# AUTH_GITHUB_CLIENT_ID=
# AUTH_GITHUB_CLIENT_SECRET=
# Resend is an email service used for signing in to Trigger.dev via a Magic Link.
# Emails will print to the console if you leave these commented out
# Configure an email transport to allow users to sign in to Trigger.dev via a Magic Link.
# If none are configured, emails will print to the console instead.
# Uncomment one of the following blocks to allow delivery of
# Resend
### 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.
# RESEND_API_KEY=<api_key>
# EMAIL_TRANSPORT=resend
# FROM_EMAIL=
# REPLY_TO_EMAIL=
# RESEND_API_KEY=
# Generic SMTP
### Enter the configuration provided by your mail provider. Visit https://nodemailer.com/smtp/ for more information
### SMTP_SECURE = false will use STARTTLS when connecting to a server that supports it (usually port 587)
# EMAIL_TRANSPORT=smtp
# FROM_EMAIL=
# REPLY_TO_EMAIL=
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=
# SMTP_PASSWORD=
# AWS Simple Email Service
### 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)
# EMAIL_TRANSPORT=aws-ses
# FROM_EMAIL=
# REPLY_TO_EMAIL=
+15
View File
@@ -44,9 +44,16 @@ const EnvironmentSchema = z.object({
HIGHLIGHT_PROJECT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
FROM_EMAIL: z.string().optional(),
REPLY_TO_EMAIL: z.string().optional(),
RESEND_API_KEY: z.string().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
SMTP_SECURE: z.coerce.boolean().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
PLAIN_API_KEY: z.string().optional(),
RUNTIME_PLATFORM: z.enum(["docker-compose", "ecs", "local"]).default("local"),
WORKER_SCHEMA: z.string().default("graphile_worker"),
@@ -195,8 +202,16 @@ const EnvironmentSchema = z.object({
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
/** These enable the alerts feature in v3 */
ALERT_EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
ALERT_FROM_EMAIL: z.string().optional(),
ALERT_REPLY_TO_EMAIL: z.string().optional(),
ALERT_RESEND_API_KEY: z.string().optional(),
ALERT_SMTP_HOST: z.string().optional(),
ALERT_SMTP_PORT: z.coerce.number().optional(),
ALERT_SMTP_SECURE: z.coerce.boolean().optional(),
ALERT_SMTP_USER: z.string().optional(),
ALERT_SMTP_PASSWORD: z.string().optional(),
MAX_SEQUENTIAL_INDEX_FAILURE_COUNT: z.coerce.number().default(96),
+35 -3
View File
@@ -1,5 +1,5 @@
import type { DeliverEmail, SendPlainTextOptions } from "emails";
import { EmailClient } from "emails";
import { EmailClient, MailTransportOptions } from "emails";
import type { SendEmailOptions } from "remix-auth-email-link";
import { redirect } from "remix-typedjson";
import { env } from "~/env.server";
@@ -13,7 +13,7 @@ const client = singleton(
"email-client",
() =>
new EmailClient({
apikey: env.RESEND_API_KEY,
transport: buildTransportOptions(),
imagesBaseUrl: env.APP_ORIGIN,
from: env.FROM_EMAIL ?? "team@email.trigger.dev",
replyTo: env.REPLY_TO_EMAIL ?? "help@email.trigger.dev",
@@ -24,13 +24,45 @@ const alertsClient = singleton(
"alerts-email-client",
() =>
new EmailClient({
apikey: env.ALERT_RESEND_API_KEY,
transport: buildTransportOptions(true),
imagesBaseUrl: env.APP_ORIGIN,
from: env.ALERT_FROM_EMAIL ?? "noreply@alerts.trigger.dev",
replyTo: env.REPLY_TO_EMAIL ?? "help@email.trigger.dev",
})
);
function buildTransportOptions(alerts?: boolean): MailTransportOptions {
const transportType = alerts ? env.ALERT_EMAIL_TRANSPORT : env.EMAIL_TRANSPORT
logger.debug(`Constructing email transport '${transportType}' for usage '${alerts?'alerts':'general'}'`)
switch (transportType) {
case "aws-ses":
return { type: "aws-ses" };
case "resend":
return {
type: "resend",
config: {
apiKey: alerts ? env.ALERT_RESEND_API_KEY : env.RESEND_API_KEY,
}
}
case "smtp":
return {
type: "smtp",
config: {
host: alerts ? env.ALERT_SMTP_HOST : env.SMTP_HOST,
port: alerts ? env.ALERT_SMTP_PORT : env.SMTP_PORT,
secure: alerts ? env.ALERT_SMTP_SECURE : env.SMTP_SECURE,
auth: {
user: alerts ? env.ALERT_SMTP_USER : env.SMTP_USER,
pass: alerts ? env.ALERT_SMTP_PASSWORD : env.SMTP_PASSWORD
}
}
};
default:
return { type: undefined };
}
}
export async function sendMagicLinkEmail(options: SendEmailOptions<AuthUser>): Promise<void> {
// Auto redirect when in development mode
if (env.NODE_ENV === "development") {
+39 -1
View File
@@ -269,7 +269,45 @@ TRIGGER_IMAGE_TAG=v3.0.4
### Auth options
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.
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.
Depending on your choice of mail provider/transport, you will want to configure a set of variables like one of the following:
##### Resend:
```bash
EMAIL_TRANSPORT=resend
FROM_EMAIL=
REPLY_TO_EMAIL=
RESEND_API_KEY=<your_resend_api_key>
```
##### SMTP
Note that setting `SMTP_SECURE=false` does _not_ mean the email is sent insecurely.
This simply means that the connection is secured using the modern STARTTLS protocol command instead of implicit TLS.
You should only set this to true when the SMTP server host directs you to do so (generally when using port 465)
```bash
EMAIL_TRANSPORT=smtp
FROM_EMAIL=
REPLY_TO_EMAIL=
SMTP_HOST=<your_smtp_server>
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=<your_smtp_username>
SMTP_PASSWORD=<your_smtp_password>
```
##### AWS Simple Email Service
Credentials are to be supplied as with any other program using the AWS SDK.
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.
```bash
EMAIL_TRANSPORT=aws-ses
FROM_EMAIL=
REPLY_TO_EMAIL=
```
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:
+4 -1
View File
@@ -9,8 +9,10 @@
"dev": "PORT=3080 email dev"
},
"dependencies": {
"@aws-sdk/client-ses": "^3.716.0",
"@react-email/components": "0.0.16",
"@react-email/render": "^0.0.12",
"nodemailer": "^6.9.16",
"react": "^18.2.0",
"react-email": "^2.1.1",
"resend": "^3.2.0",
@@ -19,10 +21,11 @@
},
"devDependencies": {
"@types/node": "^18",
"@types/nodemailer": "^6.4.17",
"@types/react": "18.2.69",
"typescript": "^4.9.4"
},
"engines": {
"node": ">=18.0.0"
}
}
}
+23 -57
View File
@@ -1,7 +1,8 @@
import { render } from "@react-email/render";
import { ReactElement } from "react";
import AlertRunFailureEmail, { AlertRunEmailSchema } from "../emails/alert-run-failure";
import { z } from "zod";
import AlertAttemptFailureEmail, { AlertAttemptEmailSchema } from "../emails/alert-attempt-failure";
import AlertRunFailureEmail, { AlertRunEmailSchema } from "../emails/alert-run-failure";
import { setGlobalBasePath } from "../emails/components/BasePath";
import AlertDeploymentFailureEmail, {
AlertDeploymentFailureEmailSchema,
@@ -12,9 +13,9 @@ import AlertDeploymentSuccessEmail, {
import InviteEmail, { InviteEmailSchema } from "../emails/invite";
import MagicLinkEmail from "../emails/magic-link";
import WelcomeEmail from "../emails/welcome";
import { constructMailTransport, MailTransport, MailTransportOptions } from "./transports";
import { Resend } from "resend";
import { z } from "zod";
export { type MailTransportOptions }
export const DeliverEmailSchema = z
.discriminatedUnion("email", [
@@ -39,14 +40,20 @@ export type DeliverEmail = z.infer<typeof DeliverEmailSchema>;
export type SendPlainTextOptions = { to: string; subject: string; text: string };
export class EmailClient {
#client?: Resend;
#transport: MailTransport;
#imagesBaseUrl: string;
#from: string;
#replyTo: string;
constructor(config: { apikey?: string; imagesBaseUrl: string; from: string; replyTo: string }) {
this.#client =
config.apikey && config.apikey.startsWith("re_") ? new Resend(config.apikey) : undefined;
constructor(config: {
transport?: MailTransportOptions;
imagesBaseUrl: string;
from: string;
replyTo: string;
}) {
this.#transport = constructMailTransport(config.transport ?? { type: undefined });
this.#imagesBaseUrl = config.imagesBaseUrl;
this.#from = config.from;
this.#replyTo = config.replyTo;
@@ -57,25 +64,21 @@ export class EmailClient {
setGlobalBasePath(this.#imagesBaseUrl);
return this.#sendEmail({
return await this.#transport.send({
to: data.to,
subject,
react: component,
from: this.#from,
replyTo: this.#replyTo,
});
}
async sendPlainText(options: SendPlainTextOptions) {
if (this.#client) {
await this.#client.emails.send({
from: this.#from,
to: options.to,
reply_to: this.#replyTo,
subject: options.subject,
text: options.text,
});
return;
}
await this.#transport.sendPlainText({
...options,
from: this.#from,
replyTo: this.#replyTo,
});
}
#getTemplate(data: DeliverEmail): {
@@ -124,41 +127,4 @@ export class EmailClient {
}
}
}
async #sendEmail({ to, subject, react }: { to: string; subject: string; react: ReactElement }) {
if (this.#client) {
const result = await this.#client.emails.send({
from: this.#from,
to,
reply_to: this.#replyTo,
subject,
react,
});
if (result.error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
);
throw new EmailError(result.error);
}
return;
}
console.log(`
##### sendEmail to ${to}, subject: ${subject}
${render(react, {
plainText: true,
})}
`);
}
}
//EmailError type where you can set the name and message
export class EmailError extends Error {
constructor({ name, message }: { name: string; message: string }) {
super(message);
this.name = name;
}
}
@@ -0,0 +1,67 @@
import { render } from "@react-email/render";
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
import nodemailer from "nodemailer"
import * as awsSes from "@aws-sdk/client-ses"
export type AwsSesMailTransportOptions = {
type: 'aws-ses',
}
export class AwsSesMailTransport implements MailTransport {
#client: nodemailer.Transporter;
constructor(options: AwsSesMailTransportOptions) {
const ses = new awsSes.SESClient()
this.#client = nodemailer.createTransport({
SES: {
aws: awsSes,
ses
}
})
}
async send({to, from, replyTo, subject, react}: MailMessage): Promise<void> {
try {
await this.#client.sendMail({
from: from,
to,
replyTo: replyTo,
subject,
html: render(react),
});
}
catch (error) {
if (error instanceof Error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
);
throw new EmailError(error);
} else {
throw error;
}
}
}
async sendPlainText({to, from, replyTo, subject, text}: PlainTextMailMessage): Promise<void> {
try {
await this.#client.sendMail({
from: from,
to,
replyTo: replyTo,
subject,
text: text,
});
}
catch (error) {
if (error instanceof Error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
);
throw new EmailError(error);
} else {
throw error;
}
}
}
}
@@ -0,0 +1,52 @@
import { ReactElement } from "react";
import { AwsSesMailTransport, AwsSesMailTransportOptions } from "./aws-ses";
import { NullMailTransport, NullMailTransportOptions } from "./null";
import { ResendMailTransport, ResendMailTransportOptions } from "./resend";
import { SmtpMailTransport, SmtpMailTransportOptions } from "./smtp";
export type MailMessage = {
to: string;
from: string;
replyTo: string;
subject: string;
react: ReactElement;
};
export type PlainTextMailMessage = {
to: string;
from: string;
replyTo: string;
subject: string;
text: string;
}
export interface MailTransport {
send(message: MailMessage): Promise<void>;
sendPlainText(message: PlainTextMailMessage): Promise<void>;
}
export class EmailError extends Error {
constructor({ name, message }: { name: string; message: string }) {
super(message);
this.name = name;
}
}
export type MailTransportOptions =
AwsSesMailTransportOptions |
ResendMailTransportOptions |
NullMailTransportOptions |
SmtpMailTransportOptions
export function constructMailTransport(options: MailTransportOptions): MailTransport {
switch(options.type) {
case "aws-ses":
return new AwsSesMailTransport(options);
case "resend":
return new ResendMailTransport(options);
case "smtp":
return new SmtpMailTransport(options);
case undefined:
return new NullMailTransport(options);
}
}
@@ -0,0 +1,29 @@
import { render } from "@react-email/render";
import { MailMessage, MailTransport, PlainTextMailMessage } from "./index";
export type NullMailTransportOptions = {
type: undefined,
}
export class NullMailTransport implements MailTransport {
constructor(options: NullMailTransportOptions) {
}
async send({to, subject, react}: MailMessage): Promise<void> {
console.log(`
##### sendEmail to ${to}, subject: ${subject}
${render(react, {
plainText: true,
})}
`);
}
async sendPlainText({to, subject, text}: PlainTextMailMessage): Promise<void> {
console.log(`
##### sendEmail to ${to}, subject: ${subject}
${text}
`);
}
}
@@ -0,0 +1,51 @@
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
import { Resend } from "resend";
export type ResendMailTransportOptions = {
type: 'resend',
config: {
apiKey?: string
}
}
export class ResendMailTransport implements MailTransport {
#client: Resend;
constructor(options: ResendMailTransportOptions) {
this.#client = new Resend(options.config.apiKey)
}
async send({to, from, replyTo, subject, react}: MailMessage): Promise<void> {
const result = await this.#client.emails.send({
from: from,
to,
reply_to: replyTo,
subject,
react,
});
if (result.error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
);
throw new EmailError(result.error);
}
}
async sendPlainText({to, from, replyTo, subject, text}: PlainTextMailMessage): Promise<void> {
const result = await this.#client.emails.send({
from: from,
to,
reply_to: replyTo,
subject,
text,
});
if (result.error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
);
throw new EmailError(result.error);
}
}
}
@@ -0,0 +1,66 @@
import { render } from "@react-email/render";
import nodemailer from "nodemailer";
import { EmailError, MailMessage, MailTransport, PlainTextMailMessage } from "./index";
export type SmtpMailTransportOptions = {
type: "smtp";
config: {
host?: string;
port?: number;
secure?: boolean;
auth?: {
user?: string;
pass?: string;
};
};
};
export class SmtpMailTransport implements MailTransport {
#client: nodemailer.Transporter;
constructor(options: SmtpMailTransportOptions) {
this.#client = nodemailer.createTransport(options.config);
}
async send({ to, from, replyTo, subject, react }: MailMessage): Promise<void> {
try {
await this.#client.sendMail({
from: from,
to,
replyTo: replyTo,
subject,
html: render(react),
});
} catch (error) {
if (error instanceof Error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
);
throw new EmailError(error);
} else {
throw error;
}
}
}
async sendPlainText({ to, from, replyTo, subject, text }: PlainTextMailMessage): Promise<void> {
try {
await this.#client.sendMail({
from: from,
to,
replyTo: replyTo,
subject,
text: text,
});
} catch (error) {
if (error instanceof Error) {
console.error(
`Failed to send email to ${to}, ${subject}. Error ${error.name}: ${error.message}`
);
throw new EmailError(error);
} else {
throw error;
}
}
}
}
+880
View File
File diff suppressed because it is too large Load Diff