From 726080994ad0e7d1761f7f57d01b283ea8375492 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 30 Jun 2026 15:29:10 -0500 Subject: [PATCH 1/2] feat(emails): log every POST /api/emails attempt to email_send_log (7-day observability) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an empty/footer-only email went out we could not recover what the API received: safeParseJson discards malformed bodies, the route logged nothing, and the sandbox that built the request is ephemeral. This records every attempt — sent, send_failed, and rejected (empty/malformed) — so a send is debuggable days later. - lib/supabase/email_send_log/insertEmailSendLog.ts: typed insert. - lib/emails/logEmailAttempt.ts: builds the row (computes body_parsed, truncates raw_body to 10k); best-effort — never throws, so logging can't break a send. - sendEmailHandler: capture the raw body via request.clone() and log on every outcome (rejected attempts keep the raw body, so a malformed send is visible). - types/database.types.ts: email_send_log added so the typed insert compiles. The migration lives in the recoupable/database repo (mono/database) — applied via its CI — not here. Tests: logEmailAttempt unit (parsed/malformed/truncation/swallow-errors) + handler asserts the sent/rejected/send_failed log calls. Full lib/emails: 150. Ref: recoupable/chat#1829 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/emails/__tests__/logEmailAttempt.test.ts | 56 ++++++++++++++++++ lib/emails/__tests__/sendEmailHandler.test.ts | 14 +++++ lib/emails/logEmailAttempt.ts | 59 +++++++++++++++++++ lib/emails/sendEmailHandler.ts | 40 +++++++++++++ .../email_send_log/insertEmailSendLog.ts | 16 +++++ types/database.types.ts | 48 +++++++++++++++ 6 files changed, 233 insertions(+) create mode 100644 lib/emails/__tests__/logEmailAttempt.test.ts create mode 100644 lib/emails/logEmailAttempt.ts create mode 100644 lib/supabase/email_send_log/insertEmailSendLog.ts 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/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 { 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; From 5ac5c578a372e674a945a81e2d09067fc98ef987 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 30 Jun 2026 15:16:17 -0500 Subject: [PATCH 2/2] feat(emails): hasDeliveredEmail building block for the verify-the-send guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads email_send_log to answer "did this run actually deliver a non-empty email?" — the check a report task needs so it can fail/flag instead of reporting a silent success when the agent never sent (the observed 30% no-send case). Best-effort (returns false on error). Stacked on the email_send_log PR. This PR intentionally ships ONLY the verifiable building block + tests; the enforcement point (where/whether to fail vs warn, and how to detect that a task was "supposed to email") is the open design question — see PR body. Ref: recoupable/chat#1829 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/hasDeliveredEmail.test.ts | 23 +++++++++++++++++++ lib/emails/hasDeliveredEmail.ts | 14 +++++++++++ .../email_send_log/countDeliveredEmails.ts | 20 ++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 lib/emails/__tests__/hasDeliveredEmail.test.ts create mode 100644 lib/emails/hasDeliveredEmail.ts create mode 100644 lib/supabase/email_send_log/countDeliveredEmails.ts 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/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/supabase/email_send_log/countDeliveredEmails.ts b/lib/supabase/email_send_log/countDeliveredEmails.ts new file mode 100644 index 00000000..39a86ac3 --- /dev/null +++ b/lib/supabase/email_send_log/countDeliveredEmails.ts @@ -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 { + 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; +}