♻️ Improve email abstraction (#861)

This commit is contained in:
Luke Vella 2023-09-15 14:21:35 +01:00 committed by GitHub
parent 9e453da1dd
commit 93cc5b9b4d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 749 additions and 152 deletions

View file

@ -1,34 +0,0 @@
import { devices, PlaywrightTestConfig } from "@playwright/test";
const ci = process.env.CI === "true";
// Use process.env.PORT by default and fallback to port 3000
const PORT = process.env.PORT || 3000;
// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port
const baseURL = `http://localhost:${PORT}`;
// Reference: https://playwright.dev/docs/test-configuration
const config: PlaywrightTestConfig = {
// Artifacts folder where screenshots, videos, and traces are stored.
outputDir: "test-results/",
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
use: {
viewport: { width: 1280, height: 720 },
baseURL,
permissions: ["clipboard-read"],
trace: "retain-on-failure",
},
webServer: {
command: `NODE_ENV=test yarn dev --port ${PORT}`,
url: baseURL,
timeout: 120 * 1000,
reuseExistingServer: !ci,
},
reporter: [
[ci ? "github" : "list"],
["html", { open: !ci ? "on-failure" : "never" }],
],
workers: 1,
};
export default config;

View file

@ -1,10 +1,10 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { absoluteUrl } from "@rallly/utils"; import { absoluteUrl } from "@rallly/utils";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { createToken, decryptToken } from "../../session"; import { createToken, decryptToken } from "../../session";
import { emailClient } from "../../utils/email-client";
import { generateOtp } from "../../utils/nanoid"; import { generateOtp } from "../../utils/nanoid";
import { publicProcedure, router } from "../trpc"; import { publicProcedure, router } from "../trpc";
import { LoginTokenPayload, RegistrationTokenPayload } from "../types"; import { LoginTokenPayload, RegistrationTokenPayload } from "../types";
@ -107,7 +107,7 @@ export const auth = router({
code, code,
}); });
await sendEmail("RegisterEmail", { await emailClient.sendTemplate("RegisterEmail", {
to: input.email, to: input.email,
subject: `${input.name}, please verify your email address`, subject: `${input.name}, please verify your email address`,
props: { props: {
@ -193,7 +193,7 @@ export const auth = router({
code, code,
}); });
await sendEmail("LoginEmail", { await emailClient.sendTemplate("LoginEmail", {
to: input.email, to: input.email,
subject: `${code} is your 6-digit code`, subject: `${code} is your 6-digit code`,
props: { props: {

View file

@ -1,5 +1,4 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { absoluteUrl, shortUrl } from "@rallly/utils"; import { absoluteUrl, shortUrl } from "@rallly/utils";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import dayjs from "dayjs"; import dayjs from "dayjs";
@ -10,6 +9,7 @@ import * as ics from "ics";
import { z } from "zod"; import { z } from "zod";
import { getTimeZoneAbbreviation } from "../../utils/date"; import { getTimeZoneAbbreviation } from "../../utils/date";
import { emailClient } from "../../utils/email-client";
import { nanoid } from "../../utils/nanoid"; import { nanoid } from "../../utils/nanoid";
import { import {
possiblyPublicProcedure, possiblyPublicProcedure,
@ -133,7 +133,7 @@ export const polls = router({
}); });
if (user) { if (user) {
await sendEmail("NewPollEmail", { await emailClient.sendTemplate("NewPollEmail", {
to: user.email, to: user.email,
subject: `Let's find a date for ${poll.title}`, subject: `Let's find a date for ${poll.title}`,
props: { props: {
@ -678,7 +678,7 @@ export const polls = router({
}); });
} }
const emailToHost = sendEmail("FinalizeHostEmail", { const emailToHost = emailClient.sendTemplate("FinalizeHostEmail", {
subject: `Date booked for ${poll.title}`, subject: `Date booked for ${poll.title}`,
to: poll.user.email, to: poll.user.email,
props: { props: {
@ -702,7 +702,7 @@ export const polls = router({
}); });
const emailsToParticipants = participantsToEmail.map((p) => { const emailsToParticipants = participantsToEmail.map((p) => {
return sendEmail("FinalizeParticipantEmail", { return emailClient.sendTemplate("FinalizeParticipantEmail", {
subject: `Date booked for ${poll.title}`, subject: `Date booked for ${poll.title}`,
to: p.email, to: p.email,
props: { props: {

View file

@ -1,9 +1,9 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { absoluteUrl } from "@rallly/utils"; import { absoluteUrl } from "@rallly/utils";
import { z } from "zod"; import { z } from "zod";
import { createToken } from "../../../session"; import { createToken } from "../../../session";
import { emailClient } from "../../../utils/email-client";
import { publicProcedure, router } from "../../trpc"; import { publicProcedure, router } from "../../trpc";
import { DisableNotificationsPayload } from "../../types"; import { DisableNotificationsPayload } from "../../types";
@ -82,7 +82,7 @@ export const comments = router({
{ ttl: 0 }, { ttl: 0 },
); );
emailsToSend.push( emailsToSend.push(
sendEmail("NewCommentEmail", { emailClient.sendTemplate("NewCommentEmail", {
to: email, to: email,
subject: `${authorName} has commented on ${poll.title}`, subject: `${authorName} has commented on ${poll.title}`,
props: { props: {

View file

@ -1,10 +1,10 @@
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { sendEmail } from "@rallly/emails";
import { absoluteUrl } from "@rallly/utils"; import { absoluteUrl } from "@rallly/utils";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { createToken } from "../../../session"; import { createToken } from "../../../session";
import { emailClient } from "../../../utils/email-client";
import { publicProcedure, router } from "../../trpc"; import { publicProcedure, router } from "../../trpc";
import { DisableNotificationsPayload } from "../../types"; import { DisableNotificationsPayload } from "../../types";
@ -107,7 +107,7 @@ export const participants = router({
); );
emailsToSend.push( emailsToSend.push(
sendEmail("NewParticipantConfirmationEmail", { emailClient.sendTemplate("NewParticipantConfirmationEmail", {
to: email, to: email,
subject: `Thanks for responding to ${poll.title}`, subject: `Thanks for responding to ${poll.title}`,
props: { props: {
@ -144,7 +144,7 @@ export const participants = router({
{ ttl: 0 }, { ttl: 0 },
); );
emailsToSend.push( emailsToSend.push(
sendEmail("NewParticipantEmail", { emailClient.sendTemplate("NewParticipantEmail", {
to: email, to: email,
subject: `${participant.name} has responded to ${poll.title}`, subject: `${participant.name} has responded to ${poll.title}`,
props: { props: {

View file

@ -0,0 +1,19 @@
import { EmailClient, SupportedEmailProviders } from "@rallly/emails";
const env = process.env["NODE" + "_ENV"];
export const emailClient = new EmailClient({
openPreviews: env === "developement",
useTestServer: env === "test",
provider: {
name: process.env.EMAIL_PROVIDER as SupportedEmailProviders,
},
mail: {
from: {
name: "Rallly",
address:
(process.env.NOREPLY_EMAIL as string) ||
(process.env.SUPPORT_EMAIL as string),
},
},
});

View file

@ -7,8 +7,8 @@
"lint": "eslint ./src", "lint": "eslint ./src",
"lint:tsc": "tsc --noEmit" "lint:tsc": "tsc --noEmit"
}, },
"main": "./src/index.tsx", "main": "./src/index.ts",
"types": "./src/index.tsx", "types": "./src/index.ts",
"dependencies": { "dependencies": {
"@aws-sdk/client-ses": "^3.292.0", "@aws-sdk/client-ses": "^3.292.0",
"@aws-sdk/credential-provider-node": "^3.292.0", "@aws-sdk/credential-provider-node": "^3.292.0",
@ -16,12 +16,14 @@
"@react-email/render": "0.0.6", "@react-email/render": "0.0.6",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"nodemailer": "^6.9.1", "nodemailer": "^6.9.1",
"preview-email": "^3.0.19",
"react-email": "^1.9.1" "react-email": "^1.9.1"
}, },
"devDependencies": { "devDependencies": {
"@rallly/tailwind-config": "*", "@rallly/tailwind-config": "*",
"@rallly/tsconfig": "*", "@rallly/tsconfig": "*",
"@rallly/utils": "*", "@rallly/utils": "*",
"@types/nodemailer": "^6.4.7" "@types/nodemailer": "^6.4.7",
"@types/preview-email": "^3.0.1"
} }
} }

View file

@ -3,6 +3,7 @@ import { defaultProvider } from "@aws-sdk/credential-provider-node";
import { render } from "@react-email/render"; import { render } from "@react-email/render";
import { createTransport, Transporter } from "nodemailer"; import { createTransport, Transporter } from "nodemailer";
import type Mail from "nodemailer/lib/mailer"; import type Mail from "nodemailer/lib/mailer";
import previewEmail from "preview-email";
import React from "react"; import React from "react";
import * as templates from "./templates"; import * as templates from "./templates";
@ -17,66 +18,6 @@ type TemplateProps<T extends TemplateName> = React.ComponentProps<
type TemplateComponent<T extends TemplateName> = Templates[T]; type TemplateComponent<T extends TemplateName> = Templates[T];
const env = process.env["NODE" + "_ENV"] || "development";
let transport: Transporter;
const getTransport = () => {
if (transport) {
// Reuse the transport if it exists
return transport;
}
if (env === "test") {
transport = createTransport({ port: 4025 });
return transport;
}
switch (process.env.EMAIL_PROVIDER) {
case "ses":
{
const ses = new aws.SES({
region: process.env["AWS" + "_REGION"],
credentialDefaultProvider: defaultProvider,
});
transport = createTransport({
SES: {
ses,
aws,
sendingRate: 10,
},
});
}
break;
default: {
const hasAuth = process.env.SMTP_USER || process.env.SMTP_PWD;
transport = createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT
? parseInt(process.env.SMTP_PORT)
: undefined,
secure: process.env.SMTP_SECURE === "true",
auth: hasAuth
? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PWD,
}
: undefined,
tls:
process.env.SMTP_TLS_ENABLED === "true"
? {
ciphers: "SSLv3",
rejectUnauthorized: false,
}
: undefined,
});
}
}
return transport;
};
type SendEmailOptions<T extends TemplateName> = { type SendEmailOptions<T extends TemplateName> = {
to: string; to: string;
subject: string; subject: string;
@ -84,42 +25,146 @@ type SendEmailOptions<T extends TemplateName> = {
attachments?: Mail.Options["attachments"]; attachments?: Mail.Options["attachments"];
}; };
export const sendEmail = async <T extends TemplateName>( type EmailProviderConfig =
templateName: T, | {
options: SendEmailOptions<T>, name: "ses";
) => { // config defined through env vars
if (!process.env.SUPPORT_EMAIL) { }
console.info("SUPPORT_EMAIL not configured - skipping email send"); | {
return; name: "smtp";
} // config defined through env vars
};
const Template = templates[templateName] as TemplateComponent<T>; export type SupportedEmailProviders = EmailProviderConfig["name"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const html = render(<Template {...(options.props as any)} />);
try { type EmailClientConfig = {
await sendRawEmail({ /**
from: { * Whether to open previews of each email in the browser
name: "Rallly", */
address: process.env.NOREPLY_EMAIL || process.env.SUPPORT_EMAIL, openPreviews?: boolean;
}, /**
to: options.to, * Whether to send emails to the test server
subject: options.subject, */
// eslint-disable-next-line @typescript-eslint/no-explicit-any useTestServer: boolean;
html, /**
attachments: options.attachments, * Email provider config
}); */
} catch (e) { provider: EmailProviderConfig;
console.error("Error sending email", templateName, e); /**
} * Mail config
*/
mail: {
from: {
name: string;
address: string;
};
};
}; };
export const sendRawEmail = async (options: Mail.Options) => { export class EmailClient {
const transport = getTransport(); private config: EmailClientConfig;
try { private cachedTransport?: Transporter;
await transport.sendMail(options);
return; constructor(config: EmailClientConfig) {
} catch (e) { this.config = config;
console.error("Error sending email", e);
} }
};
async sendTemplate<T extends TemplateName>(
templateName: T,
options: SendEmailOptions<T>,
) {
if (!process.env.SUPPORT_EMAIL) {
console.info("SUPPORT_EMAIL not configured - skipping email send");
return;
}
const Template = templates[templateName] as TemplateComponent<T>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const html = render(<Template {...(options.props as any)} />);
try {
await this.sendEmail({
from: this.config.mail.from,
to: options.to,
subject: options.subject,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
html,
attachments: options.attachments,
});
} catch (e) {
console.error("Error sending email", templateName, e);
}
}
async sendEmail(options: Mail.Options) {
if (this.config.openPreviews) {
await previewEmail(options, {
openSimulator: false,
});
return;
}
try {
await this.transport.sendMail(options);
return;
} catch (e) {
console.error("Error sending email", e);
}
}
private get transport() {
if (this.config.useTestServer) {
this.cachedTransport = createTransport({
port: 4025,
});
return this.cachedTransport;
}
if (this.cachedTransport) {
// Reuse the transport if it exists
return this.cachedTransport;
}
switch (this.config.provider.name) {
case "ses": {
const ses = new aws.SES({
region: process.env["AWS" + "_REGION"],
credentialDefaultProvider: defaultProvider,
});
this.cachedTransport = createTransport({
SES: {
ses,
aws,
sendingRate: 10,
},
});
}
case "smtp": {
const hasAuth = process.env.SMTP_USER || process.env.SMTP_PWD;
this.cachedTransport = createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT
? parseInt(process.env.SMTP_PORT)
: undefined,
secure: process.env.SMTP_SECURE === "true",
auth: hasAuth
? {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PWD,
}
: undefined,
tls:
process.env.SMTP_TLS_ENABLED === "true"
? {
ciphers: "SSLv3",
rejectUnauthorized: false,
}
: undefined,
});
}
}
return this.cachedTransport;
}
}

583
yarn.lock

File diff suppressed because it is too large Load diff