Agent email inbox contracts
本頁內容尚未翻譯。
The Agent Email product contains two related but distinct timelines:
- Mail is the external delivery record. It shows inbound messages, quarantine/release state, and email that an Agent actually sent.
- Agent discussion is an owner-only conversation embedded in the selected Email thread. Text entered there is an internal Agent instruction and is never sent to the external sender by itself.
Agents remain the only actors that can send external replies. The UI and
management API never accept a recipient, CC, BCC, or reply-to override. The
only external-send boundary is arinova.email.reply, which resolves the
recipient and provider thread from a dispatched inbound record.
Private Agents can discover mail through arinova.email.list_inbound and
arinova.email.get_inbound. Both are owner/agent scoped, expose only
dispatched rows, omit provider headers, frame all external content as
untrusted data, and persist only non-sensitive IDs for replay. MCP-only Agents
poll list_inbound because MCP has no inbound wakeup channel.
Trigger sources and delivery mode
Section titled “Trigger sources and delivery mode”Email emits the email.quarantined and email.released Trigger sources for
quarantine lifecycle automation. A normal accepted message deliberately does
not emit email.received, because that would create a second execution path
beside inbox delivery.
Each inbox instead selects exactly one receive path. The default agent mode
writes the Agent wakeup outbox after authentication, allowlist, and inbound
billing checks. workflow mode skips that wakeup and starts the selected
active, owner-owned Workflow with actorType: "email". The same inbound UUID
is the Workflow idempotency key, so replaying one provider webhook does not
create another run. Deleting the selected Workflow atomically changes the
inbox back to Agent delivery before the foreign key is cleared.
The Workflow trigger payload includes inbox, inbound email, message,
conversation, sender, bounded subject, receive time, attachment names, and an
optional matched allowlist pattern. It never includes the untrusted body; an
Agent step reads that through arinova.email.get_inbound. Workflow delivery
still charges the normal inbound point and never adds a separate run charge.
If the selected Workflow is inactive, missing, or over its hourly run limit,
the email remains stored and the mailbox shows a durable skip reason.
WORKFLOW_RUNS_PER_HOUR defaults to 10 per Workflow, which is intentionally
lower than normal inbox capacity. For a high-volume inbox, an operator must
raise that runtime setting or the owner must narrow the sender allowlist.
Runtime adapter and product surface
Section titled “Runtime adapter and product surface”Creating an inbox inserts only agent_email_inboxes; it does not create a
Conversation. A trusted dispatched thread lazily owns one
agent_email_thread_sessions row and one internal runtime conversation with
subtype agent_email_thread. The owner membership is system-hidden.
These runtime rows are implementation adapters, not Chat resources. General
Conversation lists, search, unread/full sync, activity, memory/rollup,
autopilot, files, sharing, pins, exports, and V1 Conversation APIs must reject
both agent_email_thread and agent_email_legacy. Direct access to an owned
internal id resolves to the exact /email?tab=inbox&inbox=...&email=...&view=...
location; another owner receives not found.
The browser receives server-derived surface: "email" on internal message,
stream, cancellation, and action events. A mounted Email reader consumes them
through the embedded-session registry. An unmounted reader drops them; they
never load or mutate the general Chat store, play Chat sounds, create Chat
toasts, or increment Chat unread. External mail unread remains exclusively on
agent_email:unread-count.
Internal Agent discussion API
Section titled “Internal Agent discussion API”All routes resolve owner, inbox, agent, and full provider thread key on the server:
GET|POST .../threads/{thread_key}/agent-sessionreads or idempotently ensures the opaque per-thread session.GET .../threads/{thread_key}/agent-messagesreturns a bounded, opaque-cursor page of internal messages and compact inbound/reply delivery events.POST .../threads/{thread_key}/agent-messagesaccepts only internal text, an optional selected inbound id, client message id, and idempotency key.POST .../threads/{thread_key}/agent-cancelcancels only the active Agent generation for that owned thread.
A quarantine-only, discarded-only, or failed-only thread cannot ensure or send.
If a thread already has a dispatched message, a newly quarantined message may
not enter context even though the established Agent tab remains available.
Internal discussion uses the existing Agent-message billing path. Only an
actual provider reply uses the email_reply debit/refund path.
EmailThreadIsolated context
Section titled “EmailThreadIsolated context”Email runtime dispatch uses a dedicated scope rather than Standard Chat:
- Context includes only dispatched inbound rows from the exact inbox and full thread key, bounded to 24 timeline rows and an 18,000-character budget.
- The selected dispatched email is preserved deterministically when older context is truncated.
- External sender, subject, and body are escaped and wrapped as untrusted data.
Owner handling instructions use a separate trusted block, and internal
messages retain explicit
eventOrigin/ownerAuthoredprovenance. - Ambient Agent memory, general message-memory embedding/retrieval, session rollup, post-completion memory workers, autopilot, identifier/link-preview side effects, skill-candidate learning, and general navigation context are disabled.
- Surface/context lookup failures fail closed as Email-isolated; they never downgrade to Standard Chat.
Cost and volume
Section titled “Cost and volume”Inbound processing and agent replies debit the configured point prices. Reply
admission also enforces rolling reputation limits before any debit or provider
reservation: 20 replies per inbox per hour and 5 replies to the same normalized
recipient per 24 hours by default. These limits are runtime-configurable and a
breach returns resource_conflict. Existing inbound rate_limited quarantine
rows remain readable for historical compatibility; current inbound dispatch
does not create new ones.
Custom inbox addresses
Section titled “Custom inbox addresses”The settings page can replace a random inbox address with a paid custom address. Custom slugs are globally unique across both current and retired addresses. They contain 8–32 lowercase ASCII letters, numbers, or underscores, start with a letter, and cannot contain consecutive underscores. The shared username guard and the dedicated mail-role guard reserve platform, brand, profanity, RFC 2142, and mail-infrastructure names.
GET /api/email-inboxes/address-check?slug=...&agentId=... is an
owner-authenticated, separately rate-limited preflight that returns
availability, an invalid, reserved, or taken reason, and the current
price. POST /api/agents/{agent_id}/email-inbox/address claims the address in
one database transaction with its slug advisory lock and a
vanity_address_purchase debit. The price comes from
AGENT_EMAIL_VANITY_ADDRESS_PRICE_POINTS (2,000 points by default), and only
paid points are eligible. A shortfall returns 402 PAID_POINTS_REQUIRED with
total balance, paid balance, price, and difference.
The one-time debit is idempotent per inbox and slug. The same inbox can reclaim any slug it previously held without another charge, including one first claimed while the configured price was zero. There is no self-service refund; exceptional refunds use the existing admin reversal workflow. Claiming another custom address retires the current address and charges the configured price again.
Retirement remains immediate: mail to the former address is discarded, not
forwarded. Retired slugs occupy the global namespace while retained in the
inbox’s bounded ten-address history. Free random rotation sets isCustom to
false; rotating a custom address requires confirmCustomLoss: true so clients
cannot silently discard a paid name. Inbound resolution prefers a current
address over a legacy current/retired collision as defense in depth.
List and search pagination
Section titled “List and search pagination”GET /api/agents/{agent_id}/email-inbox/emails defaults to conversation
grouping and accepts q, folder, unread, from, to, hasAttachment,
groupBy, status, cursor, and limit.
- Without
q, ordering is newest first and the opaque cursor is the existing(created_at, id)keyset cursor. - With
q,ts_rank_cdparticipates in ordering. Ranked results therefore use an opaque offset cursor instead of pretending the timestamp-only keyset is stable. Clients must discard a cursor whenever any search or filter input changes. - Full-text matching uses the PostgreSQL
simpleconfiguration over weighted subject/body text. Sender matching is escaped literal substring search backed bypg_trgm, so%,_, and\never become wildcard syntax.
Thread summaries count both inbound messages and agent replies. The thread detail endpoint supports both current per-thread sessions and the exact legacy inbox conversation. Both paths remain constrained to owner, inbox, agent, and full thread key even when message metadata contains a matching value.
Reply rows persist a bounded delivery state so the owner can distinguish
sent, transient_error, permanent_error (including its refund state), and
insufficient_credit directly in the conversation. An insufficient-credit row
is a durable, uncharged reservation: after a top-up, the same fingerprint
reuses that row, enters the debit boundary once, and then retries delivery.
Read state
Section titled “Read state”inbound_emails.read_at means that the owner has viewed the message. It does
not change dispatch, wakeup, or agent-processing state. Read mutations are
idempotent and broadcast the owner-scoped agent_email:unread-count WebSocket
event after the database update.
Email action catalog decision
Section titled “Email action catalog decision”The flat arinova.email.list_inbound, arinova.email.get_inbound, and
arinova.email.reply actions intentionally remain in the immutable catalog for
every private Agent. The registry, manifest version, prompt XML, schema selection, and
execution lookup share one process-wide catalog; making this family depend
on a database inbox lookup would make the manifest user-specific and introduce
cache invalidation and live-turn drift when an inbox is created, paused, or
rotated.
The compact prompt entry has a small, fixed token cost. Runtime authorization
remains the security boundary: the handler loads the inbound message through
the authenticated owner, agent, and exact Email thread session, requires a
dispatched message and active inbox, and never accepts a recipient argument.
General Chat, another thread, agent, or owner receives resource_not_found.
When MCP calls reply without a conversation id, the inbound id resolves the
unique current thread session. Legacy mail without a session remains hidden.
Because the MCP action call has no conversation, its action_event card is not
inserted into a discussion timeline; the successful surface=email message
event still broadcasts to the owner using the resolved session conversation.
Reply fingerprints continue to search both current and legacy runtime rows so
the existing 24-hour idempotency, debit, retry, and refund semantics survive
the session split. A future catalog-wide capability-scoping design may revisit
catalog visibility, but a one-off database-dependent filter is deliberately
not introduced here.
Legacy and deletion
Section titled “Legacy and deletion”The migration classifies old inbox-wide rows only through
agent_email_inboxes.conversation_id, never by translated title, as
agent_email_legacy. It hides legacy memberships and advances their read
cursor. Existing inbound/reply evidence remains dual-readable.
On the first internal interaction with an old dispatched thread, the server
creates the new thread session and exactly one idempotent runtime anchor. The
legacy message_id remains unchanged; runtime_message_id points to a message
inside the new session so reply relationships never cross conversations.
Deleting an inbox, Agent, or owner cascades the session row and deletes its
internal adapter conversations.
Build a7f47a5ca54ddcf7806cd48b81ce1b9827042766