Loopback JSON API

The service listens on http://127.0.0.1:8788 by default and refuses non-loopback bind addresses. Except for GET /healthz and the provider-owned Gmail callback, every route requires Authorization: Bearer and X-Wisent-Organization-ID: . The c

Overview#

The service listens on http://127.0.0.1:8788 by default and refuses non-loopback bind addresses. Except for GET /healthz and the provider-owned Gmail callback, every route requires Authorization: Bearer <wisent-session> and X-Wisent-Organization-ID: <organization-id>. The core verifies both with centralized Wisent identity and rejects a selected organization without a matching membership. Durable resources are filtered by the verified organization. All request and response bodies are JSON. Timestamps are RFC 3339 UTC strings. Errors use:

json
{
  "error": {
    "code": "MAILBOX_PROFILE_INVALID",
    "message": "smtp_host is required",
    "retryable": false
  }
}

Health and status#

  • GET /healthz — process readiness, schema version, product version.
  • GET /v1/status — database path, counts, poll interval, and Skarbiec availability; no credential resolution.

Skarbiec metadata#

  • GET /v1/skarbiec/items — metadata returned by skarbiec list, limited to item ID, kind, tags, revision count, and state. No field values.

Gmail profiles and authorization#

  • GET /v1/gmail/profiles — deduplicated Google account addresses and their source Skarbiec item IDs; passwords are never returned.
  • POST /v1/gmail/oauth/start with {"skarbiec_item_id":"platform-admin-google"} — create a PKCE flow and return flow_id and a trusted Google authorization_url. The flow stays pending until Google's callback settles it; it has no lifetime of Skrzynka's own.
  • GET /v1/gmail/oauth/callback?state=UUID&code=CODE — provider callback on the same loopback listener. Google invokes it after consent; it consumes the flow, verifies the authorized address, stores the durable authorization in Skarbiec, creates or reuses the Gmail mailbox, and returns only a close-window HTML result.
  • GET /v1/gmail/oauth/{flow_id} — poll pending, processing, completed, or failed. A completed response contains the mailbox; a failed response contains the normalized error.

The desktop app receives only the flow identifier and authorization URL, then polls status. OAuth codes, provider tokens, client secrets, and refresh tokens are never returned through JSON or stored in SQLite. After authorization completes, reconnecting the same account rotates its Skarbiec bundle and returns the existing mailbox. The configured Desktop-app client must accept the chosen loopback callback; see Gmail connection.

Single-account Gmail app password#

  • POST /v1/gmail/app-password with {"skarbiec_item_id":"personal-gmail-app-password","display_name":null,"mailbox":"public-alias@example.com"} — resolve an existing password credential from Skarbiec, use its username for Gmail IMAP login, and prove it by logging in to imap.gmail.com:993 over TLS. Without mailbox, the route creates or returns the normal Gmail mailbox with SMTP smtp.gmail.com:587 STARTTLS. With mailbox, the id-or-address selector chooses an existing row and attaches the item only for receiving while preserving that row's address, display name, SMTP host, port, TLS mode, and sending credential.
  • The response is the non-secret mailbox object. The request schema contains skarbiec_item_id, optional display_name, and optional mailbox; it rejects unknown fields and has no password field.

The route requires owner or admin through the same OrganizationRole::Admin guard as other mailbox-configuration routes. The operator creates the app-specific password and stores it in Skarbiec Desktop before calling this route. The loopback request carries only the item ID and optional mailbox selector: the password never enters an API request, API response, desktop process, SQLite row, or log line. Google refusal creates no mailbox and leaves a selected mailbox unchanged.

Workspace domain-wide delegation#

  • GET /v1/gmail/connection?email=<address> — which of the three connection paths this account can actually use, each verdict measured when the request is served. Answers {account, mailbox_id, receiving_skarbiec_item_id, usable_paths, paths}, where paths always carries app_password, oauth and delegation in that order, each with verdict (usable, refused or unproven), the refusal code or null, a detail carrying the provider's own words, the exact next action, and an observed map of non-secret facts including the service account, its numeric client_id, the scope https://mail.google.com/, and the admin-console URL where a grant is made. email is optional: without it only the account-independent state of each path is reported. Nothing is written and no secret is returned.
  • POST /v1/gmail/delegate with {"email":"user@domain","display_name":null} — mint a delegated access token for the address through the RFC 7523 JWT-bearer grant, persist the credential bundle in Skarbiec, and create or return the mailbox. No browser, no consent screen, no refresh token; every token is minted from the service-account key at connection time.

