An Email is a single inbound message received through AWS SES, recorded in Postgres and tied to one inbox. The original .eml is preserved on S3 and is reachable through the API.
Purpose
- Persist every message that enters the platform, including those rejected by policy.
- Carry the processing state (
received → approved | rejected → processing → processed | failed). - Provide the joining row for agent runs, system events and webhook deliveries.
Important: no direct writes
Emails are never modified via the public API except through the explicit reprocess action. They are written by the SES ingestion handler and the worker pipeline; the only way to create one through the API is POST /emails, which submits a raw .eml into that same pipeline (metadata.source is api). DELETE /emails/{id} soft-deletes.
Schema
| Field | Type | Description |
|---|---|---|
id | UUID | Internal id. |
workspace_id | UUID | Owning workspace. |
inbox_id | UUID | Inbox that matched. |
agent_id | UUID | null | Agent bound to the inbox at the time of processing. |
message_id | string | SES messageId. Unique — guarantees idempotency. |
raw_s3_key | string | Key of the original .eml on the raw bucket. |
subject | string | null | Decoded subject. Filled by the worker after parsing. |
from | string | Envelope sender. |
to | string[] | Recipients from the SES payload. |
cc | string[] | Filled by the worker. |
bcc | string[] | Filled by the worker (rarely populated — Bcc is stripped in transit). |
status | enum | See lifecycle. |
rejection_reason | enum | null | Set when status=rejected. |
spam_verdict | string | null | From SES (PASS, FAIL, GRAY, etc.). |
virus_verdict | string | null | From SES. |
aws_ses_metadata | object | Raw SPF/DKIM/DMARC + receipt info from SES. |
custom_metadata | object | Free-form, set by clients. |
agent_metadata | object | Output written by the agent run (extracted fields, tags, etc.). |
metadata | object | System metadata. |
sent_at | datetime | null | From the message Date header. |
received_at | datetime | When SES received it. |
processed_at | datetime | null | When the agent finished. |
created_at / updated_at / deleted_at | datetime |
Status
| Status | Meaning |
|---|---|
received | Ingestion handler wrote the row; worker hasn't run yet. |
approved | Worker parsed the .eml, matched filters and allowed recipients. Queued for the agent. |
rejected | Worker rejected the email. rejection_reason is set. Agent will not run. |
processing | Agent run started. |
processed | Agent finished successfully. |
failed | Agent run failed. Manual reprocess is allowed. |
Rejection reasons
| Reason | Meaning |
|---|---|
recipient_unknown | No matching Inbox. |
recipients | allowed_recipients rejected the to address. |
attachments | Attachment count, per-file size or total size exceeded. |
filters | At least one filter rule failed. |
encoding | .eml could not be parsed. |
spam | SES spamVerdict=FAIL (and inbox policy treats FAIL as block). |
virus | SES virusVerdict=FAIL. |
sender_rate_limit | Sender exceeded per-inbox cap. |
Example
{
"id": "e5f6a7b8-9c0d-4e1f-2a3b-4c5d6e7f8a9b",
"workspace_id": "a8b2c1d4-9e7f-4a3b-8c5d-1f2e3a4b5c6d",
"inbox_id": "b3c4d5e6-7a89-4b0c-9d1e-2f3a4b5c6d7e",
"agent_id": "c4d5e6f7-8a9b-4c0d-9e1f-2a3b4c5d6e7f",
"message_id": "01HZ9X8K4N0P2Q5R7S9T1V3W5X",
"raw_s3_key": "incoming/01HZ9X8K4N0P2Q5R7S9T1V3W5X.eml",
"subject": "Invoice 4123",
"from": "billing@acme.com",
"to": ["triage-3f4a@inbox.kobana.com.br"],
"cc": [],
"bcc": [],
"status": "processed",
"rejection_reason": null,
"spam_verdict": "PASS",
"virus_verdict": "PASS",
"aws_ses_metadata": {
"spfVerdict": { "status": "PASS" },
"dkimVerdict": { "status": "PASS" },
"dmarcVerdict": { "status": "PASS" }
},
"custom_metadata": {},
"agent_metadata": {
"type": "invoice",
"amount": 4123.5,
"due_date": "2026-06-01"
},
"metadata": {},
"sent_at": "2026-05-17T10:00:00.000Z",
"received_at": "2026-05-17T10:00:14.123Z",
"processed_at": "2026-05-17T10:00:18.927Z",
"created_at": "2026-05-17T10:00:14.123Z",
"updated_at": "2026-05-17T10:00:18.927Z",
"deleted_at": null
}
Downloading the .eml
GET /v1/emails/{id}/eml returns a 302 redirect to a short-lived presigned S3 URL. The URL is valid for 15 minutes and serves the original message with full headers, MIME parts and attachments preserved.
Downloading the .eml from a webhook
Every email.* webhook payload also carries a ready-to-use data.email.download_url — a link a webhook receiver can follow directly, with none of the credentials above:
https://app.inbox.kobana.com.br/eml/{id}/{token}
- It is not part of
/v1and is not served onapi.inbox.*: that host requires an mTLS client certificate on every request, which a webhook receiver has no way to present. This link lives on the app host instead (no mTLS). {token}is a one-time-issued, short-lived (30 minutes) credential minted specifically for that delivery — it is not your API token and grants access to that one email only.- Following it redirects (
302) straight to a presigned S3 URL valid for 5 minutes — the same underlying fileGET /v1/emails/{id}/emlserves, just reached without mTLS/JWT. - After 30 minutes the link stops working; re-fetch the email (
GET /v1/emails/{id}, mTLS+JWT) and download it the regular way instead.
Reprocessing
POST /v1/emails/{id}/reprocess re-enqueues the email for the agent. Only emails with status=approved, processed or failed are eligible — emails still in received, processing or rejected return 422 unprocessable_entity.