Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,14 @@ components:
$ref: "#/components/schemas/Attachment"
type: array
bcc:
description: Override Bcc recipients. The message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
cc:
description: Override Cc recipients. The message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
html:
maxLength: 1048576
Expand All @@ -257,9 +257,9 @@ components:
maxLength: 1048576
type: string
to:
description: Override primary recipients. The message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
type: object
Attachment:
Expand Down Expand Up @@ -1305,14 +1305,14 @@ components:
$ref: "#/components/schemas/Attachment"
type: array
bcc:
description: Bcc recipients. The message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
cc:
description: Cc recipients. The message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
conversation_id:
type: string
Expand All @@ -1326,9 +1326,9 @@ components:
maxLength: 1048576
type: string
to:
description: Primary recipients. The message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
required:
- to
Expand Down Expand Up @@ -2327,14 +2327,14 @@ components:
$ref: "#/components/schemas/Attachment"
type: array
bcc:
description: Additional Bcc recipients. The final message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
cc:
description: Additional Cc recipients. The final message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
conversation_id:
type: string
Expand Down Expand Up @@ -2431,14 +2431,14 @@ components:
$ref: "#/components/schemas/Attachment"
type: array
bcc:
description: Bcc recipients. The message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
cc:
description: Cc recipients. The message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
conversation_id:
type: string
Expand Down Expand Up @@ -2471,9 +2471,9 @@ components:
maxLength: 1048576
type: string
to:
description: Primary recipients. The message is limited to 50 recipients across to, cc, and bcc combined.
items:
type: string
maxItems: 50
type: array
required:
- to
Expand Down Expand Up @@ -4858,6 +4858,12 @@ paths:
schema:
$ref: "#/components/schemas/SendResultView"
description: Accepted — the approved outbound message was durably queued for async submission (status=accepted); terminal outcome via GET/webhook events.
"400":
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
description: Bad Request — invalid reviewer overrides. error.code includes too_many_recipients when to, cc, and bcc contain more than 50 recipients combined; error.details reports max_recipients and provided.
"409":
content:
application/json:
Expand Down
6 changes: 3 additions & 3 deletions internal/agent/hitl_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ type approveRequest struct {
Subject *string `json:"subject,omitempty" maxLength:"2000"`
BodyText *string `json:"text,omitempty" maxLength:"1048576"`
BodyHTML *string `json:"html,omitempty" maxLength:"1048576"`
To *[]string `json:"to,omitempty" nullable:"false" maxItems:"50"`
CC *[]string `json:"cc,omitempty" nullable:"false" maxItems:"50"`
BCC *[]string `json:"bcc,omitempty" nullable:"false" maxItems:"50"`
To *[]string `json:"to,omitempty" nullable:"false" doc:"Override primary recipients. The message is limited to 50 recipients across to, cc, and bcc combined."`
CC *[]string `json:"cc,omitempty" nullable:"false" doc:"Override Cc recipients. The message is limited to 50 recipients across to, cc, and bcc combined."`
BCC *[]string `json:"bcc,omitempty" nullable:"false" doc:"Override Bcc recipients. The message is limited to 50 recipients across to, cc, and bcc combined."`
Attachments *[]outbound.Attachment `json:"attachments,omitempty"`
}

Expand Down
28 changes: 13 additions & 15 deletions internal/httpapi/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,11 @@ const (
)

