Skip to content
Open
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
30 changes: 30 additions & 0 deletions lib/emails/__tests__/sendEmailHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
);
});
});
16 changes: 16 additions & 0 deletions lib/emails/__tests__/validateSendEmailBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
6 changes: 5 additions & 1 deletion lib/emails/sendEmailHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ export async function sendEmailHandler(request: NextRequest): Promise<NextRespon
}
} else {
response = validated.error;
attempt = { status: "rejected" };
// Carry the account through: dropping it here made every account-scoped
// email_send_log audit under-report, so the six rejections in the
// 2026-07-27 scheduled-run incident were findable only by filtering
// raw_body on the recipient (chat#1889).
attempt = { status: "rejected", accountId: validated.accountId };
}

await logEmailAttempt({ rawBody: validated.rawBody, ...attempt });
Expand Down
15 changes: 13 additions & 2 deletions lib/emails/validateSendEmailBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,18 @@ export type ValidatedSendEmailRequest = Omit<SendEmailBody, "to" | "subject"> &
* 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 };

/**
Expand Down Expand Up @@ -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 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Reduce oversized domain functions.

Both changed areas are inside functions that exceed the repository’s function-size limits:

  • lib/emails/validateSendEmailBody.ts#L95-L95,105-105,121-121: extract parsing, authentication, recipient resolution, and recipient checks.
  • lib/emails/sendEmailHandler.ts#L59-L63: extract send-result or audit-attempt construction.

As per coding guidelines, functions longer than 20 lines must be flagged. As per path instructions, domain functions must remain under 50 lines.

📍 Affects 2 files
  • lib/emails/validateSendEmailBody.ts#L95-L95 (this comment)
  • lib/emails/sendEmailHandler.ts#L59-L63
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/emails/validateSendEmailBody.ts` at line 95, Reduce oversized domain
functions by extracting cohesive helpers from validateSendEmailBody for parsing,
authentication, recipient resolution, and recipient validation, while preserving
existing behavior; also extract send-result or audit-attempt construction from
sendEmailHandler. Apply the changes in lib/emails/validateSendEmailBody.ts at
lines 95-95 and lib/emails/sendEmailHandler.ts at lines 59-63, keeping both
domain functions under the repository’s size limits.

Sources: Coding guidelines, Path instructions

}

let to = result.data.to ?? [];
Expand All @@ -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() },
Expand All @@ -108,6 +118,7 @@ export async function validateSendEmailBody(
if (recipientCheck.allowed === false) {
return {
rawBody,
accountId: authContext.accountId,
error: NextResponse.json(
{
status: "error",
Expand Down
Loading