Failure codes: GOOGLE_DELEGATION_NOT_GRANTED (the Workspace admin has not granted the client ID; the message carries the exact recovery), GOOGLE_DELEGATION_REJECTED (the address is not an active user of a granting Workspace domain — consumer @gmail.com addresses can never delegate), GOOGLE_TOKEN_UNAVAILABLE (retryable transport failure). Delegation reads mail of any user in the granting domain: the service-account key in Skarbiec is a domain-wide credential and shares the vault's protection boundary.

Mailboxes#

  • GET /v1/mailboxes — reconcile with Skarbiec, then list the declared mailboxes.
  • POST /v1/mailboxes/declare — tag one Skarbiec item skrzynka:mailbox and import every INBOX message, one commit each.
  • POST /v1/mailboxes/{mailbox_id}/undeclare — remove the tag; the mailbox keeps its mail and stops polling.
  • GET /v1/mailboxes/{mailbox_id}
  • DELETE /v1/mailboxes/{mailbox_id}?confirm=true — delete the local mail of an undeclared mailbox; a declared one is refused with MAILBOX_STILL_DECLARED.
  • POST /v1/mailboxes/{mailbox_id}/sync
  • POST /v1/sync — reconcile with Skarbiec, then bounded synchronization of every declared mailbox; the response carries reconciliation.

Declare body:

json
{
  "skarbiec_item_id": "team-inbox"
}

Only skarbiec_item_id is accepted. The selected Skarbiec item supplies the account address and server profile; unknown request fields are refused. Password and authentication identity fields are intentionally absent. Declaring and undeclaring require owner or admin.

The declare response contains applied, source kind and item reference, mailbox_state, the public mailbox, message counts {imported, unchanged, conflicting, rejected}, and rejected_by_reason. It never returns the credential or provider message bodies. POST /v1/mailboxes/:id/sync and POST /v1/sync import everything past each cursor; there is no continuation flag.

The core fetches messages in UID order and commits each supported message with its cursor. Equal retained UIDs are unchanged. Source display-name and SMTP changes are adopted and reported as mailbox_state: updated. A changed receiving address or IMAP endpoint is refused because the retained cursor identifies a different source. A later fetch or validation failure preserves earlier commits; a failed pass does not imply an untouched mailbox. Unsupported provider rows are returned by rejection reason.

Messages and replies#

  • GET /v1/messages?mailbox_id=UUID&limit=N&offset=0 — limit optional: every message when absent
  • GET /v1/messages/{message_id}
  • GET /v1/messages/{message_id}/replies
  • POST /v1/messages/{message_id}/replies

Reply body:

json
{
  "idempotency_key": "d7fb235a-2177-4fa4-a2e0-e6dfa66a78aa",
  "body": "Thank you. We will get back to you today."
}

Reply text must not be empty; the SMTP server accepts or refuses its size. Reusing the same key returns the existing attempt and does not send again. sent means the configured SMTP server accepted the message. uncertain means acceptance could not be established and automatic resend is prohibited.

Outbound messages#

  • POST /v1/mailboxes/{mailbox_id}/outbound — originate one message from that mailbox. Requires the member role, so owner, admin, and member may all send.
  • GET /v1/mailboxes/{mailbox_id}/outbound?limit=N&offset=0 — outbound messages of one mailbox, newest first; every one when limit is absent. Any verified organization role may read.
  • GET /v1/outbound?mailbox_id=UUID&limit=N&offset=0 — every outbound message in the organization, optionally narrowed to one mailbox; a page when limit is given. Any verified organization role may read.
  • GET /v1/outbound/{outbound_id} — one outbound message. Any verified organization role may read.

Send body:

json
{
  "idempotency_key": "5c4b0c22-1c3a-4c8f-a2d3-4f6f4bcbb0f1",
  "to": ["buyer@example.net"],
  "cc": ["records@example.net"],
  "subject": "Quote request",
  "body": "Please quote 40 units for delivery next week."
}

to is required and must name at least one bare address; cc defaults to an empty list. Addresses are validated, deduplicated in the order given, and stored as comma-separated strings. subject must not be empty after trimming; body must be non-empty, and its size is the SMTP server's to accept or refuse. The row is claimed by idempotency_key before anything reaches the provider, so repeating the same call returns the first attempt instead of sending twice. The response is the stored outbound message with status sent, failed, or uncertain. No threading headers are written: an outbound message starts a thread rather than joining one.

Bounds and compatibility#

Lists return every row unless limit names a page. Each sync imports every message past the cursor, one commit each. Clients must tolerate additive response fields. Breaking resource or semantic changes use a new API path version under the repository's release policy.