Use message queue for emails (#1446)

This commit is contained in:
Luke Vella 2024-11-30 19:00:56 +00:00 committed by GitHub
parent 673fc79801
commit a452e5b764
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 150 additions and 35 deletions

View file

@ -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
}

View file

@ -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",

View file

@ -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);
};

View file

@ -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;

View file

@ -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 (

View file

@ -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! });
}

View file

@ -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<T extends TemplateName> = Omit<
React.ComponentProps<TemplateComponent<T>>,
"ctx"
>;
type TemplateComponent<T extends TemplateName> = Templates[T] & {
getSubject?: (props: TemplateProps<T>, ctx: EmailContext) => string;
};
import { createQstashClient } from "./queue";
import { templates } from "./templates";
import type { TemplateComponent, TemplateName, TemplateProps } from "./types";
type SendEmailOptions<T extends TemplateName> = {
to: string;
@ -81,11 +72,30 @@ export class EmailClient {
templateName: T,
options: SendEmailOptions<T>,
) {
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<T extends TemplateName>(

View file

@ -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 };

View file

@ -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 };

View file

@ -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<T extends TemplateName> = Omit<
React.ComponentProps<EmailTemplates[T]>,
"ctx"
>;
export type TemplateComponent<T extends TemplateName> = EmailTemplates[T] & {
getSubject?: (props: TemplateProps<T>, ctx: EmailContext) => string;
};

View file

@ -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"
]
}

View file

@ -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==