diff --git a/.vscode/settings.json b/.vscode/settings.json index 13841e061..41cbdcb56 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,6 @@ "typescript.preferences.importModuleSpecifier": "shortest", "cSpell.words": ["Rallly", "Vella"], "jestrunner.codeLensSelector": "", - "vitest.filesWatcherInclude": "**/*.test.ts" + "vitest.filesWatcherInclude": "**/*.test.ts", + "editor.formatOnSave": true } diff --git a/apps/web/package.json b/apps/web/package.json index 3ab1bdc63..e75d65e61 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,6 +42,7 @@ "@trpc/next": "^10.13.0", "@trpc/react-query": "^10.13.0", "@trpc/server": "^10.13.0", + "@upstash/qstash": "^2.7.17", "@upstash/ratelimit": "^1.2.1", "@vercel/functions": "^1.0.2", "@vercel/kv": "^2.0.0", diff --git a/apps/web/src/app/api/send-email/route.ts b/apps/web/src/app/api/send-email/route.ts new file mode 100644 index 000000000..4570b7f2e --- /dev/null +++ b/apps/web/src/app/api/send-email/route.ts @@ -0,0 +1,33 @@ +import * as Sentry from "@sentry/nextjs"; +import { verifySignatureAppRouter } from "@upstash/qstash/nextjs"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +import { getEmailClient } from "@/utils/emails"; + +const emailClient = getEmailClient(); + +export const POST = async (req: NextRequest) => { + /** + * We need to call verifySignatureAppRouter inside the route handler + * to avoid the build breaking when env vars are not set. + */ + return verifySignatureAppRouter(async (req: NextRequest) => { + const body = await req.json(); + + // TODO: Add validation for templateName and options + + try { + await emailClient.sendTemplate(body.templateName, body.options); + + return NextResponse.json({ success: true }); + } catch (error) { + Sentry.captureException(error); + + return NextResponse.json( + { success: false, error: "Failed to send email" }, + { status: 500 }, + ); + } + })(req); +}; diff --git a/packages/emails/i18next.d.ts b/packages/emails/i18next.d.ts index 9becd83ca..68c204a1f 100644 --- a/packages/emails/i18next.d.ts +++ b/packages/emails/i18next.d.ts @@ -1,6 +1,6 @@ import "i18next"; -import emails from "./locales/en/emails.json"; +import type emails from "./locales/en/emails.json"; interface I18nNamespaces { emails: typeof emails; diff --git a/packages/emails/src/previews/new-participant-confirmation.tsx b/packages/emails/src/previews/new-participant-confirmation.tsx index 3d7e33494..7da58a59f 100644 --- a/packages/emails/src/previews/new-participant-confirmation.tsx +++ b/packages/emails/src/previews/new-participant-confirmation.tsx @@ -1,5 +1,5 @@ import { previewEmailContext } from "../components/email-context"; -import NewParticipantConfirmationEmail from "../templates/new-participant-confirmation"; +import { NewParticipantConfirmationEmail } from "../templates/new-participant-confirmation"; export default function NewParticipantConfirmationPreview() { return ( diff --git a/packages/emails/src/queue.ts b/packages/emails/src/queue.ts new file mode 100644 index 000000000..1360c5ec4 --- /dev/null +++ b/packages/emails/src/queue.ts @@ -0,0 +1,9 @@ +import { Client } from "@upstash/qstash"; + +export function createQstashClient() { + if (!process.env.QSTASH_TOKEN) { + return null; + } + + return new Client({ token: process.env.QSTASH_TOKEN! }); +} diff --git a/packages/emails/src/send-email.tsx b/packages/emails/src/send-email.tsx index 0c7c4ce29..4adad1432 100644 --- a/packages/emails/src/send-email.tsx +++ b/packages/emails/src/send-email.tsx @@ -1,6 +1,8 @@ import * as aws from "@aws-sdk/client-ses"; import { defaultProvider } from "@aws-sdk/credential-provider-node"; +import { absoluteUrl } from "@rallly/utils/absolute-url"; import { renderAsync } from "@react-email/render"; +import * as Sentry from "@sentry/nextjs"; import { waitUntil } from "@vercel/functions"; import type { Transporter } from "nodemailer"; import { createTransport } from "nodemailer"; @@ -8,20 +10,9 @@ import type Mail from "nodemailer/lib/mailer"; import React from "react"; import { i18nDefaultConfig, i18nInstance } from "./i18n"; -import * as templates from "./templates"; -import type { EmailContext } from "./types"; - -type Templates = typeof templates; - -type TemplateName = keyof typeof templates; - -type TemplateProps = Omit< - React.ComponentProps>, - "ctx" ->; -type TemplateComponent = Templates[T] & { - getSubject?: (props: TemplateProps, ctx: EmailContext) => string; -}; +import { createQstashClient } from "./queue"; +import { templates } from "./templates"; +import type { TemplateComponent, TemplateName, TemplateProps } from "./types"; type SendEmailOptions = { to: string; @@ -81,11 +72,30 @@ export class EmailClient { templateName: T, options: SendEmailOptions, ) { - return waitUntil( - (async () => { + const createEmailJob = async () => { + const client = createQstashClient(); + + if (client) { + const queue = client.queue({ + queueName: "emails", + }); + + queue + .enqueueJSON({ + url: absoluteUrl("/api/send-email"), + body: { templateName, options }, + }) + .catch(() => { + Sentry.captureException(new Error("Failed to queue email")); + // If there's an error queuing the email, send it immediately + this.sendTemplate(templateName, options); + }); + } else { this.sendTemplate(templateName, options); - })(), - ); + } + }; + + waitUntil(createEmailJob()); } async sendTemplate( diff --git a/packages/emails/src/templates.ts b/packages/emails/src/templates.ts index 54041fbfc..64eeb9769 100644 --- a/packages/emails/src/templates.ts +++ b/packages/emails/src/templates.ts @@ -1,8 +1,26 @@ -export * from "./templates/finalized-host"; -export * from "./templates/finalized-participant"; -export * from "./templates/login"; -export * from "./templates/new-comment"; -export * from "./templates/new-participant"; -export * from "./templates/new-participant-confirmation"; -export * from "./templates/new-poll"; -export * from "./templates/register"; +import { FinalizeHostEmail } from "./templates/finalized-host"; +import { FinalizeParticipantEmail } from "./templates/finalized-participant"; +import { LoginEmail } from "./templates/login"; +import { NewCommentEmail } from "./templates/new-comment"; +import { NewParticipantEmail } from "./templates/new-participant"; +import { NewParticipantConfirmationEmail } from "./templates/new-participant-confirmation"; +import { NewPollEmail } from "./templates/new-poll"; +import { RegisterEmail } from "./templates/register"; +import type { TemplateName } from "./types"; + +const templates = { + FinalizeHostEmail, + FinalizeParticipantEmail, + LoginEmail, + NewCommentEmail, + NewParticipantEmail, + NewParticipantConfirmationEmail, + NewPollEmail, + RegisterEmail, +}; + +export const emailTemplates = Object.keys(templates) as TemplateName[]; + +export type EmailTemplates = typeof templates; + +export { templates }; diff --git a/packages/emails/src/templates/new-participant-confirmation.tsx b/packages/emails/src/templates/new-participant-confirmation.tsx index 6df5cef8a..1aa27c4ab 100644 --- a/packages/emails/src/templates/new-participant-confirmation.tsx +++ b/packages/emails/src/templates/new-participant-confirmation.tsx @@ -15,7 +15,8 @@ interface NewParticipantConfirmationEmailProps { editSubmissionUrl: string; ctx: EmailContext; } -export const NewParticipantConfirmationEmail = ({ + +const NewParticipantConfirmationEmail = ({ title, editSubmissionUrl, ctx, @@ -96,4 +97,4 @@ NewParticipantConfirmationEmail.getSubject = ( }); }; -export default NewParticipantConfirmationEmail; +export { NewParticipantConfirmationEmail }; diff --git a/packages/emails/src/types.ts b/packages/emails/src/types.ts index af9efc387..855b9470c 100644 --- a/packages/emails/src/types.ts +++ b/packages/emails/src/types.ts @@ -1,6 +1,7 @@ import type { TFunction } from "i18next"; import type { I18nInstance } from "./i18n"; +import type { EmailTemplates } from "./templates"; export type EmailContext = { logoUrl: string; @@ -10,3 +11,14 @@ export type EmailContext = { i18n: I18nInstance; t: TFunction; }; + +export type TemplateName = keyof EmailTemplates; + +export type TemplateProps = Omit< + React.ComponentProps, + "ctx" +>; + +export type TemplateComponent = EmailTemplates[T] & { + getSubject?: (props: TemplateProps, ctx: EmailContext) => string; +}; diff --git a/turbo.json b/turbo.json index afafaf2ff..9658ed1ac 100644 --- a/turbo.json +++ b/turbo.json @@ -116,6 +116,9 @@ "SENTRY_ORG", "SENTRY_PROJECT", "SUPPORT_EMAIL", - "REACT_EMAIL_LANG" + "QSTASH_TOKEN", + "QSTASH_CURRENT_SIGNING_KEY", + "QSTASH_NEXT_SIGNING_KEY", + "QSTASH_URL" ] } diff --git a/yarn.lock b/yarn.lock index 3b36a77bc..98cfc6baf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7071,6 +7071,15 @@ dependencies: "@upstash/redis" "^1.28.3" +"@upstash/qstash@^2.7.17": + version "2.7.17" + resolved "https://registry.yarnpkg.com/@upstash/qstash/-/qstash-2.7.17.tgz#df0561371787c62bdb8267766883f9375402ded4" + integrity sha512-EJW4BwK7KfIpqBHp2rTohwtY+lvP8TtFC+G/nMFLmSiClpD2DzcPDZ0SxXO6QEOZWtfqQTXVCSYppQZgRxH/tQ== + dependencies: + crypto-js ">=4.2.0" + jose "^5.2.3" + neverthrow "^7.0.1" + "@upstash/ratelimit@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@upstash/ratelimit/-/ratelimit-1.2.1.tgz#835a33ce715e999d646431f70a71a69de7d439ee" @@ -8385,7 +8394,7 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -crypto-js@^4.2.0: +crypto-js@>=4.2.0, crypto-js@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== @@ -10883,6 +10892,11 @@ jose@^4.11.1, jose@^4.11.4, jose@^4.15.1: resolved "https://registry.yarnpkg.com/jose/-/jose-4.15.5.tgz#6475d0f467ecd3c630a1b5dadd2735a7288df706" integrity sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg== +jose@^5.2.3: + version "5.9.6" + resolved "https://registry.yarnpkg.com/jose/-/jose-5.9.6.tgz#77f1f901d88ebdc405e57cce08d2a91f47521883" + integrity sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ== + js-beautify@^1.14.11: version "1.14.11" resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.14.11.tgz#57b17e009549ac845bdc58eddf8e1862e311314e" @@ -11899,6 +11913,11 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +neverthrow@^7.0.1: + version "7.2.0" + resolved "https://registry.yarnpkg.com/neverthrow/-/neverthrow-7.2.0.tgz#76fa0a6cf1f6d59f0770df461c92b8b270910694" + integrity sha512-iGBUfFB7yPczHHtA8dksKTJ9E8TESNTAx1UQWW6TzMF280vo9jdPYpLUXrMN1BCkPdHFdNG3fxOt2CUad8KhAw== + next-auth@^4.24.5: version "4.24.5" resolved "https://registry.yarnpkg.com/next-auth/-/next-auth-4.24.5.tgz#1fd1bfc0603c61fd2ba6fd81b976af690edbf07e" @@ -13936,8 +13955,16 @@ strict-uri-encode@^2.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: - name string-width-cjs +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==