Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { prismaClient } from "@/prisma-client";

type FailedEmailsQueryResult = {
tenancyId: string,
Comment thread
N2D4 marked this conversation as resolved.
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",
Comment thread
N2D4 marked this conversation as resolved.
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'
Comment thread
BilalG1 marked this conversation as resolved.
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;
};
Original file line number Diff line number Diff line change
@@ -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 changes: 8 additions & 0 deletions apps/backend/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/latest/internal/failed-emails-digest",
"schedule": "0 0 * * *"
}
]
}
Original file line number Diff line number Diff line change
@@ -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", () => {
Comment thread
BilalG1 marked this conversation as resolved.
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
);
Comment thread
N2D4 marked this conversation as resolved.
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();
});
Comment thread
N2D4 marked this conversation as resolved.
});