mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
[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:
parent
78812ec535
commit
4f34200db9
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
163
apps/backend/src/app/health/email/route.tsx
Normal file
163
apps/backend/src/app/health/email/route.tsx
Normal 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",
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user