Skip to content

feat(emails): log every POST /api/emails attempt (email_send_log, 7-day observability)#731

Merged
sweetmantech merged 5 commits into
mainfrom
feat/email-send-log
Jun 30, 2026
Merged

feat(emails): log every POST /api/emails attempt (email_send_log, 7-day observability)#731
sweetmantech merged 5 commits into
mainfrom
feat/email-send-log

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

What

Records every POST /api/emails attempt — sent, send_failed, rejected (empty/malformed) — in email_send_log, so a send is debuggable days later without the ephemeral agent sandbox.

Why

The empty footer-only emails reached customers and we could not recover what the API received (malformed bodies swallowed, route logged nothing, sandbox ephemeral). This is the instrument that makes the rest of the issue measurable.

Change

  • lib/emails/logEmailAttempt.ts — best-effort writer (never throws), inserts the 5 settable columns: account_id, chat_id, status, resend_id, raw_body. Stores the full raw body (no truncation).
  • lib/emails/sendEmailHandler.ts — captures the raw body via request.clone() (validation still reads the original) and logs on every outcome; rejected attempts keep the raw body, so a malformed/empty send is visible after the fact.
  • lib/supabase/email_send_log/insertEmailSendLog.ts — typed insert.
  • types/database.types.ts — regenerated from the live schema (supabase gen types equivalent); adds the 7-column email_send_log (id, created_at, account_id, chat_id, status, resend_id, raw_body).

The table + RLS ship in the database repo — #37 (create) and #38 (drop error, enable RLS), both merged. Schema reviewed down to the minimal 7 columns (KISS).

Tests

logEmailAttempt unit (fields + full-body + swallow-errors) and sendEmailHandler (sent/rejected/send_failed log calls). Full lib/emails suite: 149 passing. tsc + lint clean.

Part of recoupable/chat#1829. Base main.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jun 30, 2026 11:41pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5df88c26-7f30-4f10-af09-10721400b7c9

📥 Commits

Reviewing files that changed from the base of the PR and between 0151c41 and 780fe79.

⛔ Files ignored due to path filters (4)
  • lib/emails/__tests__/logEmailAttempt.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/emails/__tests__/sendEmailHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/emails/__tests__/validateSendEmailBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/networking/__tests__/readRawBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (5)
  • lib/emails/logEmailAttempt.ts
  • lib/emails/sendEmailHandler.ts
  • lib/emails/validateSendEmailBody.ts
  • lib/networking/readRawBody.ts
  • lib/supabase/email_send_log/insertEmailSendLog.ts
📝 Walkthrough

Walkthrough

This PR adds best-effort logging of email send attempts into a new email_send_log table. A Supabase insert helper (insertEmailSendLog) and a logging utility (logEmailAttempt) are introduced and wired into sendEmailHandler to log rejected, failed, and successful send attempts, including the raw request body.

Changes

Email attempt logging

Layer / File(s) Summary
Storage helper for email send log
lib/supabase/email_send_log/insertEmailSendLog.ts
Adds a Supabase insert helper that writes to email_send_log and returns { error } instead of throwing.
Logging types and utility
lib/emails/logEmailAttempt.ts
Defines EmailAttemptStatus and EmailAttemptLog types and the logEmailAttempt function, which maps fields, normalizes optional values to null, calls insertEmailSendLog, and swallows insertion errors.
Wire logging into send handler
lib/emails/sendEmailHandler.ts
Adds a readRawBody helper to capture the raw request body, then logs rejected, send_failed, or sent attempts at the corresponding points in the handler flow before returning responses.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant sendEmailHandler
  participant validateSendEmailBody
  participant processAndSendEmail
  participant logEmailAttempt
  participant insertEmailSendLog

  Client->>sendEmailHandler: HTTP request
  sendEmailHandler->>sendEmailHandler: readRawBody
  sendEmailHandler->>validateSendEmailBody: validate body
  alt validation rejected
    validateSendEmailBody-->>sendEmailHandler: NextResponse
    sendEmailHandler->>logEmailAttempt: log rejected (rawBody)
    logEmailAttempt->>insertEmailSendLog: insert row
    sendEmailHandler-->>Client: validation response
  else validation passed
    sendEmailHandler->>processAndSendEmail: send email
    alt send failed
      processAndSendEmail-->>sendEmailHandler: success false
      sendEmailHandler->>logEmailAttempt: log send_failed
      logEmailAttempt->>insertEmailSendLog: insert row
      sendEmailHandler-->>Client: 502 error
    else send succeeded
      processAndSendEmail-->>sendEmailHandler: result.id
      sendEmailHandler->>logEmailAttempt: log sent (resendId)
      logEmailAttempt->>insertEmailSendLog: insert row
      sendEmailHandler-->>Client: 200 success
    end
  end
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~15 minutes

Poem

A rabbit hops by the mail queue's door,
Logging each letter, success or no more.
"Sent, rejected, or failed," it writes with care,
Best-effort whispers tucked safely there. 🐇✉️
No error shall break the send-email chain!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning sendEmailHandler.ts now contains a second function, readRawBody, and sendEmailHandler itself is ~47 lines—violating the file-name/one-primary-function rule and small-function guidance. Move readRawBody to its own matching file or inline it, and split sendEmailHandler into smaller focused helpers so the file has one primary function.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/email-send-log

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found and verified against the latest diff

