---
title: "Received email detail"
method: GET
path: "/inbound/{uid}"
tags: ["Inbound"]
---

# Received email detail

`GET /inbound/{uid}`

## Request

- Base URL: `https://api.oveyon.com/v1`
- URL: `https://api.oveyon.com/v1/inbound/{uid}`
- Auth: HTTP bearer

## Path parameters

- `uid` string, required

## Response `200`

Message with attachment manifest and links

- InboundDetail
  - `id` string, uuid
  - `receivedAt` string, date-time
  - `from` string, nullable: Envelope sender (MAIL FROM); `""` is a bounce (null sender).
  - `fromHeader` string, nullable
  - `fromName` string, nullable
  - `subject` string, nullable
  - `domain` string, nullable
  - `thread` InboundThreadRef, nullable: The conversation this message belongs to (grouped by In-Reply-To/References within your account, including replies you sent through OVEYON). null = not resolved (fail-open) or older than the backfill.
    - `id` string, uuid, required: Thread id — GET /v1/inbound/threads/{id}; filter the list with ?thread=.
  - `recipients` object[]: One item per mailbox that received the message. Each recipient is judged by the policies and the security quarantine of ITS mailbox: one copy can be delivered while another is held.
    - `id` string
    - `to` string
    - `outcome` string: This copy: `accepted` (delivered) or `quarantined` (held for review). Messages from before per-mailbox policies carry the message outcome here.
    - `heldBy` 'agent_safety' | 'policy' | 'null', nullable: Who held this copy (kept after release, as history).
    - `policy` InboundPolicyInfo, nullable: The inbound policy outcome for this message (F4): the effective action, the name of the policy that closed the chain (or the first one that matched), and the accumulated tags. `would_<action>:<name>` tags (e.g. `would_hold:…`, `would_restrict_channels:…`) mean a policy WOULD have held/muted/restricted it, but it did not apply — see the decision feed for why; an agent should treat them as the rule's intent. null = no policy matched.
      - `action` 'hold' | 'mute' | 'tag' | 'restrict_channels' | 'pass' | 'notify'
      - `name` string, nullable
      - `tags` string[]
    - `spamFiltered` boolean: This copy's mailbox had the spam filter ON when it arrived: the message `spamFilter` applies to it (and its policies judged `spam_filter.score`). false = this mailbox did not ask; a score, if any, belongs to another mailbox of the same message.
  - `authentication` Authentication: null = not evaluated.
    - `spf` string, nullable
    - `dkim` string, nullable
    - `dmarc` string, nullable
  - `spamScore` integer, nullable
  - `spamFilter` InboundSpamFilter, nullable: The spam filter of the MAILBOX (rspamd at our receiving edge) — OPT-IN per mailbox: null when no mailbox of this message switched it on (or the message predates the filter), which is «no opinion», never «clean». It only annotates: we never refuse, hold or discard because of it; what to do with the score is your inbound policy (`spam_filter.score`). Different from `spamScore` (our own 0–100 heuristic, computed for every message).
    - `status` 'scanned' | 'unavailable' | 'skipped', required: `scanned` = there is a score; `unavailable` = the mailbox asked and the filter did not answer in time (delivered WITHOUT a score — fail-open); `skipped` = not scanned on purpose (`reason`).
    - `score` number, nullable, required: The rspamd score (decimal; negative = looks legitimate). Common thresholds: 6 (probable spam), 15 (spam). null unless `scanned`.
    - `action` 'no action' | 'greylist' | 'add header' | 'rewrite subject' | 'soft reject' | 'reject' | 'null', nullable, required: What rspamd SUGGESTS. Informational: we never execute it.
    - `reason` 'too_large' | 'bypass' | 'timeout' | 'error' | 'null', nullable, required: `skipped`: too_large (over 10 MiB) or bypass (mail from one of our own domains, on an explicit list, DKIM-signed by that same domain and passing DMARC — subdomains are not on it); `unavailable`: timeout or error.
    - `symbols` object[]: The rules that weighed, heaviest first (detail and webhook; omitted from the paginated list).
      - `name` string
      - `score` number
  - `agentSafety` AgentSafety, nullable
    - `verdict` 'clean' | 'suspicious' | 'dangerous': clean | suspicious | dangerous. Treat `null` (field absent) as «not evaluated», never as safe.
    - `score` integer: 0 = nothing found, 100 = maximum risk. The thresholds are 25 (suspicious) and 60 (dangerous).
    - `coverage` AgentSafetyCoverage, nullable: What the verdict actually looked at. null = message from before coverage was recorded. Read it before trusting `clean`.
      - `bodySampled` boolean, required: true when the scanner saw a head+tail sample (128 KiB) instead of the whole text/HTML, or a textual attachment was cut at its cap. A payload planted in the unsampled middle is not seen. Also true when an absurd header (a field over 64 KiB, a header block over 512 KiB, or over 5000 lines — 1000 in a MIME part) was cut before reading, or the message has more than 1000 MIME parts (the first 1000 are read).
      - `sanitized` boolean, required: true when hidden characters (Unicode Tags, zero-width, bidi overrides) were stripped from the text we hand you — the body, the subject or the sender display name (`fromName`). The raw copy (/raw) keeps them, for forensics.
      - `unscannedAttachments` integer, required: Attachments whose content the scanner did not read because they are not text: PDFs, images, spreadsheets, archives. Extract them yourself before a model reads them. Attached messages (.eml) ARE decoded and scanned.
    - `signals` object[]: Why the score is what it is. Present in the webhook payload and in GET /v1/inbound/{uid}; omitted from the paginated list. Each entry names one finding.
      - `type` string: Machine name of the finding, e.g. unicode_tags, imperativo_oculto, bidi, homoglifo. texto_ilegivel is a passage that cannot be normalized safely — too long without a single ASCII character (a very long paragraph of unbroken Chinese or Japanese, for example) or with too many combining marks — and is read in pieces; on its own, it leads at most to `suspicious`.
      - `points` integer: How much this finding added to the score.
      - `detail` string: Human-readable evidence — including the hidden text, decoded, so you can see what your agent would have read.
  - `sizeBytes` integer
  - `attachmentCount` integer
  - `expiresAt` string, date-time, nullable
  - `outcome` string: `accepted`, `blocked`, `quarantined` (every copy held) or `partial` (some copies delivered, some held — see `recipients[].outcome`) — branch on this, never on blockedReason. `policy` at message level is the first recipient's.
  - `blockedReason` string, nullable
  - `quarantine` InboundQuarantineInfo, nullable: Present ONLY on a message that was held by the agent-safety verdict (or, later, a reception policy) and then released by a human — panel or POST /v1/inbound/{uid}/release. Absent (null) on a normal delivery. Release is not absolution: agentSafety travels with it.
    - `reason` 'agent_safety_dangerous' | 'policy', required
    - `heldAt` string, date-time, required
    - `releasedAt` string, date-time, required
    - `releasedBy` string, nullable: `portal:<user>` or `api:<key prefix>`.
    - `agentSafety` object, nullable
      - `verdict` string
      - `score` integer
    - `policy` object, nullable: When one of your inbound policies held it (reason `policy`): its name and action.
      - `name` string
      - `action` string, nullable
  - `policy` InboundPolicyInfo, nullable: The inbound policy outcome for this message (F4): the effective action, the name of the policy that closed the chain (or the first one that matched), and the accumulated tags. `would_<action>:<name>` tags (e.g. `would_hold:…`, `would_restrict_channels:…`) mean a policy WOULD have held/muted/restricted it, but it did not apply — see the decision feed for why; an agent should treat them as the rule's intent. null = no policy matched.
    - `action` 'hold' | 'mute' | 'tag' | 'restrict_channels' | 'pass' | 'notify'
    - `name` string, nullable
    - `tags` string[]
  - `deliveries` InboundDelivery[]: Where this message was dispatched and what happened — one item per (recipient, channel, kind), oldest first. Includes the destination MX's answer for forwarded copies.
    - `id` string, uuid
    - `recipient` object
      - `id` string
      - `to` string
    - `channel` 'webhook' | 'forward' | 'telegram' | 'slack'
    - `kind` 'received' | 'quarantined' | 'retro': What was dispatched: the message itself at arrival (`received`), the quarantine notice (`quarantined`) or the retroactive link-check notice (`retro`).
    - `status` 'pending' | 'delivered' | 'failed' | 'dropped' | 'held'
    - `detail` string, nullable: Human-readable outcome of the last attempt (e.g. "HTTP 404"). Free text — never branch on it.
    - `attempts` integer
    - `createdAt` string, date-time
    - `completedAt` string, date-time, nullable
    - `destination` object, nullable: Forward channel only, and only when the copy left through one of OVEYON's sending nodes (the usual route; a copy the master delivers directly in a fallback stays `null`): the destination MX's verdict on the forwarded copy. `null` = not a forward, or not known yet. Synchronous verdict only — a mailbox that accepts and bounces later (asynchronous DSN) is not reflected here. And `accepted` is what the MX said, not what the mailbox shows: Gmail, for one, answers 250 and then silently discards (no Spam, no Trash) a copy whose Message-ID that account has already seen — duplicate suppression — and forwarding keeps the original Message-ID on purpose (it is what preserves the conversation and the sender's DKIM).
      - `status` 'accepted' | 'deferred' | 'bounced'
      - `response` string, nullable: The raw SMTP response, e.g. "250 2.0.0 OK … - gsmtp" or "550 5.1.1 … does not exist".
      - `host` string, nullable: The MX that answered.
      - `at` string, date-time, nullable: When it answered, as stamped by the node that delivered; `null` when the node sent no usable stamp.
  - `replies` InboundReply[]: Replies sent for this message (portal, Telegram, Slack or API), oldest first.
    - `id` string, uuid: Same id as the outbound message (GET /v1/messages/{id}).
    - `at` string, date-time
    - `origin` 'portal' | 'telegram' | 'slack' | 'api' | 'draft'
    - `from` string
    - `to` string
    - `subject` string, nullable
    - `preview` string, nullable: First 500 characters of the reply text; `null` once the reply is older than the inbound retention period (30 days), when the snippet is deleted and the rest of the record stays.
  - `attachments` InboundAttachment[]: The attachment manifest, stamped at ingest (never a re-parse).
    - `ord` integer: 1-based, stamped when the message came in; stable forever.
    - `filename` string, nullable: Display only — it came from the sender (may repeat, be empty or carry a path).
    - `contentType` string: The type the SENDER declared (the Content-Type of the MIME part), as is. `application/octet-stream` when the part declared none.
    - `detectedType` string, nullable: The type read from the first bytes of the file itself (its signature), independent of the label. Recognized families: PDF; legacy Office, Outlook .msg and .msi installers (`application/x-ole-storage` — the container is the same); .docx/.xlsx/.pptx, OpenDocument and EPUB; zip, 7z, rar, gzip, bzip2, jar; PNG, JPEG, GIF, TIFF, BMP, WebP, HEIC, AVIF, JPEG XL; MP4, MOV, 3GP, AVI; MP3, OGG, WAV, M4A; RTF, PostScript, XML; Windows (.exe/.dll) and Linux (ELF) executables. `null` = no recognized signature (plain text, a rare format) — it does not mean the label is wrong, only that nothing was recognized. Also `null` for messages received before 2026-10-15.
    - `typeMismatch` boolean: true when `contentType` and `detectedType` both exist and belong to different families — image, audio/video, PDF, PostScript, office document (legacy and modern Office, OpenDocument, RTF), archive, executable, text (e.g. a `.doc` labelled `image/png`). Synonyms of the same type (`image/jpg`, `application/x-zip-compressed`) and the same container (`application/zip` for a .docx) do not count; a generic label (`application/octet-stream`) never mismatches. A label outside these families only mismatches when the content is an executable — an executable mismatches every label that does not declare one. Do not discard an attachment on the label alone — prefer `detectedType` when it exists.
    - `sizeBytes` integer: Measured on decode, not declared.
    - `sha256` string, nullable: Hex digest of the decoded bytes.
    - `url` string: Where to fetch the bytes (GET, scope read:inbound).
  - `links` object
    - `self` string
    - `content` string
    - `raw` string
    - `reply` string: POST here to reply (scope reply:inbound).

## Other responses

- `401`: Missing or invalid API key.
- `403`: Insufficient scope (`insufficient_scope`, with `required` and `have`), a scope the key holds but cannot use while its human-approval lock is on (`scope_blocked_by_approval_lock`, with `required` — approve:holds, write:send-policies, manage:webhooks, write:inbound, write:templates, write:surveys, write:domains, write:contacts), API key switched off by its owner in the panel (`key_disabled` — the key is valid; turn it back on or use another one, do not rotate), sending disabled, account suspended (`account_suspended`) or unknown (`account_unknown`), or the key used from an IP outside its allowed ranges (`ip_not_allowed`, with the `allowed` list — see /v1/credentials/ip-rules). 403 is not a limit: do not retry.
- `404`: Does not exist, belongs to another account, or the identifier is malformed — the response is identical in all three cases, on purpose.
- `429`: Per-key limit (`rate_limited`, 600 requests/min by default per API key — or per MCP OAuth connection —, from any IP), per-IP limit for requests WITHOUT a valid credential (`rate_limited`, 600/min by default), too many unknown or malformed credentials from your address (`too_many_auth_failures`, 20/min by default, counted per IPv4 address or per IPv6 /48; a missing `Authorization`, an expired OAuth token or a disabled key do not count, and anything without the shape of one of our keys counts without being checked; from an address over that limit a valid key still gets through within 120 credential checks/min, and above them every credential not validated in the last 5 minutes gets this 429 until the minute turns) — all checked before the route, on every operation —, account quota (`quota_exceeded` with `window`, `used`, `limit`), warmup cap (`warmup_cap_reached`) or saturation (`queue_full`). Honor `Retry-After` with exponential backoff and jitter.
- `500`: internal_error — an unexpected failure on our side. The detail stays in our logs, never in the body. Retry reads; retry a write only when it is idempotent or carries an idempotency key.
- `503`: ip_rules_unavailable — the IP ranges bound to your key could not be checked right now (caution, not a denial). Retry after `Retry-After` (30 s).

---

[API](https://skmtc.dev/oveyon/apis/oveyon-api.md) · [All operations](https://skmtc.dev/oveyon/apis/oveyon-api/llms.txt) · [OpenAPI document](https://skmtc.dev/oveyon/apis/oveyon-api/revisions/abf64c09d297?raw)
