跳到內容

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.

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.

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.

All routes resolve owner, inbox, agent, and full provider thread key on the server:

  • GET|POST .../threads/{thread_key}/agent-session reads or idempotently ensures the opaque per-thread session.
  • GET .../threads/{thread_key}/agent-messages returns a bounded, opaque-cursor page of internal messages and compact inbound/reply delivery events.
  • POST .../threads/{thread_key}/agent-messages accepts only internal text, an optional selected inbound id, client message id, and idempotency key.
  • POST .../threads/{thread_key}/agent-cancel cancels 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.

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 / ownerAuthored provenance.
  • 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.

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.

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.

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_cd participates 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 simple configuration over weighted subject/body text. Sender matching is escaped literal substring search backed by pg_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.

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.

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.

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