mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
added cron job to for daily failed email digest (#714)
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Preview Docs / run (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Preview Docs / run (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
parent
ab5d33647d
commit
71c35fd672
@ -42,3 +42,4 @@ STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS=50
|
||||
STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING=yes
|
||||
|
||||
STACK_INTEGRATION_CLIENTS_CONFIG=[{"client_id": "neon-local", "client_secret": "neon-local-secret", "id_token_signed_response_alg": "ES256", "redirect_uris": ["http://localhost:30000/api/v2/identity/authorize", "http://localhost:30000/api/v2/auth/authorize"]}, {"client_id": "custom-local", "client_secret": "custom-local-secret", "id_token_signed_response_alg": "ES256", "redirect_uris": ["http://localhost:30000/api/v2/identity/authorize", "http://localhost:30000/api/v2/auth/authorize"]}]
|
||||
CRON_SECRET=mock_cron_secret
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import { prismaClient } from "@/prisma-client";
|
||||
|
||||
type FailedEmailsQueryResult = {
|
||||
tenancyId: string,
|
||||
projectId: string,
|
||||
to: string[],
|
||||
subject: string,
|
||||
contactEmail: string,
|
||||
}
|
||||
|
||||
type FailedEmailsByTenancyData = {
|
||||
emails: Array<{ subject: string, to: string[] }>,
|
||||
tenantOwnerEmail: string,
|
||||
projectId: string,
|
||||
}
|
||||
|
||||
export const getFailedEmailsByTenancy = async (after: Date) => {
|
||||
const result = await prismaClient.$queryRaw<Array<FailedEmailsQueryResult>>`
|
||||
SELECT
|
||||
se."tenancyId",
|
||||
t."projectId",
|
||||
se."to",
|
||||
se."subject",
|
||||
cc."value" as "contactEmail"
|
||||
FROM "SentEmail" se
|
||||
INNER JOIN "Tenancy" t ON se."tenancyId" = t.id
|
||||
LEFT JOIN "ProjectUser" pu ON pu."mirroredProjectId" = 'internal'
|
||||
AND pu."mirroredBranchId" = 'main'
|
||||
AND pu."serverMetadata"->'managedProjectIds' ? t."projectId"
|
||||
LEFT JOIN "ContactChannel" cc ON pu."projectUserId" = cc."projectUserId"
|
||||
AND cc."isPrimary" = 'TRUE'
|
||||
AND cc."type" = 'EMAIL'
|
||||
WHERE se."error" IS NOT NULL
|
||||
AND se."createdAt" >= ${after}
|
||||
`;
|
||||
|
||||
const failedEmailsByTenancy = new Map<string, FailedEmailsByTenancyData>();
|
||||
for (const failedEmail of result) {
|
||||
let failedEmails = failedEmailsByTenancy.get(failedEmail.tenancyId) ?? {
|
||||
emails: [],
|
||||
tenantOwnerEmail: failedEmail.contactEmail,
|
||||
projectId: failedEmail.projectId
|
||||
};
|
||||
failedEmails.emails.push({ subject: failedEmail.subject, to: failedEmail.to });
|
||||
failedEmailsByTenancy.set(failedEmail.tenancyId, failedEmails);
|
||||
}
|
||||
return failedEmailsByTenancy;
|
||||
};
|
||||
@ -0,0 +1,88 @@
|
||||
import { getSharedEmailConfig, sendEmail } from "@/lib/emails";
|
||||
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { yupArray, yupBoolean, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { escapeHtml } from "@stackframe/stack-shared/dist/utils/html";
|
||||
import { getFailedEmailsByTenancy } from "./crud";
|
||||
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: {
|
||||
hidden: true,
|
||||
},
|
||||
request: yupObject({
|
||||
headers: yupObject({
|
||||
"authorization": yupTuple([yupString()]).defined(),
|
||||
}),
|
||||
method: yupString().oneOf(["POST"]).defined(),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([200, 401]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
success: yupBoolean().defined(),
|
||||
error_message: yupString().optional(),
|
||||
failed_emails_by_tenancy: yupArray(yupObject({
|
||||
emails: yupArray(yupObject({
|
||||
subject: yupString().defined(),
|
||||
to: yupArray(yupString().defined()).defined(),
|
||||
})).defined(),
|
||||
tenant_owner_email: yupString().defined(),
|
||||
project_id: yupString().defined(),
|
||||
tenancy_id: yupString().defined(),
|
||||
})).optional(),
|
||||
}).defined(),
|
||||
}),
|
||||
handler: async ({ headers }) => {
|
||||
const authHeader = headers.authorization[0];
|
||||
if (authHeader !== `Bearer ${getEnvVariable('CRON_SECRET')}`) {
|
||||
throw new StatusError(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const failedEmailsByTenancy = await getFailedEmailsByTenancy(new Date(Date.now() - 1000 * 60 * 60 * 24));
|
||||
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
|
||||
const emailConfig = await getSharedEmailConfig("Stack Auth");
|
||||
const dashboardUrl = getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL", "https://app.stack-auth.com");
|
||||
|
||||
for (const failedEmailsBatch of failedEmailsByTenancy.values()) {
|
||||
const viewInStackAuth = `<a href="${dashboardUrl}/projects/${encodeURIComponent(failedEmailsBatch.projectId)}/emails">View all email logs on the Dashboard</a>`;
|
||||
const emailHtml = `
|
||||
<p>Thank you for using Stack Auth!</p>
|
||||
<p>We detected that, on your project, there have been ${failedEmailsBatch.emails.length} emails that failed to deliver in the last 24 hours. Please check your email server configuration.</p>
|
||||
<p>${viewInStackAuth}</p>
|
||||
<p>Last failing emails:</p>
|
||||
${failedEmailsBatch.emails.slice(-10).map((failedEmail) => {
|
||||
const escapedSubject = escapeHtml(failedEmail.subject).replace(/\s+/g, ' ').slice(0, 50);
|
||||
const escapedTo = failedEmail.to.map(to => escapeHtml(to)).join(", ");
|
||||
return `<div><p>Subject: ${escapedSubject}<br />To: ${escapedTo}</p></div>`;
|
||||
}).join("")}
|
||||
${failedEmailsBatch.emails.length > 10 ? `<div>...</div>` : ""}
|
||||
`;
|
||||
await sendEmail({
|
||||
tenancyId: internalTenancy.id,
|
||||
emailConfig,
|
||||
to: failedEmailsBatch.tenantOwnerEmail,
|
||||
subject: "Failed emails digest",
|
||||
html: emailHtml,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: 'json',
|
||||
body: {
|
||||
success: true,
|
||||
failed_emails_by_tenancy: Array.from(failedEmailsByTenancy.entries()).map(([tenancyId, batch]) => (
|
||||
{
|
||||
emails: batch.emails,
|
||||
tenant_owner_email: batch.tenantOwnerEmail,
|
||||
project_id: batch.projectId,
|
||||
tenancy_id: tenancyId,
|
||||
}
|
||||
),
|
||||
)
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
8
apps/backend/vercel.json
Normal file
8
apps/backend/vercel.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/latest/internal/failed-emails-digest",
|
||||
"schedule": "0 0 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,183 @@
|
||||
import { describe } from "vitest";
|
||||
import { it } from "../../../../../helpers";
|
||||
import { Auth, backendContext, InternalProjectKeys, niceBackendFetch, Project } from "../../../../backend-helpers";
|
||||
|
||||
describe("unauthorized requests", () => {
|
||||
it("should return 401 when invalid authorization is provided", async ({ expect }) => {
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/internal/failed-emails-digest",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
headers: {
|
||||
"Authorization": "Bearer some_invalid_secret",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 401,
|
||||
"body": "Unauthorized",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("should return 400 when no authorization header is provided", async ({ expect }) => {
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/internal/failed-emails-digest",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
}
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("should return 401 when authorization header is malformed", async ({ expect }) => {
|
||||
const response = await niceBackendFetch(
|
||||
"/api/v1/internal/failed-emails-digest",
|
||||
{
|
||||
method: "POST",
|
||||
accessType: "server",
|
||||
headers: {
|
||||
"Authorization": "InvalidFormat",
|
||||
}
|
||||
}
|
||||
);
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 401,
|
||||
"body": "Unauthorized",
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with valid credentials", () => {
|
||||
it("should return 200 and process failed emails digest", async ({ expect }) => {
|
||||
backendContext.set({
|
||||
projectKeys: InternalProjectKeys,
|
||||
userAuth: null,
|
||||
});
|
||||
await Auth.Otp.signIn();
|
||||
const adminAccessToken = backendContext.value.userAuth?.accessToken;
|
||||
const { projectId } = await Project.create({
|
||||
display_name: "Test Failed Emails Project",
|
||||
config: {
|
||||
email_config: {
|
||||
type: "standard",
|
||||
host: "invalid-smtp-host.example.com",
|
||||
port: 587,
|
||||
username: "invalid_user",
|
||||
password: "invalid_password",
|
||||
sender_name: "Test Project",
|
||||
sender_email: "test@invalid-domain.example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
backendContext.set({
|
||||
projectKeys: {
|
||||
projectId,
|
||||
},
|
||||
userAuth: null,
|
||||
});
|
||||
|
||||
const testEmailResponse = await niceBackendFetch("/api/v1/internal/send-test-email", {
|
||||
method: "POST",
|
||||
accessType: "admin",
|
||||
headers: {
|
||||
"x-stack-admin-access-token": adminAccessToken,
|
||||
},
|
||||
body: {
|
||||
"recipient_email": "test-email-recipient@stackframe.co",
|
||||
"email_config": {
|
||||
"host": "this-is-not-a-valid-host.example.com",
|
||||
"port": 123,
|
||||
"username": "123",
|
||||
"password": "123",
|
||||
"sender_email": "123@g.co",
|
||||
"sender_name": "123"
|
||||
}
|
||||
},
|
||||
});
|
||||
expect(testEmailResponse).toMatchInlineSnapshot(`
|
||||
NiceResponse {
|
||||
"status": 200,
|
||||
"body": {
|
||||
"error_message": "Failed to connect to the email host. Please make sure the email host configuration is correct.",
|
||||
"success": false,
|
||||
},
|
||||
"headers": Headers { <some fields may have been hidden> },
|
||||
}
|
||||
`);
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": "Bearer mock_cron_secret" }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
console.log(response.body);
|
||||
|
||||
const failedEmailsByTenancy = response.body.failed_emails_by_tenancy;
|
||||
const mockProjectFailedEmails = failedEmailsByTenancy.filter(
|
||||
(batch: any) => batch.tenant_owner_email === backendContext.value.mailbox.emailAddress
|
||||
);
|
||||
expect(mockProjectFailedEmails).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"emails": [
|
||||
{
|
||||
"subject": "Test Email from Stack Auth",
|
||||
"to": ["test-email-recipient@stackframe.co"],
|
||||
},
|
||||
],
|
||||
"project_id": "<stripped UUID>",
|
||||
"tenancy_id": "<stripped UUID>",
|
||||
"tenant_owner_email": "default-mailbox--<stripped UUID>@stack-generated.example.com",
|
||||
},
|
||||
]
|
||||
`);
|
||||
|
||||
const messages = await backendContext.value.mailbox.fetchMessages();
|
||||
const digestEmail = messages.find(msg => msg.subject === "Failed emails digest");
|
||||
expect(digestEmail).toBeDefined();
|
||||
expect(digestEmail!.from).toBe("Stack Auth <noreply@example.com>");
|
||||
});
|
||||
|
||||
it("should return 200 and not send digest email when all emails are successful", async ({ expect }) => {
|
||||
await Auth.Otp.signIn();
|
||||
const { projectId } = await Project.create({
|
||||
display_name: "Test Successful Emails Project",
|
||||
config: {
|
||||
email_config: {
|
||||
type: "standard",
|
||||
host: "localhost",
|
||||
port: 2500,
|
||||
username: "test",
|
||||
password: "test",
|
||||
sender_name: "Test Project",
|
||||
sender_email: "test@example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": "Bearer mock_cron_secret" }
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const failedEmailsByTenancy = response.body.failed_emails_by_tenancy;
|
||||
const mockProjectFailedEmails = failedEmailsByTenancy.filter(
|
||||
(batch: any) => batch.tenant_owner_email === backendContext.value.mailbox.emailAddress
|
||||
);
|
||||
expect(mockProjectFailedEmails).toMatchInlineSnapshot(`[]`);
|
||||
|
||||
const messages = await backendContext.value.mailbox.fetchMessages();
|
||||
const digestEmail = messages.find(msg => msg.subject === "Failed emails digest");
|
||||
expect(digestEmail).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user