diff --git a/lib/emails/__tests__/sendEmailHandler.test.ts b/lib/emails/__tests__/sendEmailHandler.test.ts index 8bc132c1..37c74386 100644 --- a/lib/emails/__tests__/sendEmailHandler.test.ts +++ b/lib/emails/__tests__/sendEmailHandler.test.ts @@ -104,4 +104,34 @@ describe("sendEmailHandler", () => { expect.objectContaining({ status: "send_failed" }), ); }); + // sendEmailHandler dropped the account on the rejected branch, so every + // account-scoped email_send_log audit under-reported: the six rejections in + // the 2026-07-27 scheduled-run incident were findable only by filtering + // raw_body on the recipient (chat#1889 row 26). + it("records the account on a rejected attempt when validation resolved one", async () => { + mockValidateSendEmailBody.mockResolvedValue({ + rawBody: '{"account_id":"account-123"}', + accountId: "account-123", + error: NextResponse.json({ status: "error" }, { status: 403 }), + }); + + await sendEmailHandler(createRequest()); + + expect(mockLogEmailAttempt).toHaveBeenCalledWith( + expect.objectContaining({ status: "rejected", accountId: "account-123" }), + ); + }); + + it("still logs a rejected attempt when no account could be resolved", async () => { + mockValidateSendEmailBody.mockResolvedValue({ + rawBody: "{}", + error: NextResponse.json({ status: "error" }, { status: 400 }), + }); + + await sendEmailHandler(createRequest()); + + expect(mockLogEmailAttempt).toHaveBeenCalledWith( + expect.objectContaining({ status: "rejected", accountId: undefined }), + ); + }); }); diff --git a/lib/emails/__tests__/validateSendEmailBody.test.ts b/lib/emails/__tests__/validateSendEmailBody.test.ts index 07b0f512..a9881465 100644 --- a/lib/emails/__tests__/validateSendEmailBody.test.ts +++ b/lib/emails/__tests__/validateSendEmailBody.test.ts @@ -62,6 +62,22 @@ describe("validateSendEmailBody", () => { } }); + // Without the account on the rejected result, an account-scoped + // email_send_log audit misses this send entirely (chat#1889 row 26). + it("carries the authenticated account on the rejected result", async () => { + mockAssertRecipientsAllowed.mockResolvedValue({ + allowed: false, + disallowed: ["stranger@example.com"], + }); + const request = createRequest( + { to: ["stranger@example.com"], subject: "Hi", text: "body" }, + { "x-api-key": "test-api-key" }, + ); + const result = await validateSendEmailBody(request); + + expect("error" in result && result.accountId).toBe("account-123"); + }); + it("checks to + cc together against the authenticated account", async () => { const request = createRequest( { to: ["a@example.com"], cc: ["b@example.com"], subject: "Hi", text: "body" }, diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts index 6fcc3ab5..e5aa86aa 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -56,7 +56,11 @@ export async function sendEmailHandler(request: NextRequest): Promise & * Validation outcome. Always carries `rawBody` — the request body as received — * so the handler can record it in `email_send_log` on both the rejected and the * accepted paths without re-reading the request. + * + * The rejected branch also carries `accountId` whenever one could be resolved, + * so an account-scoped `email_send_log` audit sees failures and not just + * successes. **It is the best-known account, not proof of authorization:** it is + * the authenticated account when auth succeeded and the request was rejected + * later (no recipient on file, recipient restriction), but the account the body + * *claimed* when auth itself failed — which is the case that matters, since an + * expired credential is exactly how a legitimate account's sends go missing from + * the audit (chat#1889). Never read it as evidence a caller held the account. */ export type ValidateSendEmailResult = - | { rawBody: string; error: NextResponse } + | { rawBody: string; accountId?: string; error: NextResponse } | { rawBody: string; data: ValidatedSendEmailRequest }; /** @@ -83,7 +92,7 @@ export async function validateSendEmailBody( const authContext = await validateAuthContext(request, { accountId: result.data.account_id }); if (authContext instanceof NextResponse) { - return { rawBody, error: authContext }; + return { rawBody, accountId: result.data.account_id, error: authContext }; } let to = result.data.to ?? []; @@ -93,6 +102,7 @@ export async function validateSendEmailBody( if (to.length === 0) { return { rawBody, + accountId: authContext.accountId, error: NextResponse.json( { status: "error", error: "No email address found for the authenticated account." }, { status: 400, headers: getCorsHeaders() }, @@ -108,6 +118,7 @@ export async function validateSendEmailBody( if (recipientCheck.allowed === false) { return { rawBody, + accountId: authContext.accountId, error: NextResponse.json( { status: "error",