diff --git a/apps/landing/playwright.config.ts b/apps/landing/playwright.config.ts deleted file mode 100644 index 0a35429a1..000000000 --- a/apps/landing/playwright.config.ts +++ /dev/null @@ -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; diff --git a/packages/backend/trpc/context.ts b/packages/backend/trpc/context.ts index c6abcc8f5..5407b2d2b 100644 --- a/packages/backend/trpc/context.ts +++ b/packages/backend/trpc/context.ts @@ -1,3 +1,4 @@ +import { EmailClient, SupportedEmailProviders } from "@rallly/emails"; import { createProxySSGHelpers } from "@trpc/react-query/ssg"; import * as trpc from "@trpc/server"; import * as trpcNext from "@trpc/server/adapters/next"; @@ -7,6 +8,9 @@ import superjson from "superjson"; import { randomid } from "../utils/nanoid"; import { appRouter } from "./routers"; +// Avoid use NODE_ENV directly because it will be replaced when using the dev server for e2e tests +const env = process.env["NODE" + "_ENV"]; + export async function createContext( opts: trpcNext.CreateNextContextOptions | GetServerSidePropsContext, ) { @@ -19,12 +23,30 @@ export async function createContext( opts.req.session.user = user; await opts.req.session.save(); } + + const emailClient = new EmailClient({ + openPreviews: env === "development", + useTestServer: env === "test", + provider: { + name: (process.env.EMAIL_PROVIDER as SupportedEmailProviders) ?? "smtp", + }, + mail: { + from: { + name: "Rallly", + address: + (process.env.NOREPLY_EMAIL as string) || + (process.env.SUPPORT_EMAIL as string), + }, + }, + }); + return { user, session: opts.req.session, req: opts.req, res: opts.res, isSelfHosted: process.env.NEXT_PUBLIC_SELF_HOSTED === "true", + emailClient, }; } diff --git a/packages/backend/trpc/routers/auth.ts b/packages/backend/trpc/routers/auth.ts index d563330a3..fbd1a023d 100644 --- a/packages/backend/trpc/routers/auth.ts +++ b/packages/backend/trpc/routers/auth.ts @@ -1,5 +1,4 @@ import { prisma } from "@rallly/database"; -import { sendEmail } from "@rallly/emails"; import { absoluteUrl } from "@rallly/utils"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -78,6 +77,7 @@ export const auth = router({ .mutation( async ({ input, + ctx, }): Promise< | { ok: true; token: string } | { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" } @@ -107,7 +107,7 @@ export const auth = router({ code, }); - await sendEmail("RegisterEmail", { + await ctx.emailClient.sendTemplate("RegisterEmail", { to: input.email, subject: `${input.name}, please verify your email address`, props: { @@ -168,6 +168,7 @@ export const auth = router({ .mutation( async ({ input, + ctx, }): Promise< | { ok: true; token: string } | { ok: false; reason: "emailNotAllowed" | "userNotFound" } @@ -193,7 +194,7 @@ export const auth = router({ code, }); - await sendEmail("LoginEmail", { + await ctx.emailClient.sendTemplate("LoginEmail", { to: input.email, subject: `${code} is your 6-digit code`, props: { diff --git a/packages/backend/trpc/routers/polls.ts b/packages/backend/trpc/routers/polls.ts index c25ef46c1..823faa01c 100644 --- a/packages/backend/trpc/routers/polls.ts +++ b/packages/backend/trpc/routers/polls.ts @@ -1,5 +1,4 @@ import { prisma } from "@rallly/database"; -import { sendEmail } from "@rallly/emails"; import { absoluteUrl, shortUrl } from "@rallly/utils"; import { TRPCError } from "@trpc/server"; import dayjs from "dayjs"; @@ -133,7 +132,7 @@ export const polls = router({ }); if (user) { - await sendEmail("NewPollEmail", { + await ctx.emailClient.sendTemplate("NewPollEmail", { to: user.email, subject: `Let's find a date for ${poll.title}`, props: { @@ -678,7 +677,7 @@ export const polls = router({ }); } - const emailToHost = sendEmail("FinalizeHostEmail", { + const emailToHost = ctx.emailClient.sendTemplate("FinalizeHostEmail", { subject: `Date booked for ${poll.title}`, to: poll.user.email, props: { @@ -702,7 +701,7 @@ export const polls = router({ }); const emailsToParticipants = participantsToEmail.map((p) => { - return sendEmail("FinalizeParticipantEmail", { + return ctx.emailClient.sendTemplate("FinalizeParticipantEmail", { subject: `Date booked for ${poll.title}`, to: p.email, props: { diff --git a/packages/backend/trpc/routers/polls/comments.ts b/packages/backend/trpc/routers/polls/comments.ts index b54d69705..c8daa3516 100644 --- a/packages/backend/trpc/routers/polls/comments.ts +++ b/packages/backend/trpc/routers/polls/comments.ts @@ -1,5 +1,4 @@ import { prisma } from "@rallly/database"; -import { sendEmail } from "@rallly/emails"; import { absoluteUrl } from "@rallly/utils"; import { z } from "zod"; @@ -82,7 +81,7 @@ export const comments = router({ { ttl: 0 }, ); emailsToSend.push( - sendEmail("NewCommentEmail", { + ctx.emailClient.sendTemplate("NewCommentEmail", { to: email, subject: `${authorName} has commented on ${poll.title}`, props: { diff --git a/packages/backend/trpc/routers/polls/participants.ts b/packages/backend/trpc/routers/polls/participants.ts index ae74f476d..08b053b6e 100644 --- a/packages/backend/trpc/routers/polls/participants.ts +++ b/packages/backend/trpc/routers/polls/participants.ts @@ -1,5 +1,4 @@ import { prisma } from "@rallly/database"; -import { sendEmail } from "@rallly/emails"; import { absoluteUrl } from "@rallly/utils"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -107,7 +106,7 @@ export const participants = router({ ); emailsToSend.push( - sendEmail("NewParticipantConfirmationEmail", { + ctx.emailClient.sendTemplate("NewParticipantConfirmationEmail", { to: email, subject: `Thanks for responding to ${poll.title}`, props: { @@ -144,7 +143,7 @@ export const participants = router({ { ttl: 0 }, ); emailsToSend.push( - sendEmail("NewParticipantEmail", { + ctx.emailClient.sendTemplate("NewParticipantEmail", { to: email, subject: `${participant.name} has responded to ${poll.title}`, props: { diff --git a/packages/emails/package.json b/packages/emails/package.json index d9e46c677..1da271767 100644 --- a/packages/emails/package.json +++ b/packages/emails/package.json @@ -7,8 +7,8 @@ "lint": "eslint ./src", "lint:tsc": "tsc --noEmit" }, - "main": "./src/index.tsx", - "types": "./src/index.tsx", + "main": "./src/index.ts", + "types": "./src/index.ts", "dependencies": { "@aws-sdk/client-ses": "^3.292.0", "@aws-sdk/credential-provider-node": "^3.292.0", @@ -16,12 +16,14 @@ "@react-email/render": "0.0.6", "clsx": "^1.2.1", "nodemailer": "^6.9.1", + "preview-email": "^3.0.19", "react-email": "^1.9.1" }, "devDependencies": { "@rallly/tailwind-config": "*", "@rallly/tsconfig": "*", "@rallly/utils": "*", - "@types/nodemailer": "^6.4.7" + "@types/nodemailer": "^6.4.7", + "@types/preview-email": "^3.0.1" } } diff --git a/packages/emails/src/index.tsx b/packages/emails/src/index.ts similarity index 100% rename from packages/emails/src/index.tsx rename to packages/emails/src/index.ts diff --git a/packages/emails/src/send-email.tsx b/packages/emails/src/send-email.tsx index 7cffbc423..8e6472fcb 100644 --- a/packages/emails/src/send-email.tsx +++ b/packages/emails/src/send-email.tsx @@ -3,6 +3,7 @@ import { defaultProvider } from "@aws-sdk/credential-provider-node"; import { render } from "@react-email/render"; import { createTransport, Transporter } from "nodemailer"; import type Mail from "nodemailer/lib/mailer"; +import previewEmail from "preview-email"; import React from "react"; import * as templates from "./templates"; @@ -17,66 +18,6 @@ type TemplateProps = React.ComponentProps< type TemplateComponent = 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 = { to: string; subject: string; @@ -84,42 +25,147 @@ type SendEmailOptions = { attachments?: Mail.Options["attachments"]; }; -export const sendEmail = async ( - templateName: T, - options: SendEmailOptions, -) => { - if (!process.env.SUPPORT_EMAIL) { - console.info("SUPPORT_EMAIL not configured - skipping email send"); - return; - } +type EmailProviderConfig = + | { + name: "ses"; + // config defined through env vars + } + | { + name: "smtp"; + // config defined through env vars + }; - const Template = templates[templateName] as TemplateComponent; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const html = render(