mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-06 12:41:48 +02:00
✨ Use message queue for emails (#1446)
This commit is contained in:
parent
673fc79801
commit
a452e5b764
12 changed files with 150 additions and 35 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
33
apps/web/src/app/api/send-email/route.ts
Normal file
33
apps/web/src/app/api/send-email/route.ts
Normal 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);
|
||||
};
|
2
packages/emails/i18next.d.ts
vendored
2
packages/emails/i18next.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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 (
|
||||
|
|
9
packages/emails/src/queue.ts
Normal file
9
packages/emails/src/queue.ts
Normal 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! });
|
||||
}
|
|
@ -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>(
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
33
yarn.lock
33
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==
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue