Skip to content

Commit 71c35fd

Browse files
BilalG1N2D4
andauthored
added cron job to for daily failed email digest (#714)
Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
1 parent ab5d336 commit 71c35fd

5 files changed

Lines changed: 328 additions & 0 deletions

File tree

apps/backend/.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS=50
4242
STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING=yes
4343

4444
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"]}]
45+
CRON_SECRET=mock_cron_secret
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { prismaClient } from "@/prisma-client";
2+
3+
type FailedEmailsQueryResult = {
4+
tenancyId: string,
5+
projectId: string,
6+
to: string[],
7+
subject: string,
8+
contactEmail: string,
9+
}
10+
11+
type FailedEmailsByTenancyData = {
12+
emails: Array<{ subject: string, to: string[] }>,
13+
tenantOwnerEmail: string,
14+
projectId: string,
15+
}
16+
17+
export const getFailedEmailsByTenancy = async (after: Date) => {
18+
const result = await prismaClient.$queryRaw<Array<FailedEmailsQueryResult>>`
19+
SELECT
20+
se."tenancyId",
21+
t."projectId",
22+
se."to",
23+
se."subject",
24+
cc."value" as "contactEmail"
25+
FROM "SentEmail" se
26+
INNER JOIN "Tenancy" t ON se."tenancyId" = t.id
27+
LEFT JOIN "ProjectUser" pu ON pu."mirroredProjectId" = 'internal'
28+
AND pu."mirroredBranchId" = 'main'
29+
AND pu."serverMetadata"->'managedProjectIds' ? t."projectId"
30+
LEFT JOIN "ContactChannel" cc ON pu."projectUserId" = cc."projectUserId"
31+
AND cc."isPrimary" = 'TRUE'
32+
AND cc."type" = 'EMAIL'
33+
WHERE se."error" IS NOT NULL
34+
AND se."createdAt" >= ${after}
35+
`;
36+
37+
const failedEmailsByTenancy = new Map<string, FailedEmailsByTenancyData>();
38+
for (const failedEmail of result) {
39+
let failedEmails = failedEmailsByTenancy.get(failedEmail.tenancyId) ?? {
40+
emails: [],
41+
tenantOwnerEmail: failedEmail.contactEmail,
42+
projectId: failedEmail.projectId
43+
};
44+
failedEmails.emails.push({ subject: failedEmail.subject, to: failedEmail.to });
45+
failedEmailsByTenancy.set(failedEmail.tenancyId, failedEmails);
46+
}
47+
return failedEmailsByTenancy;
48+
};
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { getSharedEmailConfig, sendEmail } from "@/lib/emails";
2+
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
3+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { yupArray, yupBoolean, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
5+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
6+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
7+
import { escapeHtml } from "@stackframe/stack-shared/dist/utils/html";
8+
import { getFailedEmailsByTenancy } from "./crud";
9+
10+
export const POST = createSmartRouteHandler({
11+
metadata: {
12+
hidden: true,
13+
},
14+
request: yupObject({
15+
headers: yupObject({
16+
"authorization": yupTuple([yupString()]).defined(),
17+
}),
18+
method: yupString().oneOf(["POST"]).defined(),
19+
}),
20+
response: yupObject({
21+
statusCode: yupNumber().oneOf([200, 401]).defined(),
22+
bodyType: yupString().oneOf(["json"]).defined(),
23+
body: yupObject({
24+
success: yupBoolean().defined(),
25+
error_message: yupString().optional(),
26+
failed_emails_by_tenancy: yupArray(yupObject({
27+
emails: yupArray(yupObject({
28+
subject: yupString().defined(),
29+
to: yupArray(yupString().defined()).defined(),
30+
})).defined(),
31+
tenant_owner_email: yupString().defined(),
32+
project_id: yupString().defined(),
33+
tenancy_id: yupString().defined(),
34+
})).optional(),
35+
}).defined(),
36+
}),
37+
handler: async ({ headers }) => {
38+
const authHeader = headers.authorization[0];
39+
if (authHeader !== `Bearer ${getEnvVariable('CRON_SECRET')}`) {
40+
throw new StatusError(401, "Unauthorized");
41+
}
42+
43+
const failedEmailsByTenancy = await getFailedEmailsByTenancy(new Date(Date.now() - 1000 * 60 * 60 * 24));
44+
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
45+
const emailConfig = await getSharedEmailConfig("Stack Auth");
46+
const dashboardUrl = getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL", "https://app.stack-auth.com");
47+
48+
for (const failedEmailsBatch of failedEmailsByTenancy.values()) {
49+
const viewInStackAuth = `<a href="${dashboardUrl}/projects/${encodeURIComponent(failedEmailsBatch.projectId)}/emails">View all email logs on the Dashboard</a>`;
50+
const emailHtml = `
51+
<p>Thank you for using Stack Auth!</p>
52+
<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>
53+
<p>${viewInStackAuth}</p>
54+
<p>Last failing emails:</p>
55+
${failedEmailsBatch.emails.slice(-10).map((failedEmail) => {
56+
const escapedSubject = escapeHtml(failedEmail.subject).replace(/\s+/g, ' ').slice(0, 50);
57+
const escapedTo = failedEmail.to.map(to => escapeHtml(to)).join(", ");
58+
return `<div><p>Subject: ${escapedSubject}<br />To: ${escapedTo}</p></div>`;
59+
}).join("")}
60+
${failedEmailsBatch.emails.length > 10 ? `<div>...</div>` : ""}
61+
`;
62+
await sendEmail({
63+
tenancyId: internalTenancy.id,
64+
emailConfig,
65+
to: failedEmailsBatch.tenantOwnerEmail,
66+
subject: "Failed emails digest",
67+
html: emailHtml,
68+
});
69+
}
70+
71+
return {
72+
statusCode: 200,
73+
bodyType: 'json',
74+
body: {
75+
success: true,
76+
failed_emails_by_tenancy: Array.from(failedEmailsByTenancy.entries()).map(([tenancyId, batch]) => (
77+
{
78+
emails: batch.emails,
79+
tenant_owner_email: batch.tenantOwnerEmail,
80+
project_id: batch.projectId,
81+
tenancy_id: tenancyId,
82+
}
83+
),
84+
)
85+
},
86+
};
87+
},
88+
});

apps/backend/vercel.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"crons": [
3+
{
4+
"path": "/api/latest/internal/failed-emails-digest",
5+
"schedule": "0 0 * * *"
6+
}
7+
]
8+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { describe } from "vitest";
2+
import { it } from "../../../../../helpers";
3+
import { Auth, backendContext, InternalProjectKeys, niceBackendFetch, Project } from "../../../../backend-helpers";
4+
5+
describe("unauthorized requests", () => {
6+
it("should return 401 when invalid authorization is provided", async ({ expect }) => {
7+
const response = await niceBackendFetch(
8+
"/api/v1/internal/failed-emails-digest",
9+
{
10+
method: "POST",
11+
accessType: "server",
12+
headers: {
13+
"Authorization": "Bearer some_invalid_secret",
14+
}
15+
}
16+
);
17+
expect(response).toMatchInlineSnapshot(`
18+
NiceResponse {
19+
"status": 401,
20+
"body": "Unauthorized",
21+
"headers": Headers { <some fields may have been hidden> },
22+
}
23+
`);
24+
});
25+
26+
it("should return 400 when no authorization header is provided", async ({ expect }) => {
27+
const response = await niceBackendFetch(
28+
"/api/v1/internal/failed-emails-digest",
29+
{
30+
method: "POST",
31+
accessType: "server",
32+
}
33+
);
34+
expect(response.status).toBe(400);
35+
});
36+
37+
it("should return 401 when authorization header is malformed", async ({ expect }) => {
38+
const response = await niceBackendFetch(
39+
"/api/v1/internal/failed-emails-digest",
40+
{
41+
method: "POST",
42+
accessType: "server",
43+
headers: {
44+
"Authorization": "InvalidFormat",
45+
}
46+
}
47+
);
48+
expect(response).toMatchInlineSnapshot(`
49+
NiceResponse {
50+
"status": 401,
51+
"body": "Unauthorized",
52+
"headers": Headers { <some fields may have been hidden> },
53+
}
54+
`);
55+
});
56+
});
57+
58+
describe("with valid credentials", () => {
59+
it("should return 200 and process failed emails digest", async ({ expect }) => {
60+
backendContext.set({
61+
projectKeys: InternalProjectKeys,
62+
userAuth: null,
63+
});
64+
await Auth.Otp.signIn();
65+
const adminAccessToken = backendContext.value.userAuth?.accessToken;
66+
const { projectId } = await Project.create({
67+
display_name: "Test Failed Emails Project",
68+
config: {
69+
email_config: {
70+
type: "standard",
71+
host: "invalid-smtp-host.example.com",
72+
port: 587,
73+
username: "invalid_user",
74+
password: "invalid_password",
75+
sender_name: "Test Project",
76+
sender_email: "test@invalid-domain.example.com",
77+
},
78+
},
79+
});
80+
81+
backendContext.set({
82+
projectKeys: {
83+
projectId,
84+
},
85+
userAuth: null,
86+
});
87+
88+
const testEmailResponse = await niceBackendFetch("/api/v1/internal/send-test-email", {
89+
method: "POST",
90+
accessType: "admin",
91+
headers: {
92+
"x-stack-admin-access-token": adminAccessToken,
93+
},
94+
body: {
95+
"recipient_email": "test-email-recipient@stackframe.co",
96+
"email_config": {
97+
"host": "this-is-not-a-valid-host.example.com",
98+
"port": 123,
99+
"username": "123",
100+
"password": "123",
101+
"sender_email": "123@g.co",
102+
"sender_name": "123"
103+
}
104+
},
105+
});
106+
expect(testEmailResponse).toMatchInlineSnapshot(`
107+
NiceResponse {
108+
"status": 200,
109+
"body": {
110+
"error_message": "Failed to connect to the email host. Please make sure the email host configuration is correct.",
111+
"success": false,
112+
},
113+
"headers": Headers { <some fields may have been hidden> },
114+
}
115+
`);
116+
117+
const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", {
118+
method: "POST",
119+
headers: { "Authorization": "Bearer mock_cron_secret" }
120+
});
121+
expect(response.status).toBe(200);
122+
console.log(response.body);
123+
124+
const failedEmailsByTenancy = response.body.failed_emails_by_tenancy;
125+
const mockProjectFailedEmails = failedEmailsByTenancy.filter(
126+
(batch: any) => batch.tenant_owner_email === backendContext.value.mailbox.emailAddress
127+
);
128+
expect(mockProjectFailedEmails).toMatchInlineSnapshot(`
129+
[
130+
{
131+
"emails": [
132+
{
133+
"subject": "Test Email from Stack Auth",
134+
"to": ["test-email-recipient@stackframe.co"],
135+
},
136+
],
137+
"project_id": "<stripped UUID>",
138+
"tenancy_id": "<stripped UUID>",
139+
"tenant_owner_email": "default-mailbox--<stripped UUID>@stack-generated.example.com",
140+
},
141+
]
142+
`);
143+
144+
const messages = await backendContext.value.mailbox.fetchMessages();
145+
const digestEmail = messages.find(msg => msg.subject === "Failed emails digest");
146+
expect(digestEmail).toBeDefined();
147+
expect(digestEmail!.from).toBe("Stack Auth <noreply@example.com>");
148+
});
149+
150+
it("should return 200 and not send digest email when all emails are successful", async ({ expect }) => {
151+
await Auth.Otp.signIn();
152+
const { projectId } = await Project.create({
153+
display_name: "Test Successful Emails Project",
154+
config: {
155+
email_config: {
156+
type: "standard",
157+
host: "localhost",
158+
port: 2500,
159+
username: "test",
160+
password: "test",
161+
sender_name: "Test Project",
162+
sender_email: "test@example.com",
163+
},
164+
},
165+
});
166+
167+
const response = await niceBackendFetch("/api/v1/internal/failed-emails-digest", {
168+
method: "POST",
169+
headers: { "Authorization": "Bearer mock_cron_secret" }
170+
});
171+
expect(response.status).toBe(200);
172+
173+
const failedEmailsByTenancy = response.body.failed_emails_by_tenancy;
174+
const mockProjectFailedEmails = failedEmailsByTenancy.filter(
175+
(batch: any) => batch.tenant_owner_email === backendContext.value.mailbox.emailAddress
176+
);
177+
expect(mockProjectFailedEmails).toMatchInlineSnapshot(`[]`);
178+
179+
const messages = await backendContext.value.mailbox.fetchMessages();
180+
const digestEmail = messages.find(msg => msg.subject === "Failed emails digest");
181+
expect(digestEmail).toBeUndefined();
182+
});
183+
});

0 commit comments

Comments
 (0)