[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:** `<RESEND_API_KEY>`
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 <STACK_EMAIL_MONITOR_SECRET_TOKEN>"
}
```
This commit is contained in:
Aman Ganapathy 2026-01-28 19:09:47 -08:00 committed by GitHub
parent 78812ec535
commit 4f34200db9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 226 additions and 9 deletions

View File

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

View File

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

View File

@ -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 "<email@example.com>"
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",
};
},
});

View File

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

View File

@ -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 { <some fields may have been hidden> },
}
`);
});
it("should reject requests with invalid token", async ({ expect }) => {
const response = await niceBackendFetch("/health/email", {
headers: {
"authorization": "Bearer invalid-token",
},
});
expect(response).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": "Unauthorized",
"headers": Headers { <some fields may have been hidden> },
}
`);
});

View File

@ -15939,6 +15939,7 @@ packages:
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-fetch@3.6.20:
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
@ -27330,8 +27331,8 @@ snapshots:
'@typescript-eslint/parser': 6.21.0(eslint@8.30.0)(typescript@5.8.3)
eslint: 8.30.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint@8.30.0))(eslint@8.30.0)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.30.0)
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.30.0)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint@8.30.0)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.30.0)
eslint-plugin-react: 7.37.2(eslint@8.30.0)
eslint-plugin-react-hooks: 5.1.0(eslint@8.30.0)
@ -27386,19 +27387,19 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint@8.30.0))(eslint@8.30.0):
eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.30.0):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.0
enhanced-resolve: 5.17.1
eslint: 8.30.0
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint@8.30.0))(eslint@8.30.0))(eslint@8.30.0)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.30.0)
fast-glob: 3.3.3
get-tsconfig: 4.8.1
is-bun-module: 1.2.1
is-glob: 4.0.3
optionalDependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.30.0)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint@8.30.0)
transitivePeerDependencies:
- '@typescript-eslint/parser'
- eslint-import-resolver-node
@ -27437,14 +27438,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint@8.30.0))(eslint@8.30.0))(eslint@8.30.0):
eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.30.0):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 6.21.0(eslint@8.30.0)(typescript@5.8.3)
eslint: 8.30.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint@8.30.0))(eslint@8.30.0)
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.30.0)
transitivePeerDependencies:
- supports-color
@ -27541,7 +27542,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.30.0):
eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint@8.30.0):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
@ -27552,7 +27553,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.30.0
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint@8.30.0))(eslint@8.30.0))(eslint@8.30.0)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.30.0)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.30.0)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3