// Per-field GA contract limits on outbound request bodies. These are the named
// source-of-truth for the maxLength / maxItems struct tags on SendEmailRequest,
// ReplyRequest, ForwardRequest, and agent.ApproveOverrides — the tags use the
// numeric literals (struct tags can't reference consts), so keep the literals in
// sync with these values (TestOutboundFieldLimitTagsMatchConsts guards against
// drift). Grounded in provider research: subject matches Resend/Postmark parity
// (SES accepts it); the 1 MiB body field is a per-field backstop independent of
// the composed-message ceiling; maxItems mirrors the runtime maxRecipients cap.
// source of truth for maxLength struct tags on SendEmailRequest, ReplyRequest,
// ForwardRequest, and agent.ApproveOverrides. Struct tags use numeric literals,
// so TestOutboundFieldLimitTagsMatchConsts guards against drift. Recipient count
// is handler-validated separately so every distribution across to/cc/bcc returns
// the same structured too_many_recipients error.
const (
// maxSubjectLen caps a single subject line. Enforced via maxLength struct tags.
maxSubjectLen = 2000
Expand Down Expand Up @@ -193,9 +191,9 @@ func recipientCountError(groups ...[]string) *ErrorEnvelope {
// processing. subject/text moved from schema-required to handler-enforced so
// the template shape can omit them.
type SendEmailRequest struct {
To []string `json:"to" nullable:"false" maxItems:"50"`
CC []string `json:"cc,omitempty" nullable:"false" maxItems:"50"`
BCC []string `json:"bcc,omitempty" nullable:"false" maxItems:"50"`
To []string `json:"to" nullable:"false" doc:"Primary recipients. The message is limited to 50 recipients across to, cc, and bcc combined."`
CC []string `json:"cc,omitempty" nullable:"false" doc:"Cc recipients. The message is limited to 50 recipients across to, cc, and bcc combined."`
BCC []string `json:"bcc,omitempty" nullable:"false" doc:"Bcc recipients. The message is limited to 50 recipients across to, cc, and bcc combined."`
Subject string `json:"subject,omitempty" maxLength:"2000" doc:"Literal subject. Required unless a template reference is used (mutually exclusive with template_id/template_alias)."`
Body string `json:"text,omitempty" maxLength:"1048576" doc:"Literal plain-text body. Required unless a template reference is used (mutually exclusive with template_id/template_alias)."`
HTMLBody string `json:"html,omitempty" maxLength:"1048576" doc:"Literal HTML body. Mutually exclusive with template_id/template_alias."`
Expand Down Expand Up @@ -322,8 +320,8 @@ type ReplyRequest struct {
Body string `json:"text" maxLength:"1048576"` // required (MSG-3); to/subject derived from the original
HTMLBody string `json:"html,omitempty" maxLength:"1048576"`
ReplyAll bool `json:"reply_all,omitempty"`
CC []string `json:"cc,omitempty" nullable:"false" maxItems:"50"`
BCC []string `json:"bcc,omitempty" nullable:"false" maxItems:"50"`
CC []string `json:"cc,omitempty" nullable:"false" doc:"Additional Cc recipients. The final message is limited to 50 recipients across to, cc, and bcc combined."`
BCC []string `json:"bcc,omitempty" nullable:"false" doc:"Additional Bcc recipients. The final message is limited to 50 recipients across to, cc, and bcc combined."`
ConversationID string `json:"conversation_id,omitempty"`
ReplyTo string `json:"reply_to,omitempty" doc:"Sets the Reply-To header — where replies to this message are directed. A single RFC 5322 address, optionally with a display name. Defaults to the sending agent's own address."`
Attachments []outbound.Attachment `json:"attachments,omitempty" nullable:"false" doc:"File attachments (base64 in each item's data). Limits: at most 10 attachments, each ≤ 10 MB decoded, and ≤ 25 MB decoded combined. Exceeding the count → 400 invalid_request; exceeding a size → 413 payload_too_large."`
Expand Down Expand Up @@ -455,9 +453,9 @@ func (s *Server) replyRecipients(msg *identity.Message, replyAll bool, extraCC [

// ForwardRequest mirrors the legacy forward body.
type ForwardRequest struct {
To []string `json:"to" nullable:"false" maxItems:"50"` // required (MSG-3)
CC []string `json:"cc,omitempty" nullable:"false" maxItems:"50"`
BCC []string `json:"bcc,omitempty" nullable:"false" maxItems:"50"`
To []string `json:"to" nullable:"false" doc:"Primary recipients. The message is limited to 50 recipients across to, cc, and bcc combined."` // required (MSG-3)
CC []string `json:"cc,omitempty" nullable:"false" doc:"Cc recipients. The message is limited to 50 recipients across to, cc, and bcc combined."`
BCC []string `json:"bcc,omitempty" nullable:"false" doc:"Bcc recipients. The message is limited to 50 recipients across to, cc, and bcc combined."`
Body string `json:"text" maxLength:"1048576"` // required (MSG-3); subject derived as "Fwd:"
HTMLBody string `json:"html,omitempty" maxLength:"1048576"`
ConversationID string `json:"conversation_id,omitempty"`
Expand Down
15 changes: 4 additions & 11 deletions internal/httpapi/outbound_field_limits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,10 @@ func TestComposedMessageSizeCountsDecodedNotWire(t *testing.T) {

// --- struct-tag / const drift guard ---

// The maxLength / maxItems struct-tag literals can't reference Go consts, so
// this test guards against drift: if someone bumps the const but forgets the
// tag (or vice versa), this fails. Covers all four outbound request shapes.
// The maxLength struct-tag literals can't reference Go consts, so this test
// guards against drift: if someone bumps a const but forgets the tag (or vice
// versa), this fails. Recipient totals are handler-validated because per-field
// maxItems would preempt the documented too_many_recipients error contract.
func TestOutboundFieldLimitTagsMatchConsts(t *testing.T) {
type want struct {
field string
Expand All @@ -152,22 +153,14 @@ func TestOutboundFieldLimitTagsMatchConsts(t *testing.T) {
{"Subject", "maxLength", fmt.Sprintf("%d", maxSubjectLen)},
{"Body", "maxLength", fmt.Sprintf("%d", maxBodyFieldBytes)},
{"HTMLBody", "maxLength", fmt.Sprintf("%d", maxBodyFieldBytes)},
{"To", "maxItems", fmt.Sprintf("%d", maxRecipients)},
{"CC", "maxItems", fmt.Sprintf("%d", maxRecipients)},
{"BCC", "maxItems", fmt.Sprintf("%d", maxRecipients)},
}},
{"ReplyRequest", ReplyRequest{}, []want{
{"Body", "maxLength", fmt.Sprintf("%d", maxBodyFieldBytes)},
{"HTMLBody", "maxLength", fmt.Sprintf("%d", maxBodyFieldBytes)},
{"CC", "maxItems", fmt.Sprintf("%d", maxRecipients)},
{"BCC", "maxItems", fmt.Sprintf("%d", maxRecipients)},
}},
{"ForwardRequest", ForwardRequest{}, []want{
{"Body", "maxLength", fmt.Sprintf("%d", maxBodyFieldBytes)},
{"HTMLBody", "maxLength", fmt.Sprintf("%d", maxBodyFieldBytes)},
{"To", "maxItems", fmt.Sprintf("%d", maxRecipients)},
{"CC", "maxItems", fmt.Sprintf("%d", maxRecipients)},
{"BCC", "maxItems", fmt.Sprintf("%d", maxRecipients)},
}},
}
for _, c := range cases {
Expand Down
128 changes: 103 additions & 25 deletions internal/httpapi/outbound_recipient_cap_test.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
package httpapi

import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"

"github.com/Mnexa-AI/e2a/internal/identity"
)

// MED-4 — a send whose total to+cc+bcc recipient count exceeds the cap (50)
// is rejected before any delivery. With the GA maxItems:"50" schema tags, a
// single field over 50 is now caught earlier — at the Huma schema-validation
// layer — as a 422 invalid_request (the same canonical validation code as 400,
// just the "well-formed but unprocessable" status). The runtime combined-count
// cap (to+cc+bcc > 50 with each field ≤ 50) is covered by
// TestSendRecipientCapCountsAllFields below and still returns 400
// too_many_recipients.
func assertTooManyRecipients(t *testing.T, code int, body map[string]any, provided int) {
t.Helper()
if code != 400 || errCode(body) != "too_many_recipients" {
t.Fatalf("want 400 too_many_recipients, got %d %v", code, body)
}
errObj, _ := body["error"].(map[string]any)
details, _ := errObj["details"].(map[string]any)
if details["max_recipients"] != float64(maxRecipients) || details["provided"] != float64(provided) {
t.Fatalf("want recipient-cap details max=%d provided=%d, got %v", maxRecipients, provided, body)
}
}

// A single recipient field over the total cap must reach the same handler-level
// validation as a cap violation distributed across fields.
func TestSendTooManyRecipients(t *testing.T) {
srv := testServer(t)
to := make([]string, 51)
Expand All @@ -22,9 +33,7 @@ func TestSendTooManyRecipients(t *testing.T) {
code, body := postJSON(t, srv.URL+sendURL, "good", map[string]any{
"to": to, "subject": "Hi", "text": "hello",
})
if code != 422 || errCode(body) != "invalid_request" {
t.Fatalf("want 422 invalid_request (schema-level maxItems), got %d %v", code, body)
}
assertTooManyRecipients(t, code, body, 51)
}

// The cap counts to+cc+bcc together, so 50 split across the three fields must
Expand All @@ -44,9 +53,7 @@ func TestSendRecipientCapCountsAllFields(t *testing.T) {
code, body := postJSON(t, srv.URL+sendURL, "good", map[string]any{
"to": mk("t", 20), "cc": mk("c", 20), "bcc": mk("b", 11), "subject": "Hi", "text": "hello",
})
if code != 400 || errCode(body) != "too_many_recipients" {
t.Fatalf("split over cap: want 400 too_many_recipients, got %d %v", code, body)
}
assertTooManyRecipients(t, code, body, 51)
// 20 + 20 + 10 = 50 -> at the cap, allowed (subject HOLD avoided -> sent).
code, _ = postJSON(t, srv.URL+sendURL, "good", map[string]any{
"to": mk("t", 20), "cc": mk("c", 20), "bcc": mk("b", 10), "subject": "Hi", "text": "hello",
Expand All @@ -56,9 +63,7 @@ func TestSendRecipientCapCountsAllFields(t *testing.T) {
}
}

// The forward path enforces the same schema-level maxItems cap (>50 in a single
// field → 422). The runtime combined-count path is shared with send via
// recipientCountError.
// Forward uses the same 400/code/details contract.
func TestForwardTooManyRecipients(t *testing.T) {
srv := testServer(t)
to := make([]string, 51)
Expand All @@ -68,14 +73,10 @@ func TestForwardTooManyRecipients(t *testing.T) {
code, body := postJSON(t, srv.URL+"/v1/agents/support%40acme.com/messages/msg_in1/forward", "good", map[string]any{
"to": to, "text": "fwd",
})
if code != 422 || errCode(body) != "invalid_request" {
t.Fatalf("forward: want 422 invalid_request (schema-level maxItems), got %d %v", code, body)
}
assertTooManyRecipients(t, code, body, 51)
}

// The reply path enforces the same schema-level maxItems cap on cc (>50 in a
// single field → 422). The runtime combined-count path is shared via
// recipientCountError.
// Reply uses the same 400/code/details contract.
func TestReplyTooManyRecipients(t *testing.T) {
srv := testServer(t)
cc := make([]string, 51)
Expand All @@ -85,7 +86,84 @@ func TestReplyTooManyRecipients(t *testing.T) {
code, body := postJSON(t, srv.URL+"/v1/agents/support%40acme.com/messages/msg_in1/reply", "good", map[string]any{
"text": "re", "cc": cc,
})
if code != 422 || errCode(body) != "invalid_request" {
t.Fatalf("reply: want 422 invalid_request (schema-level maxItems), got %d %v", code, body)
assertTooManyRecipients(t, code, body, 51)
}

// Reviewer overrides must not bypass or drift from the normal outbound cap.
func TestApproveOverrideTooManyRecipients(t *testing.T) {
srv := testServer(t)
to := make([]string, 51)
for i := range to {
to[i] = fmt.Sprintf("r%d@x.com", i)
}
code, body := postJSON(t, srv.URL+"/v1/reviews/msg_pending/approve", "good", map[string]any{"to": to})
assertTooManyRecipients(t, code, body, 51)
}

// An override replaces only the fields it supplies. Count the final merged
// draft, not just the override arrays, so a partial edit cannot push a valid
// held message over the cap.
func TestApprovePartialOverrideCountsStoredRecipients(t *testing.T) {
srv := testServer(t, func(d *Deps) {
d.GetReviewWithContent = func(_ context.Context, _, id string) (*identity.Message, error) {
if id != "msg_pending" {
return nil, fmt.Errorf("not found")
}
to := make([]string, 30)
for i := range to {
to[i] = fmt.Sprintf("stored%d@x.com", i)
}
return &identity.Message{
ID: id, AgentID: "support@acme.com", Direction: "outbound",
Status: "pending_review", ToRecipients: to,
}, nil
}
})
bcc := make([]string, 21)
for i := range bcc {
bcc[i] = fmt.Sprintf("override%d@x.com", i)
}
code, body := postJSON(t, srv.URL+"/v1/reviews/msg_pending/approve", "good", map[string]any{"bcc": bcc})
assertTooManyRecipients(t, code, body, 51)
}

// The combined cap cannot be represented by JSON Schema maxItems on each
// individual array without changing the error contract. Keep it discoverable
// in the generated schema descriptions while leaving handler validation in
// control of the uniform 400 response.
func TestRecipientCapIsDocumentedWithoutPerFieldSchemaValidation(t *testing.T) {
raw, err := json.Marshal(New(Deps{}).API.OpenAPI())
if err != nil {
t.Fatal(err)
}
var doc struct {
Components struct {
Schemas map[string]struct {
Properties map[string]struct {
Description string `json:"description"`
MaxItems *int `json:"maxItems"`
} `json:"properties"`
} `json:"schemas"`
} `json:"components"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
for schema, fields := range map[string][]string{
"SendEmailRequest": {"to", "cc", "bcc"},
"ReplyRequest": {"cc", "bcc"},
"ForwardRequest": {"to", "cc", "bcc"},
"ApproveRequest": {"to", "cc", "bcc"},
} {
for _, field := range fields {
property := doc.Components.Schemas[schema].Properties[field]
if property.MaxItems != nil {
t.Errorf("%s.%s: maxItems must not preempt the uniform handler error, got %d", schema, field, *property.MaxItems)
}
description := strings.ToLower(property.Description)
if !strings.Contains(description, "50") || !strings.Contains(description, "combined") {
t.Errorf("%s.%s: recipient cap is not discoverable in description %q", schema, field, property.Description)
}
}
}
}
Loading
Loading