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:
{
"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 byskarbiec list, limited to item ID, kind, tags, revision count, and state. No field values.
Single-account Gmail app password#
POST /v1/gmail/app-passwordwith{"skarbiec_item_id":"personal-gmail-app-password","display_name":null,"mailbox":"public-alias@example.com"}— resolve an existing password credential from Skarbiec, use itsusernamefor Gmail IMAP login, and prove it by logging in toimap.gmail.com:993over TLS. Withoutmailbox, the route creates or returns the normal Gmail mailbox with SMTPsmtp.gmail.com:587STARTTLS. Withmailbox, 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, optionaldisplay_name, and optionalmailbox; 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}, wherepathsalways carriesapp_password,oauthanddelegationin that order, each withverdict(usable,refusedorunproven), the refusalcodeor null, adetailcarrying the provider's own words, the exact nextaction, and anobservedmap of non-secret facts including the service account, its numericclient_id, the scopehttps://mail.google.com/, and the admin-console URL where a grant is made.emailis optional: without it only the account-independent state of each path is reported. Nothing is written and no secret is returned.POST /v1/gmail/delegatewith{"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 itemskrzynka:mailboxand 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 withMAILBOX_STILL_DECLARED.POST /v1/mailboxes/{mailbox_id}/syncPOST /v1/sync— reconcile with Skarbiec, then bounded synchronization of every declared mailbox; the response carriesreconciliation.
Declare body:
{
"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—limitoptional: every message when absentGET /v1/messages/{message_id}GET /v1/messages/{message_id}/repliesPOST /v1/messages/{message_id}/replies
Reply body:
{
"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 thememberrole, soowner,admin, andmembermay all send.GET /v1/mailboxes/{mailbox_id}/outbound?limit=N&offset=0— outbound messages of one mailbox, newest first; every one whenlimitis 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 whenlimitis given. Any verified organization role may read.GET /v1/outbound/{outbound_id}— one outbound message. Any verified organization role may read.
Send body:
{
"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.