Skip to content
Closed
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
23 changes: 23 additions & 0 deletions lib/emails/__tests__/hasDeliveredEmail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { hasDeliveredEmail } from "../hasDeliveredEmail";

const mockCount = vi.fn();
vi.mock("@/lib/supabase/email_send_log/countDeliveredEmails", () => ({
countDeliveredEmails: (...args: unknown[]) => mockCount(...args),
}));

describe("hasDeliveredEmail", () => {
beforeEach(() => vi.clearAllMocks());

it("returns true when at least one email was delivered", async () => {
mockCount.mockResolvedValue(2);
expect(await hasDeliveredEmail("chat-1")).toBe(true);
expect(mockCount).toHaveBeenCalledWith("chat-1");
});

it("returns false when none were delivered", async () => {
mockCount.mockResolvedValue(0);
expect(await hasDeliveredEmail("chat-1")).toBe(false);
});
});
56 changes: 56 additions & 0 deletions lib/emails/__tests__/logEmailAttempt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { logEmailAttempt } from "../logEmailAttempt";

const mockInsert = vi.fn();
vi.mock("@/lib/supabase/email_send_log/insertEmailSendLog", () => ({
insertEmailSendLog: (...args: unknown[]) => mockInsert(...args),
}));

describe("logEmailAttempt", () => {
beforeEach(() => {
vi.clearAllMocks();
mockInsert.mockResolvedValue({ error: null });
});

it("records a sent attempt with parsed body, lengths, and resend id", async () => {
await logEmailAttempt({
rawBody: JSON.stringify({ to: ["a@b.com"], html: "<p>hi</p>" }),
status: "sent",
accountId: "acc-1",
to: ["a@b.com"],
subject: "S",
html: "<p>hi</p>",
chatId: "chat-1",
resendId: "re_123",
});
expect(mockInsert).toHaveBeenCalledTimes(1);
const row = mockInsert.mock.calls[0][0];
expect(row.status).toBe("sent");
expect(row.body_parsed).toBe(true);
expect(row.account_id).toBe("acc-1");
expect(row.to_count).toBe(1);
expect(row.html_length).toBe("<p>hi</p>".length);
expect(row.resend_id).toBe("re_123");
expect(row.chat_id).toBe("chat-1");
});

it("marks body_parsed false for a malformed body and still logs", async () => {
await logEmailAttempt({ rawBody: '{"to":[', status: "rejected" });
const row = mockInsert.mock.calls[0][0];
expect(row.body_parsed).toBe(false);
expect(row.status).toBe("rejected");
expect(row.account_id).toBeNull();
});

it("truncates a large raw body to 10000 chars", async () => {
await logEmailAttempt({ rawBody: "x".repeat(20000), status: "rejected" });
const row = mockInsert.mock.calls[0][0];
expect(row.raw_body.length).toBe(10000);
});

it("never throws when the insert fails", async () => {
mockInsert.mockRejectedValue(new Error("db down"));
await expect(logEmailAttempt({ rawBody: "{}", status: "rejected" })).resolves.toBeUndefined();
});
});
14 changes: 14 additions & 0 deletions lib/emails/__tests__/sendEmailHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { sendEmailHandler } from "../sendEmailHandler";

const mockValidateSendEmailBody = vi.fn();
const mockProcessAndSendEmail = vi.fn();
const mockLogEmailAttempt = vi.fn();

vi.mock("@/lib/emails/validateSendEmailBody", () => ({
validateSendEmailBody: (...args: unknown[]) => mockValidateSendEmailBody(...args),
Expand All @@ -13,6 +14,10 @@ vi.mock("@/lib/emails/processAndSendEmail", () => ({
processAndSendEmail: (...args: unknown[]) => mockProcessAndSendEmail(...args),
}));

vi.mock("@/lib/emails/logEmailAttempt", () => ({
logEmailAttempt: (...args: unknown[]) => mockLogEmailAttempt(...args),
}));

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));
Expand Down Expand Up @@ -60,6 +65,9 @@ describe("sendEmailHandler", () => {
room_id: "chat-1",
}),
);
expect(mockLogEmailAttempt).toHaveBeenCalledWith(
expect.objectContaining({ status: "sent", resendId: "resend-id-1" }),
);
});

it("propagates the NextResponse from validateSendEmailBody (auth/validation/recipient errors)", async () => {
Expand All @@ -69,6 +77,9 @@ describe("sendEmailHandler", () => {
const response = await sendEmailHandler(createRequest());
expect(response.status).toBe(403);
expect(mockProcessAndSendEmail).not.toHaveBeenCalled();
expect(mockLogEmailAttempt).toHaveBeenCalledWith(
expect.objectContaining({ status: "rejected" }),
);
});

it("returns 502 when Resend delivery fails", async () => {
Expand All @@ -77,5 +88,8 @@ describe("sendEmailHandler", () => {
expect(response.status).toBe(502);
const json = await response.json();
expect(json.error).toBe("resend boom");
expect(mockLogEmailAttempt).toHaveBeenCalledWith(
expect.objectContaining({ status: "send_failed", error: "resend boom" }),
);
});
});
14 changes: 14 additions & 0 deletions lib/emails/hasDeliveredEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { countDeliveredEmails } from "@/lib/supabase/email_send_log/countDeliveredEmails";

/**
* Whether a chat/run delivered at least one non-empty email (per
* `email_send_log`). The building block for the "verify-the-send" guarantee:
* a report task that completes without a delivered email should be flagged
* rather than reported as a silent success.
*
* @param chatId - The chat/run id.
* @returns true if ≥1 'sent' email is logged for the chat.
*/
export async function hasDeliveredEmail(chatId: string): Promise<boolean> {
return (await countDeliveredEmails(chatId)) > 0;
}
59 changes: 59 additions & 0 deletions lib/emails/logEmailAttempt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { insertEmailSendLog } from "@/lib/supabase/email_send_log/insertEmailSendLog";

/** Cap the stored raw body so a 12KB+ HTML report doesn't bloat the log row. */
const MAX_RAW_BODY = 10000;

export type EmailAttemptStatus = "sent" | "send_failed" | "rejected";

export type EmailAttemptLog = {
/** The raw request body, as received (used to record body_parsed + a truncated copy). */
rawBody: string;
status: EmailAttemptStatus;
accountId?: string | null;
to?: string[];
subject?: string;
html?: string;
text?: string;
chatId?: string;
resendId?: string;
error?: string;
};

/**
* Records one POST /api/emails attempt in `email_send_log` so a send can be
* debugged days later without the (ephemeral) sandbox — capturing whether the
* body parsed, a truncated copy of it, the resolved fields, and the outcome.
*
* Best-effort: this never throws. A logging failure must not affect the send.
*
* @param attempt - The attempt to record.
*/
export async function logEmailAttempt(attempt: EmailAttemptLog): Promise<void> {
let bodyParsed = false;
try {
if (attempt.rawBody && attempt.rawBody.trim()) {
JSON.parse(attempt.rawBody);
bodyParsed = true;
}
} catch {
bodyParsed = false;
}

try {
await insertEmailSendLog({
account_id: attempt.accountId ?? null,
chat_id: attempt.chatId ?? null,
status: attempt.status,
body_parsed: bodyParsed,
raw_body: attempt.rawBody ? attempt.rawBody.slice(0, MAX_RAW_BODY) : null,
to_count: attempt.to?.length ?? null,
subject: attempt.subject ?? null,
html_length: attempt.html?.length ?? null,
text_length: attempt.text?.length ?? null,
resend_id: attempt.resendId ?? null,
error: attempt.error ?? null,
});
} catch {
// Best-effort logging — swallow so the email send result is unaffected.
}
}
40 changes: 40 additions & 0 deletions lib/emails/sendEmailHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateSendEmailBody } from "@/lib/emails/validateSendEmailBody";
import { processAndSendEmail } from "@/lib/emails/processAndSendEmail";
import { logEmailAttempt } from "@/lib/emails/logEmailAttempt";

/** Read the raw request body without consuming it (validation reads the original). */
async function readRawBody(request: NextRequest): Promise<string> {
try {
return await request.clone().text();
} catch {
return "";
}
}

/**
* Handler for POST /api/emails.
Expand All @@ -13,12 +23,19 @@ import { processAndSendEmail } from "@/lib/emails/processAndSendEmail";
* Body validation, auth, and the recipient restriction all live in
* `validateSendEmailBody`.
*
* Every attempt — sent, send-failed, and rejected (e.g. an empty/malformed
* body) — is recorded in `email_send_log` via `logEmailAttempt`, so a send can
* be debugged days later without the ephemeral sandbox that built the request.
*
* @param request - The request object.
* @returns A NextResponse with the send result.
*/
export async function sendEmailHandler(request: NextRequest): Promise<NextResponse> {
const rawBody = await readRawBody(request);

const validated = await validateSendEmailBody(request);
if (validated instanceof NextResponse) {
await logEmailAttempt({ rawBody, status: "rejected" });
return validated;
}

Expand All @@ -35,12 +52,35 @@ export async function sendEmailHandler(request: NextRequest): Promise<NextRespon
});

if (result.success === false) {
await logEmailAttempt({
rawBody,
status: "send_failed",
accountId: validated.accountId,
to,
subject,
html,
text,
chatId: chat_id,
error: result.error,
});
return NextResponse.json(
{ status: "error", error: result.error },
{ status: 502, headers: getCorsHeaders() },
);
}

await logEmailAttempt({
rawBody,
status: "sent",
accountId: validated.accountId,
to,
subject,
html,
text,
chatId: chat_id,
resendId: result.id,
});

return NextResponse.json(
{ success: true, message: result.message, id: result.id },
{ status: 200, headers: getCorsHeaders() },
Expand Down
20 changes: 20 additions & 0 deletions lib/supabase/email_send_log/countDeliveredEmails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import supabase from "../serverClient";

/**
* Counts the **delivered, non-empty** emails recorded for a chat/run in
* `email_send_log` (status = 'sent'). Used to verify that a task which was
* supposed to email actually delivered one. Returns 0 on error.
*
* @param chatId - The chat/run id (email_send_log.chat_id).
* @returns The number of 'sent' rows for the chat.
*/
export async function countDeliveredEmails(chatId: string): Promise<number> {
const { count, error } = await supabase
.from("email_send_log")
.select("id", { count: "exact", head: true })
.eq("chat_id", chatId)
.eq("status", "sent");

if (error) return 0;
return count ?? 0;
}
16 changes: 16 additions & 0 deletions lib/supabase/email_send_log/insertEmailSendLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import supabase from "../serverClient";
import type { Database } from "@/types/database.types";

/**
* Inserts one row into `email_send_log`. Best-effort: callers should not let a
* logging failure affect the email send, so errors are returned, never thrown.
*
* @param row - The email_send_log insert payload.
* @returns `{ error }` — null on success.
*/
export async function insertEmailSendLog(
row: Database["public"]["Tables"]["email_send_log"]["Insert"],
): Promise<{ error: unknown }> {
const { error } = await supabase.from("email_send_log").insert(row);
return { error };
}
48 changes: 48 additions & 0 deletions types/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,54 @@ export type Database = {
};
public: {
Tables: {
email_send_log: {
Row: {
account_id: string | null;
body_parsed: boolean;
chat_id: string | null;
created_at: string;
error: string | null;
html_length: number | null;
id: string;
raw_body: string | null;
resend_id: string | null;
status: string;
subject: string | null;
text_length: number | null;
to_count: number | null;
};
Insert: {
account_id?: string | null;
body_parsed?: boolean;
chat_id?: string | null;
created_at?: string;
error?: string | null;
html_length?: number | null;
id?: string;
raw_body?: string | null;
resend_id?: string | null;
status: string;
subject?: string | null;
text_length?: number | null;
to_count?: number | null;
};
Update: {
account_id?: string | null;
body_parsed?: boolean;
chat_id?: string | null;
created_at?: string;
error?: string | null;
html_length?: number | null;
id?: string;
raw_body?: string | null;
resend_id?: string | null;
status?: string;
subject?: string | null;
text_length?: number | null;
to_count?: number | null;
};
Relationships: [];
};
account_api_keys: {
Row: {
account: string | null;
Expand Down
Loading