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

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
BilalG1 2025-06-17 15:38:09 -07:00 committed by GitHub
parent ab5d33647d
commit 71c35fd672
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 328 additions and 0 deletions

View File

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

View File

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

View File

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

@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/latest/internal/failed-emails-digest",
"schedule": "0 0 * * *"
}
]
}

View File

@ -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();
});
});