Confidence score: 2/5

  • In supabase/migrations/20260630_email_send_log.sql, the new email_send_log table stores raw email request bodies but does not enable RLS, which creates a real data-exposure risk through the Supabase API if public-schema access exists; merging as-is could leak sensitive request content — enable RLS (and corresponding policies) before merging.
  • In supabase/migrations/20260630_email_send_log.sql, status is only documented in a comment today, so invalid values can still be written and downstream logic/reporting can drift or fail unexpectedly — add a CHECK constraint for the allowed status set before merging.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="supabase/migrations/20260630_email_send_log.sql">

<violation number="1" location="supabase/migrations/20260630_email_send_log.sql:4">
P1: Enable RLS on this new public table before storing raw email request bodies. Without it, the log table can expose captured request content through the Supabase API if public-schema privileges are available.</violation>

<violation number="2" location="supabase/migrations/20260630_email_send_log.sql:9">
P2: Add a CHECK constraint for the documented status values. The comment alone does not protect email_send_log from invalid statuses.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment on lines +4 to +18
create table if not exists public.email_send_log (
id uuid primary key default gen_random_uuid(),
created_at timestamptz not null default now(),
account_id uuid,
chat_id text,
status text not null, -- 'sent' | 'send_failed' | 'rejected'
body_parsed boolean not null default false,
raw_body text, -- truncated copy of the request body
to_count integer,
subject text,
html_length integer,
text_length integer,
resend_id text,
error text
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Enable RLS on this new public table before storing raw email request bodies. Without it, the log table can expose captured request content through the Supabase API if public-schema privileges are available.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/migrations/20260630_email_send_log.sql, line 4:

<comment>Enable RLS on this new public table before storing raw email request bodies. Without it, the log table can expose captured request content through the Supabase API if public-schema privileges are available.</comment>

<file context>
@@ -0,0 +1,20 @@
+-- Durable log of every POST /api/emails attempt (success, send-failure, and
+-- rejected/malformed), retained for debugging which params the API received
+-- up to several days back — the sandbox that built the request is ephemeral.
+create table if not exists public.email_send_log (
+  id uuid primary key default gen_random_uuid(),
+  created_at timestamptz not null default now(),
</file context>
Suggested change
create table if not exists public.email_send_log (
id uuid primary key default gen_random_uuid(),
created_at timestamptz not null default now(),
account_id uuid,
chat_id text,
status text not null, -- 'sent' | 'send_failed' | 'rejected'
body_parsed boolean not null default false,
raw_body text, -- truncated copy of the request body
to_count integer,
subject text,
html_length integer,
text_length integer,
resend_id text,
error text
);
create table if not exists public.email_send_log (
id uuid primary key default gen_random_uuid(),
created_at timestamptz not null default now(),
account_id uuid,
chat_id text,
status text not null, -- 'sent' | 'send_failed' | 'rejected'
body_parsed boolean not null default false,
raw_body text, -- truncated copy of the request body
to_count integer,
subject text,
html_length integer,
text_length integer,
resend_id text,
error text
);
alter table public.email_send_log enable row level security;

created_at timestamptz not null default now(),
account_id uuid,
chat_id text,
status text not null, -- 'sent' | 'send_failed' | 'rejected'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Add a CHECK constraint for the documented status values. The comment alone does not protect email_send_log from invalid statuses.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/migrations/20260630_email_send_log.sql, line 9:

<comment>Add a CHECK constraint for the documented status values. The comment alone does not protect email_send_log from invalid statuses.</comment>

<file context>
@@ -0,0 +1,20 @@
+  created_at timestamptz not null default now(),
+  account_id uuid,
+  chat_id text,
+  status text not null,            -- 'sent' | 'send_failed' | 'rejected'
+  body_parsed boolean not null default false,
+  raw_body text,                   -- truncated copy of the request body
</file context>
Suggested change
status text not null, -- 'sent' | 'send_failed' | 'rejected'
status text not null check (status in ('sent', 'send_failed', 'rejected')), -- 'sent' | 'send_failed' | 'rejected'

cubic-dev-ai[bot]
cubic-dev-ai Bot previously approved these changes Jun 30, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 1 file (changes from recent commits).

Auto-approved: Adds a new database table and best-effort logging for email send attempts, plus tests. No existing logic is changed; the risk of breakage is very low.

Re-trigger cubic

…-day observability)

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) <noreply@anthropic.com>
…mn schema

database#37 + #38 shipped email_send_log as 7 columns
(id, created_at, account_id, chat_id, status, resend_id, raw_body) with RLS.
Regenerate types/database.types.ts from the live schema (replaces the earlier
hand-edit; also picks up unrelated drift) and trim the writer to match:

- logEmailAttempt: insert only account_id/chat_id/status/resend_id/raw_body;
  drop body_parsed, error, subject, to_count, html_length, text_length; store
  the FULL raw_body (no truncation).
- sendEmailHandler: drop the removed fields from the send_failed/sent log calls.
- tests updated accordingly.

Full lib/emails suite: 149 passing; tsc + lint clean.

Ref: recoupable/chat#1829

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (5)
lib/emails/logEmailAttempt.ts (1)

30-30: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

|| instead of ?? collapses a legitimately empty body into null.

