Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Support for Alternate Email Transports #1580

Merged
merged 9 commits into from
Dec 30, 2024
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" };
matt-aitken marked this conversation as resolved.
Show resolved Hide resolved
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
Loading