diff --git a/lib/emails/__tests__/logEmailAttempt.test.ts b/lib/emails/__tests__/logEmailAttempt.test.ts new file mode 100644 index 000000000..4026da1e5 --- /dev/null +++ b/lib/emails/__tests__/logEmailAttempt.test.ts @@ -0,0 +1,57 @@ +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", () => { + let errorSpy: ReturnType; + beforeEach(() => { + vi.clearAllMocks(); + mockInsert.mockResolvedValue({ error: null }); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("records account, chat, status, resend id, and the raw body", async () => { + const raw = JSON.stringify({ html: "

hi

" }); + await logEmailAttempt({ + rawBody: raw, + status: "sent", + accountId: "acc-1", + chatId: "chat-1", + resendId: "re_123", + }); + expect(mockInsert).toHaveBeenCalledTimes(1); + const row = mockInsert.mock.calls[0][0]; + expect(row.status).toBe("sent"); + expect(row.account_id).toBe("acc-1"); + expect(row.chat_id).toBe("chat-1"); + expect(row.resend_id).toBe("re_123"); + expect(row.raw_body).toBe(raw); + }); + + it("stores the full raw body (no truncation)", async () => { + await logEmailAttempt({ rawBody: "x".repeat(20000), status: "rejected" }); + expect(mockInsert.mock.calls[0][0].raw_body.length).toBe(20000); + }); + + it("nulls optional ids and never throws when the insert fails", async () => { + mockInsert.mockRejectedValue(new Error("db down")); + await expect(logEmailAttempt({ rawBody: "{}", status: "rejected" })).resolves.toBeUndefined(); + const row = mockInsert.mock.calls[0][0]; + expect(row.account_id).toBeNull(); + expect(row.chat_id).toBeNull(); + expect(errorSpy).toHaveBeenCalled(); + }); + + it("surfaces a returned insert error to the server logs", async () => { + mockInsert.mockResolvedValue({ error: { message: "rls denied" } }); + await logEmailAttempt({ rawBody: "{}", status: "sent" }); + expect(errorSpy).toHaveBeenCalledWith("email_send_log insert failed:", { + message: "rls denied", + }); + }); +}); diff --git a/lib/emails/__tests__/sendEmailHandler.test.ts b/lib/emails/__tests__/sendEmailHandler.test.ts index 3b5fa6e88..8bc132c14 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": "*" })), })); @@ -29,12 +34,15 @@ describe("sendEmailHandler", () => { beforeEach(() => { vi.clearAllMocks(); mockValidateSendEmailBody.mockResolvedValue({ - to: ["dest@example.com"], - cc: ["cc@example.com"], - subject: "Weekly report", - text: "body", - chat_id: "chat-1", - accountId: "account-123", + rawBody: '{"subject":"Weekly report"}', + data: { + to: ["dest@example.com"], + cc: ["cc@example.com"], + subject: "Weekly report", + text: "body", + chat_id: "chat-1", + accountId: "account-123", + }, }); mockProcessAndSendEmail.mockResolvedValue({ success: true, @@ -60,15 +68,30 @@ describe("sendEmailHandler", () => { room_id: "chat-1", }), ); + // Single call, on every path (DRY); rawBody comes from validateSendEmailBody (SRP). + expect(mockLogEmailAttempt).toHaveBeenCalledTimes(1); + expect(mockLogEmailAttempt).toHaveBeenCalledWith( + expect.objectContaining({ + status: "sent", + resendId: "resend-id-1", + rawBody: '{"subject":"Weekly report"}', + accountId: "account-123", + chatId: "chat-1", + }), + ); }); it("propagates the NextResponse from validateSendEmailBody (auth/validation/recipient errors)", async () => { - mockValidateSendEmailBody.mockResolvedValue( - NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }), - ); + mockValidateSendEmailBody.mockResolvedValue({ + rawBody: "{}", + error: NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }), + }); 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 +100,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" }), + ); }); }); diff --git a/lib/emails/__tests__/validateSendEmailBody.test.ts b/lib/emails/__tests__/validateSendEmailBody.test.ts index a53a2c56e..9fdb86ee2 100644 --- a/lib/emails/__tests__/validateSendEmailBody.test.ts +++ b/lib/emails/__tests__/validateSendEmailBody.test.ts @@ -22,10 +22,6 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/networking/safeParseJson", () => ({ - safeParseJson: vi.fn(async (req: Request) => req.json()), -})); - function createRequest(body: unknown, headers: Record = {}): NextRequest { return new NextRequest("http://localhost/api/emails", { method: "POST", @@ -58,10 +54,10 @@ describe("validateSendEmailBody", () => { ); const result = await validateSendEmailBody(request); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(403); - const json = await result.json(); + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error.status).toBe(403); + const json = await result.error.json(); expect(json.disallowed_recipients).toEqual(["stranger@example.com"]); } }); @@ -87,18 +83,18 @@ describe("validateSendEmailBody", () => { { "x-api-key": "k" }, ); const result = await validateSendEmailBody(request); - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.subject).toBe("Pulse Report"); + expect("data" in result).toBe(true); + if ("data" in result) { + expect(result.data.subject).toBe("Pulse Report"); } }); it("defaults to `Message from Recoup` when subject and body are empty", async () => { const request = createRequest({ to: ["d@example.com"] }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.subject).toBe("Message from Recoup"); + expect("data" in result).toBe(true); + if ("data" in result) { + expect(result.data.subject).toBe("Message from Recoup"); } }); }); @@ -111,12 +107,12 @@ describe("validateSendEmailBody", () => { ); const result = await validateSendEmailBody(request); - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.to).toEqual(["dest@example.com"]); - expect(result.subject).toBe("Hi"); - expect(result.text).toBe("Hello world"); - expect(result.accountId).toBe("account-123"); + expect("data" in result).toBe(true); + if ("data" in result) { + expect(result.data.to).toEqual(["dest@example.com"]); + expect(result.data.subject).toBe("Hi"); + expect(result.data.text).toBe("Hello world"); + expect(result.data.accountId).toBe("account-123"); } }); @@ -135,13 +131,13 @@ describe("validateSendEmailBody", () => { ); const result = await validateSendEmailBody(request); - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.to).toEqual(["a@example.com", "b@example.com"]); - expect(result.cc).toEqual(["cc@example.com"]); - expect(result.html).toBe("

h

"); - expect(result.headers).toEqual({ "X-Custom": "v" }); - expect(result.chat_id).toBe("chat-1"); + expect("data" in result).toBe(true); + if ("data" in result) { + expect(result.data.to).toEqual(["a@example.com", "b@example.com"]); + expect(result.data.cc).toEqual(["cc@example.com"]); + expect(result.data.html).toBe("

h

"); + expect(result.data.headers).toEqual({ "X-Custom": "v" }); + expect(result.data.chat_id).toBe("chat-1"); } }); @@ -163,9 +159,9 @@ describe("validateSendEmailBody", () => { const request = createRequest({ subject: "s", text: "hi" }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.to).toEqual(["owner@example.com"]); + expect("data" in result).toBe(true); + if ("data" in result) { + expect(result.data.to).toEqual(["owner@example.com"]); } expect(mockSelectAccountEmails).toHaveBeenCalledWith({ accountIds: "account-123" }); }); @@ -178,9 +174,9 @@ describe("validateSendEmailBody", () => { const request = createRequest({ subject: "s" }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.to).toEqual(["owner@example.com", "owner.alt@example.com"]); + expect("data" in result).toBe(true); + if ("data" in result) { + expect(result.data.to).toEqual(["owner@example.com", "owner.alt@example.com"]); } }); @@ -188,8 +184,8 @@ describe("validateSendEmailBody", () => { mockSelectAccountEmails.mockResolvedValue([]); const request = createRequest({ subject: "s" }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) expect(result.status).toBe(400); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.error.status).toBe(400); }); it("does not resolve account emails when 'to' is provided", async () => { @@ -206,15 +202,15 @@ describe("validateSendEmailBody", () => { it("rejects an empty 'to' array", async () => { const request = createRequest({ to: [], subject: "s" }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) expect(result.status).toBe(400); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.error.status).toBe(400); }); it("rejects a non-email recipient", async () => { const request = createRequest({ to: ["not-an-email"], subject: "s" }, { "x-api-key": "k" }); const result = await validateSendEmailBody(request); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) expect(result.status).toBe(400); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.error.status).toBe(400); }); // subject is now optional (defaults from the body) — covered by "subject defaulting" above. @@ -227,8 +223,8 @@ describe("validateSendEmailBody", () => { ); const request = createRequest({ to: ["d@example.com"], subject: "s" }); const result = await validateSendEmailBody(request); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) expect(result.status).toBe(401); + expect("error" in result).toBe(true); + if ("error" in result) expect(result.error.status).toBe(401); }); }); }); diff --git a/lib/emails/logEmailAttempt.ts b/lib/emails/logEmailAttempt.ts new file mode 100644 index 000000000..14c13f0c3 --- /dev/null +++ b/lib/emails/logEmailAttempt.ts @@ -0,0 +1,38 @@ +import { insertEmailSendLog } from "@/lib/supabase/email_send_log/insertEmailSendLog"; + +export type EmailAttemptStatus = "sent" | "send_failed" | "rejected"; + +export type EmailAttemptLog = { + /** The request body as received (stored in full; "" when empty/unreadable). */ + rawBody: string; + status: EmailAttemptStatus; + accountId?: string | null; + chatId?: string | null; + resendId?: string; +}; + +/** + * Records one POST /api/emails attempt in `email_send_log` so a send can be + * debugged days later without the (ephemeral) sandbox. + * + * Best-effort: never throws (a logging failure must not affect the send), but a + * failed write IS surfaced to server logs so observability gaps are visible. + * + * @param attempt - The attempt to record. + */ +export async function logEmailAttempt(attempt: EmailAttemptLog): Promise { + try { + const { error } = await insertEmailSendLog({ + account_id: attempt.accountId ?? null, + chat_id: attempt.chatId ?? null, + status: attempt.status, + resend_id: attempt.resendId ?? null, + raw_body: attempt.rawBody, + }); + if (error) { + console.error("email_send_log insert failed:", error); + } + } catch (err) { + console.error("logEmailAttempt threw (swallowed):", err); + } +} diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts index 8eac17e53..6fcc3ab51 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -2,47 +2,63 @@ 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, type EmailAttemptLog } from "@/lib/emails/logEmailAttempt"; /** - * Handler for POST /api/emails. + * Handler for POST /api/emails. Auth + body validation + the recipient + * restriction live in `validateSendEmailBody` (which also returns the raw body). + * Here we send the validated email via Resend, shape the response, and record + * the attempt. * - * Sends an email to the explicit recipients in the request body via Resend - * (from `Agent by Recoup `), reusing the same - * `processAndSendEmail` domain function as the `send_email` MCP tool. - * Account-scoped: requires authentication via x-api-key or Authorization Bearer. - * Body validation, auth, and the recipient restriction all live in - * `validateSendEmailBody`. - * - * @param request - The request object. - * @returns A NextResponse with the send result. + * Every outcome — sent, send_failed, rejected — is resolved into a single + * `{ response, attempt }` and logged with ONE `logEmailAttempt` call, so a send + * is debuggable days later without the ephemeral sandbox. */ export async function sendEmailHandler(request: NextRequest): Promise { const validated = await validateSendEmailBody(request); - if (validated instanceof NextResponse) { - return validated; - } - const { to, cc = [], subject, text, html = "", headers = {}, chat_id } = validated; + let response: NextResponse; + let attempt: Omit; - const result = await processAndSendEmail({ - to, - cc, - subject, - text, - html, - headers, - room_id: chat_id, - }); + if ("data" in validated) { + const { + to, + cc = [], + subject, + text, + html = "", + headers = {}, + chat_id, + accountId, + } = validated.data; + const result = await processAndSendEmail({ + to, + cc, + subject, + text, + html, + headers, + room_id: chat_id, + }); - if (result.success === false) { - return NextResponse.json( - { status: "error", error: result.error }, - { status: 502, headers: getCorsHeaders() }, - ); + if (result.success === false) { + response = NextResponse.json( + { status: "error", error: result.error }, + { status: 502, headers: getCorsHeaders() }, + ); + attempt = { status: "send_failed", accountId, chatId: chat_id }; + } else { + response = NextResponse.json( + { success: true, message: result.message, id: result.id }, + { status: 200, headers: getCorsHeaders() }, + ); + attempt = { status: "sent", accountId, chatId: chat_id, resendId: result.id }; + } + } else { + response = validated.error; + attempt = { status: "rejected" }; } - return NextResponse.json( - { success: true, message: result.message, id: result.id }, - { status: 200, headers: getCorsHeaders() }, - ); + await logEmailAttempt({ rawBody: validated.rawBody, ...attempt }); + return response; } diff --git a/lib/emails/validateSendEmailBody.ts b/lib/emails/validateSendEmailBody.ts index 041437dac..02b908e50 100644 --- a/lib/emails/validateSendEmailBody.ts +++ b/lib/emails/validateSendEmailBody.ts @@ -3,8 +3,8 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { assertRecipientsAllowed } from "@/lib/emails/assertRecipientsAllowed"; import { resolveEmailSubject } from "@/lib/emails/resolveEmailSubject"; -import { safeParseJson } from "@/lib/networking/safeParseJson"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { readRawBody } from "@/lib/networking/readRawBody"; import { z } from "zod"; export const sendEmailBodySchema = z.object({ @@ -30,47 +30,51 @@ export type ValidatedSendEmailRequest = Omit & }; /** - * Validates POST /api/emails: parses the body, authenticates via - * validateAuthContext (x-api-key or Bearer), resolves the recipients, then - * enforces the recipient restriction (without a payment method on file, - * `to`/`cc` are limited to the account's own email). - * - * `to` is optional: when omitted, the email defaults to the authenticated - * account's own email address(es) (via `account_emails`), so a caller can - * "email me this" without restating their address. `subject` is optional too: - * when omitted it defaults to the body's first heading/line, else - * `Message from Recoup`. Returns the resolved `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. + */ +export type ValidateSendEmailResult = + | { rawBody: string; error: NextResponse } + | { rawBody: string; data: ValidatedSendEmailRequest }; + +/** + * Validates POST /api/emails: reads the raw body, parses + schema-checks it, + * authenticates (x-api-key or Bearer), resolves the recipients, and enforces the + * recipient restriction. `to` defaults to the account's own email when omitted; + * `subject` is derived from the body when omitted. Always returns `rawBody`. * * @param request - The NextRequest object - * @returns A NextResponse with an error if validation/auth/recipients fail, or the validated request data. + * @returns `{ rawBody, error }` if validation/auth/recipients fail, else `{ rawBody, data }`. */ export async function validateSendEmailBody( request: NextRequest, -): Promise { - const body = await safeParseJson(request); - const result = sendEmailBodySchema.safeParse(body); +): Promise { + const rawBody = await readRawBody(request); + let parsed: unknown = {}; + if (rawBody) { + try { + parsed = JSON.parse(rawBody); + } catch { + parsed = {}; + } + } + const result = sendEmailBodySchema.safeParse(parsed); if (!result.success) { const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - missing_fields: firstError.path, - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); + return { + rawBody, + error: NextResponse.json( + { status: "error", missing_fields: firstError.path, error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ), + }; } - const authContext = await validateAuthContext(request, { - accountId: result.data.account_id, - }); - + const authContext = await validateAuthContext(request, { accountId: result.data.account_id }); if (authContext instanceof NextResponse) { - return authContext; + return { rawBody, error: authContext }; } let to = result.data.to ?? []; @@ -78,13 +82,13 @@ export async function validateSendEmailBody( const ownRows = await selectAccountEmails({ accountIds: authContext.accountId }); to = ownRows.map(row => row.email); if (to.length === 0) { - return NextResponse.json( - { - status: "error", - error: "No email address found for the authenticated account.", - }, - { status: 400, headers: getCorsHeaders() }, - ); + return { + rawBody, + error: NextResponse.json( + { status: "error", error: "No email address found for the authenticated account." }, + { status: 400, headers: getCorsHeaders() }, + ), + }; } } @@ -93,14 +97,17 @@ export async function validateSendEmailBody( recipients: [...to, ...(result.data.cc ?? [])], }); if (recipientCheck.allowed === false) { - return NextResponse.json( - { - status: "error", - error: `Without a payment method on file, emails can only be sent to the account's own address. Disallowed recipients: ${recipientCheck.disallowed.join(", ")}. Add a payment method to send to any recipient.`, - disallowed_recipients: recipientCheck.disallowed, - }, - { status: 403, headers: getCorsHeaders() }, - ); + return { + rawBody, + error: NextResponse.json( + { + status: "error", + error: `Without a payment method on file, emails can only be sent to the account's own address. Disallowed recipients: ${recipientCheck.disallowed.join(", ")}. Add a payment method to send to any recipient.`, + disallowed_recipients: recipientCheck.disallowed, + }, + { status: 403, headers: getCorsHeaders() }, + ), + }; } const subject = resolveEmailSubject({ @@ -109,10 +116,5 @@ export async function validateSendEmailBody( html: result.data.html, }); - return { - ...result.data, - to, - subject, - accountId: authContext.accountId, - }; + return { rawBody, data: { ...result.data, to, subject, accountId: authContext.accountId } }; } diff --git a/lib/networking/__tests__/readRawBody.test.ts b/lib/networking/__tests__/readRawBody.test.ts new file mode 100644 index 000000000..b3b11a6fc --- /dev/null +++ b/lib/networking/__tests__/readRawBody.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { NextRequest } from "next/server"; +import { readRawBody } from "../readRawBody"; + +describe("readRawBody", () => { + it("returns the body verbatim", async () => { + const raw = '{"subject":"Hi","html":"

x

"}'; + const request = new NextRequest("http://localhost/api/emails", { method: "POST", body: raw }); + expect(await readRawBody(request)).toBe(raw); + }); + + it("returns an empty string for an empty body", async () => { + const request = new NextRequest("http://localhost/api/emails", { method: "POST" }); + expect(await readRawBody(request)).toBe(""); + }); + + it("returns an empty string when reading the body throws", async () => { + const broken = { + text: () => Promise.reject(new Error("stream consumed")), + } as unknown as NextRequest; + expect(await readRawBody(broken)).toBe(""); + }); +}); diff --git a/lib/networking/readRawBody.ts b/lib/networking/readRawBody.ts new file mode 100644 index 000000000..27b23980d --- /dev/null +++ b/lib/networking/readRawBody.ts @@ -0,0 +1,17 @@ +import { NextRequest } from "next/server"; + +/** + * Reads a request body once, as text — the single source for both JSON parsing + * and raw-body logging. Returns an empty string if the body can't be read, so + * callers never throw on a missing/unreadable body. + * + * @param request - The NextRequest object + * @returns The body as a string, or "" when it can't be read + */ +export async function readRawBody(request: NextRequest): Promise { + try { + return await request.text(); + } catch { + return ""; + } +} diff --git a/lib/supabase/email_send_log/insertEmailSendLog.ts b/lib/supabase/email_send_log/insertEmailSendLog.ts new file mode 100644 index 000000000..07cf540de --- /dev/null +++ b/lib/supabase/email_send_log/insertEmailSendLog.ts @@ -0,0 +1,17 @@ +import supabase from "../serverClient"; +import type { Database } from "@/types/database.types"; +import type { PostgrestError } from "@supabase/supabase-js"; + +/** + * Inserts one row into `email_send_log`. Best-effort: returns the Supabase error + * rather than throwing, so a logging failure never affects the email send. + * + * @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: PostgrestError | null }> { + 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 872750b72..45da666d6 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -1212,6 +1212,36 @@ export type Database = { }; Relationships: []; }; + email_send_log: { + Row: { + account_id: string | null; + chat_id: string | null; + created_at: string; + id: string; + raw_body: string | null; + resend_id: string | null; + status: string; + }; + Insert: { + account_id?: string | null; + chat_id?: string | null; + created_at?: string; + id?: string; + raw_body?: string | null; + resend_id?: string | null; + status: string; + }; + Update: { + account_id?: string | null; + chat_id?: string | null; + created_at?: string; + id?: string; + raw_body?: string | null; + resend_id?: string | null; + status?: string; + }; + Relationships: []; + }; error_logs: { Row: { account_id: string | null; @@ -3167,38 +3197,6 @@ export type Database = { }, ]; }; - songstats_quota_ledger: { - Row: { - account: string | null; - hits: number; - id: string; - purpose: string | null; - spent_at: string; - }; - Insert: { - account?: string | null; - hits: number; - id?: string; - purpose?: string | null; - spent_at?: string; - }; - Update: { - account?: string | null; - hits?: number; - id?: string; - purpose?: string | null; - spent_at?: string; - }; - Relationships: [ - { - foreignKeyName: "songstats_quota_ledger_account_fkey"; - columns: ["account"]; - isOneToOne: false; - referencedRelation: "accounts"; - referencedColumns: ["id"]; - }, - ]; - }; spotify: { Row: { clientId: string | null;