attempt.rawBody || null treats an empty string the same as undefined/missing, converting a genuinely empty request body into null. Given the PR's explicit goal of preserving "malformed or empty requests whose original payload would otherwise be lost," using ?? would more faithfully distinguish "body was empty" (stored as "") from "no body field was ever provided." This is a minor fidelity gap since readRawBody in the handler already collapses read-failures to "" too, so the distinction is already partially lost upstream.

🤖 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/logEmailAttempt.ts` at line 30, The `logEmailAttempt` mapping
currently uses `attempt.rawBody || null`, which turns an intentional empty
string into `null`. Update the `raw_body` assignment in `logEmailAttempt` to use
nullish coalescing so `""` is preserved while only `undefined`/missing values
become `null`, keeping the body fidelity consistent with the request handling
path.
lib/supabase/email_send_log/insertEmailSendLog.ts (2)

13-16: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

error: unknown discards a known, typed error shape.

Supabase's insert/update/select calls return a PostgrestError | null, not unknown. Typing the return as unknown forces every caller to cast/narrow before they can inspect error.message/error.code, and the path instruction for this directory calls for returning typed results.

✏️ Suggested fix
+import type { PostgrestError } from "`@supabase/supabase-js`";
+
 export async function insertEmailSendLog(
   row: Database["public"]["Tables"]["email_send_log"]["Insert"],
-): Promise<{ error: unknown }> {
+): Promise<{ error: PostgrestError | null }> {
   const { error } = await supabase.from("email_send_log").insert(row);
   return { error };
 }
🤖 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/supabase/email_send_log/insertEmailSendLog.ts` around lines 13 - 16, The
return type in insertEmailSendLog is too generic because it uses unknown for the
Supabase error, which prevents callers from safely inspecting error details.
Update the insertEmailSendLog function to return the typed Supabase/Postgrest
error shape used by supabase.from(...).insert(...), and keep the destructured
error value aligned with that type so callers can access fields like message and
code without casting.

Source: Path instructions


1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Relative import instead of the documented alias path.

As per path instructions, "Import serverClient only within lib/supabase/" — and the coding guidelines for this directory specifically call out importing serverClient via @/lib/supabase/serverClient. This file uses a relative import (../serverClient) instead.

✏️ Suggested fix
-import supabase from "../serverClient";
+import supabase from "`@/lib/supabase/serverClient`";
🤖 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/supabase/email_send_log/insertEmailSendLog.ts` at line 1, The import in
insertEmailSendLog should use the documented supabase alias path instead of a
relative path. Update the serverClient import in this module to reference
`@/lib/supabase/serverClient` so it matches the directory guidance and keeps
imports consistent with other lib/supabase code.

Source: Coding guidelines

lib/emails/sendEmailHandler.ts (2)

38-38: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Best-effort logging blocks every response path with no timeout.

All three await logEmailAttempt(...) calls sit directly on the response path (rejected, send_failed, sent) before the handler returns. Since logEmailAttempt/insertEmailSendLog impose no timeout on the underlying Supabase call, a slow or unresponsive database adds latency — or, in a worse-case Supabase outage, indefinitely hangs — every single POST /api/emails request, even though this write is explicitly "best-effort" and shouldn't gate the response.

Next.js (16.0.10, per this file's library context) has a stable after() API from next/server specifically intended for "tasks and other side effects that should not block the response, such as logging and analytics." Moving these three calls into after(() => logEmailAttempt(...)) would let the response return immediately while the log write happens post-response.

♻️ Example refactor (rejection path shown; same pattern applies to the other two call sites)
+import { after } from "next/server";
+
   if (validated instanceof NextResponse) {
-    await logEmailAttempt({ rawBody, status: "rejected" });
+    after(() => logEmailAttempt({ rawBody, status: "rejected" }));
     return validated;
   }

Also applies to: 55-60, 67-73

🤖 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/sendEmailHandler.ts` at line 38, The three best-effort email
logging calls are still blocking the `POST /api/emails` response path because
`sendEmailHandler` awaits `logEmailAttempt` directly. Update `sendEmailHandler`
to schedule the `logEmailAttempt(...)` work inside `after()` from `next/server`
for the rejected, send_failed, and sent branches so the handler can return
immediately while `logEmailAttempt`/`insertEmailSendLog` runs post-response
without gating success.

33-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

sendEmailHandler now exceeds the 20-line function-length guideline.

The function spans roughly lines 33-79 (~47 lines), over the documented "flag functions longer than 20 lines" guideline (still under the 50-line path-instruction cap for lib/**/*.ts). The new logging calls compound an already large handler. Consider extracting a small logOutcome(status, extra) wrapper to shrink the inline blocks at lines 38, 55-60, and 67-73.

