odysseus-dev/odysseus · error · HTTPException

Invalid send payload: {exc}

Error message

Invalid send payload: {exc}

What it means

Raised by the Codex email proxy POST /api/codex/emails/send when the raw JSON body fails to construct routes.email_routes.SendEmailRequest. That Pydantic model requires the fields to, subject, and body (cc, bcc, body_html, attachments, account_id, in_reply_to, references, source_uid, source_folder are optional), so any missing required field, wrong type, or extra/misspelled key surfaces as ValidationError and is re-raised as HTTP 400 with the Pydantic message embedded.

Source

Thrown at routes/codex_routes.py:386

        from routes.email_routes import SendEmailRequest

        try:
            req = SendEmailRequest(**body)
        except Exception as exc:
            raise HTTPException(400, f"Invalid draft payload: {exc}")
        return await email_draft_endpoint(req=req, owner=owner)

    @router.post("/emails/send")
    async def codex_email_send(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
        owner = _scope_owner(request, EMAIL_SEND_SCOPES)
        if email_send_endpoint is None:
            raise HTTPException(503, "Email integration is not available")
        from routes.email_routes import SendEmailRequest

        try:
            req = SendEmailRequest(**body)
        except Exception as exc:
            raise HTTPException(400, f"Invalid send payload: {exc}")
        return await email_send_endpoint(req=req, background_tasks=BackgroundTasks(), owner=owner)

    # ── Memory ────────────────────────────────────────────────────────────

    @router.get("/memory")
    async def codex_memory_list(request: Request):
        owner = _scope_owner(request, MEMORY_READ_SCOPES)
        if memory_list_endpoint is None:
            raise HTTPException(503, "Memory integration is not available")
        return await _as_owner(request, owner, memory_list_endpoint, request)

    @router.post("/memory")
    async def codex_memory_add(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
        owner = _scope_owner(request, MEMORY_WRITE_SCOPES)
        if memory_add_endpoint is None:
            raise HTTPException(503, "Memory integration is not available")
        from src.request_models import MemoryAddRequest

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Include all required fields: to (string), subject (string), body (plain-text string); optional fields are cc, bcc, body_html, in_reply_to, references, attachments, account_id, source_uid, source_folder.
  2. Read the ValidationError text in the 400 response body — it names the exact offending field and reason.
  3. Keep 'to' as a single comma/semicolon-separated string, matching the model's str type, not a JSON array.
  4. Pre-validate locally with the same model: from routes.email_routes import SendEmailRequest; SendEmailRequest(**payload).

Example fix

// before
fetch('/api/codex/emails/send', {method:'POST', body: JSON.stringify({recipient: 'a@b.c', subject: 'hi', body: 'hello'})})
// after
fetch('/api/codex/emails/send', {method:'POST', body: JSON.stringify({to: 'a@b.c', subject: 'hi', body: 'hello'})})
Defensive patterns

Strategy: validation

Validate before calling

from routes.email_routes import SendEmailRequest

def valid_send_payload(body: dict) -> bool:
    try:
        SendEmailRequest(**body)
        return True
    except Exception:
        return False

# client-side (no imports): required string fields
# ok = all(isinstance(body.get(k), str) and body[k] for k in ('to', 'subject', 'body'))

Type guard

def is_send_payload(b: dict) -> bool:
    return (
        isinstance(b, dict)
        and isinstance(b.get('to'), str) and b['to'].strip()
        and isinstance(b.get('subject'), str)
        and isinstance(b.get('body'), str)
    )

Try / catch

resp = requests.post(url, json=payload)
if resp.status_code == 400 and 'Invalid send payload' in resp.text:
    # parse the embedded pydantic message, fix fields, do NOT retry unchanged
    show_validation_error(resp.json()['detail'])

Prevention

When it happens

Trigger: POST /api/codex/emails/send with a body missing 'to', 'subject', or 'body'; passing 'recipient' instead of 'to'; sending 'to' as a list when the model declares str; or any nested object the model cannot coerce.

Common situations: An LLM agent or script composing the payload by hand guesses field names; a UI refactor renames compose fields; sending HTML-only mail and forgetting the plain-text 'body' fallback; copying a payload from a different mail API (e.g. 'html' instead of 'body_html').

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/3054707c03008d40. Report an issue: GitHub.