mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-21 18:27:53 +02:00
♻️ Improve email abstraction (#861)
This commit is contained in:
parent
9e453da1dd
commit
93cc5b9b4d
10 changed files with 749 additions and 152 deletions
|
@ -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;
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
19
packages/backend/utils/email-client.ts
Normal file
19
packages/backend/utils/email-client.ts
Normal 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue