Skip to content

Commit

Permalink
Add Support for Alternate Email Transports (#1580)
Browse files Browse the repository at this point in the history
* Add support for AWS SES and SMTP email transports

Signed-off-by: Erin Allison <[email protected]>

* Correct environment variable names for new email settings

Signed-off-by: Erin Allison <[email protected]>

* Correct option names being passed to nodemailer for SMTP

Signed-off-by: Erin Allison <[email protected]>

* Remove use of AWS SDK synthetic default export

It apparently causes issues when transpiled/bundled

Signed-off-by: Erin Allison <[email protected]>

* Add documentation for new email settings

Signed-off-by: Erin Allison <[email protected]>

* Move nodemailer types to devDependencies

Signed-off-by: Erin Allison <[email protected]>

* Adjust mail transport error handling

Gotta keep the linter happy :)

Signed-off-by: Erin Allison <[email protected]>

* Fix typecheck error on MailTransportOptions

Signed-off-by: Erin Allison <[email protected]>

* Correct environment variable usage for SMTP email transport

Signed-off-by: Erin Allison <[email protected]>

---------

Signed-off-by: Erin Allison <[email protected]>
  • Loading branch information
erin-allison authored Dec 30, 2024
1 parent 21a4fab commit 4d2412a
Show file tree
Hide file tree
Showing 12 changed files with 1,286 additions and 65 deletions.
28 changes: 25 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
15 changes: 15 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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),

Expand Down
38 changes: 35 additions & 3 deletions apps/webapp/app/services/email.server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 ?? "[email protected]",
replyTo: env.REPLY_TO_EMAIL ?? "[email protected]",
Expand All @@ -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 ?? "[email protected]",
replyTo: env.REPLY_TO_EMAIL ?? "[email protected]",
})
);

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") {
Expand Down
40 changes: 39 additions & 1 deletion docs/open-source-self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
5 changes: 4 additions & 1 deletion internal-packages/emails/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
}
80 changes: 23 additions & 57 deletions internal-packages/emails/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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", [
Expand All @@ -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;
Expand All @@ -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): {
Expand Down Expand Up @@ -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;
}
}
67 changes: 67 additions & 0 deletions internal-packages/emails/src/transports/aws-ses.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
Loading

0 comments on commit 4d2412a

Please sign in to comment.