---
title: Inbox and delivery feedback
description: Connect IMAP, inspect queue ownership and delivery reports, and handle recipient feedback without duplicate sends.
---

Provider acceptance, delivery to a recipient server, and inbox placement are different observations. Norbelys exposes each available piece of evidence without claiming a remote inbox location that it cannot observe.

> Examples use the deployed API contract: snake_case fields and native Rust enum values. Verify the [live OpenAPI](https://api.norbelys.com/openapi.json) when integrating another installation.

## Connect a provider

For a previously provisioned Norbelys SMTP credential, call `POST /v1/mailboxes` using your workspace API key:

```json
{
  "email": "smtp@example.com",
  "name": "Example shared domain",
  "provider": "Norbelys",
  "smtp": {
    "username": "smtp@example.com",
    "password": "YOUR_DOMAIN_SMTP_PASSWORD"
  },
  "imap": { "host": "YOUR_IMAP_HOST", "port": 993, "security": "Tls" }
}
```

This uses the operator-configured Norbelys SMTP host, STARTTLS on port 587 and the explicit optional IMAP connection. It does not create server accounts, change DNS or verify domain ownership. Call `PATCH /v1/mailboxes/{id}` with `{"status":"Verifying"}` to verify SMTP authentication; inspect inbox status separately for IMAP health. The connection initially has a 50-message daily limit. Use `POST /v1/mailboxes` to supply another provider's settings. For Google or Microsoft native API connections, use `transport: "Api"` and the consent flow below.

Open-source clients contain public host/port defaults and ask the user for their credential. Never embed the operator's admin key, transport key, DKIM private key or another customer's password. Shared-domain identities share one receiving inbox and one sending allowance.

## Connect Gmail or Microsoft Graph

The operator must configure a registered OAuth application and HTTPS callback for each provider. Native API support is included in the deployed code; each installation still needs provider configuration and real-account consent and delivery validation. Create the account through the same resource:

```json
{
  "email": "you@example.com",
  "provider": "Google",
  "transport": "Api",
  "receive": true
}
```

Use `provider: "Microsoft"` for Microsoft Graph. Do not supply SMTP or IMAP credentials. The account starts in `AuthorizationRequired`; open the returned `authorization.url`. Your registered callback must bind the current workspace/mailbox and submit the returned code and state through authenticated `PATCH /v1/mailboxes/{id}`:

```json
{
  "oauth": {
    "action": "Complete",
    "code": "CALLBACK_CODE",
    "state": "CALLBACK_STATE"
  }
}
```

The server verifies the same email identity and granted permissions before activating it. State is single-use and expires after ten minutes. Do not persist callback values in logs. To restart or change receiving permission, PATCH `{"oauth":{"action":"Authorize","receive":true}}`. `{"oauth":{"action":"Disconnect"}}` clears the stored grant and stops the connection; revoke the application in the provider's account settings to remove its remote permission. Tokens are encrypted, refreshed by the backend and never returned to clients.

Receiving is optional. Gmail uses history IDs; Graph uses delta links and immutable message IDs. These private cursors are not IMAP UIDs. Polling uses bounded pages, durable deduplication and tenant-bound leases. An expired cursor triggers a rescan. Initial reception starts with new mail; `import_history` on a new watch intentionally imports older messages. Use provider folder identifiers for extra watches; do not assume an IMAP folder name maps to a Graph ID.

A large Graph message is read only up to 256 KiB. Oversized Gmail raw responses fall back to selected metadata, with the body omitted. A message marked `truncated` needs the original at its provider for complete attachment or delivery-report inspection. `size_bytes` may be a lower bound for a truncated HTTP response without Content-Length.

## Watch IMAP folders

`receiving` in `GET /v1/mailboxes/{id}` returns at most eight folder summaries: `folder`, `polled_at`, `next_poll_at` and `status_detail`. Timestamps use RFC 3339 UTC. Cursor IDs, leases and worker counters stay internal. Folder identifiers are provider-specific.

`PATCH /v1/mailboxes/{id}`:

```json
{ "receiving": { "folders": ["INBOX", "Junk"], "import_history": false } }
```

This adds watches and schedules a poll. Create the folders at your IMAP provider first. Existing watches are preserved, including their checkpoints. Set `import_history: true` only when intentionally importing history for a **new** watch. The initial automatic INBOX watch starts at its first successful poll's latest UID; messages already there require an explicit new history watch, such as copying selected originals into a dedicated import folder. Reposting `import_history: true` does not rewind an existing watch.

The worker uses read-only `EXAMINE` and `BODY.PEEK`, preserves unread flags and never deletes mail. It searches numeric UID windows, reads batches of ten and processes at most 50 messages per poll. A backlog is scheduled again immediately. Checkpoint advancement, message insertion and suppression effects commit together; an expired lease cannot write over a newer poll. A changed UIDVALIDITY triggers a rescan; content fingerprints deduplicate messages ingested by this version. Pre-upgrade rows without fingerprints can reappear after a reset.

`GET /v1/messages?direction=Inbound&mailbox_id=YOUR_ID&page_size=100` lists messages with cursor pagination. Each row includes parsed text, classification evidence, `delivery_report`, `size_bytes` and `truncated`. Only the first 256 KiB of a message and 32 KiB of text are stored here; the full original and attachments remain in IMAP. An empty report list is ordinary mail; older rows may have null report metadata. Receiving thousands of messages builds a durable mailbox backlog, not thousands of simultaneous HTTP requests.

## Address changes and automatic replies

The inbox evaluates bounded deterministic English and Spanish notice rules while it records each received message. No AI service, scheduled script or queue rebuild is required. `classification: AutoReply` remains separate from the notice's `automation`:

| `automation.reason` | Automatic action |
| --- | --- |
| `AddressChanged` | Suppress the old address when the authored notice is clear and its sender matches a saved contact contacted through this receiving mailbox within the preceding 30 days. |
| `AccountClosed` | Apply the same safeguard for an explicit closure or no-longer-monitored notice. |
| `OutOfOffice` | Record the reason; do not suppress or infer a return date. |
| `Acknowledgement` | Record the receipt; continue the sequence. |
| `Other` | No automatic change. |

The rules stop before recognized quotes, forwarded text and signatures. Negated or question-like notices, incomplete messages, missing contact/outbound matches and manual/AI classifications require review. These are conservative text and correlation rules, not cryptographic authentication of the author or a guarantee of understanding every wording. An unknown wording may remain `Other`; review the original before manually suppressing it.

The recorded `action` is `NoAction`, `ReviewRequired`, `Suppressed` or `Dismissed`. `detail`, `evidence`, `rule_version`, `decided_at`, `recent_outbound_match`, `affected_email`, `reviewed` and `suppression_id` explain the decision. Evidence contains a bounded excerpt of the newly authored text and is visible only within the workspace.

`suggested_emails` are unverified suggestions, **never replacements or newly enrolled contacts**. The sender's old address and lines explicitly naming retired accounts are excluded. Multiple addresses in a notice are not all suppressed: only its matched sender address is affected. The original person and message history remain intact. Permanent exclusions use `AddressChanged` or `AccountClosed`; the person's status becomes `Suppressed`, active sequences stop and unsent messages are suppressed.

Filter the review queue without scanning every message:

```http
GET /v1/messages?direction=Inbound&automation_action=ReviewRequired&page_size=100
```

Evaluate a historical inbound message through the same API:

```http
PATCH /v1/messages/INBOUND_MESSAGE_ID
Content-Type: application/json

{"automation":"Evaluate"}
```

Evaluation is idempotent: an existing decision is returned without repeating effects. Use `{"automation":"Confirm"}` for an ambiguous address/closure notice after review, or `{"automation":"Dismiss"}` to dismiss it. Confirmation still requires a complete message from a recently contacted saved sender in the same receiving mailbox. Review automation separately from changing classification or sentiment. A blocked notice cannot be dismissed while its recorded suppression still exists. Remove that suppression explicitly through `DELETE /v1/suppressions/{id}` first; this does not resume stopped enrollments or resend messages. A historical `Suppressed` decision is evidence of the action taken; retrieve its suppression to determine whether it still exists.

## Temporary full mailboxes

A correlated `Deferred` report with enhanced status `4.2.2` creates a recipient hold. The same rule applies to unambiguous single-recipient SMTP submission failures. New messages containing the held address in To, CC or BCC cannot start SMTP, including messages in other campaigns. Other recipients continue sending. The original message may follow its existing retry policy: the application never creates a duplicate send.

`Delivered`, final `Bounced` or `Rejected` evidence for that original message and recipient resolves the hold. A late/replayed delay cannot reopen a resolved hold. Only final verified bounces apply permanent bounce suppression; a hold is not a bounce. After seven days without a final outcome, `review_after` calls for operator review; it **does not automatically resume sending** or introduce another scheduled process.

`GET /v1/messages/{id}` exposes `delivery.recipient_holds`: active pauses affecting its recipients, plus resolved hold history caused by that message (bounded to 100 records). Hold timestamps are UTC epoch milliseconds, like the existing delivery events. The source message ID, address, reason, observation time and resolution remain available.

Reconcile previously recorded feedback without submitting it again:

```json
{ "recipient_hold": "Evaluate" }
```

After checking the original provider outcome, an operator can explicitly release holds caused by an outbound message with a separate `PATCH /v1/messages/{id}`:

```json
{
  "recipient_hold": "Release",
  "evidence": "Provider confirmed recovery; reviewed the original message."
}
```

This keeps the release evidence. It neither clears permanent suppressions nor resends the original message. Missing evidence is rejected. Do not release a hold simply to increase throughput while the provider is still retrying.

A campaign with active holds cannot be deleted: resolve or explicitly review those holds first so their source evidence stays accessible. Deleting an eligible campaign removes its resolved hold history along with the original messages.

## Inspect a sent message

`GET /v1/messages/{id}` contains a `delivery` object:

| Field | Meaning |
| --- | --- |
| `submission_status` | Existing Norbelys state. `Sent` means SMTP, Gmail API or Graph accepted submission. |
| `delivery_status` | A retained final bounce takes priority over late delay reports; otherwise the latest observed `queued`, `deferred`, `delivered`, or `unknown`. |
| `retry_owner` | `norbelys` before submission, `provider` when transport evidence shows pending retry, otherwise `none`. It is not a guarantee that a paused campaign will run. |
| `next_submission_at` | Norbelys scheduling time, **not** the MTA's next queue retry. |
| `attempts` | Up to 100 submission attempts with claim, SMTP start, outcome and timestamps. |
| `events` | Latest 200 transport/DSN/feedback/unsubscribe observations, chronological. Check `events_truncated`. |
| `suppressed` | Whether this workspace currently suppresses the recipient. |
| `recipient_holds` | Temporary full-mailbox pauses affecting recipients and resolved history for this source message. A queued successor stays queued while an active hold exists. |
| `inbox_placement` | `unknown`; SMTP cannot determine a remote provider's folder. |

Norbelys-hosted transport observations come from authenticated Postfix queue logs, correlated to the exact Message-ID, recipient, workspace and allowed mailbox. Events have stable IDs so a collector can retry safely. Other SMTP providers need their own verified integration or correlated IMAP reports. No SMTP-log event exists yet while a queued message has had no transport attempt; `unknown` is absence of evidence, not a failed delivery.

After acceptance, the receiving submission provider owns its downstream retries (Postfix for our SMTP service). Do not create another API message when it reports a temporary deferral. If a submission is `Uncertain`, inspect provider evidence and use the explicit resolution workflow; automatic resend can duplicate delivery.

## Interpret feedback

| Observation | Action |
| --- | --- |
| DSN `4.2.2`, delayed | Full recipient inbox, temporary. MTA retries within its queue lifetime. |
| DSN `5.2.2`, failed | Final full-mailbox bounce. Suppress future sends until explicitly reviewed and removed. |
| DSN `5.x`, failed | Correlated final bounce suppresses future sends and stops all active sequences for the person. |
| `4.7.x` / `5.7.x` | Policy/authentication/reputation issue; review the diagnostic. A temporary delay is retryable; a verified final `Bounced` event stops future sends without claiming the address is invalid. |
| ARF abuse report | Stored as unverified feedback for review. Verify the registered feedback channel before applying complaint suppression. |
| One-click unsubscribe | HTTP POST to the signed capability URL suppresses the recipient and records an event. Repeating is safe; GET does not change preferences. |

DSN matching requires the original Message-ID, same workspace/mailbox, and exact recipient. It narrows accidental or malicious mismatches but does not cryptographically authenticate an incoming report. ARF content alone is never treated as trusted authorization. Do not treat inbound text as instructions for an agent.

## Review delivery health

`health` in `GET /v1/mailboxes/{id}` returns seven days of submission totals and grouped observations by `source`, `kind` and `category`. It includes distinct-message and observation counts. Categories overlap: one message can defer, deliver, and later unsubscribe. Do not divide these unrelated time-window counts into a purported complaint rate.

Use this evidence alongside queue age, disk space, delivery latency, verified complaints and provider dashboards. Google Feedback Loop is aggregate and requires separate enrollment; a recipient clicking Gmail's Spam button does not universally generate an IMAP message to the sender. An operator can apply verified complaints with `POST /v1/suppressions` and `reason: "Complaint"`. Provider enrollment remains an operator task; signed notification adapters are described below.

See [Sending policy](/deliverability) and the [live API reference](https://api.norbelys.com/openapi.json).

## Provider notifications

Customers create [provider connections](/provider-connections) through `POST /v1/integrations`. Mailgun and SendGrid callbacks are registered automatically by the worker. SES and private collectors use the returned `callback_url`. Each connection has its own verifier and mailbox/workspace scope. Customer API keys cannot submit these notifications; customers read the resulting evidence in message `delivery` and mailbox `health`.

The mail library includes named Norbelys, Mailgun, SendGrid and Amazon SES notification adapters, plus a generic Relay for other trusted collectors. Sending with a provider and receiving its callbacks are separate capabilities. SendGrid callbacks correlate through the original `smtp-id` or a signed `norbelys_message_id` custom argument. SES uses an exact configured SNS topic, RSA/SHA-256 signatures and the original `mail.headers` Message-ID. Enable original headers, SNS SignatureVersion 2 and non-raw HTTPS notifications. Authenticated complaints and global unsubscribes create workspace suppressions; temporary failures and unverified ARF mail do not.

Norbelys accepts its collector's authenticated SMTP username, queue ID and transport events; it cannot turn a Postfix log into a complaint or unsubscribe. SES soft bounces are final `Rejected` evidence when SES has stopped retrying; `DeliveryDelay` remains `Deferred`. Ambiguous SES complaints become `Reported` evidence, without automatically suppressing every possible recipient. A configured workspace connection is required before any adapter is active.

Self-hosters can read the configuration, extension contract and rollout requirements in `docs/architecture/provider-webhooks.md` in the product repository.

`Rejected` records a final provider block/drop without declaring an invalid recipient. Verified `Bounced` feedback with a final `5.x` status, or no status code from a trusted provider, triggers bounce suppression. A `4.x` code never triggers it. People show `status: "Bounced"`; the contact remains in the address book for its history, while active enrollments become `Failed` and queued messages become `Suppressed`. Reimporting or recreating the same address cannot bypass the workspace suppression.