🤖 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/sendEmailHandler.ts` around lines 33 - 79, sendEmailHandler is now
too long and should be shortened to fit the 20-line guideline. Extract the
repeated email-attempt logging and response handling into small helpers, such as
a logOutcome wrapper around logEmailAttempt and a separate failure/success
response helper, so the main flow in sendEmailHandler stays compact. Keep the
core orchestration in sendEmailHandler and move the inline blocks around
validated checks and processAndSendEmail result handling into named functions to
reduce the function size.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Inline comments:
In `@lib/emails/logEmailAttempt.ts`:
- Around line 23-35: logEmailAttempt is ignoring the { error } result from
insertEmailSendLog, so failed email_send_log writes are never visible. Update
logEmailAttempt to inspect the return value from insertEmailSendLog and emit the
error (for example via console.error or the project logger) when present, while
still keeping the function best-effort and non-throwing. The try/catch around
logEmailAttempt should remain only as a safeguard for unexpected throws.

---

Nitpick comments:
In `@lib/emails/logEmailAttempt.ts`:
- Line 30: The `logEmailAttempt` mapping currently uses `attempt.rawBody ||
null`, which turns an intentional empty string into `null`. Update the
`raw_body` assignment in `logEmailAttempt` to use nullish coalescing so `""` is
preserved while only `undefined`/missing values become `null`, keeping the body
fidelity consistent with the request handling path.

In `@lib/emails/sendEmailHandler.ts`:
- Line 38: The three best-effort email logging calls are still blocking the
`POST /api/emails` response path because `sendEmailHandler` awaits
`logEmailAttempt` directly. Update `sendEmailHandler` to schedule the
`logEmailAttempt(...)` work inside `after()` from `next/server` for the
rejected, send_failed, and sent branches so the handler can return immediately
while `logEmailAttempt`/`insertEmailSendLog` runs post-response without gating
success.
- Around line 33-79: sendEmailHandler is now too long and should be shortened to
fit the 20-line guideline. Extract the repeated email-attempt logging and
response handling into small helpers, such as a logOutcome wrapper around
logEmailAttempt and a separate failure/success response helper, so the main flow
in sendEmailHandler stays compact. Keep the core orchestration in
sendEmailHandler and move the inline blocks around validated checks and
processAndSendEmail result handling into named functions to reduce the function
size.

In `@lib/supabase/email_send_log/insertEmailSendLog.ts`:
- Around line 13-16: The return type in insertEmailSendLog is too generic
because it uses unknown for the Supabase error, which prevents callers from
safely inspecting error details. Update the insertEmailSendLog function to
return the typed Supabase/Postgrest error shape used by
supabase.from(...).insert(...), and keep the destructured error value aligned
with that type so callers can access fields like message and code without
casting.
- Line 1: The import in insertEmailSendLog should use the documented supabase
alias path instead of a relative path. Update the serverClient import in this
module to reference `@/lib/supabase/serverClient` so it matches the directory
guidance and keeps imports consistent with other lib/supabase code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fe795af5-0a40-4365-9eba-e15fb8ace42c

📥 Commits

Reviewing files that changed from the base of the PR and between e393ac0 and 0151c41.

⛔ Files ignored due to path filters (3)
  • lib/emails/__tests__/logEmailAttempt.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/emails/__tests__/sendEmailHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • types/database.types.ts is excluded by none and included by none
📒 Files selected for processing (3)
  • lib/emails/logEmailAttempt.ts
  • lib/emails/sendEmailHandler.ts
  • lib/supabase/email_send_log/insertEmailSendLog.ts

Comment thread lib/emails/logEmailAttempt.ts
Comment thread lib/emails/sendEmailHandler.ts Outdated
* @returns A NextResponse with the send result.
*/
export async function sendEmailHandler(request: NextRequest): Promise<NextResponse> {
const rawBody = await readRawBody(request);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SRP

  • actual: readRawBody called in handler
  • required: readRawBody called in validateSendEmailBody

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 48d01b4. readRawBody now lives in validateSendEmailBody — it reads the request body once (as text), parses it for schema validation, and returns rawBody on both the rejected and accepted paths ({ rawBody, error } | { rawBody, data }). The handler no longer touches the request; it just consumes validated.rawBody. (This also drops the separate safeParseJson read — one body read total.)

Comment thread lib/emails/sendEmailHandler.ts Outdated
Comment on lines +66 to +73

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

DRY

  • actual: logEmailAttempt called twice in the handler
  • required: logEmailAttempt called once in the handler for both cases handled in one call

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 48d01b4. The handler now resolves a single { response, attempt } outcome — deliver() returns the sent/send_failed attempt, and the rejected branch yields { status: "rejected" } — then calls logEmailAttempt({ rawBody, ...attempt }) exactly once for every path. Locked in with expect(mockLogEmailAttempt).toHaveBeenCalledTimes(1).

@sweetmantech

Copy link
Copy Markdown
Contributor Author

✅ Preview verification — 2026-06-30

Tested against the Vercel preview built from 0151c418: https://api-ezzci26hu-recoup.vercel.app (deployment state success; auth probe GET /api/tasks200).

Goal: every POST /api/emails attempt lands one row in email_send_log with the full raw body + the outcome — verified by querying the table directly (the preview writes to the prod DB).

# Request HTTP email_send_log row (verified via SQL)
A valid send, Bearer auth, to omitted → owner; unique marker + chat_id=9c9cc001… in body 200 success:true, Resend id 860b3a0c… status=sent · account_id=fb678396… · chat_id=9c9cc001… · resend_id=860b3a0c… (matches the API response) · raw_body full, 179 B, marker present
B no auth, unique marker in body 401 status=rejected · account_id=null · chat_id=null · resend_id=null · raw_body full, 137 B, marker present

Confirmed

  • One row per attempt, on both the sent and rejected paths.
  • raw_body stored in full (no truncation) for both — so a malformed/rejected request body is fully recoverable later (the whole point of the table).
  • resend_id on the sent row == the id the API returned → the bridge to Resend's delivered copy works.
  • The rejected (unauth) attempt still captured the body, with null account/chat (we don't have them pre-auth) — exactly the design.
  • Best-effort writer is non-blocking: A still returned 200, B still returned 401.

Notes

Schema matches the merged table (recoupable/database #37 + #38): id, created_at, account_id, chat_id, status, resend_id, raw_body.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="supabase/migrations/20260630_email_send_log.sql">

<violation number="1" location="supabase/migrations/20260630_email_send_log.sql:4">
P1: Enable RLS on this new public table before storing raw email request bodies. Without it, the log table can expose captured request content through the Supabase API if public-schema privileges are available.</violation>

<violation number="2" location="supabase/migrations/20260630_email_send_log.sql:9">
P2: Add a CHECK constraint for the documented status values. The comment alone does not protect email_send_log from invalid statuses.</violation>
</file>

<file name="lib/emails/logEmailAttempt.ts">

<violation number="1" location="lib/emails/logEmailAttempt.ts:30">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

The PR description claims `logEmailAttempt.ts` truncates `raw_body` to 10k and computes `body_parsed` via `JSON.parse`, but the diff removes both features. The description and the actual code changes are inconsistent.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread lib/emails/logEmailAttempt.ts Outdated
chat_id: attempt.chatId ?? null,
status: attempt.status,
resend_id: attempt.resendId ?? null,
raw_body: attempt.rawBody || null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Custom agent: Flag AI Slop and Fabricated Changes

The PR description claims logEmailAttempt.ts truncates raw_body to 10k and computes body_parsed via JSON.parse, but the diff removes both features. The description and the actual code changes are inconsistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/logEmailAttempt.ts, line 30:

<comment>The PR description claims `logEmailAttempt.ts` truncates `raw_body` to 10k and computes `body_parsed` via `JSON.parse`, but the diff removes both features. The description and the actual code changes are inconsistent.</comment>

<file context>
@@ -1,57 +1,33 @@
-      text_length: attempt.text?.length ?? null,
       resend_id: attempt.resendId ?? null,
-      error: attempt.error ?? null,
+      raw_body: attempt.rawBody || null,
     });
   } catch {
</file context>

Comment thread lib/emails/logEmailAttempt.ts Outdated
@cubic-dev-ai cubic-dev-ai Bot dismissed their stale review June 30, 2026 23:16

Dismissed because Cubic found issues in a newer review.

Address review on #731:
- SRP: validateSendEmailBody now reads the request body once (readRawBody)
  and returns rawBody on both the rejected and accepted paths, so the handler
  no longer reads the request itself.
- DRY: sendEmailHandler computes a single outcome and calls logEmailAttempt
  exactly once for every path (sent / send_failed / rejected).
- Surface a returned insert error to server logs (observability) and store the
  raw body verbatim (preserve empty string).
- Type insertEmailSendLog's error as PostgrestError | null.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Addressed review — 48d01b4

Your comments

  • SRP (readRawBody in handler → validate): validateSendEmailBody now reads the body once and returns rawBody on both paths ({ rawBody, error } | { rawBody, data }); the handler no longer reads the request. (thread)
  • DRY (logEmailAttempt called twice → once): handler resolves one { response, attempt } outcome and logs once on every path; asserted with toHaveBeenCalledTimes(1). (thread)

Bot findings — triaged

  • ✅ CodeRabbit: surface insertEmailSendLog's returned { error } to server logs (it was silently dropped) — added console.error, plus a test asserting it. Ironic-for-observability point was fair.
  • ✅ CodeRabbit: store the raw body verbatim (attempt.rawBody, was || null) so an empty body stays "".
  • ✅ CodeRabbit: typed insertEmailSendLog error as PostgrestError | null instead of unknown.
  • ⏭️ CodeRabbit: relative ../serverClient import — kept to match the immediate sibling insertApiKey.ts (consistency with the neighbor over the global alias rule).
  • ⏭️ CodeRabbit: move logging into after() / extract a logOutcome helper for function length — deferred; the single-call refactor already shrank the handler, and after() is a behavior change (post-response write) worth its own discussion, not a review fix.
  • ❎ cubic P1/P2 (RLS + status CHECK on supabase/migrations/20260630_email_send_log.sql) — stale/moot: that in-api migration no longer exists in this PR. The table ships from recoupable/database (Sweetmantech/myc 3645 migrate all tools from chat into the new vercel api codebase #37/Migrate tools - catalogTools #38); RLS was enabled in database#38. cubic's follow-up auto-approved the current diff.

Full email suite green: 150 passed; tsc/eslint clean on touched files (the remaining tsc errors are pre-existing in unrelated inbound test fixtures).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

6 issues found across 7 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="supabase/migrations/20260630_email_send_log.sql">

<violation number="1" location="supabase/migrations/20260630_email_send_log.sql:4">
P1: Enable RLS on this new public table before storing raw email request bodies. Without it, the log table can expose captured request content through the Supabase API if public-schema privileges are available.</violation>

<violation number="2" location="supabase/migrations/20260630_email_send_log.sql:9">
P2: Add a CHECK constraint for the documented status values. The comment alone does not protect email_send_log from invalid statuses.</violation>
</file>

<file name="lib/emails/logEmailAttempt.ts">

<violation number="1" location="lib/emails/logEmailAttempt.ts:30">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

The PR description claims `logEmailAttempt.ts` truncates `raw_body` to 10k and computes `body_parsed` via `JSON.parse`, but the diff removes both features. The description and the actual code changes are inconsistent.</violation>
</file>

<file name="lib/emails/__tests__/validateSendEmailBody.test.ts">

<violation number="1" location="lib/emails/__tests__/validateSendEmailBody.test.ts:57">
P3: Same: the validation-error test (empty body → 400) asserts shape with `"error" in result` but never checks `rawBody` contents.</violation>

<violation number="2" location="lib/emails/__tests__/validateSendEmailBody.test.ts:86">
P3: Tests assert `"data" in result` / `"error" in result` to check the discriminated-union shape, but never verify the `rawBody` field — the central contract of the return type.</violation>
</file>

<file name="lib/emails/validateSendEmailBody.ts">

<violation number="1" location="lib/emails/validateSendEmailBody.ts:62">
P1: Malformed JSON is converted into a valid empty body, so authenticated bad requests can still reach `deliver()` and send the footer-only/default email this PR is meant to reject. Return a 400 on empty or unparsable raw bodies instead of passing `{}` to Zod.</violation>
</file>

<file name="lib/emails/__tests__/sendEmailHandler.test.ts">

<violation number="1" location="lib/emails/__tests__/sendEmailHandler.test.ts:72">
P3: Rejected and send_failed tests don't assert `toHaveBeenCalledTimes(1)`, so a double-log regression on error paths wouldn't be caught. Add the count assertion for consistency.</violation>

<violation number="2" location="lib/emails/__tests__/sendEmailHandler.test.ts:86">
P2: Rejected and send_failed test assertions omit `rawBody`, so a regression where `rawBody` is silently dropped on error paths would not be caught. Add `rawBody` to both assertions.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment on lines +62 to +69
let parsed: unknown = {};
if (rawBody) {
try {
parsed = JSON.parse(rawBody);
} catch {
parsed = {};
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Malformed JSON is converted into a valid empty body, so authenticated bad requests can still reach deliver() and send the footer-only/default email this PR is meant to reject. Return a 400 on empty or unparsable raw bodies instead of passing {} to Zod.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/validateSendEmailBody.ts, line 62:

<comment>Malformed JSON is converted into a valid empty body, so authenticated bad requests can still reach `deliver()` and send the footer-only/default email this PR is meant to reject. Return a 400 on empty or unparsable raw bodies instead of passing `{}` to Zod.</comment>

<file context>
@@ -30,61 +29,74 @@ export type ValidatedSendEmailRequest = Omit<SendEmailBody, "to" | "subject"> &
-  const result = sendEmailBodySchema.safeParse(body);
+): Promise<ValidateSendEmailResult> {
+  const rawBody = await readRawBody(request);
+  let parsed: unknown = {};
+  if (rawBody) {
+    try {
</file context>
Suggested change
let parsed: unknown = {};
if (rawBody) {
try {
parsed = JSON.parse(rawBody);
} catch {
parsed = {};
}
}
if (!rawBody) {
return {
rawBody,
error: NextResponse.json(
{ status: "error", missing_fields: [], error: "Request body must be valid JSON." },
{ status: 400, headers: getCorsHeaders() },
),
};
}
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
return {
rawBody,
error: NextResponse.json(
{ status: "error", missing_fields: [], error: "Request body must be valid JSON." },
{ status: 400, headers: getCorsHeaders() },
),
};
}

NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }),
);
mockValidateSendEmailBody.mockResolvedValue({
rawBody: "{}",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Rejected and send_failed test assertions omit rawBody, so a regression where rawBody is silently dropped on error paths would not be caught. Add rawBody to both assertions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/__tests__/sendEmailHandler.test.ts, line 86:

<comment>Rejected and send_failed test assertions omit `rawBody`, so a regression where `rawBody` is silently dropped on error paths would not be caught. Add `rawBody` to both assertions.</comment>

<file context>
@@ -65,15 +68,24 @@ describe("sendEmailHandler", () => {
-      NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }),
-    );
+    mockValidateSendEmailBody.mockResolvedValue({
+      rawBody: "{}",
+      error: NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }),
+    });
</file context>

}),
);
// Single call, on every path (DRY); rawBody comes from validateSendEmailBody (SRP).
expect(mockLogEmailAttempt).toHaveBeenCalledTimes(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Rejected and send_failed tests don't assert toHaveBeenCalledTimes(1), so a double-log regression on error paths wouldn't be caught. Add the count assertion for consistency.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/__tests__/sendEmailHandler.test.ts, line 72:

<comment>Rejected and send_failed tests don't assert `toHaveBeenCalledTimes(1)`, so a double-log regression on error paths wouldn't be caught. Add the count assertion for consistency.</comment>

<file context>
@@ -65,15 +68,24 @@ describe("sendEmailHandler", () => {
       }),
     );
+    // 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" }),
</file context>

expect(result).not.toBeInstanceOf(NextResponse);
if (!(result instanceof NextResponse)) {
expect(result.subject).toBe("Pulse Report");
expect("data" in result).toBe(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Tests assert "data" in result / "error" in result to check the discriminated-union shape, but never verify the rawBody field — the central contract of the return type.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/__tests__/validateSendEmailBody.test.ts, line 86:

<comment>Tests assert `"data" in result` / `"error" in result` to check the discriminated-union shape, but never verify the `rawBody` field — the central contract of the return type.</comment>

<file context>
@@ -87,18 +83,18 @@ describe("validateSendEmailBody", () => {
-      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");
</file context>

if (result instanceof NextResponse) {
expect(result.status).toBe(403);
const json = await result.json();
expect("error" in result).toBe(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Same: the validation-error test (empty body → 400) asserts shape with "error" in result but never checks rawBody contents.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/emails/__tests__/validateSendEmailBody.test.ts, line 57:

<comment>Same: the validation-error test (empty body → 400) asserts shape with `"error" in result` but never checks `rawBody` contents.</comment>

<file context>
@@ -58,10 +54,10 @@ describe("validateSendEmailBody", () => {
-      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);
</file context>

Comment thread lib/emails/validateSendEmailBody.ts Outdated
Comment thread lib/emails/sendEmailHandler.ts Outdated

const { to, cc = [], subject, text, html = "", headers = {}, chat_id } = validated;
/** Sends a validated email; returns the HTTP response plus the attempt to log. */
async function deliver(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SRP - new lib file for deliver function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 70c155e. deliver is now lib/emails/deliverEmail.ts (one exported function: Resend send + response/attempt shaping), with __tests__/deliverEmail.test.ts covering the sent (200, resendId, chat_id→room_id) and send_failed (502, no resendId) paths. sendEmailHandler is now pure orchestration — validate → deliverEmail → one logEmailAttempt.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reverted in 780fe79 after discussion. On reflection deliverEmail's { response, attempt } return was handler-internal glue (single caller, only coherent inside the handler), not a reusable unit — so it's now inlined back into sendEmailHandler (~24 lines, still one logEmailAttempt call). readRawBody stays its own file since it's a cohesive, reusable utility next to safeParseJson. Mapping coverage (200 / 502 / rejected) remains in sendEmailHandler.test.ts via the processAndSendEmail mock.

Comment thread lib/emails/validateSendEmailBody.ts Outdated
| { rawBody: string; data: ValidatedSendEmailRequest };

/** Read the request body once, as text (the source for both parsing and logging). */
async function readRawBody(request: NextRequest): Promise<string> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

SRP - new lib file for readRawBody

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 70c155e. readRawBody is now its own file — lib/networking/readRawBody.ts, sibling to safeParseJson (same NextRequest signature) — with a dedicated __tests__/readRawBody.test.ts (body verbatim / empty body / throw → ""). validateSendEmailBody imports it.

Per the one-exported-function-per-file convention (review on #731):
- lib/networking/readRawBody.ts — body-as-text read (sibling of safeParseJson);
  validateSendEmailBody imports it.
- lib/emails/deliverEmail.ts — Resend send + response/attempt shaping;
  sendEmailHandler delegates to it. Handler is now pure orchestration.
Both extractions get dedicated unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
Contributor Author

Live preview verification — 70c155e8 (extraction commit)

Preview https://api-bvzo7b1hu-recoup.vercel.app (built from 70c155e8).

Rejected pathPOST /api/emails, no auth:

  • HTTP 401 (Exactly one of x-api-key or Authorization must be provided) ✅
  • email_send_log row written by the new build: status=rejected, account_id/chat_id/resend_id all null, full 91-byte raw_body matching the request. ✅ (test row deleted)

This exercises the refactored plumbing end-to-end: validateSendEmailBody reads the body (readRawBody), returns { rawBody, error }, and the handler makes a single logEmailAttempt call. Behavior is identical to the pre-refactor build (48d01b4, same result).

Sent path (200 → status=sent row with resend_id): not re-run on this commit — the 1h Privy JWT I used earlier expired (exp 2026-07-01T00:05Z) and was passed via env, so I don't have it to replay. It's behavior-preserving vs. the earlier verified run, but if you want a fresh authenticated send against this exact preview, drop me a current token (personal account fb678396, recipient stays sweetmantech@gmail.com) and I'll run it + confirm the sent row.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Sent path verified — 70c155e8, same preview

Ran the authenticated send against https://api-bvzo7b1hu-recoup.vercel.app (to omitted → defaults to the account's own email, so it only hit sweetmantech@gmail.com).

POST /api/emails with Authorization: Bearer …, body {subject, html, chat_id}:

  • HTTP 200, success:true, Resend id 10439bff-2454-47d9-bc2c-469ba0e7e05e
  • email_send_log row written by the build: status=sent, account_id=fb678396-… (resolved from the Bearer token), chat_id=…-chat (plumbed through deliverEmail), resend_id matches the 200 response id, full 123-byte raw_body. ✅ (test row deleted)

Both outcomes now confirmed end-to-end on the extraction commit:

Path Auth HTTP logged status account_id chat_id resend_id raw_body
rejected none 401 rejected null null null full (91 B)
sent Bearer 200 sent fb678396… …-chat matches response full (123 B)

Closes out the review on this PR — SRP/DRY refactor + the deliverEmail/readRawBody extractions are behavior-identical and observability logs correctly on both paths.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="supabase/migrations/20260630_email_send_log.sql">

<violation number="1" location="supabase/migrations/20260630_email_send_log.sql:4">
P1: Enable RLS on this new public table before storing raw email request bodies. Without it, the log table can expose captured request content through the Supabase API if public-schema privileges are available.</violation>

<violation number="2" location="supabase/migrations/20260630_email_send_log.sql:9">
P2: Add a CHECK constraint for the documented status values. The comment alone does not protect email_send_log from invalid statuses.</violation>
</file>

<file name="lib/emails/logEmailAttempt.ts">

<violation number="1" location="lib/emails/logEmailAttempt.ts:30">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**

The PR description claims `logEmailAttempt.ts` truncates `raw_body` to 10k and computes `body_parsed` via `JSON.parse`, but the diff removes both features. The description and the actual code changes are inconsistent.</violation>
</file>

<file name="lib/emails/__tests__/validateSendEmailBody.test.ts">

<violation number="1" location="lib/emails/__tests__/validateSendEmailBody.test.ts:57">
P3: Same: the validation-error test (empty body → 400) asserts shape with `"error" in result` but never checks `rawBody` contents.</violation>

<violation number="2" location="lib/emails/__tests__/validateSendEmailBody.test.ts:86">
P3: Tests assert `"data" in result` / `"error" in result` to check the discriminated-union shape, but never verify the `rawBody` field — the central contract of the return type.</violation>
</file>

<file name="lib/emails/validateSendEmailBody.ts">

<violation number="1" location="lib/emails/validateSendEmailBody.ts:62">
P1: Malformed JSON is converted into a valid empty body, so authenticated bad requests can still reach `deliver()` and send the footer-only/default email this PR is meant to reject. Return a 400 on empty or unparsable raw bodies instead of passing `{}` to Zod.</violation>
</file>

<file name="lib/emails/__tests__/sendEmailHandler.test.ts">

<violation number="1" location="lib/emails/__tests__/sendEmailHandler.test.ts:72">
P3: Rejected and send_failed tests don't assert `toHaveBeenCalledTimes(1)`, so a double-log regression on error paths wouldn't be caught. Add the count assertion for consistency.</violation>

<violation number="2" location="lib/emails/__tests__/sendEmailHandler.test.ts:86">
P2: Rejected and send_failed test assertions omit `rawBody`, so a regression where `rawBody` is silently dropped on error paths would not be caught. Add `rawBody` to both assertions.</violation>
</file>

<file name="lib/networking/readRawBody.ts">

<violation number="1" location="lib/networking/readRawBody.ts:15">
P1: `readRawBody` catches every body-read failure and silently returns `""`, making an unreadable request body indistinguishable from a genuinely empty body. Downstream, `validateSendEmailBody` treats `""` as a malformed/empty request, returns 400, and `sendEmailHandler` logs `raw_body = ""` with `status: "rejected"`. This loses the very evidence the PR is meant to preserve when a read error occurs, and it misattributes server-side read failures to the client.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread lib/emails/deliverEmail.ts Outdated
try {
return await request.text();
} catch {
return "";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: readRawBody catches every body-read failure and silently returns "", making an unreadable request body indistinguishable from a genuinely empty body. Downstream, validateSendEmailBody treats "" as a malformed/empty request, returns 400, and sendEmailHandler logs raw_body = "" with status: "rejected". This loses the very evidence the PR is meant to preserve when a read error occurs, and it misattributes server-side read failures to the client.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/networking/readRawBody.ts, line 15:

<comment>`readRawBody` catches every body-read failure and silently returns `""`, making an unreadable request body indistinguishable from a genuinely empty body. Downstream, `validateSendEmailBody` treats `""` as a malformed/empty request, returns 400, and `sendEmailHandler` logs `raw_body = ""` with `status: "rejected"`. This loses the very evidence the PR is meant to preserve when a read error occurs, and it misattributes server-side read failures to the client.</comment>

<file context>
@@ -0,0 +1,17 @@
+  try {
+    return await request.text();
+  } catch {
+    return "";
+  }
+}
</file context>

Per review discussion on #731: deliverEmail's {response, attempt} return was
handler-internal glue with a single caller, not a reusable unit. Inline it back
into sendEmailHandler — still one logEmailAttempt call (DRY), still ~24 lines
(under the cap). Keep readRawBody as its own file (cohesive, reusable). Mapping
coverage stays in sendEmailHandler.test.ts via the processAndSendEmail mock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sweetmantech sweetmantech merged commit 61ed2f7 into main Jun 30, 2026
6 checks passed
sweetmantech added a commit that referenced this pull request Jun 30, 2026
…ent footer-only sends)

Guard sendEmailBodySchema with a refine requiring a non-empty html or text body,
and drop the html "" default. A malformed/empty body parses to {} and now returns
400 instead of silently sending a "Message from Recoup" footer-only email.

Rebased onto main after #731 (email_send_log observability) rewrote
validateSendEmailBody: reconciled to the new { rawBody, error } | { rawBody, data }
return shape and the readRawBody read path (comment updated; safeParseJson no
longer used here). With #731 in place, a guarded 400 is also recorded as a
`rejected` row in email_send_log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request Jun 30, 2026
…ent footer-only sends) (#729)

Guard sendEmailBodySchema with a refine requiring a non-empty html or text body,
and drop the html "" default. A malformed/empty body parses to {} and now returns
400 instead of silently sending a "Message from Recoup" footer-only email.

Rebased onto main after #731 (email_send_log observability) rewrote
validateSendEmailBody: reconciled to the new { rawBody, error } | { rawBody, data }
return shape and the readRawBody read path (comment updated; safeParseJson no
longer used here). With #731 in place, a guarded 400 is also recorded as a
`rejected` row in email_send_log.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant