diff --git a/lib/emails/__tests__/hasDeliveredEmail.test.ts b/lib/emails/__tests__/hasDeliveredEmail.test.ts
new file mode 100644
index 00000000..c62f34c9
--- /dev/null
+++ b/lib/emails/__tests__/hasDeliveredEmail.test.ts
@@ -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);
+ });
+});
diff --git a/lib/emails/__tests__/logEmailAttempt.test.ts b/lib/emails/__tests__/logEmailAttempt.test.ts
new file mode 100644
index 00000000..a352a9e3
--- /dev/null
+++ b/lib/emails/__tests__/logEmailAttempt.test.ts
@@ -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: "
hi
" }),
+ status: "sent",
+ accountId: "acc-1",
+ to: ["a@b.com"],
+ subject: "S",
+ html: "hi
",
+ 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("hi
".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();
+ });
+});
diff --git a/lib/emails/__tests__/sendEmailHandler.test.ts b/lib/emails/__tests__/sendEmailHandler.test.ts
index 3b5fa6e8..03610bb1 100644
--- a/lib/emails/__tests__/sendEmailHandler.test.ts
+++ b/lib/emails/__tests__/sendEmailHandler.test.ts
@@ -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),
@@ -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": "*" })),
}));
@@ -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 () => {
@@ -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 () => {
@@ -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" }),
+ );
});
});
diff --git a/lib/emails/hasDeliveredEmail.ts b/lib/emails/hasDeliveredEmail.ts
new file mode 100644
index 00000000..86d86856
--- /dev/null
+++ b/lib/emails/hasDeliveredEmail.ts
@@ -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 {
+ return (await countDeliveredEmails(chatId)) > 0;
+}
diff --git a/lib/emails/logEmailAttempt.ts b/lib/emails/logEmailAttempt.ts
new file mode 100644
index 00000000..969d2ea0
--- /dev/null
+++ b/lib/emails/logEmailAttempt.ts
@@ -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 {
+ 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.
+ }
+}
diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts
index 8eac17e5..6a884db9 100644
--- a/lib/emails/sendEmailHandler.ts
+++ b/lib/emails/sendEmailHandler.ts
@@ -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 {
+ try {
+ return await request.clone().text();
+ } catch {
+ return "";
+ }
+}
/**
* Handler for POST /api/emails.
@@ -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 {
+ const rawBody = await readRawBody(request);
+
const validated = await validateSendEmailBody(request);
if (validated instanceof NextResponse) {
+ await logEmailAttempt({ rawBody, status: "rejected" });
return validated;
}
@@ -35,12 +52,35 @@ export async function sendEmailHandler(request: NextRequest): Promise {
+ 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;
+}
diff --git a/lib/supabase/email_send_log/insertEmailSendLog.ts b/lib/supabase/email_send_log/insertEmailSendLog.ts
new file mode 100644
index 00000000..cb16241a
--- /dev/null
+++ b/lib/supabase/email_send_log/insertEmailSendLog.ts
@@ -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 };
+}
diff --git a/types/database.types.ts b/types/database.types.ts
index 872750b7..3e15dda8 100644
--- a/types/database.types.ts
+++ b/types/database.types.ts
@@ -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;