From 4f34200db9d37893dff1aa3dcaa343fc99982e23 Mon Sep 17 00:00:00 2001 From: Aman Ganapathy <84686202+nams1570@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:09:47 -0800 Subject: [PATCH] [Feat] Setup new route for email monitoring (#1095) ### Summary of Changes We need a way to tell if our email service is working. So, we set up a route that will be periodically hit by uptime kuma. This route tries to sign up to stack auth. Upon signing up, an verification email will be sent. We use resend to check the inbox and return a success if the email is found. We use resend to setup a test email domain, and we query that domain to see if the email has been received. In test environments/dev, we use inbucket instead. This route also requires a secret token, which can be configured via `.env` variables. ### Necessary config changes Note we add several new environment variables which will need to be populated in prod. Also, the [config settings for resend](https://resend.com/docs/send-with-smtp) are as follows: 1. **Host:** `smtp.resend.com` 2. **Port:** `465` 3. **Username:** `resend` 4. **Password:** `` These may need to be set up in docker to enable emails being sent out to resend. To set this up with uptime kuma, follow the steps below: 1. Create a new monitor 2. Set the monitor type to `HTTP(s)` 3. The URL should hit the `/health/email` endpoint. 4. Suggested request timeout is at least 120 seconds. Reading emails from the resend inbox can take a bit of time. 5. In headers, set the header as below: ``` { "authorization": "Bearer " } ``` --- apps/backend/.env | 9 + apps/backend/.env.development | 9 + apps/backend/src/app/health/email/route.tsx | 163 ++++++++++++++++++ apps/e2e/.env.development | 2 + .../endpoints/health/email-monitor.test.ts | 33 ++++ pnpm-lock.yaml | 19 +- 6 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 apps/backend/src/app/health/email/route.tsx create mode 100644 apps/e2e/tests/backend/endpoints/health/email-monitor.test.ts diff --git a/apps/backend/.env b/apps/backend/.env index 00225682a..19b5f0253 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -81,6 +81,15 @@ STACK_QSTASH_TOKEN= STACK_QSTASH_CURRENT_SIGNING_KEY= STACK_QSTASH_NEXT_SIGNING_KEY= +# Email monitor +STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY=# enter the resend poller api key here +STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN=# enter the resend domain that should receive the emails +STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY=# enter the publishable client key for email monitor to use when attempting a sign up +STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=# enter a valid verification callback url for the project that the email monitor will attempt to sign up for +STACK_EMAIL_MONITOR_INBUCKET_API_URL=# enter a valid inbucket api url for the email monitor to check emails from in test mode +STACK_EMAIL_MONITOR_USE_INBUCKET=# enter true/false based on whether the email monitor should use inbucket or resend. Note that if this is set to true in prod, the email monitor will throw an error. +STACK_EMAIL_MONITOR_SECRET_TOKEN=# enter the secret token value needed for the request to the email monitor to be accepted + # Clickhouse STACK_CLICKHOUSE_URL=# URL of the Clickhouse instance STACK_CLICKHOUSE_ADMIN_USER=# username of the admin account diff --git a/apps/backend/.env.development b/apps/backend/.env.development index a885fe4a0..992dc7783 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -55,6 +55,15 @@ STACK_OPENAI_API_KEY=mock_openai_api_key STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret +# Email monitor configuration for tests +STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification +STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only +STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN=stack-generated.example.com +STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY=this-is-a-fake-key +STACK_EMAIL_MONITOR_INBUCKET_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}05 +STACK_EMAIL_MONITOR_USE_INBUCKET=true +STACK_EMAIL_MONITOR_SECRET_TOKEN=this-secret-token-is-for-local-development-only + # S3 Configuration for local development using s3mock STACK_S3_ENDPOINT=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21 STACK_S3_REGION=us-east-1 diff --git a/apps/backend/src/app/health/email/route.tsx b/apps/backend/src/app/health/email/route.tsx new file mode 100644 index 000000000..52b29356b --- /dev/null +++ b/apps/backend/src/app/health/email/route.tsx @@ -0,0 +1,163 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; +import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; +import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { wait } from "@stackframe/stack-shared/dist/utils/promises"; + +type ResendEmail = { + to: string[], + subject: string, + from: string, + created_at: string, +}; + +type InbucketMessage = { + id: string, + subject: string, + from: string, + to: string[], + date: string, +}; + +const transformInbucketToResendFormat = (messages: InbucketMessage[]): { data: ResendEmail[] } => { + return { + data: messages.map(msg => ({ + to: msg.to, + subject: msg.subject, + from: msg.from, + created_at: msg.date, + })), + }; +}; + +const fetchFromInbucket = async (testEmail: string): Promise<{ data: ResendEmail[] }> => { + const inbucketUrl = getEnvVariable("STACK_EMAIL_MONITOR_INBUCKET_API_URL"); + const mailboxName = testEmail.split("@")[0]; + + const response = await fetch(`${inbucketUrl}/api/v1/mailbox/${encodeURIComponent(mailboxName)}`); + if (!response.ok) { + return { data: [] }; + } + + const messages = await response.json() as InbucketMessage[]; + return transformInbucketToResendFormat(messages); +}; + +const fetchFromResend = async (): Promise<{ data: ResendEmail[] }> => { + const resendApiKey = getEnvVariable("STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY"); + const response = await fetch("https://api.resend.com/emails/receiving", { + method: "GET", + headers: { + "Authorization": `Bearer ${resendApiKey}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + return { data: [] }; + } + + return await response.json(); +}; + +const performSignUp = async (email: string, password: string) => { + const apiBaseUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL"); + const response = await fetch(`${apiBaseUrl}/api/v1/auth/password/sign-up`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Stack-Access-Type": "client", + "X-Stack-Publishable-Client-Key": getEnvVariable("STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY"), + "X-Stack-Project-Id": "internal", + }, + body: JSON.stringify({ + email, + password, + verification_callback_url: getEnvVariable("STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL"), + }), + }); + + const responseBody = await response.text(); + + if (!response.ok) { + throw new StackAssertionError(`Sign-up failed: ${response.status} - ${responseBody}`, { + responseBody, + }); + } +}; + +const isExpectedVerificationEmail = (email: ResendEmail, testEmail: string): boolean => { + const EXPECTED_EMAIL_SUBJECT_CONTAINS = "verify"; + + // Inbucket wraps emails in angle brackets like "" + const matchesRecipient = email.to.some(to => to.includes(testEmail)); + const matchesSubject = email.subject.toLowerCase().includes(EXPECTED_EMAIL_SUBJECT_CONTAINS.toLowerCase()); + // Skip sender check - in dev it's example.com, in prod it's stackframe.co + + return matchesRecipient && matchesSubject; +}; + +const waitForVerificationEmail = async (testEmail: string, useInbucket: boolean) => { + const MAX_POLL_ATTEMPTS = 24; + const POLL_INTERVAL_MS = 5000; + + for (let attempt = 1; attempt <= MAX_POLL_ATTEMPTS; attempt++) { + await wait(POLL_INTERVAL_MS); + + const listData = useInbucket + ? await fetchFromInbucket(testEmail) + : await fetchFromResend(); + + const emails = listData.data; + const verificationEmail = emails.find((email) => isExpectedVerificationEmail(email, testEmail)); + + if (verificationEmail) { + return; + } + } + + throw new StackAssertionError(`Couldn't find verification email in time limit`, { recipient_email: testEmail, max_poll_attempts: MAX_POLL_ATTEMPTS, poll_interval_ms: POLL_INTERVAL_MS }); +}; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + summary: "Email Health Monitor", + description: "Tests the sign-up + email verification flow. Returns 200 if successful.", + tags: ["Monitoring"], + }, + request: yupObject({ + headers: yupObject({ + "authorization": yupTuple([yupString()]).defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["success"]).defined(), + }), + handler: async ({ headers }) => { + const authHeader = headers.authorization[0]; + if (authHeader !== `Bearer ${getEnvVariable("STACK_EMAIL_MONITOR_SECRET_TOKEN")}`) { + throw new StatusError(401, "Unauthorized"); + } + + const useInbucket = getEnvVariable("STACK_EMAIL_MONITOR_USE_INBUCKET") === "true"; + if (useInbucket && getNodeEnvironment().includes("prod")) { + throw new StackAssertionError("Inbucket is not supported as the email monitor inbox in production"); + } + + const uniqueId = generateSecureRandomString(); + const testEmail = `monitor+${uniqueId}@${getEnvVariable("STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN")}`; + const testPassword = generateSecureRandomString(); + + await performSignUp(testEmail, testPassword); + + await waitForVerificationEmail(testEmail, useInbucket); + + return { + statusCode: 200, + bodyType: "success", + }; + }, +}); diff --git a/apps/e2e/.env.development b/apps/e2e/.env.development index f98baa240..331666f8c 100644 --- a/apps/e2e/.env.development +++ b/apps/e2e/.env.development @@ -8,3 +8,5 @@ STACK_DATABASE_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC STACK_INBUCKET_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}05 STACK_SVIX_SERVER_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}13 + +STACK_EMAIL_MONITOR_SECRET_TOKEN=this-secret-token-is-for-local-development-only diff --git a/apps/e2e/tests/backend/endpoints/health/email-monitor.test.ts b/apps/e2e/tests/backend/endpoints/health/email-monitor.test.ts new file mode 100644 index 000000000..d37f16f5c --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/health/email-monitor.test.ts @@ -0,0 +1,33 @@ +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { it } from "../../../helpers"; +import { niceBackendFetch } from "../../backend-helpers"; + +it("should return ok when email health check succeeds", async ({ expect }) => { + const response = await niceBackendFetch("/health/email", { + headers: { + "authorization": `Bearer ${getEnvVariable("STACK_EMAIL_MONITOR_SECRET_TOKEN")}`, + }, + }); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { "success": true }, + "headers": Headers {