openapi: 3.1.0 info: title: Zomail API (user-facing) version: 0.10.102 summary: Mail, calendar, contacts, tasks, Drive and Meet for signed-in Zomail users. description: | The HTTP API behind Zomail webmail, for native iOS/Android clients and other first-party-style apps that act **on behalf of one signed-in mailbox**. * **Auth** — `Authorization: Bearer `. One token = one mailbox. A multi-account app keeps one token per account and sends each request with the token of the account it is about. Mobile apps should use the device sign-in / refresh-token flow described in the mobile spec (`mobile.openapi.yaml`, merged into the published bundle) and fall back to `POST /v1/auth/login` only where noted. * **Envelope** — successful JSON responses are `{"data": ...}`; `204 No Content` has no body. * **Errors** — `{"error": "", "code": "", "message": "", "details": {...}}`. Every error body has `error` and `code` (`code` equals `error` when there is nothing more specific). Translate `code` (falling back to `error`) in the app; never show `message` to end users. See the `Error` schema. An unknown route (any path and method, checked before authentication) answers `404 {error: not_found, code: route_not_found}`; a JSON body over a route's size limit `413 {error: bad_request, code: payload_too_large}`. * **Time** — timestamps are ISO 8601 in UTC (`2026-10-04T08:15:30.000Z`); calendar dates are `YYYY-MM-DD`. * **Language** — the API returns codes, not sentences. The mailbox's language (used for e-mails the server sends on its behalf, e.g. invitations, codes, vacation subject) is set with `PUT /v1/account/locale` (`vi` or `en`). * **Limits** — JSON request bodies are limited to 1 MiB unless an operation states a larger limit (sending: ~35 MiB). Administration (`/v1/admin/*`), platform/fleet/infra, partner (`/v1/partner/*`), billing and sign-up (`/v1/public/signup*`, `/v1/public/billing/*`), Private Mail licence sales (`/v1/public/private/*`: price list with volume and multi-year discounts, quotes, checkout — see the handbook chapter "Private Mail licences"), relay and WOPI routes exist but are **not** part of this document. contact: name: Zomail developer support url: https://zomail.io/developers/ license: name: Proprietary identifier: LicenseRef-Zomail-Proprietary servers: - url: https://api.zomail.io description: Zomail Cloud - url: '{apiBaseUrl}' description: Zomail Private server (the customer's own install; resolve the base URL per account, see README "Discovery") variables: apiBaseUrl: default: https://api.example.com description: Origin of the Private server's API (no trailing slash) tags: - name: Auth description: Sign in (password, then optional TOTP second factor) and sign out. x-displayName: Auth - name: Account description: The signed-in mailbox, its sessions and language. x-displayName: Account - name: Account security description: Password, two-factor authentication (TOTP), app passwords for IMAP/SMTP clients, recovery e-mail. x-displayName: Account security - name: Mail settings description: Signature, vacation responder, filters and automatic forwarding (one settings document per mailbox). x-displayName: Mail settings - name: Delegation & forwarding description: Giving colleagues access to your mailbox, opening mailboxes delegated to you, verified forwarding addresses. x-displayName: Delegation & forwarding - name: Mail import description: Copy mail from another IMAP account (Gmail, Microsoft 365, Yahoo, any IMAP server) into this mailbox. x-displayName: Mail import - name: Mail description: Folders and message lists. x-displayName: Mail - name: Messages description: Reading messages, threads, attachments, raw source, and bulk actions (read, star, move, delete...). x-displayName: Messages - name: Drafts description: Simple draft storage in the Drafts folder (see also Compose). x-displayName: Drafts - name: Sending description: Sending a message and following its delivery (submissions, outbox). x-displayName: Sending - name: Compose description: Rich compose — undo send, schedule send, recall, compose preferences, AI writing help. x-displayName: Compose - name: Templates description: Reusable message templates. x-displayName: Templates - name: Labels description: User labels (IMAP keywords) on messages. x-displayName: Labels - name: Organize description: Gmail-like views, inbox categories (tabs), mute, snooze, display preferences. x-displayName: Organize - name: Safety & unsubscribe description: Report spam / not spam, blocked and allowed senders, one-click unsubscribe. x-displayName: Safety & unsubscribe - name: AI description: Optional AI features (tenant opt-in, metered per hour). x-displayName: AI - name: Calendar description: Events (CalDAV-backed) and invitations received by mail. x-displayName: Calendar - name: Contacts description: Personal address book (CardDAV-backed). x-displayName: Contacts - name: Tasks description: Personal to-do list (CalDAV VTODO-backed). x-displayName: Tasks - name: Drive description: Files and folders, uploads, sharing, Office editing. x-displayName: Drive - name: Meet description: Video meetings (LiveKit). x-displayName: Meet - name: Public description: Endpoints that need no sign-in. x-displayName: Public - name: Mobile sign-in description: Device sessions — access and refresh tokens per (installation, mailbox). x-displayName: Mobile sign-in - name: Devices & push description: The account's signed-in devices, remote sign-out, push registration. x-displayName: Devices & push - name: Discovery & app config description: Which server hosts an address, what it offers, which app versions it supports. x-displayName: Discovery & app config - name: Delta sync description: What changed since a cursor (mail per folder, calendar, contacts). x-displayName: Delta sync - name: Uploads description: Resumable attachment uploads for compose. x-displayName: Uploads - name: Account deletion description: In-app account deletion requests (App Store / Google Play rule). x-displayName: Account deletion - name: Meet calls description: Ringing colleagues on their phones. x-displayName: Meet calls - name: QR sign-in description: Sign a browser in by scanning the web login page's QR code with the signed-in app (docs/api/qr-login.md). x-displayName: QR sign-in paths: /v1/auth/login: post: operationId: authLogin tags: - Auth summary: Sign in with e-mail and password description: | Exchanges the mailbox address and password for a **session token** (valid 12 hours from creation, not extended by use). When the mailbox has two-factor authentication on, the answer is an MFA challenge instead: send it with a TOTP or recovery code to `POST /v1/auth/login/mfa` within 5 minutes. * Throttling: 5 failed attempts for one address within 15 minutes lock that address for 15 minutes (`429 login_throttled`, `Retry-After` in seconds). Networks caught password-spraying are blocked as a whole (same 429, `retryAfterSeconds` in the body). * Cloud only: if the address belongs to a customer on a **Private server**, the answer is `409 hosted_elsewhere` with the server's web URL — before any password check. Resolve that server's API and sign in there (README, "Discovery"). * Mobile apps: prefer the device sign-in flow of the mobile spec (long-lived refresh token); this endpoint returns a short-lived web session. security: [] requestBody: required: true content: application/json: schema: type: object required: - email - password properties: email: type: string format: email maxLength: 320 description: Trimmed and lower-cased by the server. password: type: string minLength: 1 maxLength: 1024 example: email: lan@example.vn password: correct horse battery staple responses: '200': description: Signed in, or a second factor is required. content: application/json: schema: type: object required: - data properties: data: oneOf: - $ref: '#/components/schemas/Session' - $ref: '#/components/schemas/MfaChallenge' examples: session: value: data: token: Jq1b6l3o0vN8m4b5Zr8w1tq9X2pQkq0hJb5rM7oT1yE expiresAt: '2026-10-04T20:15:30.000Z' mailbox: id: 6f1c1d8e-2a51-4f3b-9d6e-0c9a1b2c3d4e primaryAddress: lan@example.vn displayName: Nguyễn Lan quotaBytes: 5368709120 mfa: value: data: mfaRequired: true challenge: 3mJ0aJqvJ2l8qk1d7n1cG5yJcT0gq4Fv0N7yL2xQb1s '400': $ref: '#/components/responses/BadRequest' '401': description: '`error: invalid_credentials` — wrong address or password (same answer for unknown addresses).' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: invalid_credentials code: invalid_credentials message: Incorrect email or password '409': description: '`error: hosted_elsewhere` — the mailbox lives on a Private server; `url` is that server''s web origin.' content: application/json: schema: type: object required: - error - url properties: error: type: string const: hosted_elsewhere url: type: string format: uri example: error: hosted_elsewhere url: https://mail.company.vn '429': description: '`error: login_throttled` — too many failures for this address or from this network. `Retry-After` and `retryAfterSeconds` give the wait.' headers: Retry-After: $ref: '#/components/headers/RetryAfter' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: login_throttled code: login_throttled details: retryAfterSeconds: 812 message: Too many failed sign-ins; try again later retryAfterSeconds: 812 '503': $ref: '#/components/responses/ServiceUnavailable' /v1/auth/login/mfa: post: operationId: authLoginMfa tags: - Auth summary: Complete sign-in with a second factor description: | Turns the `challenge` from `POST /v1/auth/login` into a session. `code` is a 6-digit TOTP code or an unused recovery code (`xxxxx-xxxxx`, consumed on use). A challenge lives 5 minutes and allows 5 attempts; then it is `mfa_expired` and the user must sign in again. Wrong codes also count towards the address's login throttle. security: [] requestBody: required: true content: application/json: schema: type: object required: - challenge - code properties: challenge: type: string minLength: 20 maxLength: 200 code: type: string minLength: 6 maxLength: 20 description: TOTP code or recovery code (trimmed). example: challenge: 3mJ0aJqvJ2l8qk1d7n1cG5yJcT0gq4Fv0N7yL2xQb1s code: '492071' responses: '200': description: Signed in. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Session' '400': description: '`error: validation_error` (no details on this route).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': description: | `error: invalid_mfa` — wrong code, try again; `error: mfa_expired` — challenge expired or used up, start over with `POST /v1/auth/login`. (`code: mfa_unavailable` with `error: invalid_mfa` when the server has no MFA.) content: application/json: schema: $ref: '#/components/schemas/Error' examples: wrong: value: error: invalid_mfa code: invalid_mfa message: Incorrect verification code expired: value: error: mfa_expired code: mfa_expired message: The sign-in challenge has expired; sign in again '503': $ref: '#/components/responses/ServiceUnavailable' /v1/auth/logout: post: operationId: authLogout tags: - Auth summary: Sign out (end this session) description: | Deletes the session of the bearer token (a delegated `dlg.` token ends only that delegated session). Always `204`, also without or with an unknown token — safe to call when removing an account from the app. responses: '204': description: Signed out. '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account: get: operationId: accountOverview tags: - Account summary: The signed-in mailbox, its app passwords and web sessions description: | Use it to validate a stored token and to show the account (address, display name, quota). `sessions` lists the mailbox's unexpired **browser** sessions (newest use first); `current: true` marks the caller's. Signed-in mobile devices are not in this list — they are managed with the device endpoints of the mobile spec. Not available to delegated sessions (use `GET /v1/account/delegated-session`). responses: '200': description: Account overview. content: application/json: schema: type: object required: - data properties: data: type: object required: - mailbox - appPasswords - sessions properties: mailbox: $ref: '#/components/schemas/Mailbox' appPasswords: type: array items: $ref: '#/components/schemas/AppPassword' sessions: type: array items: $ref: '#/components/schemas/WebSession' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/delegated-session: get: operationId: accountDelegatedSession tags: - Delegation & forwarding summary: Who am I (works for own and delegated sessions) description: | Returns the mailbox the token acts on and, for a delegated `dlg.` session, the delegation (who opened it and with which permissions). `delegation` is `null` for a normal session. Allowed for delegated sessions. responses: '200': description: Session identity. content: application/json: schema: type: object required: - data properties: data: type: object required: - mailbox - delegation properties: mailbox: $ref: '#/components/schemas/Mailbox' delegation: oneOf: - $ref: '#/components/schemas/DelegatedAccess' - type: 'null' '401': $ref: '#/components/responses/Unauthorized' security: - bearerAuth: [] /v1/account/password: post: operationId: accountChangePassword tags: - Account security summary: Change the password description: | Requires the current password. On success every **other** session of the mailbox (browser sessions and other signed-in mobile devices) is signed out and IMAP connections are dropped; the caller's session — and, when called from a mobile app, its own device — keeps working. App passwords keep working. Other devices of a multi-account app get `401 invalid_session` for this account and must sign in again. requestBody: required: true content: application/json: schema: type: object required: - currentPassword - newPassword properties: currentPassword: type: string minLength: 1 maxLength: 1024 newPassword: type: string minLength: 12 maxLength: 128 responses: '204': description: Password changed. '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: wrong_password` — the current password is incorrect. (`delegated_scope` for delegated sessions.)' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: rule_violation`, `code: password_unchanged` — the new password equals the current one.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/app-passwords: post: operationId: accountCreateAppPassword tags: - Account security summary: Create an app password (IMAP/SMTP/CalDAV clients) description: | An app password lets a third-party mail client sign in over IMAP/SMTP without the second factor, so creating one requires re-authentication: `currentPassword`, or (when MFA is on) a fresh `mfaCode`. The `secret` (26 lower-case characters) is returned **once**; only its hash is stored. At most 20 active app passwords per mailbox. A native Zomail app does not need app passwords — it uses the HTTP API. With MFA on (and the organisation's default policy) IMAP, SMTP and CalDAV/CardDAV accept only app passwords. Each new app password is mailed to the mailbox (and its verified recovery address). Wrong `currentPassword` answers count toward the per-mailbox re-authentication lock (`429`, `code: reauth_throttled`). requestBody: required: true content: application/json: schema: type: object required: - name properties: name: type: string minLength: 1 maxLength: 80 description: Label shown in the list (e.g. "Outlook on laptop"). currentPassword: type: string minLength: 1 maxLength: 1024 mfaCode: type: string minLength: 6 maxLength: 20 description: Used only when currentPassword is absent and MFA is enabled. example: name: Thunderbird currentPassword: correct horse battery staple responses: '201': description: Created; show `secret` once. content: application/json: schema: type: object required: - data properties: data: type: object required: - id - name - secret properties: id: type: string format: uuid name: type: string secret: type: string pattern: ^[a-km-np-z2-9]{26}$ '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: wrong_password`, `code` one of `wrong_password`, `mfa_invalid_code`, `reauth_required` (neither credential sent). `delegated_scope` for delegated sessions.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: rule_violation`, `code: app_password_limit` (`details.max` = 20).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/app-passwords/{id}: delete: operationId: accountRevokeAppPassword tags: - Account security summary: Revoke an app password description: Revokes it and drops the mailbox's open IMAP connections (the device using it must sign in again). parameters: - name: id in: path required: true schema: type: string format: uuid responses: '204': description: Revoked. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: rule_violation`, `code: app_password_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/sessions/{id}: delete: operationId: accountRevokeSession tags: - Account security summary: Sign out one session description: Ends one of the mailbox's sessions by its `id` from `GET /v1/account` (“Sign out this device”). parameters: - name: id in: path required: true schema: type: string responses: '204': description: Session ended. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: rule_violation`, `code: session_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/sessions/revoke-others: post: operationId: accountRevokeOtherSessions tags: - Account security summary: Sign out every other session description: Keeps only the caller's session (and, from a mobile app, its own device). Other browser sessions and other mobile devices are ended. Returns how many were ended. responses: '200': description: Done. content: application/json: schema: type: object required: - data properties: data: type: object required: - revoked properties: revoked: type: integer minimum: 0 '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/webpush: get: operationId: accountWebPushStatus tags: - Account summary: Web Push (webmail PWA) — the server key and this mailbox's browser subscriptions description: | For browsers only (the native apps use `POST /v1/account/devices/{id}/push`). `publicKey` is the server's VAPID key (`applicationServerKey` for `pushManager.subscribe`); when it differs from the key of the browser's current subscription, unsubscribe and subscribe again. Push-service URLs are never echoed (only their host). responses: '200': description: Key and subscriptions. content: application/json: schema: type: object required: - data properties: data: type: object required: - publicKey - subscriptions - organisationPreview - categories properties: publicKey: type: string description: Uncompressed P-256 point, base64url (65 bytes). organisationPreview: type: string enum: - full - minimal categories: type: array items: type: string enum: - newMail - meet - security subscriptions: type: array items: $ref: '#/components/schemas/WebPushSubscription' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] post: operationId: accountWebPushSubscribe tags: - Account summary: Subscribe this browser (or renew its subscription) description: | Send the browser's `PushSubscription` (`endpoint`, `keys.p256dh`, `keys.auth`). Call again on every page load while notification permission is granted: a subscription lives 90 days from the last call. Only https endpoints of the browser push services are accepted (Google FCM, Mozilla, Apple, Windows). At most 10 browsers per mailbox (the least recently seen go). A password change ends every subscription of the mailbox. Notification text follows the mobile push rules: `preview: minimal` (or the organisation's setting) never shows sender or subject; never the body. requestBody: required: true content: application/json: schema: type: object required: - endpoint - keys properties: endpoint: type: string format: uri maxLength: 2048 keys: type: object required: - p256dh - auth properties: p256dh: type: string description: base64url, 65 bytes auth: type: string description: base64url, 16 bytes locale: type: string examples: - vi - en categories: type: array items: type: string enum: - newMail - meet - security description: Default [newMail] for a new subscription; omitted on a renewal = keep the stored choice. preview: type: string enum: - full - minimal description: Default full; omitted = keep. userAgent: type: string maxLength: 2000 responses: '201': description: Subscribed. content: application/json: schema: type: object required: - data properties: data: type: object required: - id - subscriptions properties: id: type: string format: uuid subscriptions: type: array items: $ref: '#/components/schemas/WebPushSubscription' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`code: webpush_endpoint_not_allowed` — not a known browser push service.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] delete: operationId: accountWebPushUnsubscribe tags: - Account summary: Unsubscribe a browser description: Body `{endpoint}`. Always `204` (idempotent). Call it before signing out of the browser. requestBody: content: application/json: schema: type: object required: - endpoint properties: endpoint: type: string responses: '204': description: Removed (or nothing to remove). '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/webpush/test: post: operationId: accountWebPushTest tags: - Account summary: Send a test notification to this mailbox's browsers responses: '202': description: Queued (the worker sends it within seconds). content: application/json: schema: type: object required: - data properties: data: type: object properties: queued: type: integer '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '409': description: '`code: webpush_not_subscribed`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/mfa: get: operationId: accountMfaStatus tags: - Account security summary: Two-factor authentication status responses: '200': description: Status. content: application/json: schema: type: object required: - data properties: data: type: object required: - enabled - recoveryCodesLeft properties: enabled: type: boolean recoveryCodesLeft: type: integer minimum: 0 maximum: 10 description: 0 when MFA is off. appPasswordsOnly: type: boolean description: | The organisation's policy (on by default): with MFA on, IMAP, SMTP submission and CalDAV/CardDAV accept app passwords only, never the main password. Show the user that mail apps need an app password. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/mfa/setup: post: operationId: accountMfaSetup tags: - Account security summary: Start TOTP setup description: | Creates a new (not yet active) TOTP secret, replacing an unfinished setup. Show `uri` as a QR code (`otpauth://totp/...`) or `secret` (base32) for manual entry, then confirm with `POST /v1/account/mfa/enable`. Requires the current password (a stolen session alone cannot enrol its own authenticator). Wrong current passwords are counted per mailbox across every re-authentication prompt: 5 within 15 minutes lock them for 15 minutes (`429`, `code: reauth_throttled`). requestBody: required: true content: application/json: schema: type: object required: - currentPassword properties: currentPassword: type: string maxLength: 1024 responses: '200': description: Secret created. content: application/json: schema: type: object required: - data properties: data: type: object required: - secret - uri properties: secret: type: string description: Base32 TOTP secret. uri: type: string description: otpauth URI for authenticator apps. '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: wrong_password` (`code` `wrong_password` or `reauth_required`), or a delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: mfa_error`, `code: mfa_already_enabled`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/mfa/enable: post: operationId: accountMfaEnable tags: - Account security summary: Confirm TOTP setup and turn MFA on description: | Verifies a code from the authenticator and returns 10 single-use recovery codes (shown once). Signs out the mailbox's other web sessions, disconnects its mail apps and mails a notice to the mailbox (and its verified recovery address). requestBody: required: true content: application/json: schema: type: object required: - code properties: code: type: string description: Current 6-digit TOTP code. responses: '200': description: MFA enabled. content: application/json: schema: type: object required: - data properties: data: type: object required: - recoveryCodes properties: recoveryCodes: type: array minItems: 10 maxItems: 10 items: type: string pattern: ^[0-9a-f]{5}-[0-9a-f]{5}$ '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: mfa_error`, `code` one of `mfa_setup_required` (start setup again), `mfa_invalid_code`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/mfa/disable: post: operationId: accountMfaDisable tags: - Account security summary: Turn MFA off description: | Requires the current password and a valid TOTP or recovery code. 5 wrong codes within 15 minutes lock this action for 15 minutes. A notice is mailed to the mailbox (and its verified recovery address). requestBody: required: true content: application/json: schema: type: object required: - code - currentPassword properties: code: type: string currentPassword: type: string maxLength: 1024 responses: '204': description: MFA disabled. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: mfa_error`, `code: mfa_invalid_code`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: mfa_error`, `code: mfa_throttled`; `details.retryAfterSeconds`, `details.minutes`; `Retry-After` header.' headers: Retry-After: $ref: '#/components/headers/RetryAfter' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/recovery-email: get: operationId: accountRecoveryEmailStatus tags: - Account security summary: Recovery e-mail for self-service password reset description: | The address that receives "forgot password" links. Self-service reset can be unavailable for the mailbox (`unavailable` not null). Not available to delegated sessions. Present only on servers with password reset configured (otherwise the route does not exist: 404). responses: '200': $ref: '#/components/responses/RecoveryEmailStatus' '401': description: '`error: unauthorized` (no token, or the session is invalid).' content: application/json: schema: $ref: '#/components/schemas/Error' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: not_found`, `code: mailbox_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] put: operationId: accountRecoveryEmailSet tags: - Account security summary: Set (or re-send the code for) the recovery e-mail description: | Mails a 6-digit code (valid 24 hours) to `address`; the address is used only after `POST /v1/account/recovery-email/verify`. Requires the current password. One code per minute, at most 5 codes per day. Must not be the mailbox's own address. requestBody: required: true content: application/json: schema: type: object required: - address - currentPassword properties: address: type: string format: email maxLength: 320 currentPassword: type: string minLength: 1 maxLength: 1024 responses: '200': $ref: '#/components/responses/RecoveryEmailStatus' '400': $ref: '#/components/responses/BadRequest' '401': description: '`error: unauthorized`.' content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: '`error: wrong_password`; or `code: delegated_scope` for delegated sessions.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: | `error: rule_violation`, `code` one of `recovery_is_own_address`, `code_resend_too_soon` (`details.seconds`), `code_send_limit`, `reset_unavailable_platform_staff`, `reset_unavailable_shared_mailbox`, `reset_unavailable_suspended`, `reset_unavailable_tenant_disabled`. content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] delete: operationId: accountRecoveryEmailRemove tags: - Account security summary: Remove the recovery e-mail description: | Requires the current password **in the request body** (DELETE with a JSON body). Pending reset links stop working. Some HTTP stacks drop DELETE bodies — make sure yours sends it. requestBody: required: true content: application/json: schema: type: object required: - currentPassword properties: currentPassword: type: string minLength: 1 maxLength: 1024 responses: '200': $ref: '#/components/responses/RecoveryEmailStatus' '400': $ref: '#/components/responses/BadRequest' '401': description: '`error: unauthorized`.' content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: '`error: wrong_password`; or `code: delegated_scope`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/account/recovery-email/verify: post: operationId: accountRecoveryEmailVerify tags: - Account security summary: Confirm the recovery e-mail with the mailed code description: 5 wrong codes invalidate the code (`code_attempts_exceeded`); send a new one with PUT. requestBody: required: true content: application/json: schema: type: object required: - code properties: code: type: string pattern: ^\d{6}$ responses: '200': $ref: '#/components/responses/RecoveryEmailStatus' '400': $ref: '#/components/responses/BadRequest' '401': description: '`error: unauthorized`.' content: application/json: schema: $ref: '#/components/schemas/Error' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: rule_violation`, `code` one of `no_pending_recovery`, `code_expired`, `code_attempts_exceeded`, `invalid_code` (`details.attemptsLeft`).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/account/mail-settings: get: operationId: mailSettingsGet tags: - Mail settings summary: Signature, vacation responder, filters, forwarding description: | One document per mailbox. `applied` tells whether the filters/vacation are active on the mail server yet (applied by a background worker within seconds); `applyError` carries the last failure. Not available to delegated sessions. `revision` (also sent as the `ETag` header, `""`) identifies this version for a conditional PUT. responses: '200': description: Settings. headers: ETag: description: '`""` — send it back as `If-Match` on PUT.' schema: type: string example: '"4"' content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MailSettingsState' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] put: operationId: mailSettingsPut tags: - Mail settings summary: Replace the mail settings description: | **Full replace**: omitted fields fall back to their defaults (empty signature, vacation off, no filters, forwarding off). Read with GET, change, and PUT the whole document. **Optimistic concurrency** (0.10.102): send `If-Match: ""` (the GET's ETag) — or a numeric `revision` in the body — and the save only applies while the stored revision is still that one; otherwise `412 precondition_failed` (`code: settings_changed`) with the current settings in `current`: merge your change into them and PUT again with the new revision. Without If-Match / `revision` the PUT replaces unconditionally, as before (last write wins). `revision` 0 means "never saved". Forwarding targets (in `forwarding.address` and in filters with `action: forward`) must be **verified** forwarding addresses (`/v1/account/forwarding-addresses`). Returns the saved state (`applied: false` until the worker has pushed it). parameters: - name: If-Match in: header required: false description: '`""` (or `W/""`) from GET; `*` or absent = no precondition.' schema: type: string example: '"4"' requestBody: required: true content: application/json: schema: allOf: - $ref: '#/components/schemas/MailSettings' - type: object properties: revision: type: integer minimum: 0 description: Alternative to If-Match (used when the header is absent). responses: '200': description: Saved. headers: ETag: description: '`""` of the saved version.' schema: type: string content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MailSettingsState' '400': description: | `error: validation_error`; `details` are zod issues. Field rules carry `params.code` in the issue, one of `invalid_character`, `end_before_start`, `value_required`, `size_invalid`, `forward_address_required`, `label_required`, `never_spam_from_only`. content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '412': description: | `error: precondition_failed`, `code: settings_changed` — the settings changed since `If-Match` / `revision` (another device or tab saved). Body adds `revision` (the current one) and `current` (the stored settings); the `ETag` header carries the current revision. Also `400 bad_request` / `code: if_match_invalid` for a malformed If-Match. content: application/json: schema: allOf: - $ref: '#/components/schemas/Error' - type: object properties: revision: type: integer current: $ref: '#/components/schemas/MailSettingsState' '422': description: | `error: rule_violation`, `code` one of `forward_rule_limit` (max 3 forwarding filters), `forward_target_limit` (max 4 distinct targets), `label_not_found`, `forward_not_verified` (`details.address`), `forward_loop` (`details.address`), `forward_external_disabled`, `forward_self`, `mailbox_not_found`. content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/forwarding-addresses: get: operationId: forwardingAddressesList tags: - Delegation & forwarding summary: Verified and pending forwarding addresses description: | `externalForwarding: false` means the organisation only allows forwarding to its own domains. Present only on servers with delegation/forwarding configured. responses: '200': description: Addresses. content: application/json: schema: type: object required: - data properties: data: type: object required: - addresses - externalForwarding properties: addresses: type: array items: $ref: '#/components/schemas/ForwardingAddress' externalForwarding: type: boolean '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] post: operationId: forwardingAddressesAdd tags: - Delegation & forwarding summary: Add a forwarding address (mails it a confirmation code) description: | Mails a 6-digit code (valid 7 days) to the address, whose owner gives it back to the user. Calling again re-sends the code (at most once a minute, 5 times per address). At most 10 addresses per mailbox. Returns the updated list. requestBody: required: true content: application/json: schema: type: object required: - address properties: address: type: string format: email maxLength: 320 responses: '201': description: Code sent; updated list. content: application/json: schema: type: object required: - data properties: data: type: array items: $ref: '#/components/schemas/ForwardingAddress' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: | `error: rule_violation`, `code` one of `forward_self`, `forward_external_disabled`, `forward_mail_unavailable`, `forward_already_verified`, `forward_resend_too_soon` (`details.seconds`), `forward_resend_limit`, `forward_address_limit` (`details.max`), `mailbox_not_found`. content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/forwarding-addresses/verify: post: operationId: forwardingAddressesVerify tags: - Delegation & forwarding summary: Confirm a forwarding address with its code description: Idempotent for an already verified address. 5 wrong codes require a new code. requestBody: required: true content: application/json: schema: type: object required: - address - code properties: address: type: string format: email maxLength: 320 code: type: string pattern: ^\d{6}$ responses: '200': description: Updated list. content: application/json: schema: type: object required: - data properties: data: type: array items: $ref: '#/components/schemas/ForwardingAddress' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: rule_violation`, `code` one of `forward_not_found`, `forward_code_attempts`, `forward_code_expired`, `forward_code_invalid`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/forwarding-addresses/{address}: delete: operationId: forwardingAddressesRemove tags: - Delegation & forwarding summary: Remove a forwarding address description: URL-encode the address (`@` → `%40`). parameters: - name: address in: path required: true schema: type: string format: email responses: '204': description: Removed. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/delegation: get: operationId: delegationOverview tags: - Delegation & forwarding summary: Delegations granted by and to this mailbox description: | `granted` = people who can open this mailbox; `received` = mailboxes (and shared mailboxes) this user can open. To show delegated/shared mailboxes as extra accounts in the app, list `received` entries with `status: active` and open each with `POST /v1/account/delegation/{id}/open`. responses: '200': description: Overview. content: application/json: schema: type: object required: - data properties: data: type: object required: - delegationEnabled - granted - received properties: delegationEnabled: type: boolean description: The organisation allows delegation. granted: type: array items: $ref: '#/components/schemas/Delegation' received: type: array items: $ref: '#/components/schemas/Delegation' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': $ref: '#/components/responses/DelegationError404' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] post: operationId: delegationInvite tags: - Delegation & forwarding summary: Invite a colleague to access this mailbox description: The colleague (same organisation, active user mailbox) must accept. At most 25 open delegations per mailbox. requestBody: required: true content: application/json: schema: type: object required: - address properties: address: type: string format: email maxLength: 320 canSend: type: boolean default: true description: May send as / on behalf of this mailbox. canDelete: type: boolean default: true description: May delete messages permanently / move to Trash. responses: '201': description: 'Invitation created (`status: pending`).' content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Delegation' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': $ref: '#/components/responses/DelegationError404' '409': description: '`error: delegation_error`, `code: delegation_exists`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: delegation_error`, `code` one of `delegation_not_allowed`, `delegation_disabled`, `delegate_self`, `delegation_limit` (`details.max`).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/delegation/{id}/accept: post: operationId: delegationAccept tags: - Delegation & forwarding summary: Accept an invitation (as the delegate) parameters: - name: id in: path required: true schema: type: string format: uuid responses: '200': description: Accepted. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Delegation' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': $ref: '#/components/responses/DelegationError404' '422': description: '`error: delegation_error`, `code` one of `delegation_disabled`, `delegation_not_pending`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/delegation/{id}/decline: post: operationId: delegationDecline tags: - Delegation & forwarding summary: Decline an invitation (as the delegate) parameters: - name: id in: path required: true schema: type: string format: uuid responses: '204': description: Declined. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': $ref: '#/components/responses/DelegationError404' '422': description: '`error: delegation_error`, `code: delegation_not_pending`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/delegation/{id}: delete: operationId: delegationRevoke tags: - Delegation & forwarding summary: Revoke a delegation (owner) or give up access (delegate) description: Shared-mailbox memberships are managed by administrators and cannot be removed here (404). Revocation is immediate, including open delegated sessions. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '204': description: Revoked. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': $ref: '#/components/responses/DelegationError404' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/delegation/{id}/open: post: operationId: delegationOpen tags: - Delegation & forwarding summary: Open a mailbox delegated to you (get a delegated session token) description: | Returns a `dlg.`-prefixed token for the **owner's** mailbox. It expires with the caller's own session (at most 12 hours) and ends when the delegation is revoked or the parent session ends. Use it like any session token, but only on the mail routes delegated sessions may call (reading, organising, drafts; sending only with `canSend`; permanent delete only with `canDelete`). Other routes answer `403` with `code: delegated_scope`. Call it with the user's own (non-delegated) token — a web session or a mobile access token both work. The delegated token expires at `min(parent token expiry, now + 12 h)` (`expiresAt` in the answer) and is re-checked on every request (delegation active, organisation policy, parent session still valid). With a mobile access token (≈60 min) it therefore lives at most as long as that access token: after refreshing, open the delegated session again with the new access token (before `expiresAt`); a request with an ended delegated token answers `401 invalid_session`. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '201': description: Delegated session opened. content: application/json: schema: type: object required: - data properties: data: type: object required: - token - expiresAt - mailbox - delegation properties: token: type: string pattern: ^dlg\. expiresAt: type: string format: date-time mailbox: $ref: '#/components/schemas/Mailbox' delegation: $ref: '#/components/schemas/DelegatedAccess' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': $ref: '#/components/responses/DelegationError404' '422': description: '`error: delegation_error`, `code` one of `delegation_not_active`, `delegation_disabled`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: delegation_error`, `code: delegation_unavailable` (not configured on this server), or `webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/account/imports: get: operationId: mailImportList tags: - Mail import summary: Recent import jobs of this mailbox description: The 10 most recent jobs, newest first; the first one includes `folders` (per-folder progress). Poll while a job is `queued`/`running`. responses: '200': description: Jobs. content: application/json: schema: type: object required: - data properties: data: type: array maxItems: 10 items: $ref: '#/components/schemas/ImportJob' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] post: operationId: mailImportStart tags: - Mail import summary: Start importing from another account description: | Tests the source first (same checks and rate limit as `/test`), then queues the job. Only one active import per mailbox. The source password is write-only (encrypted, never returned). For Gmail/Yahoo use an app password of that provider. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ImportSource' responses: '201': description: Job queued. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ImportJob' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`error: import_already_active`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': $ref: '#/components/responses/ImportSourceError' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/imports/test: post: operationId: mailImportTest tags: - Mail import summary: Test the source account and preview folder mapping description: Signs in to the source and lists its folders with their target folder here (or why they are skipped). At most 10 tests per 10 minutes per mailbox (per API server). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ImportSource' responses: '200': description: Source reachable. content: application/json: schema: type: object required: - data properties: data: type: object required: - folders properties: folders: type: array items: type: object required: - source - target properties: source: type: string target: type: - string - 'null' description: Target folder path here; null = skipped. skipReason: type: string messages: type: integer '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': $ref: '#/components/responses/ImportSourceError' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/imports/{id}: get: operationId: mailImportGet tags: - Mail import summary: One import job with per-folder progress parameters: - name: id in: path required: true schema: type: string format: uuid responses: '200': description: Job. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ImportJob' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/account/imports/{id}/cancel: post: operationId: mailImportCancel tags: - Mail import summary: Cancel an import job description: 'Messages already imported stay. Returns the job (`cancelRequested: true` while a running job stops).' parameters: - name: id in: path required: true schema: type: string format: uuid responses: '200': description: Job. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ImportJob' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/mail/folders: get: operationId: mailListFolders tags: - Mail summary: System folders with counts, and storage usage description: | The six fixed folders (`inbox`, `sent`, `drafts`, `junk`, `trash`, `archive`) with total and unread counts. When inbox categories (tabs) are on, `inbox.unread` is the **Primary** tab's unread count (like Gmail). `storage` is the IMAP quota (null when unknown). Allowed for delegated sessions. responses: '200': description: Folders. content: application/json: schema: type: object required: - data properties: data: type: object required: - mailbox - folders - storage properties: mailbox: $ref: '#/components/schemas/Mailbox' folders: type: array items: $ref: '#/components/schemas/FolderSummary' storage: oneOf: - $ref: '#/components/schemas/StorageUsage' - type: 'null' example: data: mailbox: id: 6f1c1d8e-2a51-4f3b-9d6e-0c9a1b2c3d4e primaryAddress: lan@example.vn displayName: Nguyễn Lan quotaBytes: 5368709120 folders: - folder: inbox total: 1204 unread: 3 - folder: sent total: 310 unread: 0 - folder: drafts total: 2 unread: 0 - folder: junk total: 14 unread: 14 - folder: trash total: 40 unread: 0 - folder: archive total: 5120 unread: 0 storage: usedBytes: 734003200 limitBytes: 5368709120 '401': $ref: '#/components/responses/Unauthorized' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] /v1/mail/messages: get: operationId: mailListMessages tags: - Mail summary: List messages of a folder (newest first), with optional search description: | Cursor pagination by IMAP UID: pass `nextCursor` of a page as `before` to get the next (older) page; `nextCursor: null` means there is nothing older. `total` = all messages of the folder matching `q` (the whole folder without `q`) — the same value on every page, so "1–50 of 1,204" stays right while paging. `uidValidity` changes when the folder was recreated — then drop every cached UID of that folder. `q` searches subject, from, to and body (server-side IMAP SEARCH, case-insensitive substring; max 200 chars). For Gmail-like views (categories, labels, starred, snoozed...) use `GET /v1/mail/view`. Allowed for delegated sessions. parameters: - $ref: '#/components/parameters/FolderQuery' - name: before in: query description: Cursor — return only messages with a UID lower than this. schema: type: integer minimum: 1 - name: limit in: query description: Page size (clamped to 1–100). schema: type: integer minimum: 1 maximum: 100 default: 50 - name: q in: query schema: type: string maxLength: 200 responses: '200': description: One page. content: application/json: schema: type: object required: - data properties: data: type: object required: - mailbox - folder - query - messages - nextCursor - total - uidValidity properties: mailbox: $ref: '#/components/schemas/Mailbox' folder: $ref: '#/components/schemas/MailFolder' query: type: - string - 'null' messages: type: array items: $ref: '#/components/schemas/MessageSummary' nextCursor: type: - integer - 'null' description: Pass as `before` for the next (older) page; null = last page. total: type: integer description: Messages in the folder matching `q` (independent of `before`). uidValidity: type: string description: IMAP UIDVALIDITY of the folder (decimal string). '400': description: '`error: invalid_folder` or `error: invalid_cursor`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] post: operationId: mailSendMessage tags: - Sending summary: Send a message (simple send) parameters: - $ref: '#/components/parameters/IdempotencyKey' description: | Queues a message for delivery and answers **202** with the submission (`status: queued`). Delivery happens in the background; follow it with `GET /v1/mail/submissions/{id}` or `GET /v1/mail/outbox`. A copy is saved to Sent. * **Idempotency** — a key of 16–128 chars `[A-Za-z0-9_-]` (e.g. a UUID without dashes generated when the user taps Send) is required, in the `Idempotency-Key` header (preferred for apps) or the body's `idempotencyKey` (both → must be equal, else `400 idempotency_key_mismatch`). A key that already has a submission of this mailbox is answered **200** with that submission (header `Idempotent-Replayed: true`) **before** the request is validated or processed — a retry can never send twice, and never fails where the first attempt succeeded. Always retry a send whose response you did not receive with the same key. * The From is always the signed-in mailbox (or, in a delegated session with `canSend`, the owner on behalf of the delegate). * Limits: 1–100 distinct recipients; attachments inline as base64, ≤10 files, ≤15 MB decoded in total; the whole MIME message ≤25 MB; request body ≤35 MiB. Sending rate per mailbox: default 200 messages and 1,000 recipients per hour (server-configurable) → `429 rate_limited` with `Retry-After`. Trial organisations have a daily cap on external recipients. * `draftUid`: the Drafts UID of the draft being sent; it is deleted after queuing (best effort). * For undo send, scheduling and Drive attachments use `POST /v1/mail/compose/send`. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ComposeMessage' example: idempotencyKey: 3f9d2c7e5b8a4c1d9e0f1a2b3c4d5e6f to: - minh@example.com cc: [] bcc: [] subject: Báo giá tháng 10 text: Chào anh Minh, gửi anh báo giá đính kèm. html:

Chào anh Minh, gửi anh báo giá đính kèm.

attachments: - filename: bao-gia.pdf contentType: application/pdf contentBase64: JVBERi0xLjQK... responses: '200': description: 'Duplicate — this idempotency key was already used; the original submission (`Idempotent-Replayed: true`).' headers: Idempotent-Replayed: $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MessageSubmission' '202': description: Queued. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MessageSubmission' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '413': description: '`error: message_too_large`, `code` one of `message_too_large` (`details.maxMb` 25), `attachments_too_large` (`details.maxMb` 15), `html_too_large` (`details.maxBytes`). A body over the request limit answers `413` with `error: bad_request`, `code: payload_too_large`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: message_infected` — malware found; `details.signature`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`error: sending_held` — sending is on hold for this account (suspected compromise); `reason`. Ask the user to contact their administrator.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: rate_limited` — hourly sending limit; `retryAfterSeconds`, `Retry-After`.' headers: Retry-After: $ref: '#/components/headers/RetryAfter' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: sending_unavailable` (no outbound relay configured), `mailbox_moving` (Retry-After 10) or `webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/messages/{uid}: get: operationId: mailGetMessage tags: - Messages summary: Read a message description: | Returns the parsed message. **Side effect: marks it read** (`\Seen`) — unless `peek=1` (0.10.102): then the mailbox is opened read-only, nothing is changed and `unread` reports the real state (offline prefetch). `html` is sanitized server-side (scripts, forms, event handlers removed); remote images are blocked unless `images=1` (`blockedRemoteImages` counts them). Render `html` in a sandboxed web view with JavaScript disabled; fall back to `text`. `text: ""` with `html: null` means there is no displayable body. Attachments are listed by MIME `part`; download them with `GET /v1/mail/messages/{uid}/attachments/{part}`. `details` are Gmail-style "mailed-by/signed-by/TLS" facts measured by Zomail's gateway. Reader extras (safety verdict, unsubscribe option, smart replies) are merged into `data` when available. Allowed for delegated sessions. parameters: - $ref: '#/components/parameters/Uid' - $ref: '#/components/parameters/FolderQuery' - name: images in: query description: '`1` = keep remote images in `html` (only after the user asked to show them).' schema: type: string enum: - '1' - name: peek in: query description: '`1` = read without marking the message read (BODY.PEEK). Default: reading marks it read.' schema: type: string enum: - '1' - 'true' - name: links in: query description: '`1` = add `links`: every link of the HTML body with its own safety verdict (the link sheet).' schema: type: string enum: - '1' responses: '200': description: The message. content: application/json: schema: type: object required: - data properties: data: allOf: - type: object required: - mailbox - folder - message - details properties: mailbox: $ref: '#/components/schemas/Mailbox' folder: $ref: '#/components/schemas/MailFolder' message: $ref: '#/components/schemas/MessageDetail' details: $ref: '#/components/schemas/MessageDetails' links: type: array description: Only with `links=1`. Duplicate href + text pairs are listed once (max 300). items: $ref: '#/components/schemas/LinkVerdict' - $ref: '#/components/schemas/ReaderExtras' '400': description: '`error: invalid_uid` or `invalid_folder`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '404': description: '`error: message_not_found` (deleted or moved; refresh the list).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] /v1/mail/messages/{uid}/thread: get: operationId: mailGetThread tags: - Messages summary: The conversation a message belongs to description: | Messages linked by Message-ID / References / In-Reply-To, searched in Inbox, Sent and Archive (up to 50 per folder, 20 most recent ids of the chain), oldest first. `current: true` marks the requested message. Empty array when the message has no Message-ID. Does not mark anything read. Allowed for delegated sessions. parameters: - $ref: '#/components/parameters/Uid' - $ref: '#/components/parameters/FolderQuery' responses: '200': description: Thread entries. content: application/json: schema: type: object required: - data properties: data: type: array items: $ref: '#/components/schemas/ThreadEntry' '400': description: '`error: invalid_request`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] /v1/mail/messages/{uid}/navigation: get: operationId: mailGetNavigation tags: - Messages summary: Position of a message in its folder ("12 of 223", previous/next) description: Newest first, like the list. `index` is 1-based (null if the UID is gone); `newerUid`/`olderUid` are the neighbours. Not allowed for delegated sessions. parameters: - $ref: '#/components/parameters/Uid' - $ref: '#/components/parameters/FolderQuery' responses: '200': description: Position. content: application/json: schema: type: object required: - data properties: data: type: object required: - index - total - newerUid - olderUid properties: index: type: - integer - 'null' total: type: integer newerUid: type: - integer - 'null' olderUid: type: - integer - 'null' '400': description: '`error: invalid_request`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] /v1/mail/messages/{uid}/raw: get: operationId: mailGetRawMessage tags: - Messages summary: Original message source (.eml) description: | The message exactly as stored. `download=1` → `message/rfc822` with `Content-Disposition: attachment; filename="message-.eml"`; otherwise `text/plain; charset=utf-8` ("Show original"). Not allowed for delegated sessions. parameters: - $ref: '#/components/parameters/Uid' - $ref: '#/components/parameters/FolderQuery' - name: download in: query schema: type: string enum: - '1' responses: '200': description: Raw RFC 5322 source. content: message/rfc822: schema: type: string format: binary text/plain: schema: type: string '400': description: '`error: invalid_request`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: message_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] /v1/mail/messages/{uid}/attachments/{part}: get: operationId: mailDownloadAttachment tags: - Messages summary: Download an attachment description: | Streams the decoded attachment. Always `application/octet-stream` with `Content-Disposition: attachment` (RFC 6266, UTF-8 `filename*`); the real type is in `X-Original-Content-Type`. `Cache-Control: private, no-store`. Allowed for delegated sessions. **Resumable downloads (Range).** Every response carries `Accept-Ranges: bytes` and a strong `ETag` (a part's content never changes for a given UID, so the ETag is stable). Send one `Range: bytes=start-end`, `bytes=start-` or suffix `bytes=-N` to get `206` with `Content-Range: bytes start-end/size`. A range that starts at or past the end (or `bytes=-0`) answers `416` with `Content-Range: bytes */size` and no body. Several ranges or other units are ignored (full `200`). When resuming, send `If-Range: `: if it no longer matches, the whole file comes back as `200` instead of a part. Ranged requests are served from the decoded part held in memory, so a ranged request for an attachment over 64 MiB answers `413 attachment_too_large` (download it without `Range`). parameters: - $ref: '#/components/parameters/Uid' - name: part in: path required: true description: MIME part number from `message.attachments[].part`, e.g. `2` or `1.2`. schema: type: string pattern: ^\d+(\.\d+)*$ - $ref: '#/components/parameters/FolderQuery' - name: Range in: header required: false description: 'One byte range: `bytes=0-99999`, `bytes=100000-` or `bytes=-500` (the last 500 bytes).' schema: type: string examples: - bytes=100000- - name: If-Range in: header required: false description: The `ETag` of an earlier response; the `Range` is honoured only while it still matches. schema: type: string responses: '200': description: File content (no `Range`, an ignored `Range`, or an `If-Range` that no longer matches). headers: Content-Disposition: schema: type: string description: attachment; filename="..."; filename*=UTF-8''... X-Original-Content-Type: schema: type: string description: The attachment's declared MIME type. Accept-Ranges: schema: type: string const: bytes description: Always `bytes`. ETag: schema: type: string description: Strong validator for `If-Range`, e.g. `"inbox-5012-2"`. content: application/octet-stream: schema: type: string format: binary '206': description: The requested byte range. headers: Content-Range: schema: type: string description: '`bytes start-end/size`.' Content-Length: schema: type: integer description: Length of the range. Content-Disposition: schema: type: string description: Same as for `200`. X-Original-Content-Type: schema: type: string description: Same as for `200`. Accept-Ranges: schema: type: string const: bytes description: Always `bytes`. ETag: schema: type: string description: Same as for `200`. content: application/octet-stream: schema: type: string format: binary '400': description: '`error: invalid_request`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '404': description: '`error: attachment_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: '`error: attachment_too_large` — a ranged request for an attachment over 64 MiB.' content: application/json: schema: $ref: '#/components/schemas/Error' '416': description: 'Range not satisfiable: `Content-Range: bytes */size`, empty body.' headers: Content-Range: schema: type: string description: '`bytes */size`.' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] /v1/mail/messages/actions: post: operationId: mailMessageActions tags: - Messages summary: Mark read/unread, star, pin, important, move, delete (bulk) description: | Applies one action to 1–500 UIDs of one folder. Archive = `move` to `archive`; spam = `move` to `junk` (or better, `POST /v1/mail/messages/report` which also trains the filter); delete = `move` to `trash`. `delete` is a **permanent** delete and is only allowed in `trash` and `junk`. At most 20 pinned messages. Moving a message changes its UID (it gets a new UID in the target folder). Idempotent per action. **New UIDs** (0.10.102): send `Prefer: return=representation` and the answer is `200` with the target folder, its UIDVALIDITY and `uidMap` (old UID → new UID, from the server's COPYUID) — Undo moves `uidMap` values back without searching. Without the header the answer stays `204` (existing clients). Delegated sessions: allowed; permanent delete and moves to Trash need `canDelete`. Mailboxes under legal hold refuse permanent delete with `423 legal_hold`. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/MessageActionRequest' examples: markRead: value: folder: inbox uids: - 5012 - 5011 action: type: read archive: value: folder: inbox uids: - 5012 action: type: move target: archive emptyTrash: value: folder: trash uids: - 12 - 13 - 14 action: type: delete parameters: - name: Prefer in: header required: false description: '`return=representation` = answer 200 with where moved messages went (RFC 7240).' schema: type: string enum: - return=representation responses: '200': description: 'Done, with `Prefer: return=representation` (header `Preference-Applied`). Flag actions and permanent delete answer `target: null` and an empty map.' content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MoveResult' example: data: target: archive uidValidity: '1696402800' uidMap: '5012': 311 '204': description: Done. '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: action_not_allowed`, `code` one of `permanent_delete_not_allowed`, `pin_limit`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`error: legal_hold` — permanent delete refused while the mailbox is under legal hold.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] /v1/mail/drafts: put: operationId: mailSaveDraft tags: - Drafts summary: Save a draft (no attachments) description: | Appends the draft to Drafts and, with `replaceUid`, deletes the previous version — so autosave = PUT with the last returned `uid`. Recipients are not validated (incomplete addresses are fine). Bcc is kept. `html` is sanitized like a sent message. Returns the new UID (`null` if the server did not report it — then refresh the Drafts list). Attachments and Drive files: use `PUT /v1/mail/compose/draft`. Allowed for delegated sessions. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SaveDraft' responses: '200': description: Saved. content: application/json: schema: type: object required: - data properties: data: type: object required: - uid properties: uid: type: - integer - 'null' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] /v1/mail/drafts/{uid}: delete: operationId: mailDeleteDraft tags: - Drafts summary: Discard a draft description: Permanently deletes the draft with this UID from Drafts. Allowed for delegated sessions. parameters: - $ref: '#/components/parameters/Uid' responses: '204': description: Deleted. '400': description: '`error: invalid_uid`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] /v1/mail/submissions: get: operationId: mailListSubmissions tags: - Sending summary: Recently sent messages and their queue status description: 'The 50 most recent submissions of this mailbox (newest first). `sendingEnabled: false` means this server cannot send mail.' responses: '200': description: Submissions. content: application/json: schema: type: object required: - data properties: data: type: object required: - sendingEnabled - submissions properties: sendingEnabled: type: boolean submissions: type: array items: $ref: '#/components/schemas/MessageSubmission' '401': $ref: '#/components/responses/Unauthorized' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/mail/submissions/{id}: get: operationId: mailGetSubmission tags: - Sending summary: One submission (poll after sending) parameters: - name: id in: path required: true schema: type: string format: uuid responses: '200': description: Submission. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MessageSubmission' '401': $ref: '#/components/responses/Unauthorized' '404': description: '`error: submission_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' security: - bearerAuth: [] /v1/mail/outbox: get: operationId: mailOutbox tags: - Sending summary: Delivery status per recipient description: | Recent submissions (not scheduled ones) with per-recipient delivery state, newest first. `canRetry` / `canCancel` tell which buttons to show. Allowed for delegated sessions. parameters: - name: limit in: query schema: type: integer minimum: 1 maximum: 100 default: 50 responses: '200': description: Entries. content: application/json: schema: type: object required: - data properties: data: type: array items: $ref: '#/components/schemas/OutboxEntry' '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: trace_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/outbox/{id}/retry: post: operationId: mailOutboxRetry tags: - Sending summary: Retry a deferred message now description: Only for a queued message that already failed at least once (`canRetry`). Does not reset the attempt budget. Delegated sessions need `canSend`. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '204': description: Will be retried now. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: submission_not_found` (malformed id).' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`error: not_retryable`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: trace_unavailable` or `mailbox_moving`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/outbox/{id}/cancel: post: operationId: mailOutboxCancel tags: - Sending summary: Stop a message still waiting in the queue description: Recipients already delivered stay delivered; the rest are cancelled (no bounce). A message being sent right now cannot be cancelled. Delegated sessions need `canSend`. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '204': description: Cancelled. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: submission_not_found` (malformed id).' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`error: not_cancellable`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: trace_unavailable` or `mailbox_moving`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/public/status: get: operationId: publicStatus tags: - Public summary: Service status (components, incidents, 90-day uptime) description: | The public status page data. No sign-in; cacheable (`Cache-Control: public, max-age=30`). Show a banner in the app when `overall` is not `operational` or `active` is non-empty. Names and texts come in both `vi` and `en`. Private servers usually have no status page (`503 status_unavailable`). security: [] responses: '200': description: Status. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/PublicStatus' '503': description: '`error: status_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' /v1/public/status/incidents/{id}: get: operationId: publicStatusIncident tags: - Public summary: One incident with its updates security: [] parameters: - name: id in: path required: true schema: type: string responses: '200': description: Incident. content: application/json: schema: type: object required: - data properties: data: allOf: - $ref: '#/components/schemas/StatusIncident' - type: object required: - componentNames properties: componentNames: type: object additionalProperties: $ref: '#/components/schemas/LocalizedText' '404': description: '`error: not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: status_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' /v1/mail/compose/send: post: operationId: composeSend tags: - Compose summary: Send a message (with undo delay, schedule send, draft and Drive attachments) description: | Queues a message from the signed-in mailbox (From is always the session's mailbox). Same message model as POST /v1/mail/messages plus: the user's undo-send delay, schedule send, attachments carried over from the draft being sent, and Drive files attached by ID. **Undo send.** Unless scheduled, the message is held for the user's `undoSendSeconds` preference (0/5/10/20/30 s, default 5; there is no per-request override). The response gives `undoSeconds` and `undoUntil`; show an "Undo" action until then and call POST /v1/mail/compose/submissions/{id}/recall. The submission status stays `queued` during the hold. **Schedule send.** Pass `sendAt` (exact instant) or `sendAtLocal` (+ optional `timeZone`, default the saved compose zone). The time must be ≥ 60 s and ≤ 366 days ahead. The submission gets status `scheduled`, is listed by GET /v1/mail/compose/scheduled, and is dated (Date header) at its send time. No undo window applies (`undoSeconds` 0); use recall ("cancel & edit") or send-now instead. **Idempotency.** A key (16-128 chars `[A-Za-z0-9_-]`, scoped to the mailbox) is required: the `Idempotency-Key` header (preferred for apps) or the body's `idempotencyKey` (both → must be equal, else 400 `idempotency_key_mismatch`; a malformed header → 400 `idempotency_key_invalid`). A new message returns **202**. A key that already has a submission is answered **200** with that submission (`sendAt` null, `undoSeconds` 0, `undoUntil` null, header `Idempotent-Replayed: true`) **before** anything else is checked or processed: a retry never sends twice, and never fails where the first attempt succeeded (schedule time now in the past, draft already deleted, upload ids already consumed…). Retry network failures with the same key. **Draft.** With `draftUid`, the draft is deleted (best effort) once the message is queued (202 only). **Limits.** JSON body ≤ 36,700,160 bytes (≈35 MiB, base64 overhead allowed; larger → 413 `error: bad_request`). ≤ 10 attachment files in total (inline + kept + Drive) and ≤ 15 MiB decoded; built message ≤ 25 MiB; 1-100 unique recipients; per-mailbox hourly message/recipient limits (default 200 messages and 1,000 recipients per hour), optional per-tenant hourly cap, and for paid-trial tenants 200 external recipients per 24 h. Messages are virus-scanned. **Delegated sessions:** allowed only when the delegation includes send permission (else 403 `delegated_send_forbidden`); the message is sent "on behalf of" the owner. Drive attachments are not available (422 `delegated_drive_forbidden`). parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ComposeSendRequest' examples: undo: summary: Normal send (undo window from preferences) value: idempotencyKey: 3f0c9a6e5b7d4e21a8c0d9b2 to: - lan@example.com subject: Báo giá tháng 10 text: Chào chị Lan, em gửi báo giá ạ. scheduled: summary: Schedule for 08:00 local time value: idempotencyKey: 9b1d2c3e4f5a6b7c8d9e0f1a to: - minh@example.com subject: Nhắc lịch họp text: Hẹn anh 9h thứ Sáu. sendAtLocal: 2026-10-06T08:00 timeZone: Asia/Ho_Chi_Minh withDrive: summary: Reply that keeps a draft attachment and adds a Drive file value: idempotencyKey: c0ffee00c0ffee00c0ffee00 to: - lan@example.com subject: 'Re: Hợp đồng' html:

Em gửi lại hợp đồng đã ký.

inReplyTo: references: - draftUid: 812 keepAttachments: - hop-dong.pdf|182044 driveAttachments: - 6f1c2d3e-4b5a-4c6d-8e7f-901234567890 responses: '200': description: 'Idempotent replay — a submission with this key already exists; nothing new was sent (`Idempotent-Replayed: true`).' headers: Idempotent-Replayed: $ref: '#/components/headers/IdempotentReplayed' content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ComposeSendResult' '202': description: Message accepted and queued (or scheduled). content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ComposeSendResult' examples: undo: value: data: submission: id: 0b6e2f9c-6a51-4f0e-9d3f-2a7b1c4d5e6f status: queued subject: Báo giá tháng 10 recipientCount: 1 rejectedRecipients: [] sizeBytes: 1834 attempts: 0 errorCode: null errorMessage: null sentCopySaved: false createdAt: '2026-10-04T03:12:45.120Z' acceptedAt: null failedAt: null sendAt: null undoSeconds: 5 undoUntil: '2026-10-04T03:12:50.130Z' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: | Drive attachment not found (`error`/`code: drive_not_found`). Drive errors use `error` = `code` = `drive_` with the Drive service's own HTTP status. An `uploadIds` entry that is unknown, expired or another mailbox's: `error: not_found`, `code: upload_not_found`. content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`error: upload_invalid`, `code: upload_incomplete` — an `uploadIds` entry has not received all its bytes yet; details `{offset, size}`.' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: | Too large. `error: message_too_large`, `code` one of: `attachments_too_large` (details `{maxMb: 15}`), `message_too_large` (details `{maxMb: 25}`), `html_too_large` (details `{maxBytes: 1000000}`). Uploads (`uploadIds`) that push the total over 15 MiB: `error: upload_invalid`, `code: attachments_too_large`. A JSON body over the route body limit returns 413 with `error: bad_request`, `code: payload_too_large` instead. content: application/json: schema: $ref: '#/components/schemas/Error' example: error: message_too_large code: attachments_too_large details: maxMb: 15 message: Attachments exceed 15 MB in total '422': description: | Not sendable. `error` one of: - `schedule_invalid` — `code` one of: `schedule_invalid` (not a valid date), `time_zone_invalid`, `schedule_in_past` (less than 1 minute ahead), `schedule_too_far` (details `{maxDays: 365}`); - `compose_invalid` — `code` one of: `draft_unavailable`, `draft_attachment_missing`, `delegated_drive_forbidden`, `drive_unavailable`, `uploads_unavailable` (`uploadIds` sent to a server without resumable uploads); - `message_infected` — `code: message_infected`, details `{signature}`. content: application/json: schema: $ref: '#/components/schemas/Error' example: error: schedule_invalid code: schedule_in_past message: The scheduled time must be at least one minute from now '423': description: | Outbound hold on this mailbox (suspected compromise; released by an administrator). `error: sending_held`, `code: sending_held`, plus a top-level `reason` string. content: application/json: schema: allOf: - $ref: '#/components/schemas/Error' - type: object properties: reason: type: string '429': description: | Sending limit reached. `error: rate_limited`, `code: rate_limited`, `retryAfterSeconds` (also in `details.retryAfterSeconds` and the Retry-After header). headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: | `error` one of: `sending_unavailable` (no outbound relay configured), `compose_unavailable`, `mail_backend_busy` (mailbox shard unreachable while reading draft attachments; Retry-After: 5). content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/submissions/{id}/recall: post: operationId: composeRecallSubmission tags: - Compose summary: Undo send / cancel a scheduled message (back to Drafts) description: | Stops a message that no delivery worker has picked up yet and puts it back in Drafts (Bcc recipients are restored as a Bcc header). Works for a `queued` message during its undo window and for a `scheduled` message any time before it is sent ("cancel & edit"). The submission is deleted on success, so its `idempotencyKey` becomes free again. Technically a queued message stays recallable until a worker claims it (normally right after `undoUntil`) — do not rely on more than the undo window. No request body. Delegated sessions: allowed only with send permission. parameters: - name: id in: path required: true description: Submission ID (UUID) from the send response or the scheduled list. schema: type: string format: uuid responses: '200': description: Recalled; the message is now a draft. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ComposeRecallResult' example: data: draftUid: 813 '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: submission_not_found` — only when `id` is not a UUID (an unknown UUID returns 409).' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: | Too late or unknown: already being sent/sent, already recalled, or no such submission in this mailbox. Body `{error: not_recallable, code: recall_too_late}`. content: application/json: schema: $ref: '#/components/schemas/Error' example: error: not_recallable code: recall_too_late '503': description: '`error` one of: `webmail_unavailable`, `compose_unavailable`, `mail_backend_busy` (Retry-After: 5; the message was not recalled).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/scheduled: get: operationId: composeListScheduled tags: - Compose summary: List scheduled messages description: | Messages waiting for their scheduled time (status `scheduled`), soonest first, at most 200. Delegated sessions: allowed (read). responses: '200': description: Scheduled messages. content: application/json: schema: type: object required: - data properties: data: type: array maxItems: 200 items: $ref: '#/components/schemas/ComposeScheduledMessage' example: data: - id: 0b6e2f9c-6a51-4f0e-9d3f-2a7b1c4d5e6f subject: Nhắc lịch họp recipients: - minh@example.com sendAt: '2026-10-06T01:00:00.000Z' createdAt: '2026-10-04T03:12:45.120Z' sizeBytes: 1834 '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: compose_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/scheduled/{id}/send-now: post: operationId: composeSendScheduledNow tags: - Compose summary: Send a scheduled message now description: | Moves a scheduled message into the delivery queue immediately and re-dates it (Date header = now). No request body. Delegated sessions: allowed only with send permission. parameters: - name: id in: path required: true schema: type: string format: uuid responses: '204': description: Queued for immediate delivery. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: submission_not_found` — only when `id` is not a UUID.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: 'Not (or no longer) scheduled, or unknown in this mailbox: `{error: not_scheduled, code: not_scheduled}`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: compose_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/draft: put: operationId: composeSaveDraft tags: - Compose summary: Save a draft (autosave), keeping its attachments description: | Builds the draft MIME on the server and appends it to Drafts; with `replaceUid` the previous version is deleted afterwards and the listed `keepAttachments` of the previous version are copied into the new one. Each save produces a NEW uid — use the returned `uid` as the next `replaceUid`. HTML is sanitized like a sent message. Body limit 4 MiB (413 `error: bad_request` above). **New files** (0.10.102): upload them with `POST /v1/mail/compose/uploads` and pass the finished `uploadIds`. With any attachment the answer lists the saved draft's `attachments` with their `key`; on the next save send those keys as `keepAttachments` (with the new `replaceUid`) and stop sending the uploadIds (they would be attached twice). Uploads stay usable until sent, deleted or expired (24 h), so a retried save is safe. Delegated sessions: allowed (write). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ComposeDraftRequest' responses: '200': description: Draft saved. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ComposeDraftSaved' example: data: uid: 813 attachments: - part: '2' filename: bao-gia.pdf contentType: application/pdf size: 48213 key: bao-gia.pdf|48213 '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '413': description: '`error: message_too_large`, `code` one of: `html_too_large` (details `{maxBytes: 1000000}`), `attachments_too_large` (kept attachments over 15 MiB, details `{maxMb: 15}`).' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: compose_invalid`, `code` one of `draft_attachment_missing` (the draft `replaceUid` or one of the kept attachments no longer exists), `too_many_attachments` (more than 10 kept + uploaded, details `{max: 10}`), `uploads_unavailable`. Upload problems: `404 not_found` / `code: upload_not_found`, `409 upload_invalid` / `code: upload_incomplete`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error` one of: `webmail_unavailable`, `compose_unavailable`, `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/preferences: get: operationId: composeGetPreferences tags: - Compose summary: Get compose preferences (undo delay, time zone, schedule presets) description: | Returns the undo-send delay and time zone (defaults 5 s / Asia/Ho_Chi_Minh until saved), the allowed undo options and "tomorrow morning" / "next Monday morning" (08:00) schedule presets as UTC instants. Delegated sessions: allowed (read; returns the owner's preferences). parameters: - name: tz in: query required: false description: Device IANA time zone hint, used for `presets` only while the user has not saved preferences. schema: type: string maxLength: 64 example: Asia/Ho_Chi_Minh responses: '200': description: Preferences. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ComposePreferencesView' example: data: undoSendSeconds: 5 timeZone: Asia/Ho_Chi_Minh saved: false effectiveTimeZone: Asia/Ho_Chi_Minh undoOptions: - 0 - 5 - 10 - 20 - 30 presets: tomorrowMorning: '2026-10-05T01:00:00.000Z' mondayMorning: '2026-10-05T01:00:00.000Z' '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: compose_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] put: operationId: composeSavePreferences tags: - Compose summary: Save compose preferences description: | Saves both fields (both required). Applies to later sends from any client of this mailbox. Delegated sessions: NOT allowed (403 delegated_scope). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ComposePreferencesInput' responses: '200': description: Saved preferences (`saved` is true). content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ComposePreferences' example: data: undoSendSeconds: 10 timeZone: Asia/Ho_Chi_Minh saved: true '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': description: '`error: compose_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/templates: get: operationId: templatesList tags: - Templates summary: List message templates description: | Templates of the signed-in mailbox, sorted by name (case-insensitive), at most 100. Delegated sessions: allowed (read; the owner's templates). responses: '200': description: Templates. content: application/json: schema: type: object required: - data properties: data: type: array maxItems: 100 items: $ref: '#/components/schemas/Template' '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: compose_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] post: operationId: templatesCreate tags: - Templates summary: Create a template description: | At most 100 templates per mailbox. HTML is sanitized with the compose allow-list. Delegated sessions: NOT allowed (403 delegated_scope). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TemplateInput' responses: '201': description: Created. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Template' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: template_limit`, `code: template_limit`, details `{max: 100}`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: compose_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/templates/{id}: parameters: - name: id in: path required: true schema: type: string format: uuid get: operationId: templatesGet tags: - Templates summary: Get a template description: 'Delegated sessions: allowed (read).' responses: '200': description: The template. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Template' '401': $ref: '#/components/responses/Unauthorized' '404': description: '`error: template_not_found` (also for a non-UUID id).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: compose_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] put: operationId: templatesUpdate tags: - Templates summary: Replace a template description: | Full replacement: omitted `subject`/`html` become "". Delegated sessions: NOT allowed (403 delegated_scope). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TemplateInput' responses: '200': description: Updated template. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Template' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: template_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: compose_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] delete: operationId: templatesDelete tags: - Templates summary: Delete a template description: 'Delegated sessions: NOT allowed (403 delegated_scope).' responses: '204': description: Deleted. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: template_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: compose_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/ai/write: post: operationId: composeAiWrite tags: - AI summary: Help me write — draft a new message from a prompt description: | Generates a subject and plain-text body. Content is not stored. Requires the organisation's AI opt-in (check GET /v1/mail/ai/status). Counts against the mailbox's hourly AI limit shared by all AI features (default 60 requests/hour, server setting AI_REQUESTS_PER_HOUR); refused and failed calls count too. Default body limit 1 MiB. Delegated sessions: allowed (uses the owner's AI quota). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AiWriteRequest' responses: '200': description: Generated draft. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/AiWriteResult' example: data: subject: Mời họp review hợp đồng thứ Sáu body: |- Chào anh Minh, Em xin mời anh họp review hợp đồng vào 9h thứ Sáu [ngày]… '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: | `error: ai_disabled`, `code` one of: `ai_not_configured` (no AI provider on this server), `ai_not_enabled` (organisation has not opted in). Or a delegated-scope 403 (see DelegatedForbidden). content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: ai_refused`, `code: ai_refused` — the model declined the request.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: ai_rate_limited`, `code: ai_rate_limited`, details `{limit}`. Retry-After: 600.' headers: Retry-After: schema: type: integer example: 600 content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: | `error: ai_unavailable`, `code` one of: `ai_overloaded`, `ai_invalid_output`, `ai_provider_error`, `ai_not_configured` (Retry-After: 30); or `{error: ai_unavailable, code: ai_not_configured}` without Retry-After when the AI service is not wired at all; or `error: compose_unavailable`. headers: Retry-After: schema: type: integer example: 30 content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/ai/rewrite: post: operationId: composeAiRewrite tags: - AI summary: Help me write — rewrite the draft description: | Rewrites plain text (improve / shorten / formal / friendly), keeping the draft's language. Same opt-in, hourly limit and errors as composeAiWrite. Delegated sessions: allowed. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AiRewriteRequest' responses: '200': description: Rewritten text. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/AiRewriteResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: ai_disabled`, `code` one of: `ai_not_configured`, `ai_not_enabled`; or delegated scope.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: ai_refused`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: ai_rate_limited`, details `{limit}`. Retry-After: 600.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: ai_unavailable` (`code` ai_overloaded | ai_invalid_output | ai_provider_error | ai_not_configured; Retry-After: 30 except when the service is not wired) or `compose_unavailable`.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/ai/proofread: post: operationId: composeAiProofread tags: - AI summary: Spelling and grammar check of the draft description: | Returns the corrected text and the list of changes (show them before applying). Fixes only real mistakes (incl. Vietnamese diacritics). Same opt-in, hourly limit and errors as composeAiWrite. Delegated sessions: allowed (like help me write). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AiProofreadRequest' responses: '200': description: Proofreading result. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/AiProofreadResult' example: data: corrected: Cảm ơn anh, em không đến được. issues: - original: ko suggestion: không reason: Viết tắt không chuẩn '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: ai_disabled`, `code` one of: `ai_not_configured`, `ai_not_enabled`; or delegated scope.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: ai_refused`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: ai_rate_limited`, details `{limit}`. Retry-After: 600.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: ai_unavailable` (`code` ai_overloaded | ai_invalid_output | ai_provider_error | ai_not_configured) or `compose_unavailable`.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/ai/translate: post: operationId: composeAiTranslate tags: - AI summary: Translate the draft before sending description: | Translates the draft text (and subject, if given) into `target`, keeping names, numbers, links and [placeholders]. Same opt-in, hourly limit and errors as composeAiWrite. Delegated sessions: allowed (like help me write). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AiComposeTranslateRequest' example: text: Em gửi anh báo giá tháng 10. subject: Báo giá target: en responses: '200': description: Translation. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/AiComposeTranslateResult' example: data: subject: Quotation body: Please find the October quotation attached. '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: ai_disabled`, `code` one of: `ai_not_configured`, `ai_not_enabled`; or delegated scope.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: ai_refused`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: ai_rate_limited`, details `{limit}`. Retry-After: 600.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: ai_unavailable` (`code` ai_overloaded | ai_invalid_output | ai_provider_error | ai_not_configured) or `compose_unavailable`.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/ai/status: get: operationId: aiGetStatus tags: - AI summary: Is the AI assistant available for this mailbox? description: | `enabled` = an AI provider is configured AND the organisation opted in. Use it to show or hide all AI features (reader and compose). Does not count against the hourly limit. If the server has no AI service wired at all it answers `200 {available: false, enabled: false}` without checking the session (nothing about the account is disclosed); otherwise a valid session is required (401 without one). Delegated sessions: allowed (reports the owner's organisation). responses: '200': description: Status. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/AiStatus' example: data: available: true enabled: true '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/ai/summarize: post: operationId: aiSummarize tags: - AI summary: Summarize a message's conversation description: | Summarizes the message and up to 9 earlier messages of its thread (bodies over 30,000 characters are cut at a visible marker), in the owner's UI language, with action items, priority, category and a phishing warning. A non-null `suspiciousReason` also feeds the reader's safety verdict for that message (for about an hour). Content is not stored. Counts against the shared hourly AI limit (default 60/hour). Delegated sessions: allowed. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AiMessageRef' example: folder: inbox uid: 4211 responses: '200': description: Summary. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/AiSummary' example: data: summary: Chị Lan gửi báo giá tháng 10 và cần anh xác nhận trước thứ Sáu. keyPoints: - Tổng giá trị 120 triệu đồng actionItems: - task: Xác nhận báo giá owner: me due: '2026-10-09' priority: urgent category: work suspiciousReason: null '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: ai_disabled`, `code` one of: `ai_not_configured`, `ai_not_enabled`; or delegated scope.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`error: message_not_found`, `code: message_not_found` — the message (or its thread) does not exist in that folder (moved or deleted).' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: ai_refused`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: ai_rate_limited`, details `{limit}`. Retry-After: 600.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: | `error` one of: `ai_unavailable` (`code` ai_overloaded | ai_invalid_output | ai_provider_error | ai_not_configured, Retry-After: 30; `code: ai_unavailable` when no AI service is wired), `webmail_unavailable`, `mail_backend_busy` (Retry-After: 5). headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/ai/draft-reply: post: operationId: aiDraftReply tags: - AI summary: Draft a reply to a message description: | Drafts a reply to the last message of the thread (up to 10 messages of context), in that message's language, optionally following the user's `instruction`. Same opt-in, limit and errors as aiSummarize. Delegated sessions: allowed. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AiDraftReplyRequest' example: folder: inbox uid: 4211 instruction: Đồng ý, hẹn gặp thứ Sáu tone: friendly responses: '200': description: Reply body. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/AiDraftReplyResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: ai_disabled`, `code` one of: `ai_not_configured`, `ai_not_enabled`; or delegated scope.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`error: message_not_found`, `code: message_not_found` — the message does not exist in that folder.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: ai_refused`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: ai_rate_limited`, details `{limit}`. Retry-After: 600.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error` one of: `ai_unavailable` (Retry-After: 30), `webmail_unavailable`, `mail_backend_busy` (Retry-After: 5).' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/ai/translate: post: operationId: aiTranslateMessage tags: - AI summary: Translate a received message description: | Translates one message's subject and text into `target`. Display the result as plain text, never as HTML. Same opt-in, limit and errors as aiSummarize. Delegated sessions: allowed (every delegation can read the owner's mail; metered against the owner's organisation). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AiMessageTranslateRequest' example: folder: inbox uid: 4211 target: vi responses: '200': description: Translation. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/AiMessageTranslation' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: ai_disabled`, `code` one of: `ai_not_configured`, `ai_not_enabled`; or delegated scope.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`error: message_not_found`, `code: message_not_found` — the message does not exist in that folder.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: ai_refused`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: ai_rate_limited`, details `{limit}`. Retry-After: 600.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error` one of: `ai_unavailable` (Retry-After: 30), `webmail_unavailable`, `mail_backend_busy` (Retry-After: 5).' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/ai/brief: post: operationId: aiBrief tags: - AI summary: Daily brief of unread inbox mail description: | Looks at the 40 newest inbox messages; if none is unread it returns `{overview: "", allRead: true, items: []}` without an AI call (and without checking the AI opt-in or limit). Otherwise the model picks at most 5 unread messages that need attention, in the owner's UI language. No request body. Delegated sessions: allowed. responses: '200': description: Brief. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/AiBrief' example: data: overview: Hôm nay có 2 việc cần xử lý gấp. allRead: false items: - uid: 4211 sender: Nguyễn Thị Lan subject: Báo giá tháng 10 reason: Cần xác nhận trước thứ Sáu '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: ai_disabled`, `code` one of: `ai_not_configured`, `ai_not_enabled`; or delegated scope.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: ai_refused`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: ai_rate_limited`, details `{limit}`. Retry-After: 600.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error` one of: `ai_unavailable` (Retry-After: 30), `webmail_unavailable`, `mail_backend_busy` (Retry-After: 5).' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/ai/smart-replies: post: operationId: aiSmartReplies tags: - AI summary: Smart Reply suggestions for a received message description: | Up to 3 short replies in the message's language. Cached per message for 7 days: a cached answer is returned even when the hourly AI limit is used up (but not when AI has been switched off). Only for received mail (not `sent`/`drafts`). A message the model flags as suspicious also feeds the reader's safety verdict. The same suggestions also ride on GET /v1/mail/messages/{uid} once cached. Delegated sessions: allowed. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/AiMessageRef' example: folder: inbox uid: 4211 responses: '200': description: Suggestions. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/AiSmartReplies' example: data: suggestions: - Cảm ơn, tôi đã nhận được. - Tôi sẽ phản hồi sớm. - Chúng ta trao đổi qua điện thoại nhé? language: vi cached: false '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: ai_disabled`, `code` one of: `ai_not_configured`, `ai_not_enabled`; or delegated scope.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`error: message_not_found`, `code: message_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: | `error` one of: `rule_violation` (`code: smart_reply_unavailable` — folder is sent or drafts), `ai_refused`. content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: ai_rate_limited`, details `{limit}`. Retry-After: 600.' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error` one of: `ai_unavailable` (Retry-After: 30), `reader_unavailable`, `mail_backend_busy` (Retry-After: 5).' headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/view: get: operationId: organizeGetView tags: - Organize summary: Organized message list (tabs, labels, search, sort, pinned, priority) description: | The Gmail-like list view. One call returns the page of messages plus everything needed to draw the screen: category tabs with unread counts, labels, the user's view preferences, pinned mail, priority sections and paging info. **Modes** - Folder (default): messages of `folder`. For the plain inbox (folder=inbox, no `q`, no `label`) with tabs enabled in prefs, only the active `tab` is listed (default `primary` = inbox mail not tagged with an enabled optional tab). - Label (`label`=uuid): messages carrying that label across inbox, archive, sent and drafts (or the folder of an `in:` operator in `q`). - Search (`q`): Gmail-style operators — `from:` `to:` `cc:` `subject:` `filename:` (values may be "quoted"), `has:attachment`, `is:unread|read|starred|unstarred|flagged|important|pinned` (`important` = the `$Important` keyword, `pinned` = `$Pinned`; both 0.10.102), `in:inbox|sent|drafts|archive|junk|trash|anywhere` (alias spam, bin, deleted, all…; sets the folder scope), `in:allmail` (Gmail's All mail: inbox, archive, sent, drafts — no junk/trash; 0.10.102), `label:`, `before:` / `after:` (YYYY-MM-DD or YYYY/MM/DD) and `older_than:` / `newer_than:` (7d, 2w, 3m, 1y), `larger:` / `smaller:` (10M, 500K, 2G, bytes), plain words / "phrases" (subject, from, to or body), `OR` / `|`, `-term`, parentheses. Max 40 terms. Unknown operators are searched as text; invalid values are ignored and reported as chips with `invalid: true`. **Paging** — two styles, chosen by the server; always follow what the response returns: - Cursor (single folder, sort `newest` or `priority`): pass `nextCursor` as `before` (a UID; messages with a lower UID are returned). - Offset (sort `unread_first` / `starred_first`, or a multi-folder label/`in:anywhere` view): pass `nextOffset` as `offset` (max 10000). Going back: `prevCursor` / `prevOffset`, or the first page when `hasPrev` is true and both are null. `pinned`, `sections` and tab unread counts are computed only for the first page (no before/offset). **Side effects** (best effort, never fail the call): listing the plain inbox wakes due snoozes, archives new replies in muted conversations, classifies up to 300 unclassified inbox messages into tabs (when tabs are on) and removes keywords of deleted labels. So the inbox listing is not purely read-only at the IMAP level. Allowed for delegated sessions. parameters: - name: folder in: query schema: $ref: '#/components/schemas/MailFolder' description: Folder to list (default inbox). Overridden by an `in:` operator in `q`; ignored for label views without `in:`. - name: q in: query schema: type: string maxLength: 500 description: Search query with operators (see above). Empty/whitespace = no search. - name: label in: query schema: type: string format: uuid description: Show messages with this label. Unknown id → 404. - name: tab in: query schema: $ref: '#/components/schemas/OrganizeCategory' description: Inbox tab (plain inbox with tabs on only; otherwise ignored). A tab that is not enabled falls back to `primary`. - name: sort in: query schema: $ref: '#/components/schemas/OrganizeSort' description: Overrides the sort for this call. Default = prefs.sort for the plain inbox, `newest` elsewhere. Forced to `newest` for multi-folder views. - name: before in: query schema: type: integer minimum: 1 description: Cursor (UID) from `nextCursor` / `prevCursor`. - name: offset in: query schema: type: integer minimum: 0 maximum: 10000 description: Offset from `nextOffset` / `prevOffset`. - name: limit in: query schema: type: integer minimum: 1 maximum: 100 default: 50 responses: '200': description: The view. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ViewResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '404': description: '`error: label_error`, `code: label_not_found` — the `label` id does not exist.' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: label_error code: label_not_found message: Label not found '503': description: '`error: organize_unavailable` (feature not configured) or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/view/uids: get: operationId: organizeGetViewUids tags: - Organize summary: All UIDs matching the current view ("select all N") description: | Same query parameters as GET /v1/mail/view (`before`, `offset`, `limit`, `sort` are accepted but do not restrict the result). Returns every matching UID of ONE folder, newest first, at most 10000; pinned inbox mail is included for the plain inbox (Primary tab / tabs off). Multi-folder views (label views without `in:`, `in:anywhere`) are rejected with 422 `multi_folder_selection` — act on the visible page instead. Delegated sessions: allowed (reading; every delegation grants it). Acting on the UIDs still follows the delegation's permissions (deleting needs `canDelete`). parameters: - name: folder in: query schema: $ref: '#/components/schemas/MailFolder' - name: q in: query schema: type: string maxLength: 500 - name: label in: query schema: type: string format: uuid - name: tab in: query schema: $ref: '#/components/schemas/OrganizeCategory' - name: sort in: query schema: $ref: '#/components/schemas/OrganizeSort' - name: before in: query schema: type: integer minimum: 1 description: Validated but ignored. - name: offset in: query schema: type: integer minimum: 0 maximum: 10000 description: Validated but ignored. - name: limit in: query schema: type: integer minimum: 1 maximum: 100 description: Validated but ignored. responses: '200': description: Matching UIDs. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ViewUidsResponse' example: data: folder: inbox uids: - 4211 - 4207 - 4190 total: 3 '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: label_error`, `code: label_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: organize_error`, `code: multi_folder_selection`.' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: organize_error code: multi_folder_selection message: Select all works on one folder at a time '503': description: '`error: organize_unavailable` or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/labels: get: operationId: labelsList tags: - Labels summary: List labels description: | All labels of the mailbox, sorted by name (case-insensitive). Max 200 per mailbox. Allowed for delegated sessions. responses: '200': description: Labels. content: application/json: schema: type: object required: - data properties: data: type: object required: - labels properties: labels: type: array items: $ref: '#/components/schemas/Label' '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] post: operationId: labelsCreate tags: - Labels summary: Create a label description: | Creates a label. Names are unique per mailbox (case-insensitive); at most 200 labels. Not idempotent (a second identical call returns 409 label_exists). Allowed for delegated sessions. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LabelCreateRequest' example: name: Khách hàng color: '#0a6e5c' responses: '201': description: Created. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Label' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '409': description: '`error: label_error`, `code: label_exists` (details: {name}).' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: label_error code: label_exists details: name: Khách hàng message: A label with this name already exists '422': description: '`error: label_error`, `code: label_limit` (details: {max: 200}).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/labels/{id}: parameters: - name: id in: path required: true schema: type: string format: uuid patch: operationId: labelsUpdate tags: - Labels summary: Rename or recolour a label description: | Changes name and/or colour. The IMAP keyword (and so the messages' labels) stays the same. Allowed for delegated sessions. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LabelUpdateRequest' example: color: '#d93025' responses: '200': description: Updated label. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Label' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: label_error`, `code: label_not_found` (also for a non-UUID id).' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`error: label_error`, `code: label_exists` (details: {name}).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] delete: operationId: labelsDelete tags: - Labels summary: Delete a label description: | Deletes the label. The keyword is removed from the messages in the background (and again on the next inbox listing); messages themselves are not touched. Allowed for delegated sessions. responses: '204': description: Deleted. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: label_error`, `code: label_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/labels/apply: post: operationId: labelsApply tags: - Labels summary: Add or remove a label on messages description: | Adds (or with `remove: true` removes) the label's IMAP keyword on up to 500 messages of one folder. Idempotent. Unknown UIDs are ignored. Allowed for delegated sessions. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LabelApplyRequest' example: folder: inbox uids: - 4211 - 4207 labelId: 3fa85f64-5717-4562-b3fc-2c963f66afa6 responses: '200': description: Applied. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/LabelApplyResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: label_error`, `code: label_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: organize_error`, `code: keywords_unsupported` (the folder cannot store custom keywords).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable` or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/labels/message: get: operationId: labelsGetForMessage tags: - Labels summary: Labels of one message description: | For the reader's label chips and picker: all labels, the ids applied to this message, and its category tab. Allowed for delegated sessions. parameters: - name: folder in: query schema: $ref: '#/components/schemas/MailFolder' description: Default inbox. - name: uid in: query required: true schema: type: integer minimum: 1 responses: '200': description: Labels of the message. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/LabelMessageLabels' '400': description: | `error: validation_error` — invalid `folder` (with zod issues in `details`) or missing/invalid `uid` (no details). content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '404': description: '`error: message_not_found` (no `code`).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable` or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/prefs: get: operationId: organizeGetPrefs tags: - Organize summary: Get inbox display preferences description: Inbox sort and enabled category tabs. Allowed for delegated sessions. responses: '200': description: Preferences. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/OrganizePrefs' example: data: sort: newest tabs: - promotions - social - updates - purchases '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] put: operationId: organizeSavePrefs tags: - Organize summary: Save inbox display preferences description: | Partial update (omitted fields are kept); returns the stored preferences. `tabs: []` turns tabs off. Idempotent. NOT allowed for delegated sessions (403 delegated_scope). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrganizePrefsUpdate' example: sort: priority tabs: - promotions - social responses: '200': description: Stored preferences. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/OrganizePrefs' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/mute: post: operationId: organizeMute tags: - Organize summary: Mute conversations description: | Mutes the conversations of the given messages (identified by their root Message-ID). All inbox messages of those conversations are moved to Archive now, and later replies skip the inbox (archived at delivery or by the next inbox listing). Max 1000 muted conversations per mailbox. Muting an already muted conversation is a no-op. Allowed for delegated sessions. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrganizeUidsRequest' example: folder: inbox uids: - 4211 responses: '200': description: Muted. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/OrganizeMuteResult' example: data: muted: 1 archived: 3 '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: | `error: organize_error`, `code` one of: mute_no_thread (no Message-ID on the messages / UIDs not found), mute_limit (details: {max: 1000}). content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable` or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/muted: get: operationId: organizeListMuted tags: - Organize summary: List muted conversations description: Newest first, at most 1000. Allowed for delegated sessions. responses: '200': description: Muted conversations. content: application/json: schema: type: object required: - data properties: data: type: object required: - threads properties: threads: type: array items: $ref: '#/components/schemas/OrganizeMutedThread' '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/muted/{id}: delete: operationId: organizeUnmute tags: - Organize summary: Unmute a conversation description: | Stops muting; messages already archived stay in Archive. Allowed for delegated sessions. parameters: - name: id in: path required: true schema: type: string format: uuid description: OrganizeMutedThread.id. responses: '204': description: Unmuted. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: organize_error`, `code: muted_not_found` (also for a malformed id).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/snooze: post: operationId: organizeSnooze tags: - Organize summary: Snooze messages description: | Moves up to 50 messages from Inbox or Archive to the IMAP folder "Snoozed" (created if missing) until the wake time; then they return to the Inbox, unread, with the `$snoozed` keyword (`snoozedBack` in the view). Wake-up is done by a background worker and also when the inbox is listed. The wake time must be at least 1 minute in the future and at most 366 days away. The moved messages get new UIDs (returned: `snoozes`, `uidMap`). Undo = `POST /v1/mail/organize/snoozed/wake` with the returned snooze ids. Allowed for delegated sessions. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrganizeSnoozeRequest' examples: preset: value: folder: inbox uids: - 4211 preset: tomorrow timezone: Asia/Ho_Chi_Minh custom: value: folder: inbox uids: - 4211 - 4207 preset: custom until: 2026-10-10T09:30 timezone: Europe/Berlin responses: '200': description: Snoozed. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/OrganizeSnoozeResult' example: data: count: 1 wakeAt: '2026-10-05T01:00:00.000Z' snoozes: - id: 9b2e6f1c-0d1a-4c3b-8a55-0f6e2f0b7c11 sourceUid: 4211 snoozedUid: 57 target: Snoozed uidValidity: '1696402800' uidMap: '4211': 57 '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: | `error: organize_error`, `code` one of: snooze_folder_not_allowed (folder is not inbox/archive), snooze_time_invalid (custom without a valid `until`), snooze_time_past (less than 1 minute ahead), snooze_time_too_far (details: {days: 366}). content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable` or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/snoozed: get: operationId: organizeListSnoozed tags: - Organize summary: List snoozed messages description: | Ordered by wake time (soonest first), at most 500. Open one with `GET /v1/mail/organize/snoozed/{id}/message`. Allowed for delegated sessions. responses: '200': description: Snoozes. content: application/json: schema: type: object required: - data properties: data: type: object required: - snoozes properties: snoozes: type: array items: $ref: '#/components/schemas/OrganizeSnooze' '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/snoozed/{id}/message: get: operationId: organizeReadSnoozed tags: - Organize summary: Read a snoozed message description: | A snoozed message lives in the IMAP folder "Snoozed", outside the six folders, until it wakes (0.10.102). This reads it by its snooze id: read-only, **never marks it read** (`unread` is the real state), same `message` shape as `GET /v1/mail/messages/{uid}` (sanitized `html`, remote images blocked unless `images=1`, attachments by `part`). The message is found by its stored UID, else by Message-ID (another client may have touched the folder). No reader extras. Allowed for delegated sessions. parameters: - name: id in: path required: true description: Snooze id (`GET /v1/mail/organize/snoozed`, or `snoozes[].id` of the snooze answer). schema: type: string format: uuid - name: images in: query description: '`1` = keep remote images in `html`.' schema: type: string enum: - '1' responses: '200': description: The snoozed message. content: application/json: schema: type: object required: - data properties: data: type: object required: - snooze - message properties: snooze: $ref: '#/components/schemas/OrganizeSnooze' message: $ref: '#/components/schemas/MessageDetail' '401': $ref: '#/components/responses/Unauthorized' '404': description: '`error: organize_error`, `code: snooze_not_found` (unknown id, already woken, or the message is no longer in Snoozed).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable` or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/snoozed/{id}/attachments/{part}: get: operationId: organizeSnoozedAttachment tags: - Organize summary: Download an attachment of a snoozed message description: | Like `GET /v1/mail/messages/{uid}/attachments/{part}` for a snoozed message (0.10.102): always `application/octet-stream` with the real type in `X-Original-Content-Type`, `Content-Disposition: attachment`. No Range support. Allowed for delegated sessions. parameters: - name: id in: path required: true schema: type: string format: uuid - name: part in: path required: true description: MIME part number from `message.attachments[].part`. schema: type: string pattern: ^\d+(\.\d+)*$ responses: '200': description: The attachment bytes. headers: X-Original-Content-Type: schema: type: string Content-Disposition: schema: type: string content: application/octet-stream: schema: type: string format: binary '400': description: '`error: invalid_request` (malformed id or part).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '404': description: '`error: attachment_not_found` (unknown snooze, message gone, or no such part).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable` or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/snoozed/wake: post: operationId: organizeWakeSnoozed tags: - Organize summary: Unsnooze now description: | Moves the snoozed messages back to the Inbox immediately (unread, `$snoozed` keyword) and returns their new Inbox UIDs. Ids that do not belong to the mailbox are ignored; 404 only when none matched. Messages already gone from Snoozed count as woken with uid 0. Allowed for delegated sessions. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrganizeWakeRequest' responses: '200': description: Woken. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/OrganizeWakeResult' example: data: woken: - id: 9b2e6f1c-0d1a-4c3b-8a55-0f6e2f0b7c11 uid: 4302 '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: organize_error`, `code: snooze_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable` or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/category: post: operationId: organizeMoveCategory tags: - Organize summary: Move messages to a category tab description: | Sets the category keyword on up to 100 messages (replacing any other category). With `remember` (default true) the From addresses get a per-sender rule (overrides automatic classification for future mail) and other inbox mail from them is retagged now. Idempotent. Allowed for delegated sessions. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/OrganizeCategoryRequest' example: folder: inbox uids: - 4211 category: primary remember: true responses: '200': description: Moved. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/OrganizeCategoryResult' example: data: category: primary count: 1 senders: - news@shop.vn retagged: 12 '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': description: '`error: organize_unavailable` or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/category-rules: get: operationId: organizeListCategoryRules tags: - Organize summary: List per-sender category rules description: Sorted by sender, at most 1000. Allowed for delegated sessions. responses: '200': description: Rules. content: application/json: schema: type: object required: - data properties: data: type: object required: - rules properties: rules: type: array items: $ref: '#/components/schemas/OrganizeCategoryRule' '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/organize/category-rules/{sender}: delete: operationId: organizeDeleteCategoryRule tags: - Organize summary: Delete a per-sender category rule description: | Removes the rule (matched case-insensitively). Already tagged messages keep their tab. URL-encode the address (`@` → `%40`). NOT allowed for delegated sessions (403 delegated_scope). parameters: - name: sender in: path required: true schema: type: string example: news%40shop.vn responses: '204': description: Deleted. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: organize_error`, `code: category_rule_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: organize_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/messages/report: post: operationId: junkReport tags: - Safety & unsubscribe summary: Report spam / not spam description: | `spam`: moves the messages to Junk (from inbox, archive or trash). `ham`: moves them from Junk to the Inbox. The move always happens first; spam-filter training runs in the background and never fails the call. With `rememberSender` the senders are added to the block (spam) or allow (ham) list. Limit: 300 reported messages per mailbox per clock hour. Not idempotent (UIDs change after the move). Allowed for delegated sessions. The answer tells where the messages went (`target`, `uidValidity`, `uidMap` old → new UID; 0.10.102) for Undo. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/JunkReportRequest' example: folder: inbox uids: - 4211 verdict: spam rememberSender: true responses: '200': description: Reported and moved. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/JunkReportResult' example: data: moved: 1 learning: queued senders: - promo@spam.example '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: | `error: rule_violation`, `code` one of: report_folder_invalid (details: {folder}), sender_rules_full (details: {max: 1000}, only with rememberSender — the messages were already moved). Also `error: action_not_allowed` from the mail backend (unlikely for a move). content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: | `error: rate_limited`, `code: junk_rate_limited`; seconds until the next hour in the Retry-After header and in `details.retryAfterSeconds` (not top-level). headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' example: error: rate_limited code: junk_rate_limited details: retryAfterSeconds: 1260 message: Too many spam reports from this mailbox; try again later '503': description: '`error: junk_unavailable` (feature not configured) or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/sender-rules: get: operationId: junkListSenderRules tags: - Safety & unsubscribe summary: List allowed / blocked senders description: The mailbox's allow and block lists, enforced at delivery. Allowed for delegated sessions. responses: '200': description: Rules. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/SenderRuleList' example: data: rules: - sender: boss@acme.vn kind: allow source: manual createdAt: '2026-09-30T08:00:00.000Z' - sender: '@spam.example' kind: block source: manual createdAt: '2026-10-01T08:00:00.000Z' applied: true applyError: null '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: junk_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] post: operationId: junkAddSenderRule tags: - Safety & unsubscribe summary: Allow or block a sender / domain description: | Upsert: an existing rule for the same sender is switched to the new kind (source becomes `manual`). Max 1000 rules per mailbox. Takes effect for new deliveries. NOT allowed for delegated sessions (403 delegated_scope). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SenderRuleCreateRequest' example: sender: '@spam.example' kind: block responses: '201': description: Stored (normalised). content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/SenderRuleCreated' example: data: sender: '@spam.example' kind: block '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: rule_violation`, `code: sender_rules_full` (details: {max: 1000}).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: junk_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/sender-rules/{sender}: delete: operationId: junkDeleteSenderRule tags: - Safety & unsubscribe summary: Remove a sender from the allow/block list description: | `sender` is normalised like on create (`acme.vn` and `@acme.vn` both mean the domain). URL-encode it. NOT allowed for delegated sessions (403 delegated_scope). parameters: - name: sender in: path required: true schema: type: string example: '%40spam.example' responses: '204': description: Removed. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: rule_violation`, `code: sender_rule_not_found` (also when `sender` is not a valid address/domain).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: junk_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/messages/unsubscribe: post: operationId: unsubscribePerform tags: - Safety & unsubscribe summary: Unsubscribe from a mailing list description: | Uses the message's List-Unsubscribe header (the same option shown as `unsubscribe` in GET /v1/mail/messages/{uid}). Ask the user to confirm first. - `one_click` (https + List-Unsubscribe-Post + DKIM/DMARC pass): the server POSTs to the list (status `done`). - `mailto`: the server sends the unsubscribe e-mail (the header's address, subject and body) from this mailbox through the normal outbound queue (a copy lands in Sent; sending limits and holds apply), status `sent`. The send is keyed on mailbox + list address + UTC day: a repeat the same day answers `sent` again without sending a second e-mail. A mailto failure (429/423/503) is not recorded as an unsubscribe. - `link`: nothing is done server-side (status `opened`); open `url` in the browser. Refused for messages in sent/drafts and for messages with safety level danger. Limit: 30 unsubscribes per hour per mailbox (counting recorded attempts: done, sent, opened and failed one-click attempts). Delegated sessions need send permission (rule "send": 403 delegated_send_forbidden otherwise). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UnsubscribeRequest' example: folder: inbox uid: 4211 responses: '200': description: Done. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/UnsubscribeResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: message_not_found`, `code: message_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: | `error: rule_violation`, `code` one of: unsubscribe_unavailable (no List-Unsubscribe, sent/drafts folder, or mailto without sending configured), unsubscribe_refused_suspicious (safety level danger). `error: unsubscribe_failed`, `code` one of: unsubscribe_blocked_target (private network / non-443 port), unsubscribe_not_https, unsubscribe_invalid_url. content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`error: sending_held` (mailto only; sending is on hold for this account), with `reason`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: | `error: rate_limited` with `code: unsubscribe_rate_limited` (details: {limit: 30}; Retry-After: 600), or `code: rate_limited` from the mailto submission (Retry-After and `retryAfterSeconds` top-level and in details). headers: Retry-After: schema: type: integer content: application/json: schema: $ref: '#/components/schemas/Error' '502': description: | `error: unsubscribe_failed`, `code` one of: unsubscribe_unreachable, unsubscribe_rejected (details: {status} — HTTP status of the list server), unsubscribe_timeout. Recorded with status `failed`. content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: reader_unavailable` (feature not configured), `sending_unavailable` (mailto, no outbound relay) or `mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/unsubscribes: get: operationId: unsubscribeList tags: - Safety & unsubscribe summary: List recorded unsubscribes description: Unsubscribes of this mailbox, most recently updated first, at most 500. Allowed for delegated sessions. responses: '200': description: Records. content: application/json: schema: type: object required: - data properties: data: type: array items: $ref: '#/components/schemas/UnsubscribeRecord' example: data: - sender: news@shop.vn listId: method: one_click target: list.shop.vn status: done updatedAt: '2026-10-02T10:00:00.000Z' '401': $ref: '#/components/responses/Unauthorized' '503': description: '`error: reader_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/calendar/events: get: operationId: calendarListEvents tags: - Calendar summary: List events in a time range description: | Events of the user's default calendar (CalDAV, also synced to phones/desktop DAV clients) that overlap the half-open range [`from`, `to`). Recurring series are expanded server-side: each occurrence is returned as its own item (same `uid`, `recurring: true`). Invitations you declined are hidden. Sorted by `start` ascending. No paging: the range itself is the limit (at most 400 days). Time conventions: send `from`/`to` as UTC instants (e.g. local midnight of the first visible day converted to UTC). All returned times are UTC ISO strings; all-day events need special handling (see the CalendarEvent schema). The web client pads its visible range by one day on each side so all-day events near the edges are not lost. Calendar, contacts and tasks are **not available to delegated ("dlg.") sessions** (403 `delegated_scope`). When the server has no calendar backend configured, these routes are not registered at all (plain 404). parameters: - name: from in: query required: true description: | Range start (inclusive). Any string JavaScript's `Date` parses is accepted; send ISO 8601 with an offset, e.g. `2026-09-28T17:00:00.000Z` (URL-encode `+` offsets as `%2B`). Missing or unparsable → 422 invalid_range. schema: type: string format: date-time example: '2026-09-27T17:00:00.000Z' - name: to in: query required: true description: Range end (exclusive). Must be after `from` and at most 400 days (~13 months) later. schema: type: string format: date-time example: '2026-11-08T17:00:00.000Z' responses: '200': description: Events overlapping the range. content: application/json: schema: type: object required: - data properties: data: type: array items: $ref: '#/components/schemas/CalendarEvent' example: data: - uid: 3f0c6c1e-6a39-4c55-9a8e-0f6a2b1c9d10@novamail title: Weekly sync start: '2026-10-01T02:00:00.000Z' end: '2026-10-01T03:00:00.000Z' allDay: false location: Room 1 description: '' organizer: an@acme.vn attendees: - email: lan@partner.vn name: '' status: NEEDS-ACTION recurring: false reminders: - 10 showAs: busy color: null - uid: holiday-2026@novamail title: National Day start: '2026-09-02T00:00:00.000Z' end: '2026-09-03T00:00:00.000Z' allDay: true location: '' description: '' organizer: null attendees: [] recurring: true reminders: [] showAs: free color: '#e67c73' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: calendar_rule`, `code: invalid_range` (from/to missing, unparsable, end not after start, or longer than 400 days).' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: calendar_rule code: invalid_range message: Invalid time range (at most ~13 months) '503': description: | `error: calendar_unavailable` (the calendar server failed or timed out; `message` only, no `code`), `error: mail_backend_busy` (calendar server unreachable; Retry-After: 5), or `error: webmail_unavailable` (sign-in not configured on this server). content: application/json: schema: $ref: '#/components/schemas/Error' example: error: calendar_unavailable code: calendar_unavailable message: Could not read the calendar security: - bearerAuth: [] post: operationId: calendarCreateEvent tags: - Calendar summary: Create an event (and invite attendees) description: | Creates an event in the user's default calendar (the calendar is created on first use). When `attendees` (other than yourself) are given you become the organiser and each attendee receives an email invitation (iCalendar iMIP METHOD:REQUEST, through the normal outbound mail queue — counts against sending limits). Invitations are grouped per recipient language (internal recipients get their own UI language, external ones the organiser's); times in the email text are shown in the organiser's compose time zone (default Asia/Ho_Chi_Minh). Not idempotent: every call creates a new event with a new UID. The event is saved BEFORE invitations are sent, so if sending fails (4xx/5xx below) the event already exists — re-list instead of blindly retrying. Not available to delegated sessions (403 `delegated_scope`). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CalendarEventCreateRequest' examples: timed: summary: Timed event with an attendee (local time 09:00 in UTC+7) value: title: Weekly sync start: '2026-10-02T09:00:00+07:00' end: '2026-10-02T10:00:00+07:00' location: Room 1 attendees: - lan@partner.vn allDay: summary: All-day event on 2026-10-01 and 2026-10-02 (end = day after the last day, UTC midnight) value: title: Offsite start: '2026-10-01T00:00:00.000Z' end: '2026-10-03T00:00:00.000Z' allDay: true responses: '201': description: Event created. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/CalendarEventCreated' example: data: uid: 3f0c6c1e-6a39-4c55-9a8e-0f6a2b1c9d10@novamail invited: 1 '400': description: '`error: validation_error`, zod issues in `details` (e.g. blank title, missing offset, end not after start → issue with `params.code: end_before_start` on path `["end"]`, invalid attendee address on path `["attendees", i]`).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '409': description: '`error: calendar_unavailable` — the calendar server answered 412 (an object with this UID already exists; practically never for new UUIDs).' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: '`error: message_too_large` (invitation email too large; only with huge descriptions).' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: | `error: calendar_rule`, `code: sending_unavailable` (attendees given but this server cannot send mail), or `error: message_infected` (invitation email rejected by the virus scanner). The event is already saved. content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`error: sending_held` (outgoing mail of this mailbox/domain is on hold; `reason` field). The event is already saved.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: rate_limited` — sending limit reached for the invitation emails; `retryAfterSeconds` and Retry-After header. The event is already saved.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: calendar_unavailable` (calendar server failed), `error: sending_unavailable` (outbound mail disabled on this server), `error: mail_backend_busy` (Retry-After: 5), or `error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/calendar/events/{uid}: parameters: - name: uid in: path required: true description: | The event's `uid` as listed (GET /v1/calendar/events, the delta sync's `uid` — or its `resource`), percent-encoded. Any UID works, including organisers' UIDs of accepted invitations (`{`, `+`, `/`… encoded): at most 1000 characters, no control characters, else 400 `invalid_uid`. Events you created and accepted invitations are both found by their UID (invitations are stored under an internal name; the server resolves it). schema: type: string minLength: 1 maxLength: 1000 example: 3f0c6c1e-6a39-4c55-9a8e-0f6a2b1c9d10@novamail patch: operationId: calendarUpdateEvent tags: - Calendar summary: Edit an event description: | Two kinds of fields: - **Organiser fields** — `title`, `start`, `end`, `allDay`, `location`, `description`: only on events you own (you are the organiser, or it has no organiser) and not on recurring series. On an invitation organised by someone else (e.g. one you accepted) they answer **403** `error: forbidden`, `code: not_organizer`; on a recurring event 422 `recurring_not_editable`. SEQUENCE is incremented; when you organise it and it has attendees, they receive an updated invitation (best effort, see `notified`). - **Personal fields** — `reminders`, `showAs`, `color`: allowed on every event, accepted invitations included (and on recurring series: applied to the whole series). They only change your own copy; nobody is notified, SEQUENCE is unchanged, and they are never included in invitations or replies you send. A request mixing both kinds on someone else's invitation is refused as a whole (403). The attendee list cannot be changed here. Not available to delegated sessions (403 `delegated_scope`). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CalendarEventUpdateRequest' responses: '200': description: The event as now stored, plus `notified`. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/CalendarEventUpdated' example: data: uid: 3f0c6c1e-6a39-4c55-9a8e-0f6a2b1c9d10@novamail title: Weekly sync start: '2026-10-02T04:00:00.000Z' end: '2026-10-02T05:30:00.000Z' allDay: false location: '' description: agenda organizer: null attendees: [] recurring: false reminders: - 10 showAs: busy color: null notified: null '400': description: '`error: invalid_uid` (path), or `error: validation_error` with zod issues (empty body → issue with `params.code: nothing_to_update`; unknown field → unrecognized_keys; blank title, missing offset, more than 5 reminders, bad colour…).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden`, `code: not_organizer` — an organiser field on an invitation organised by someone else (only `reminders`, `showAs`, `color` are editable there); or `code: delegated_scope` (delegated session).' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: forbidden code: not_organizer message: Only the organiser can change this; reminders, busy/free and colour are editable '404': description: '`error: not_found`, `code: event_not_found` — no such event in YOUR calendar.' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: not_found code: event_not_found '409': description: '`error: calendar_unavailable` — the calendar server refused the write with 412 (precondition failed).' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: calendar_rule`, `code` one of: recurring_not_editable (organiser fields of a recurring event), end_before_start (merged start/end).' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: calendar_rule code: end_before_start message: The end must be after the start '503': description: '`error: calendar_unavailable`, `error: mail_backend_busy` (Retry-After: 5), or `error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] delete: operationId: calendarDeleteEvent tags: - Calendar summary: Delete an event description: | Removes the event from your calendar (for a recurring event: the whole series), following iTIP (RFC 5546): - **You organised it and it has attendees:** they are first emailed a cancellation (iMIP METHOD:CANCEL); a sending failure aborts the delete (the event stays) — the 4xx/5xx sending answers below. - **An invitation from someone else** (e.g. one you accepted): the organiser is emailed a REPLY with PARTSTAT=DECLINED (only your attendee line), unless you had already declined or the organiser cancelled the event. This reply is best effort: the event is removed even when it cannot be sent. - Otherwise it is just removed. Afterwards the invitation message in your inbox shows no answer again (`myStatus` NEEDS-ACTION); answering it with /v1/calendar/invitation/respond puts the event back. Idempotent: deleting an unknown UID also answers 204. Not available to delegated sessions (403 `delegated_scope`). responses: '204': description: Deleted (or did not exist). '400': description: '`error: invalid_uid`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: calendar_rule`, `code: sending_unavailable` (cancellations cannot be sent), or `error: message_infected`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`error: sending_held` (cancellation cannot be sent; event not deleted).' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: rate_limited` (sending limit for the cancellation; Retry-After).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: calendar_unavailable`, `error: sending_unavailable`, `error: mail_backend_busy` (Retry-After: 5), or `error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/calendar/invitation: get: operationId: calendarGetInvitation tags: - Calendar summary: Read the meeting invitation in a message description: | Parses the text/calendar part (or `.ics` attachment) of a received message so the reader can show an invitation card with Accept / Tentative / Decline. Returns `data: null` when the message has no calendar part (or it holds no VEVENT with a start), when the message does not exist, and when the calendar data cannot be parsed (malformed, or cut at the 1 MB read limit). Show the RSVP buttons only for `method: REQUEST`. `myStatus` is your answer as stored in your calendar (after /v1/calendar/invitation/respond), else the PARTSTAT in the message; null when you are not an attendee. Needs the mail backend (IMAP). Not available to delegated sessions (403 `delegated_scope`). parameters: - name: folder in: query required: false schema: $ref: '#/components/schemas/MailFolder' description: Folder of the message (default inbox). - name: uid in: query required: true description: IMAP UID of the message (positive integer). schema: type: integer minimum: 1 responses: '200': description: The invitation, or null. content: application/json: schema: type: object required: - data properties: data: oneOf: - $ref: '#/components/schemas/CalendarInvitation' - type: 'null' example: data: method: REQUEST uid: 040000008200E00074C5B7101A82E008@outlook.com title: Quarterly review start: '2026-10-05T02:00:00.000Z' end: '2026-10-05T03:00:00.000Z' location: Meeting room A organizer: boss@partner.vn myStatus: NEEDS-ACTION '400': description: '`error: invalid_request` — `folder` not a known folder or `uid` not a positive integer (checked before authentication).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': description: '`error: mail_backend_busy` (Retry-After: 5), `error: calendar_unavailable`, or `error: webmail_unavailable` (no mail backend configured).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/calendar/invitation/respond: post: operationId: calendarRespondInvitation tags: - Calendar summary: Accept, decline or tentatively accept an invitation description: | Answers the invitation in message `uid`: stores your copy of the event with your PARTSTAT in your calendar (declined events are kept so the answer is remembered, but hidden from GET /v1/calendar/events) and emails an iMIP REPLY (only your attendee line) to the organiser, in the organiser's language when they are on this platform. You may answer again to change your answer; each call sends a new REPLY. When the organiser is a mailbox on this platform, their copy of the event shows your answer at once (attendee `status`). Your copy is stored under an internal name but is addressed by the organiser's `uid` like any event: PATCH /v1/calendar/events/{uid} edits its personal fields (reminders, busy/free, colour — kept when you answer again), DELETE removes it and declines. Answering again keeps your personal settings. Needs the mail backend. Not available to delegated sessions (403 `delegated_scope`). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CalendarInvitationRespondRequest' responses: '200': description: Answer saved and REPLY queued. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/CalendarInvitationRespondResult' example: data: status: ACCEPTED '400': description: '`error: validation_error` (zod issues).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '409': description: '`error: calendar_unavailable` (calendar server answered 412).' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: | `error: calendar_rule`, `code` one of: not_an_invitation (no calendar part, or METHOD is not REQUEST), sending_unavailable (answer saved but the REPLY cannot be sent); or `error: message_infected`. content: application/json: schema: $ref: '#/components/schemas/Error' example: error: calendar_rule code: not_an_invitation message: This message is not a meeting invitation '423': description: '`error: sending_held` (answer saved; REPLY not sent).' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: rate_limited` (answer saved; REPLY not sent; Retry-After).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: calendar_unavailable`, `error: sending_unavailable`, `error: mail_backend_busy` (Retry-After: 5), or `error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/recipients/frequent: get: operationId: mailFrequentRecipients tags: - Contacts summary: People this mailbox writes to most (autocomplete) description: | Recipients (To, Cc, Bcc) of the latest 500 messages in Sent, ranked by how many of those messages had them, then by the most recent (0.10.102). The mailbox's own address is left out. Computed from the mail server and cached per mailbox for 10 minutes, so a just-sent message can take that long to count. Combine with `GET /v1/contacts?q=` for compose autocomplete. Allowed for delegated sessions (the owner's Sent folder). parameters: - name: q in: query required: false description: Filter on address or name (case and accents ignored). schema: type: string maxLength: 200 - name: limit in: query required: false schema: type: integer minimum: 1 maximum: 100 default: 20 responses: '200': description: Ranked recipients. content: application/json: schema: type: object required: - data properties: data: type: object required: - recipients properties: recipients: type: array items: $ref: '#/components/schemas/FrequentRecipient' example: data: recipients: - address: lan@partner.vn name: Nguyễn Lan count: 14 lastSentAt: '2026-10-05T08:12:00.000Z' '400': description: '`error: validation_error`, `code: limit_invalid`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '503': $ref: '#/components/responses/MailUnavailable' security: - bearerAuth: [] /v1/contacts: get: operationId: contactsList tags: - Contacts summary: List contacts description: | All contacts of the user's default address book (CardDAV; also visible to phone/desktop CardDAV clients), sorted by name (Vietnamese collation). Cards without a UID are skipped. **Search** (0.10.102): `q` keeps contacts whose name, e-mail or organisation contains it (case and accents ignored); `limit` caps the list. Without them the whole address book, as before. For "people I write to" (empty address book on a new phone) see `GET /v1/mail/recipients/frequent`. Each contact carries its `etag` (when the address book server reports one) for conditional updates (`If-Match` on PUT/PATCH /v1/contacts/{uid}). Not available to delegated sessions (403 `delegated_scope`). When the server has no calendar backend configured, the route is not registered (404 `route_not_found`). parameters: - name: q in: query required: false description: Filter (name, e-mail, organisation; case and accents ignored), up to 200 characters. schema: type: string maxLength: 200 - name: limit in: query required: false description: At most this many contacts (1–1000; 400 `limit_invalid` otherwise). schema: type: integer minimum: 1 maximum: 1000 responses: '200': description: Contacts. content: application/json: schema: type: object required: - data properties: data: type: array items: $ref: '#/components/schemas/Contact' example: data: - uid: 0b8c1f61-2d7e-4c5a-9d43-2f5f8e3f7a10 name: Nguyễn Lan emails: - lan@partner.vn phones: - +84 90 123 4567 organization: Partner Co. note: '' etag: '"1c8e-1696402800"' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': description: '`error: calendar_unavailable` (address book server failed), `error: mail_backend_busy` (unreachable; Retry-After: 5), or `error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] post: operationId: contactsCreate tags: - Contacts summary: Create a contact description: | Always creates a NEW contact (new UUID). To change an existing contact use PUT or PATCH /v1/contacts/{uid} (same UID, edited in place). Not idempotent. The address book is created on first use. Not available to delegated sessions (403 `delegated_scope`). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ContactCreateRequest' responses: '201': description: Created. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/ContactCreated' example: data: uid: 0b8c1f61-2d7e-4c5a-9d43-2f5f8e3f7a10 '400': description: '`error: validation_error` (zod issues; paths like `["emails", 0]`, `["phones", 1]`, `["name"]`).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '409': description: '`error: calendar_unavailable` (address book server answered 412).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: calendar_unavailable`, `error: mail_backend_busy` (Retry-After: 5), or `error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/contacts/{uid}: parameters: - name: uid in: path required: true description: Contact `uid` as listed, percent-encoded (UIDs of phone apps such as `urn:uuid:…` work). At most 1000 characters, no control characters, else 400 `invalid_uid`. schema: type: string minLength: 1 maxLength: 1000 get: operationId: contactsGet tags: - Contacts summary: Read one contact description: | One contact by UID (also contacts created by phones / CardDAV clients under another file name), with its `etag` (also sent as the `ETag` header) for a conditional update. Not available to delegated sessions (403 `delegated_scope`). responses: '200': description: The contact. headers: ETag: schema: type: string content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Contact' '400': description: '`error: invalid_uid`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: not_found`, `code: contact_not_found` — no contact with this UID in YOUR address book.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: calendar_unavailable`, `error: mail_backend_busy` (Retry-After: 5), or `error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] put: operationId: contactsReplace tags: - Contacts summary: Update a contact (all fields) description: | Edits the contact **in place**: same UID, same stored vCard. Every API field is replaced (omitted lists become empty, omitted `organization`/`note` are removed). vCard properties the API does not manage — photo, postal addresses, birthday, labels (TYPE) of e-mails/phones that are kept, custom fields of phone apps — are preserved; a changed `name` drops the stale structured name (N). Optional `If-Match` for a conditional update. Unknown body fields are ignored (a contact sent back as read, with `uid`/`etag`, is fine). Not available to delegated sessions (403 `delegated_scope`). parameters: - name: If-Match in: header required: false description: The contact's `etag` as last read. When given, the update only happens if the stored contact still has it (else 412). Without it the last write wins. schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ContactCreateRequest' example: name: Nguyễn Lan emails: - lan@partner.vn phones: - +84 90 123 4567 organization: Partner Co. note: '' responses: '200': description: The contact as now stored; `ETag` header = its new `etag` (when the server reports one). headers: ETag: schema: type: string content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Contact' example: data: uid: 0b8c1f61-2d7e-4c5a-9d43-2f5f8e3f7a10 name: Nguyễn Lan emails: - lan@partner.vn phones: - +84 90 123 4567 - +84 28 1234 5678 organization: Partner Co. note: VIP etag: '"1c8f-1696403100"' '400': description: '`error: invalid_uid` (path), or `error: validation_error` (zod issues; empty PATCH → `params.code: nothing_to_update`; unknown fields → unrecognized_keys).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: not_found`, `code: contact_not_found` — no contact with this UID in YOUR address book.' content: application/json: schema: $ref: '#/components/schemas/Error' '412': description: '`error: precondition_failed`, `code: contact_changed` — `If-Match` no longer matches (changed on another device or in a CardDAV client): GET it again, merge, retry.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: calendar_unavailable`, `error: mail_backend_busy` (Retry-After: 5), or `error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] patch: operationId: contactsUpdate tags: - Contacts summary: Update a contact (only the given fields) description: | Like PUT, but only the fields present in the body change (at least one). Same UID, same stored vCard, other vCard properties preserved; optional `If-Match`. `uid`/`etag` in the body are ignored; other unknown fields are rejected (400). Not available to delegated sessions (403 `delegated_scope`). parameters: - name: If-Match in: header required: false description: The contact's `etag` as last read. When given, the update only happens if the stored contact still has it (else 412). Without it the last write wins. schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ContactPatchRequest' example: phones: - +84 90 123 4567 - +84 28 1234 5678 note: VIP responses: '200': description: The contact as now stored; `ETag` header = its new `etag` (when the server reports one). headers: ETag: schema: type: string content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Contact' example: data: uid: 0b8c1f61-2d7e-4c5a-9d43-2f5f8e3f7a10 name: Nguyễn Lan emails: - lan@partner.vn phones: - +84 90 123 4567 - +84 28 1234 5678 organization: Partner Co. note: VIP etag: '"1c8f-1696403100"' '400': description: '`error: invalid_uid` (path), or `error: validation_error` (zod issues; empty PATCH → `params.code: nothing_to_update`; unknown fields → unrecognized_keys).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: not_found`, `code: contact_not_found` — no contact with this UID in YOUR address book.' content: application/json: schema: $ref: '#/components/schemas/Error' '412': description: '`error: precondition_failed`, `code: contact_changed` — `If-Match` no longer matches (changed on another device or in a CardDAV client): GET it again, merge, retry.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: calendar_unavailable`, `error: mail_backend_busy` (Retry-After: 5), or `error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] delete: operationId: contactsDelete tags: - Contacts summary: Delete a contact description: | Deletes the contact with this UID — also one created by a phone / CardDAV client under another file name. Idempotent: an unknown UID also answers 204. Not available to delegated sessions (403 `delegated_scope`). responses: '204': description: Deleted (or did not exist). '400': description: '`error: invalid_uid`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': description: '`error: calendar_unavailable`, `error: mail_backend_busy` (Retry-After: 5), or `error: webmail_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/tasks: get: operationId: tasksList tags: - Tasks summary: List tasks description: | All tasks of the user's task list (CalDAV VTODO collection `tasks/`, created on first use and named "Việc cần làm" / "Tasks" in the user's language; the same list appears in iOS Reminders, DAVx5 + Tasks.org, Thunderbird…). Includes tasks created by other clients. Sorted: open tasks first (earliest `due` first, undated after, then newest created), then done tasks (most recently completed first). No paging. Not available to delegated sessions (403 `delegated_scope`). responses: '200': description: Tasks. content: application/json: schema: type: object required: - data properties: data: type: array items: $ref: '#/components/schemas/Task' example: data: - id: 7d7a3c3e-55f0-4c1f-9a2f-1b3c4d5e6f70 uid: 7d7a3c3e-55f0-4c1f-9a2f-1b3c4d5e6f70 title: Send the quote notes: '' due: '2026-10-05' done: false completedAt: null url: null messageId: null createdAt: '2026-10-04T08:15:00.000Z' updatedAt: '2026-10-04T08:15:00.000Z' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '409': description: '`error: tasks_unavailable`, `code: task_conflict` (the task server answered 412 while creating the list).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: tasks_unavailable` (`code: tasks_unavailable`: tasks not configured on this server, or the task server failed), or `error: mail_backend_busy` (task server unreachable; Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: tasks_unavailable code: tasks_unavailable message: Tasks are not configured security: - bearerAuth: [] post: operationId: tasksCreate tags: - Tasks summary: Create a task description: | Creates an open task (id = new UUID). Not idempotent. Not available to delegated sessions (403 `delegated_scope`). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TaskCreateRequest' responses: '201': description: The created task. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Task' '400': description: '`error: validation_error` (zod issues; an impossible date such as 2026-13-45 carries `params.code: invalid_date`).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '409': description: '`error: tasks_unavailable`, `code: task_conflict` (412 from the task server).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: tasks_unavailable` (`code: tasks_unavailable`), or `error: mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/tasks/from-message: post: operationId: tasksCreateFromMessage tags: - Tasks summary: Add an email as a task description: | Creates a task from a received message: title = subject (or the sender when there is no subject, max 300 chars), notes = sender + a link back to the message in webmail, `url` = that link, `messageId` = the message's Message-ID. Idempotent per message: if an OPEN task with the same Message-ID already exists it is returned unchanged with HTTP 200 and `existing: true` (a completed one does not count). New task → HTTP 201, `existing: false`. Response envelope differs from other routes: `{ "data": Task, "existing": bool }`. Not available to delegated sessions (403 `delegated_scope`). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TaskFromMessageRequest' responses: '200': description: An open task for this message already existed. content: application/json: schema: $ref: '#/components/schemas/TaskFromMessageResult' '201': description: Task created. content: application/json: schema: $ref: '#/components/schemas/TaskFromMessageResult' example: data: id: 9a1e2b3c-4d5e-4f60-8a7b-1c2d3e4f5a6b uid: 9a1e2b3c-4d5e-4f60-8a7b-1c2d3e4f5a6b title: 'Re: Quote for October' notes: |- Lan https://mail.example.com/mail/4211 due: null done: false completedAt: null url: https://mail.example.com/mail/4211 messageId: createdAt: '2026-10-04T08:15:00.000Z' updatedAt: '2026-10-04T08:15:00.000Z' existing: false '400': description: '`error: validation_error` (zod issues; unknown folder, non-positive uid, invalid due).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: task_rule`, `code: message_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: task_rule code: message_not_found message: Message not found '409': description: '`error: tasks_unavailable`, `code: task_conflict`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: task_rule`, `code: mail_unavailable` (no mail backend on this server); `error: tasks_unavailable` (`code: tasks_unavailable`); or `error: mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/tasks/{id}: parameters: - name: id in: path required: true description: | The task's `id` (resource name), percent-encoded. 1–255 characters, no `/`, `\` or control characters, not `.` or `..` (else 422 `invalid_task_id`). schema: type: string minLength: 1 maxLength: 255 patch: operationId: tasksUpdate tags: - Tasks summary: Update a task (rename, notes, due day, complete/reopen) description: | Partial update; properties other clients set (alarms, priority, categories…) are kept. Bumps SEQUENCE and LAST-MODIFIED. Last write wins (no ETag check). Not available to delegated sessions (403 `delegated_scope`). requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TaskUpdateRequest' responses: '200': description: The updated task. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Task' '400': description: '`error: validation_error` (zod issues; empty update → `params.code: empty_update`; bad date → `params.code: invalid_date`).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: task_rule`, `code: task_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`error: tasks_unavailable`, `code: task_conflict` (412 from the task server).' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`error: task_rule`, `code: invalid_task_id`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: tasks_unavailable` (`code: tasks_unavailable`), or `error: mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] delete: operationId: tasksDelete tags: - Tasks summary: Delete a task description: | Idempotent: an unknown id also answers 204. Not available to delegated sessions (403 `delegated_scope`). responses: '204': description: Deleted (or did not exist). '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '422': description: '`error: task_rule`, `code: invalid_task_id`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: tasks_unavailable` (`code: tasks_unavailable`), or `error: mail_backend_busy` (Retry-After: 5).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/meet: get: operationId: meetListMine tags: - Meet summary: List my meetings description: | The 20 most recent meetings created by the signed-in mailbox, newest first. Meetings do not expire on their own; a listed meeting of a billing-suspended organisation cannot be joined. Meet errors carry only `error` (no `code`/`message`). Not available to delegated sessions (403 `delegated_scope`). responses: '200': description: Meetings. content: application/json: schema: type: object required: - data properties: data: type: array maxItems: 20 items: $ref: '#/components/schemas/MeetMeeting' example: data: - id: 5f7c2a10-8b1d-4e2f-9a3b-4c5d6e7f8091 code: kqz-mbtr-wpx tenantId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d title: Weekly sync allowGuests: true guestLobby: true createdBy: 9e8d7c6b-5a49-4382-9170-6f5e4d3c2b1a createdAt: '2026-10-04T08:00:00.000Z' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '500': description: '`error: internal_error` (unexpected server error).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: meet_unavailable` — video meetings (LiveKit) are not configured on this server.' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: meet_unavailable code: meet_unavailable security: - bearerAuth: [] post: operationId: meetCreate tags: - Meet summary: Create a meeting description: | Creates a meeting with a fresh code. The body is optional (all fields have defaults). The shareable link is `/meet/`; people with the link join through the web page, signed-in Zomail users can also join natively with POST /v1/meet/{code}/join. The organisation's plan must include Meet and it must not be billing-suspended (else 403 `meet_disabled`). Not idempotent. To attach a meeting to a calendar event (as the web does), create the meeting first and put the link in the event's `location`/`description`. Not available to delegated sessions (403 `delegated_scope`). requestBody: required: false content: application/json: schema: $ref: '#/components/schemas/MeetCreateRequest' responses: '201': description: Created. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MeetMeeting' '400': description: '`error: validation_error` (zod issues).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: meet_disabled` (Meet not in the plan or organisation suspended), or (delegated session) `error: forbidden`, `code: delegated_scope`.' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: meet_disabled code: meet_disabled '404': description: '`error: not_found` — the signed-in mailbox is not active.' content: application/json: schema: $ref: '#/components/schemas/Error' '500': description: '`error: internal_error`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: meet_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/meet/{code}: delete: operationId: meetDelete tags: - Meet summary: Delete one of my meetings description: | Permanently deletes a meeting you created (its link stops working; people already in the LiveKit room are not disconnected by this call). Someone else's meeting, an unknown code and a malformed code all answer `404 {error: not_found, code: not_found}` (no existence oracle); a second delete is 404, not 204. Not available to delegated sessions (403 `delegated_scope`). parameters: - name: code in: path required: true schema: type: string pattern: ^[a-z]{3}-[a-z]{4}-[a-z]{3}$ example: kqz-mbtr-wpx responses: '204': description: Deleted. '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: not_found` (malformed code, unknown, or not yours).' content: application/json: schema: $ref: '#/components/schemas/Error' '500': description: '`error: internal_error`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: meet_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/meet/{code}/join: post: operationId: meetJoin tags: - Meet summary: Join a meeting (get a LiveKit token) description: | Signed-in join. No request body. Outcome depends on who you are: - **Member of the meeting's organisation** → `status: ready` with a LiveKit token (`member: true` in metadata; `host: true` + roomAdmin grant if you created the meeting). - **User of another organisation**: 403 `guests_not_allowed` if the meeting disallows guests; else, with a lobby, `status: waiting` + `requestId` + `secret` (you are now in the lobby; members see you with your address); without a lobby, `status: ready` (as a non-member). **Using the token in a native app** (media goes directly between the device and the LiveKit SFU; Zomail servers only issue tokens): - iOS (LiveKit Swift SDK `client-sdk-swift`): `let room = Room(); try await room.connect(url: data.serverUrl, token: data.token)`, then `room.localParticipant.setCamera(enabled: true)` / `setMicrophone(enabled: true)`. - Android (LiveKit Android SDK `io.livekit:livekit-android`): `val room = LiveKit.create(appContext); room.connect(data.serverUrl, data.token)`, then `room.localParticipant.setCameraEnabled(true)` / `setMicrophoneEnabled(true)`. - The room name is the meeting code (embedded in the token; do not pass it separately). Read each participant's `metadata` JSON (`guest`, `host`, `member`) to label them; `name` is the display name. - Tokens are valid for 6 hours by default and are only needed to (re)connect: call this route again to get a fresh token for a later reconnect. Do not cache or share tokens. - Request camera/microphone permissions (NSCameraUsageDescription / NSMicrophoneUsageDescription; Android CAMERA, RECORD_AUDIO) before connecting. **Waiting (`status: waiting`)**: poll `GET /v1/meet/{code}/lobby/{requestId}` with the same user token every 2–5 s while showing the waiting screen. It answers `waiting`, `denied`, or — once a member admitted you — `ready` with your LiveKit token. Stop polling when the user leaves: an entry not polled for ~45 s disappears from the members' lobby list. (`secret` is only for the web join page; apps can ignore it.) Anonymous guests (no Zomail account) join through the web page `/meet/`; they have no API token. Meetings of billing-suspended organisations, deleted meetings and malformed codes all answer 404. Not available to delegated sessions (403 `delegated_scope`). parameters: - name: code in: path required: true schema: type: string pattern: ^[a-z]{3}-[a-z]{4}-[a-z]{3}$ example: kqz-mbtr-wpx responses: '200': description: Ready to connect, or waiting in the lobby. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MeetJoinResult' examples: ready: value: data: status: ready serverUrl: wss://meet.example.com token: eyJhbGciOiJIUzI1NiJ9.eyJ2aWRlbyI6eyJyb29tIjoia3F6LW1idHItd3B4Iiwicm9vbUpvaW4iOnRydWV9fQ.sig meeting: code: kqz-mbtr-wpx title: Weekly sync waiting: value: data: status: waiting requestId: 2c9b1f0e-7a6d-4e5c-8b4a-3f2e1d0c9b8a secret: q3V9tY2mX8pL0aN4sR7wE1uI5oK6jH2g meeting: code: kqz-mbtr-wpx title: Weekly sync '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: guests_not_allowed` (you are outside the organisation and guests are disallowed), or (delegated session) `error: forbidden`, `code: delegated_scope`.' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: guests_not_allowed code: guests_not_allowed '404': description: '`error: not_found` (malformed/unknown/deleted code, organisation suspended, or your mailbox is not active).' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`error: lobby_full` — 30 people are already waiting. No Retry-After header; try again later.' content: application/json: schema: $ref: '#/components/schemas/Error' '500': description: '`error: internal_error`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: meet_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/meet/{code}/lobby: get: operationId: meetListLobby tags: - Meet summary: List people waiting to be admitted description: | For members of the meeting's organisation (typically while in the call, when the participant metadata has `member: true`): who is waiting in the lobby, oldest first, at most 30. An entry disappears once decided, or ~45 s after the waiting client stops polling. Poll every few seconds while in the meeting. Not available to delegated sessions (403 `delegated_scope`). parameters: - name: code in: path required: true schema: type: string pattern: ^[a-z]{3}-[a-z]{4}-[a-z]{3}$ responses: '200': description: Waiting people. content: application/json: schema: type: object required: - data properties: data: type: array maxItems: 30 items: $ref: '#/components/schemas/MeetLobbyEntry' example: data: - id: 2c9b1f0e-7a6d-4e5c-8b4a-3f2e1d0c9b8a name: Khách Hà address: null since: '2026-10-04T08:01:12.000Z' - id: 8d7c6b5a-4e3f-4a2b-9c1d-0e9f8a7b6c5d name: Minh address: minh@other.vn since: '2026-10-04T08:01:40.000Z' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: not_member` (you are not in the meeting''s organisation), or (delegated session) `error: forbidden`, `code: delegated_scope`.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`error: not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '500': description: '`error: internal_error`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: meet_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/meet/{code}/lobby/{requestId}: get: operationId: meetLobbyStatus tags: - Meet summary: Poll my own lobby request (was I admitted?) description: | For a signed-in user of another organisation whose `POST /v1/meet/{code}/join` answered `status: waiting`: the state of THAT request, authenticated by the same user token (no secret needed). Poll every 2–5 s: - `{status: waiting}` — still in the lobby (each poll also keeps you listed for the members); - `{status: denied}` — turned away; stop polling; - `{status: ready, serverUrl, token, meeting}` — admitted: connect with the LiveKit token as after a direct join (identity = your mailbox id, metadata `{"guest": false, "host": false, "member": false}`). Polling again returns a fresh token for the same identity (a reconnect rejoins as the same participant). Someone else's request (also a member of the meeting's organisation, or a guest's request), an unknown or malformed `requestId`, and an unknown / deleted / suspended meeting all answer 404 `not_found`. Not available to delegated sessions (403 `delegated_scope`). parameters: - name: code in: path required: true schema: type: string pattern: ^[a-z]{3}-[a-z]{4}-[a-z]{3}$ - name: requestId in: path required: true description: '`requestId` from the `waiting` join answer.' schema: type: string format: uuid responses: '200': description: Waiting, denied, or ready (with a LiveKit token). content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MeetLobbyStatus' examples: waiting: value: data: status: waiting denied: value: data: status: denied ready: value: data: status: ready serverUrl: wss://meet.example.com token: eyJhbGciOiJIUzI1NiJ9.eyJ2aWRlbyI6eyJyb29tIjoia3F6LW1idHItd3B4Iiwicm9vbUpvaW4iOnRydWV9fQ.sig meeting: code: kqz-mbtr-wpx title: Weekly sync '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '404': description: '`error: not_found` — not your request, unknown/malformed id, or unknown meeting.' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: not_found code: not_found '503': description: '`error: meet_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] post: operationId: meetDecideLobby tags: - Meet summary: Admit or turn away someone waiting description: | Members of the meeting's organisation only. The decision is final: a request already decided (by you or another member) answers 404. The admitted person's client then receives its own LiveKit token when it next polls (`GET /v1/meet/{code}/lobby/{requestId}` for signed-in users, the web join page for guests). Not available to delegated sessions (403 `delegated_scope`). parameters: - name: code in: path required: true schema: type: string pattern: ^[a-z]{3}-[a-z]{4}-[a-z]{3}$ - name: requestId in: path required: true description: The lobby entry `id` (UUID). A malformed value answers 404 `not_found`. schema: type: string format: uuid requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/MeetLobbyDecisionRequest' responses: '200': description: Decision recorded. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MeetLobbyDecisionResult' example: data: admitted: true '400': description: '`error: validation_error` (`admit` missing or not a boolean).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: not_member`, or (delegated session) `error: forbidden`, `code: delegated_scope`.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`error: not_found` (unknown meeting, unknown request, request of another meeting, or already decided).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`error: meet_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/drive/usage: get: operationId: driveGetUsage tags: - Drive summary: Drive usage and limits description: |- Organisation-wide storage quota and usage, plus the server's Drive limits. Call it once when the Drive screen opens: `directUpload` tells you whether large files can use the multipart flow (`POST /v1/drive/uploads`), `maxFileBytes` is the per-file cap and `suspended` means Drive is read-only. Not available to delegated sessions (403 `delegated_scope`). Returns 503 `drive_unavailable` when Drive is not configured on this server. responses: '200': description: Usage. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveUsage' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — `code: delegated_scope` for delegated sessions; without `code` when the mailbox is not active.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` (Drive not configured) or `storage_unavailable` (unexpected storage/database failure, `Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/items: get: operationId: driveListItems tags: - Drive summary: List the root or a folder description: |- Lists "My Drive" (no `folder`) or one folder the caller can read (own folder, or a folder shared with them directly or through an ancestor). Trashed children are excluded. Order: folders first, then name (case-insensitive). No pagination: at most 1000 children are returned. Not available to delegated sessions. parameters: - name: folder in: query required: false description: Folder id (UUID). Omit or empty for the root. A non-UUID, a file id, a trashed folder or a folder without access gives 404. schema: type: string format: uuid responses: '200': description: Folder listing. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveFolderListing' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` (`code: delegated_scope` for delegated sessions; otherwise the mailbox is not active).' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`error: not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/shared: get: operationId: driveListSharedWithMe tags: - Drive summary: Items shared with me description: |- Items other members of the organisation shared directly with the caller (live, non-expired user shares on non-trashed items), newest share first, at most 500. Each item carries the granted `role`. Open a shared folder with `GET /v1/drive/items?folder={id}`. Not available to delegated sessions. responses: '200': description: Shared items. content: application/json: schema: type: object required: - data properties: data: type: array maxItems: 500 items: $ref: '#/components/schemas/DriveSharedItem' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` (`code: delegated_scope` for delegated sessions; otherwise the mailbox is not active).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/trash: get: operationId: driveListTrash tags: - Drive summary: List the trash description: |- The caller's trashed items (every trashed item, including items inside a trashed folder), most recently trashed first, at most 1000. Items are permanently deleted automatically after `trashDays` (see `/v1/drive/usage`). Not available to delegated sessions. responses: '200': description: Trashed items. content: application/json: schema: type: object required: - data properties: data: type: array maxItems: 1000 items: $ref: '#/components/schemas/DriveItem' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` (`code: delegated_scope` for delegated sessions; otherwise the mailbox is not active).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/recent: get: operationId: driveListRecent tags: - Drive summary: Recently modified files description: |- The caller's own non-trashed FILES (no folders), most recently updated first, at most 50. Not available to delegated sessions. responses: '200': description: Recent files. content: application/json: schema: type: object required: - data properties: data: type: array maxItems: 50 items: $ref: '#/components/schemas/DriveItem' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` (`code: delegated_scope` for delegated sessions; otherwise the mailbox is not active).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/search: get: operationId: driveSearch tags: - Drive summary: Search by name description: |- Case-insensitive substring match on the names of the caller's OWN non-trashed files and folders (items shared with the caller are not searched). Most recently updated first, at most 100 results. An empty/missing `q` returns `[]`. Not available to delegated sessions. parameters: - name: q in: query required: false description: Search text (trimmed; only the first 100 characters are used). schema: type: string responses: '200': description: Matching items. content: application/json: schema: type: object required: - data properties: data: type: array maxItems: 100 items: $ref: '#/components/schemas/DriveItem' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` (`code: delegated_scope` for delegated sessions; otherwise the mailbox is not active).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/folders: post: operationId: driveCreateFolder tags: - Drive summary: Create a folder description: |- Creates a folder in the root or in a folder the caller owns or can edit (`editor` share). Folder nesting is limited to 32 levels (422 `too_deep`). Not idempotent: a second call with the same name returns 409 `name_conflict`. Not available to delegated sessions. Fails with 423 `tenant_suspended` while the organisation is suspended. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DriveCreateFolderRequest' example: name: Hop dong 2026 parentId: null responses: '201': description: Folder created. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveItem' '400': description: '`validation_error` (body not an object / `parentId` not a string; zod issues in `details`) or `invalid_name`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — only view access to the parent, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — parent missing, not a folder, trashed, or not visible to the caller.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`name_conflict` — a non-trashed item with that name (case-insensitive) already exists in the parent.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`too_deep` — nesting limit (32) reached.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended` — organisation suspended by billing; Drive is read-only.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/items/{itemId}: parameters: - name: itemId in: path required: true description: Drive item id (UUID). Non-UUID values give 404 `not_found`. schema: type: string format: uuid get: operationId: driveGetItem tags: - Drive summary: Item details (versions, shares, breadcrumbs) description: |- Returns the item, the caller's effective access level, breadcrumbs, the file's versions (newest first) and — for the owner only — its live shares and public links. Trashed items are visible to their owner only. Not available to delegated sessions. responses: '200': description: Item details. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveItemDetails' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` (`code: delegated_scope` for delegated sessions; otherwise the mailbox is not active).' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` (missing, no access, or trashed and caller is not the owner).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] patch: operationId: driveRenameItem tags: - Drive summary: Rename an item description: |- Renames a file or folder. Requires owner or `editor` access. Idempotent for the same name. Not available to delegated sessions. 423 `tenant_suspended` while the organisation is suspended. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DriveRenameRequest' example: name: Bao cao Q3 (final).pdf responses: '204': description: Renamed. '400': description: '`invalid_name`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — viewer only, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`name_conflict` — another non-trashed item in the same folder has that name.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] delete: operationId: driveDeleteItemPermanently tags: - Drive summary: Delete permanently (from the trash) description: |- Permanently deletes a TRASHED item and its whole subtree (all versions, shares and links); the bytes are released from the organisation quota. Owner only. An item that is not in the trash gives 404 — call `POST /v1/drive/items/{itemId}/trash` first. Irreversible. Allowed while the organisation is suspended. Not available to delegated sessions. responses: '204': description: Deleted. '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — not the owner, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` (missing, or not in the trash).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/items/{itemId}/content: parameters: - name: itemId in: path required: true description: File id (UUID). schema: type: string format: uuid get: operationId: driveDownloadFile tags: - Drive summary: Download a file (current or older version) description: |- Two possible outcomes, depending on the server's storage backend: * **204 + `x-novamail-redirect` header** (S3-compatible storage): the header holds a presigned object-storage URL, valid for 300 seconds. The API does NOT send a 302 — the client must itself `GET` that URL **without** the `Authorization` header. The storage response carries `Content-Disposition: attachment; filename=...` and the file's `Content-Type`, and (being plain S3) supports `Range` requests, which makes resumable downloads possible. * **200 + the bytes streamed by the API** (local-disk storage): headers `Content-Type` (version MIME type), `Content-Length`, `Content-Disposition: attachment; filename=""; filename*=UTF-8''`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: sandbox; default-src 'none'`, `Cache-Control: private, no-store`. `Range` is NOT supported on this path (always the full body). Infected versions are refused (403 `infected`); versions that are still `pending` scan are served to members. Viewer access is enough. Allowed while the organisation is suspended. Not available to delegated sessions. parameters: - name: version in: query required: false description: Version number (from `versions[].version` in item details). Omitted or not an integer = current version. A pruned / unknown version gives 404. schema: type: integer minimum: 1 responses: '200': description: File bytes (local-disk storage only). headers: Content-Disposition: description: RFC 6266 attachment header with ASCII fallback and UTF-8 `filename*`. schema: type: string Content-Length: schema: type: integer content: '*/*': schema: type: string format: binary '204': description: Download via object storage — read the `x-novamail-redirect` header and GET that URL (no auth header) within 300 s. headers: x-novamail-redirect: description: Presigned storage URL (expires after 300 s). schema: type: string format: uri '401': $ref: '#/components/responses/Unauthorized' '403': description: '`infected` (current/selected version flagged by the virus scanner) or `forbidden` (mailbox not active; `code: delegated_scope` for delegated sessions).' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — missing item, a folder, no access, unknown version, or the object is missing in storage.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] put: operationId: driveUploadNewVersion tags: - Drive summary: Upload a new version of a file (single request) description: |- Streams the request body as a NEW VERSION of an existing file (the name stays the same). Requires owner or `editor` access. Same rules and headers as `PUT /v1/drive/upload`: * `Content-Type: application/octet-stream` (required — any other type is rejected by the server); * `Content-Length` (or `x-file-size` when the body is sent chunked) — optional, lets the server fail fast with 413 before storing anything; * `x-file-type` — the file's real MIME type (else derived from the file's extension). Only the newest `maxVersions` (default 25) versions are kept. For big files on S3 storage prefer the multipart flow (`POST /v1/drive/uploads` with `itemId`). Not available to delegated sessions. 423 `tenant_suspended` while the organisation is suspended. parameters: - name: x-file-type in: header required: false description: MIME type of the file (parameters after `;` are ignored). Missing / invalid / `application/octet-stream` = guessed from the extension. schema: type: string - name: x-file-size in: header required: false description: Declared size in bytes, used only when `Content-Length` is absent (chunked transfer). schema: type: integer requestBody: required: true content: application/octet-stream: schema: type: string format: binary responses: '201': description: Version stored; the updated item (new `currentVersion`, `scanStatus` = pending until scanned). content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveItem' '400': description: '`bad_request` (with `message`) for a body the server cannot parse.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — viewer only, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — missing item, a folder, or no access.' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: | `file_too_large` — declared size (`Content-Length` / `x-file-size`) over `maxFileBytes` (checked before anything is stored), or the streamed body passing it (the upload is cut off and nothing is kept); `quota_exceeded` — the declared size does not fit the organisation quota, or (size unknown in advance) the stored file does not. The request body is not size-limited by the HTTP layer for this route: no `payload_too_large` here. content: application/json: schema: $ref: '#/components/schemas/Error' '415': description: '`bad_request` — Content-Type other than application/octet-stream (or application/json / text/plain).' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`; also on a broken upload stream).' security: - bearerAuth: [] /v1/drive/upload: put: operationId: driveUploadFile tags: - Drive summary: Upload a file (single request, streamed) description: |- Simple upload: the whole file in one streamed request. Works on every server (local disk or S3 storage); for files over ~32 MB on servers with `directUpload: true` the multipart flow (`POST /v1/drive/uploads`) is recommended (resumable parts, bytes go straight to storage). Request: * `PUT /v1/drive/upload?name=&parentId=` (`parentId` optional = root); * `Content-Type: application/octet-stream` — REQUIRED (the raw bytes; not multipart/form-data); * `Content-Length: ` — recommended; when the body is chunked send `x-file-size: ` instead. With a declared size the server rejects too-large files / quota overruns (413) before storing anything; without it the upload is aborted with 413 once `maxFileBytes` is crossed. The stored size is what was actually received; * `x-file-type: ` — the file's type (else guessed from the extension). There is no `x-file-name` header: the name travels in the `name` query parameter. If a non-trashed FILE with the same name (case-insensitive) already exists in the target folder, the upload becomes a new version of that file (same item id) instead of a new item; a FOLDER with that name gives 409 `name_conflict`. New versions start with `scanStatus: pending`. Not available to delegated sessions. 423 `tenant_suspended` while the organisation is suspended. parameters: - name: name in: query required: true description: File name (NFC, ≤ 255 chars, no `/`, `\` or control characters). Missing / invalid = 400 `invalid_name`. schema: type: string maxLength: 255 - name: parentId in: query required: false description: Target folder id (owner or editor access). Omit / empty = root. schema: type: string format: uuid - name: x-file-type in: header required: false description: MIME type of the file. Missing / invalid / `application/octet-stream` = guessed from the extension. schema: type: string - name: x-file-size in: header required: false description: Declared size in bytes, used only when `Content-Length` is absent. schema: type: integer requestBody: required: true content: application/octet-stream: schema: type: string format: binary responses: '201': description: File stored (new item, or new version of the same-named file). content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveItem' '400': description: '`invalid_name`, or `bad_request` (with `message`) for an unparsable body.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — viewer-only access to the folder, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — parent missing / not a folder / trashed / no access.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`name_conflict` — a folder with that name exists in the target folder.' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: | `file_too_large` (> `maxFileBytes`, default 5 GiB: from the declared size before anything is stored, or when the streamed body passes it — the upload is cut off, nothing is kept) or `quota_exceeded` (organisation Drive quota: checked against the declared size up front and against the stored size when committing). No HTTP-layer body limit applies to this route. content: application/json: schema: $ref: '#/components/schemas/Error' '415': description: '`bad_request` — unsupported Content-Type (send application/octet-stream).' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`too_deep`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`; also on a broken upload stream).' security: - bearerAuth: [] /v1/drive/uploads: post: operationId: driveStartDirectUpload tags: - Drive summary: Start a multipart (direct-to-storage) upload description: |- Large-file upload where the bytes go straight from the device to object storage. Only available when `GET /v1/drive/usage` returns `directUpload: true` (else 409 `direct_upload_unavailable`). Flow: 1. `POST /v1/drive/uploads` with `{name, size, contentType?, parentId?}` (new file) or `{itemId, size, contentType?}` (new version). The server checks name, access, `maxFileBytes` and the quota, and returns `{uploadId, partSize, parts}`. The session is valid for 24 hours. 2. Split the file into `parts` chunks: part N (1-based) = bytes `[(N-1)*partSize, N*partSize)`; every part except the last is exactly `partSize` bytes. 3. `POST /v1/drive/uploads/{uploadId}/parts` with `{partNumbers: [...]}` (≤ 100 per call) to get presigned URLs (valid 1 hour). Sign in batches as you go (the web app signs 20 at a time and uploads 4 parts in parallel). 4. For each part, `PUT ` with the raw part bytes — no `Authorization` header, no JSON. Keep the `ETag` response header (verbatim, quotes included). Retry a failed part by PUTting it again (re-sign if the URL expired). 5. `POST /v1/drive/uploads/{uploadId}/complete` with `{parts: [{partNumber, etag}, ...]}` covering ALL parts. The server assembles the object, checks the stored size equals `size`, takes the quota and returns the item (201). 6. To give up, `DELETE /v1/drive/uploads/{uploadId}` (stored parts are discarded). Abandoned sessions expire after 24 h. Quota is reserved only at completion. Same-name rule as `PUT /v1/drive/upload` (a same-named file gets a new version). Not available to delegated sessions. 423 `tenant_suspended` while the organisation is suspended. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DriveDirectUploadStartRequest' example: name: video-hop.mp4 size: 104857600 contentType: video/mp4 parentId: null responses: '201': description: Upload session opened. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveDirectUploadStart' '400': description: '`validation_error` (zod issues in `details`: e.g. `size` < 1 or not an integer, bad UUID) or `invalid_name`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — no edit access, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — parent folder / item missing, not the right kind, trashed or not visible.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`direct_upload_unavailable` — storage does not support multipart (use `PUT /v1/drive/upload`).' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: '`file_too_large` or `quota_exceeded`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`too_deep`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/uploads/{uploadId}: parameters: - name: uploadId in: path required: true description: Upload session id from `POST /v1/drive/uploads`. schema: type: string format: uuid delete: operationId: driveAbortDirectUpload tags: - Drive summary: Abort a multipart upload description: |- Aborts the multipart upload and discards uploaded parts. Idempotent: an unknown, expired or already finished upload also returns 204; a malformed `uploadId` (not a UUID) answers 404 `not_found`. Not available to delegated sessions. responses: '204': description: Aborted (or nothing to abort). '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden`, `code: delegated_scope` (delegated session).' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — `uploadId` is not a UUID.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/uploads/{uploadId}/parts: parameters: - name: uploadId in: path required: true schema: type: string format: uuid post: operationId: driveSignUploadParts tags: - Drive summary: Get presigned URLs for upload parts description: |- Step 3 of the multipart flow (see `POST /v1/drive/uploads`). Returns one presigned `PUT` URL per requested part, valid for 1 hour. Can be called any number of times (e.g. to re-sign an expired URL). Upload each part with a plain `PUT ` (raw bytes, no auth header) and keep the `ETag` response header for the complete call. Not available to delegated sessions. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DrivePresignPartsRequest' example: partNumbers: - 1 - 2 - 3 - 4 responses: '200': description: Presigned URLs. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DrivePresignedParts' '400': description: '`validation_error` (zod issues in `details`).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden`, `code: delegated_scope` (delegated session).' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — malformed `uploadId`, unknown upload, another mailbox''s upload, or expired (24 h).' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`upload_incomplete` — a part number is greater than the upload''s `parts`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/uploads/{uploadId}/complete: parameters: - name: uploadId in: path required: true schema: type: string format: uuid post: operationId: driveCompleteDirectUpload tags: - Drive summary: Complete a multipart upload description: |- Step 5 of the multipart flow. Send `{partNumber, etag}` for every part 1..`parts`. The server re-checks permission (if the target folder was trashed / unshared or the user lost access since the start, the upload is aborted and the error returned), assembles the object, verifies the stored size equals the declared `size`, reserves quota and commits the file. Not retryable after success (the upload session is deleted; a second call gives 404). Not available to delegated sessions. 423 `tenant_suspended` while the organisation is suspended. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DriveCompleteUploadRequest' example: parts: - partNumber: 1 etag: '"9b2cf535f27731c974343645a3985328"' - partNumber: 2 etag: '"6f5902ac237024bdd0c176cb93063dc4"' responses: '201': description: File committed. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveItem' '400': description: '`validation_error` (zod issues in `details`).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — access lost since the start (upload aborted), mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — malformed `uploadId`, unknown / expired / already completed upload, or the target folder / file is gone (upload aborted).' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`name_conflict` — a folder with the file''s name now exists in the target folder.' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: '`quota_exceeded` — the organisation quota no longer has room.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`upload_incomplete` (parts list does not cover exactly 1..parts, or the assembled size differs from `size`) or `too_deep`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`; e.g. storage rejected an ETag).' security: - bearerAuth: [] /v1/drive/items/{itemId}/preview: parameters: - name: itemId in: path required: true schema: type: string format: uuid get: operationId: drivePreviewFile tags: - Drive summary: Inline preview (image, PDF, audio, video) description: |- Inline rendering for previewable types only: images (png, jpeg, gif, webp, avif, bmp), `application/pdf`, video (mp4, webm, quicktime) and audio (mpeg, mp4, ogg, wav, webm). SVG/HTML are never previewed. Other types give 415 `not_previewable` (use download, or Office for documents). * **204 + `x-novamail-redirect`** (S3 storage): presigned URL with `Content-Disposition: inline`, valid 300 s — GET it without the `Authorization` header (supports `Range`, so it works for video seeking). * **200 + bytes** (local-disk storage): `Content-Type`, `Content-Length`, `Content-Disposition: inline`, `Cache-Control: private, max-age=300`, a restrictive `Content-Security-Policy`; no `Range` support. Both carry `x-preview-kind: image | pdf | video | audio`. Not available to delegated sessions. parameters: - name: version in: query required: false description: Version number; omitted / not an integer = current version. schema: type: integer minimum: 1 responses: '200': description: Preview bytes (local-disk storage). headers: x-preview-kind: schema: type: string enum: - image - pdf - video - audio content: image/*: schema: type: string format: binary application/pdf: schema: type: string format: binary video/*: schema: type: string format: binary audio/*: schema: type: string format: binary '204': description: Preview via object storage — GET the URL in `x-novamail-redirect` (expires after 300 s). headers: x-novamail-redirect: schema: type: string format: uri x-preview-kind: schema: type: string enum: - image - pdf - video - audio '401': $ref: '#/components/responses/Unauthorized' '403': description: '`infected`, or `forbidden` (mailbox not active; `code: delegated_scope` for delegated sessions).' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — missing, folder, no access, unknown version.' content: application/json: schema: $ref: '#/components/schemas/Error' '415': description: '`not_previewable` — MIME type not in the preview list.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/office: get: operationId: driveGetOfficeCapabilities tags: - Drive summary: Office editor capabilities description: |- Whether an Office server (Collabora / ONLYOFFICE via WOPI) is configured and which extensions it can edit / view. When the Office server's discovery cannot be fetched, `enabled` is still true but `edit`/`view` are empty (discovery is cached for 1 hour). Not available to delegated sessions. responses: '200': description: Capabilities. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveOfficeCapabilities' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/office/new: post: operationId: driveCreateOfficeDocument tags: - Drive summary: Create a blank Office document description: |- Creates an EMPTY (0-byte) `.docx` / `.xlsx` / `.pptx` file; the Office editor turns it into a new document of that type on first open (`GET /v1/drive/items/{itemId}/office`). The extension is appended to `name` when missing. Caution: like any upload, if a file with the resulting name already exists in the folder, this adds an empty new version to that file (pick a unique name). Not available to delegated sessions. 423 `tenant_suspended` while the organisation is suspended. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DriveOfficeNewRequest' example: kind: docx name: Bien ban hop parentId: null responses: '201': description: File created. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveItem' '400': description: '`validation_error` (no `details`) when `kind` is not docx/xlsx/pptx; `invalid_name`.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — no edit access to the folder, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` (parent folder).' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`name_conflict` — a folder has that name.' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: '`quota_exceeded`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`too_deep`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/items/{itemId}/office: parameters: - name: itemId in: path required: true schema: type: string format: uuid get: operationId: driveOpenInOffice tags: - Drive summary: Open a file in the Office editor (WOPI session) description: |- Returns the editor URL and a WOPI access token for this user and file. Open it in a web view by form-POSTing `access_token` and `access_token_ttl` to `url` (see `DriveOfficeSession`). Edit mode is given when the caller is owner/editor and the extension is editable; viewers get view mode. Edits are saved by the Office server as new versions (rapid autosaves by the same user are merged). Not available to delegated sessions. parameters: - name: lang in: query required: false description: Editor UI language, `xx` or `xx-YY` (e.g. `en`, `vi`, `en-US`). Anything else = `vi`. schema: type: string pattern: ^[a-z]{2}(-[A-Z]{2})?$ default: vi responses: '200': description: Editor session. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveOfficeSession' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`infected`, or `forbidden` (mailbox not active; `code: delegated_scope` for delegated sessions).' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — missing, a folder, trashed, or no access.' content: application/json: schema: $ref: '#/components/schemas/Error' '415': description: '`not_previewable` — the Office server has no action for this extension.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`office_unavailable` (no Office server configured), `drive_unavailable`, or `storage_unavailable` (`Retry-After: 10`; e.g. Office discovery unreachable).' security: - bearerAuth: [] /v1/drive/items/{itemId}/versions/{version}/restore: parameters: - name: itemId in: path required: true schema: type: string format: uuid - name: version in: path required: true description: Version number to restore. schema: type: integer minimum: 1 post: operationId: driveRestoreVersion tags: - Drive summary: Restore an older version description: |- Copies the bytes of an older version into a NEW current version (history is never rewritten; counts against the quota). Requires owner or `editor` access. Not idempotent: each call adds a version. No request body. Not available to delegated sessions. 423 `tenant_suspended` while the organisation is suspended. responses: '200': description: The updated item (new `currentVersion`). content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveItem' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — viewer only, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — item / version missing (pruned), folder, or no access.' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: '`quota_exceeded`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/items/{itemId}/move: parameters: - name: itemId in: path required: true schema: type: string format: uuid post: operationId: driveMoveItem tags: - Drive summary: Move an item description: |- Moves an item the caller OWNS into one of their own folders, or to the root (`parentId: null`). Moving a folder into itself or one of its descendants gives 422 `invalid_move`. Items shared with the caller cannot be moved. Idempotent. Not available to delegated sessions. 423 `tenant_suspended` while the organisation is suspended. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DriveMoveRequest' example: parentId: 3e2d1c0b-9a8f-4e7d-8c6b-5a4f3e2d1c0b responses: '204': description: Moved. '400': description: '`validation_error` (zod issues in `details`; `parentId` missing or not a UUID/null).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — caller does not own the item or the target, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` — item or target missing, target not a folder, or target trashed.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`name_conflict` — the target already has an item with that name.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`invalid_move`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/items/{itemId}/trash: parameters: - name: itemId in: path required: true schema: type: string format: uuid post: operationId: driveTrashItem tags: - Drive summary: Move an item to the trash description: |- Owner only. The item (and, implicitly, its contents) disappears from listings and from people it was shared with; public links stop working. Restorable for `trashDays` days, then deleted automatically. Idempotent. No request body. Allowed while the organisation is suspended. Not available to delegated sessions. responses: '204': description: Trashed. '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — not the owner, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/items/{itemId}/restore: parameters: - name: itemId in: path required: true schema: type: string format: uuid post: operationId: driveRestoreItem tags: - Drive summary: Restore an item from the trash description: |- Owner only; the item must itself be trashed (else 404). If its parent folder is still in the trash, the item is restored to the root. If the name is taken, it is renamed `name (1).ext`, `name (2).ext`, ... No request body. Allowed while the organisation is suspended. Not available to delegated sessions. responses: '204': description: Restored. '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — not the owner, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found` (missing, or not trashed itself).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/items/{itemId}/shares: parameters: - name: itemId in: path required: true schema: type: string format: uuid post: operationId: driveShareItem tags: - Drive summary: Share with a colleague description: |- Owner only. Grants `viewer` (default) or `editor` access to an active mailbox of the SAME organisation; sharing a folder covers everything inside it. Sharing again with the same person updates the role (upsert, same share id). With `notify: true` (default) the colleague receives an email in their own language. For people outside the organisation create a public link (`POST .../links`). Not available to delegated sessions. 423 `tenant_suspended` while the organisation is suspended. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DriveShareRequest' example: email: binh@example.vn role: editor notify: true responses: '201': description: Shared. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveShareCreated' '400': description: '`validation_error` (zod issues in `details`).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — not the owner, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`invalid_grantee` — not an active mailbox of the organisation, or the caller themself.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/items/{itemId}/shares/{shareId}: parameters: - name: itemId in: path required: true schema: type: string format: uuid - name: shareId in: path required: true description: Share id (`shares[].id` in item details) — a user share or a public link. schema: type: string format: uuid delete: operationId: driveRevokeShare tags: - Drive summary: Revoke a share or public link description: |- Owner only. Revokes a user share or a public link immediately. A share that is already revoked or unknown gives 404. Allowed while the organisation is suspended. Not available to delegated sessions. responses: '204': description: Revoked. '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — not the owner, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/drive/items/{itemId}/links: parameters: - name: itemId in: path required: true schema: type: string format: uuid post: operationId: driveCreateLink tags: - Drive summary: Create a public (anonymous) link description: |- Owner only. Creates a view/download link anyone can open without signing in (a folder link exposes its contents). The raw `token` is returned ONLY in this response — store or share it now; item details later list the link (`kind: link`) without the token. Public visitors only get versions the virus scanner marked clean (server default). Requires the organisation to allow external sharing (`externalSharing` in `/v1/drive/usage`). Each call creates a new link. Body optional (default 30 days). Not available to delegated sessions. 423 `tenant_suspended` while the organisation is suspended. requestBody: required: false content: application/json: schema: $ref: '#/components/schemas/DriveLinkRequest' example: expiresInDays: 7 responses: '201': description: Link created. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveLink' example: data: id: 5c4b3a29-1807-4f6e-9d5c-4b3a29180706 token: Qm9yZW1JcHN1bURvbG9yU2l0QW1ldDEy expiresAt: '2026-10-11T03:00:00.000Z' '400': description: '`validation_error` (zod issues in `details`; e.g. `expiresInDays` outside 1..365).' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`external_sharing_disabled`, or `forbidden` (not the owner, mailbox not active; `code: delegated_scope` for delegated sessions).' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable` or `storage_unavailable` (`Retry-After: 10`).' security: - bearerAuth: [] /v1/mail/messages/{uid}/attachments/{part}/save-to-drive: parameters: - name: uid in: path required: true description: IMAP UID of the message (positive integer). schema: type: integer minimum: 1 - name: part in: path required: true description: MIME part number of the attachment, e.g. `2` or `1.2` (as listed in the message's attachments). schema: type: string pattern: ^\d+(\.\d+)*$ post: operationId: driveSaveAttachment tags: - Drive summary: Save a mail attachment to Drive description: |- Copies an attachment from the mailbox into Drive server-side (no download / re-upload by the device). The file keeps the attachment's filename (or `attachment` when it has none) and content type. Same-name rule as uploads: an existing file with that name in the target folder gets a new version. Not idempotent. Not available to delegated sessions (Drive is personal). 423 `tenant_suspended` while the organisation is suspended. parameters: - name: folder in: query required: false description: Mail folder of the message. schema: $ref: '#/components/schemas/MailFolder' requestBody: required: false content: application/json: schema: $ref: '#/components/schemas/DriveSaveToDriveRequest' example: {} responses: '201': description: Saved; the Drive item. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DriveItem' '400': description: '`validation_error` (no `details`) — bad `uid`, `part` or `folder`; `invalid_name` for an unusable attachment filename.' content: application/json: schema: $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': description: '`error: forbidden` — no edit access to `parentId`, mailbox not active, or (`code: delegated_scope`) delegated session.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`error: not_found` — `code: attachment_not_found` (no such message or attachment part), or `code: not_found` (target folder not found / no access).' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`name_conflict` — a folder with the attachment''s name exists.' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: '`file_too_large` or `quota_exceeded`.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`too_deep`.' content: application/json: schema: $ref: '#/components/schemas/Error' '423': description: '`tenant_suspended`.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`drive_unavailable`; `webmail_unavailable` (no mail backend on this server); `mail_backend_busy` (`Retry-After: 5`) — the mail server could not be reached to read the attachment; `storage_unavailable` (`Retry-After: 10`) — Drive storage failed.' security: - bearerAuth: [] /v1/account/locale: put: operationId: accountSetLocale tags: - Account summary: Set the mailbox language description: |- Sets the mailbox's language (`vi` or `en`). The server uses it for texts it generates on the user's behalf — system / notification emails (e.g. "X shared a file with you", forwarding confirmations) and other server-side texts. It does not change the app UI language; call it when the user switches language in the app. Idempotent. Part of the account module: available on every server, with or without Drive. Not available to delegated sessions (403 `delegated_scope`). requestBody: required: true content: application/json: schema: type: object required: - locale properties: locale: type: string enum: - vi - en example: locale: en responses: '204': description: Saved. '400': description: '`invalid_locale` — missing or not one of vi/en.' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: invalid_locale code: invalid_locale '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/DelegatedForbidden' '503': $ref: '#/components/responses/ServiceUnavailable' description: '`account_unavailable` (the server has no account store configured).' security: - bearerAuth: [] /.well-known/zomail-configuration: get: tags: - Discovery & app config operationId: getServerConfiguration summary: This server's configuration for the apps description: | Public, cacheable (5 min). Served by the API host and by the web host (including customers' `autoconfig.` names pointing at the server), so discovery can start from any of them. security: [] responses: '200': description: Server configuration. content: application/json: schema: $ref: '#/components/schemas/ServerConfiguration' '503': $ref: '#/components/responses/MobileUnavailable' /v1/public/discovery: get: tags: - Discovery & app config operationId: discoverServer summary: Is this address hosted here (or on a known Private server)? security: [] parameters: - name: email in: query required: true schema: type: string format: email responses: '200': description: 'Hosted here (`hosted: true` + configuration), or on a Private server the Cloud knows (`hosted: false` + `redirect`).' content: application/json: schema: type: object required: - data properties: data: type: object required: - hosted - domain properties: hosted: type: boolean domain: type: string configuration: $ref: '#/components/schemas/ServerConfiguration' sso: type: - object - 'null' description: 'The domain''s single sign-on (hosted here only): `required` = no password sign-in.' properties: provider: type: string enum: - google - microsoft mode: type: string enum: - optional - required redirect: type: object properties: webUrl: type: string format: uri configurationUrl: type: string format: uri '400': $ref: '#/components/responses/MobileBadRequest' '404': description: '`code: domain_not_hosted` — try the next discovery step.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/MobileRateLimited' /v1/public/mobile/config: get: tags: - Discovery & app config operationId: getMobileConfig summary: App settings of this server (version gate, links, feature flags) description: Read at start-up and when the app comes to the foreground, per server. Cacheable 5 min. security: [] parameters: - name: platform in: query schema: type: string enum: - ios - android - name: version in: query description: The app's version (`1.2.3`); with `platform`, `update` says whether an update is required/available. schema: type: string responses: '200': description: App configuration. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MobileConfig' '503': $ref: '#/components/responses/MobileUnavailable' /v1/auth/mobile/login: post: tags: - Mobile sign-in operationId: mobileLogin summary: Sign in a device to one mailbox description: | Returns tokens, or `{mfaRequired: true, challenge}` when two-factor authentication is on (then call `/v1/auth/mobile/login/mfa` with the same `device`). A new installation id for the mailbox sends the user a "new sign-in" e-mail and a `security` push to the account's other devices. Signing in again with the same installation id replaces that device's previous session. security: [] requestBody: required: true content: application/json: schema: type: object required: - email - password - device properties: email: type: string format: email password: type: string minLength: 1 maxLength: 1024 device: $ref: '#/components/schemas/DeviceInfo' responses: '200': description: Tokens, or an MFA challenge. content: application/json: schema: type: object required: - data properties: data: oneOf: - $ref: '#/components/schemas/MobileTokens' - $ref: '#/components/schemas/MobileMfaChallenge' '400': $ref: '#/components/responses/MobileBadRequest' '401': description: '`invalid_credentials`.' content: application/json: schema: $ref: '#/components/schemas/Error' '403': $ref: '#/components/responses/MobilePolicy' '409': description: '`hosted_elsewhere` — the mailbox is on a Private server: `url` (its web address) and `configurationUrl`. No password was checked.' content: application/json: schema: allOf: - $ref: '#/components/schemas/Error' - type: object properties: url: type: string format: uri configurationUrl: type: string format: uri '429': $ref: '#/components/responses/MobileRateLimited' '503': $ref: '#/components/responses/MobileUnavailable' /v1/auth/mobile/login/mfa: post: tags: - Mobile sign-in operationId: mobileLoginMfa summary: Second step — TOTP or recovery code description: The challenge is valid 5 minutes and 5 attempts (`401 mfa_expired` → sign in again; `401 invalid_mfa` → retry the code). security: [] requestBody: required: true content: application/json: schema: type: object required: - challenge - code - device properties: challenge: type: string code: type: string description: 6-digit TOTP code or a recovery code (`xxxxx-xxxxx`). device: $ref: '#/components/schemas/DeviceInfo' responses: '200': description: Tokens. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MobileTokens' '400': $ref: '#/components/responses/MobileBadRequest' '401': description: '`invalid_mfa`, `mfa_expired`, `mfa_unavailable`.' content: application/json: schema: $ref: '#/components/schemas/Error' '403': $ref: '#/components/responses/MobilePolicy' '429': $ref: '#/components/responses/MobileRateLimited' /v1/auth/mobile/sso/start: post: tags: - Mobile sign-in operationId: mobileSsoStart summary: Single sign-on — get the identity provider's authorization URL description: | For an address whose organisation uses SSO (discovery `sso`). Open `authorizationUrl` in ASWebAuthenticationSession / Custom Tabs; the sign-in must finish within 10 minutes. 20 starts per minute per client address. security: [] requestBody: required: true content: application/json: schema: type: object required: - email - codeChallenge - redirectUri properties: email: type: string format: email codeChallenge: type: string pattern: ^[A-Za-z0-9_-]{43}$ description: BASE64URL(SHA-256(codeVerifier)), the app's own PKCE. codeChallengeMethod: type: string enum: - S256 default: S256 redirectUri: type: string description: 'Exactly one of `auth.sso.redirectUris`: `zomail://sso` or `{webUrl}/sso/mobile-callback`.' examples: - zomail://sso state: type: string maxLength: 200 pattern: ^[A-Za-z0-9._~-]*$ description: Echoed back on the redirect. responses: '200': description: Where to send the user. content: application/json: schema: type: object required: - data properties: data: type: object required: - authorizationUrl - provider - expiresAt properties: authorizationUrl: type: string format: uri description: accounts.google.com or login.microsoftonline.com. provider: type: string enum: - google - microsoft expiresAt: type: string format: date-time '400': description: '`validation_error`, `sso_redirect_not_allowed`.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`sso_not_configured` — the domain signs in with a password.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`hosted_elsewhere` — the mailbox is on a Private server (`url`, `configurationUrl`): start there.' content: application/json: schema: $ref: '#/components/schemas/Error' '422': description: '`sso_platform_app_missing`, `sso_incomplete` — the organisation''s SSO setup is not finished.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/MobileRateLimited' '502': description: '`sso_provider_unreachable` — Google / Microsoft did not answer; retry later.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/MobileUnavailable' /v1/auth/mobile/sso/complete: post: tags: - Mobile sign-in operationId: mobileSsoComplete summary: Single sign-on — exchange the one-time code for tokens description: The code from the redirect (valid 2 minutes, single use) plus the app's PKCE verifier. 20 per minute per client address. security: [] requestBody: required: true content: application/json: schema: type: object required: - code - codeVerifier - device properties: code: type: string codeVerifier: type: string pattern: ^[A-Za-z0-9._~-]{43,128}$ device: $ref: '#/components/schemas/DeviceInfo' responses: '200': description: Tokens, or the Zomail 2-step challenge (continue with `POST /v1/auth/mobile/login/mfa`). content: application/json: schema: type: object required: - data properties: data: oneOf: - $ref: '#/components/schemas/MobileTokens' - $ref: '#/components/schemas/MobileMfaChallenge' '400': $ref: '#/components/responses/MobileBadRequest' '401': description: '`sso_code_invalid` — unknown, used, expired, or a wrong verifier (the code is gone): sign in again.' content: application/json: schema: $ref: '#/components/schemas/Error' '403': description: '`sso_mailbox_disabled`, or the mobile policy codes (`mobile_access_disabled`, `mfa_setup_required`).' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/MobileRateLimited' '503': $ref: '#/components/responses/MobileUnavailable' /v1/auth/mobile/refresh: post: tags: - Mobile sign-in operationId: mobileRefresh summary: New access token and rotated refresh token description: | Rotates the refresh token: the presented one is marked used and a new pair is returned. **Grace window for lost answers.** If a used refresh token is presented again **within 30 s of its first use** and **no token issued after it has been used yet**, the server answers `200` with another new pair instead of `refresh_token_reused` (a network drop ate the first answer and the app retried). At most 3 pairs are issued from one refresh token in total (the first answer plus two retries); every pair issued this way stays valid until one of them is used, after which the others count as used. Outside the window, after a newer token was used, or past the limit, the reuse revokes the device session: `401 refresh_token_reused`, and every access and refresh token of that device session stops working. A parallel refresh racing with itself is treated like a retry inside the window. Apps should still serialise refreshes per account and store the new refresh token before using the new access token; the window only covers a lost response. security: [] requestBody: required: true content: application/json: schema: type: object required: - refreshToken properties: refreshToken: type: string examples: - zmr.4bB1… responses: '200': description: | A new pair. The presented refresh token is now used; presenting it again is tolerated only inside the 30-s grace window described above. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MobileTokens' '400': $ref: '#/components/responses/MobileBadRequest' '401': description: | The device session is gone — remove the account's tokens and ask the user to sign in: `invalid_refresh_token`, `refresh_token_reused` (session revoked), `session_expired`, `password_changed`. content: application/json: schema: $ref: '#/components/schemas/Error' '403': $ref: '#/components/responses/MobilePolicy' '429': $ref: '#/components/responses/MobileRateLimited' /v1/auth/mobile/logout: post: tags: - Mobile sign-in operationId: mobileLogout summary: Sign this device out of this account description: Ends the device session (access tokens, refresh tokens and push registration). Send the refresh token, the access token, or both. Always `204`. security: [] requestBody: content: application/json: schema: type: object properties: refreshToken: type: string responses: '204': description: Signed out (or nothing to do). /v1/auth/qr/scan: post: tags: - QR sign-in operationId: qrScan summary: Scan a web login QR code (shows what the user approves) description: | Login with QR (docs/api/qr-login.md). The QR is `https:///qr/#` or `zomail://qr?h=&i=&c=`; call this on the `apiBaseUrl` of the account chosen for that host, with that account's **mobile access token** (web and delegated tokens are refused). Marks the request `scanned` and binds it to this mailbox and device session; the answer is what the confirm screen shows (browser, OS, IP, location when known, request time). Idempotent for the same device session while `scanned`. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/QrScanRequest' responses: '200': description: The sign-in request to confirm. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/QrScanInfo' '400': $ref: '#/components/responses/MobileBadRequest' '401': $ref: '#/components/responses/MobileUnauthorized' '403': description: '`qr_mobile_only`, `delegated_session`, `qr_login_disabled` (organisation policy), `qr_mfa_required` (2-step verification was turned on after this device signed in: sign in again), `mailbox_disabled`.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`qr_not_found` — unknown id or wrong challenge (same answer).' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`qr_already_scanned` — another device session scanned it.' content: application/json: schema: $ref: '#/components/schemas/Error' '410': description: '`qr_expired` or `qr_used` (already approved, denied or exchanged).' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/MobileRateLimited' '503': $ref: '#/components/responses/MobileUnavailable' security: - bearerAuth: [] /v1/auth/qr/decide: post: tags: - QR sign-in operationId: qrDecide summary: Approve or deny a scanned web login description: | Only the mailbox **and** device session that scanned may decide; the decision is final. On approval the browser receives a one-time grant and becomes signed in to this mailbox (a normal web session); the user gets the "new sign-in" e-mail and the account's other devices a `security.new_device` push. requestBody: required: true content: application/json: schema: allOf: - $ref: '#/components/schemas/QrScanRequest' - type: object required: - approve properties: approve: type: boolean responses: '200': description: Decision recorded. content: application/json: schema: type: object required: - data properties: data: type: object required: - id - status properties: id: type: string format: uuid status: type: string enum: - approved - denied '400': $ref: '#/components/responses/MobileBadRequest' '401': $ref: '#/components/responses/MobileUnauthorized' '403': description: '`qr_scanned_elsewhere` (another device or account scanned it), `qr_mobile_only`, `delegated_session`, `qr_login_disabled`, `qr_mfa_required`, `mailbox_disabled`.' content: application/json: schema: $ref: '#/components/schemas/Error' '404': description: '`qr_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`qr_not_scanned` — call scan first.' content: application/json: schema: $ref: '#/components/schemas/Error' '410': description: '`qr_expired` or `qr_used`.' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/MobileRateLimited' '503': $ref: '#/components/responses/MobileUnavailable' security: - bearerAuth: [] /v1/account/devices: get: tags: - Devices & push operationId: listDevices summary: Mobile devices signed in to this mailbox description: Only this mailbox's device sessions (another account on the same phone is not listed). Shown in the web app under Settings → Mobile devices too. responses: '200': description: Devices, most recently used first; `current` marks the caller's own device. content: application/json: schema: type: object required: - data properties: data: type: array items: $ref: '#/components/schemas/Device' '401': $ref: '#/components/responses/MobileUnauthorized' '403': $ref: '#/components/responses/MobileDelegated' security: - bearerAuth: [] /v1/account/devices/{id}: delete: tags: - Devices & push operationId: revokeDevice summary: Sign a device out remotely parameters: - $ref: '#/components/parameters/DeviceSessionId' responses: '204': description: Revoked — its refresh token stops working, its access tokens end at once. '401': $ref: '#/components/responses/MobileUnauthorized' '403': $ref: '#/components/responses/MobileDelegated' '404': description: '`device_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/account/devices/{id}/push: post: tags: - Devices & push operationId: registerPush summary: Register (or update) this device's push token for this account description: | Call after sign-in and whenever the OS gives a new token or the user changes notification settings. A token registered under another installation id is taken away from it (reinstall). Answers the effective preview (`organisationPreview: minimal` overrides the device setting) and which providers this server can reach. parameters: - $ref: '#/components/parameters/DeviceSessionId' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PushRegistration' responses: '200': description: Registered. content: application/json: schema: type: object required: - data properties: data: type: object properties: deviceSessionId: type: string format: uuid provider: type: string enum: - apns - fcm environment: type: string enum: - sandbox - production enabledCategories: type: array items: $ref: '#/components/schemas/PushCategory' preview: type: string enum: - full - minimal organisationPreview: type: string enum: - full - minimal available: type: object properties: apns: type: boolean fcm: type: boolean '400': $ref: '#/components/responses/MobileBadRequest' '401': $ref: '#/components/responses/MobileUnauthorized' '403': $ref: '#/components/responses/MobileDelegated' '404': description: '`device_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] delete: tags: - Devices & push operationId: unregisterPush summary: Stop push notifications for this account on this device parameters: - $ref: '#/components/parameters/DeviceSessionId' responses: '204': description: Unregistered; queued notifications dropped. '401': $ref: '#/components/responses/MobileUnauthorized' '404': description: '`device_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/account/devices/{id}/push/test: post: tags: - Devices & push operationId: testPush summary: Send a test notification to this device parameters: - $ref: '#/components/parameters/DeviceSessionId' responses: '202': description: Queued (sent within seconds by the server's worker). `queued` is the number of notifications queued (1). content: application/json: schema: type: object required: - data properties: data: type: object required: - queued properties: queued: type: integer minimum: 1 example: data: queued: 1 '401': $ref: '#/components/responses/MobileUnauthorized' '404': description: '`device_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`push_not_registered`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/changes: get: tags: - Delta sync operationId: mailChanges summary: Messages added, changed and removed in a folder since a cursor description: | Without `since` (or with a cursor the server cannot use) → `reset: true` and a fresh cursor: load the folder with `GET /v1/mail/messages`, keep the cursor. With a cursor → `added` (UID ≥ the cursor's UIDNEXT, as list summaries), `updated` (flag changes of older messages), `removed` (expunged UIDs). `hasMore` → call again with the new cursor. `uidValidity` changed → `reset`. If the server lacks QRESYNC, `present` (every UID, IMAP sequence set such as `1:40,42`) replaces `removed`. Delegated sessions may call it. parameters: - name: folder in: query schema: $ref: '#/components/schemas/MailFolder' - name: since in: query description: Opaque cursor from the previous answer. schema: type: string - name: limit in: query description: Changed messages per page (1–500, default 200). schema: type: integer minimum: 1 maximum: 500 responses: '200': description: Changes. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/MailChanges' '400': $ref: '#/components/responses/MobileBadRequest' '401': $ref: '#/components/responses/MobileUnauthorized' '422': description: '`changes_unsupported` — the folder has no modification sequences; refresh by listing.' content: application/json: schema: $ref: '#/components/schemas/Error' '503': $ref: '#/components/responses/MobileUnavailable' security: - bearerAuth: [] /v1/calendar/changes: get: tags: - Delta sync operationId: calendarChanges summary: Calendar events changed or removed since a cursor description: | WebDAV sync (RFC 6578). Without `since`: every event (`reset: true`, replace the local copy) and always a cursor, also for a brand-new mailbox (its default calendar is created on this first sync: `changed: []`). With a cursor: only changes. `reset: true` with `cursor: null` → the cursor expired (or the calendar was deleted): call again without `since`. Events are the series masters (`rrule`, `recurring`); expand occurrences with `GET /v1/calendar/events` for the range on screen. parameters: - name: since in: query schema: type: string responses: '200': description: Changes. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DavChanges' '401': $ref: '#/components/responses/MobileUnauthorized' '403': $ref: '#/components/responses/MobileDelegated' '503': $ref: '#/components/responses/MobileUnavailable' security: - bearerAuth: [] /v1/contacts/changes: get: tags: - Delta sync operationId: contactChanges summary: Contacts changed or removed since a cursor description: Same rules as `/v1/calendar/changes`; `changed` holds contacts (same shape as `GET /v1/contacts`). parameters: - name: since in: query schema: type: string responses: '200': description: Changes. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DavChanges' '401': $ref: '#/components/responses/MobileUnauthorized' '403': $ref: '#/components/responses/MobileDelegated' '503': $ref: '#/components/responses/MobileUnavailable' security: - bearerAuth: [] /v1/mail/compose/uploads: post: tags: - Uploads operationId: createUpload summary: Start a resumable attachment upload description: | Announce the file; then send it in chunks with `PATCH` (≤ `chunkSize`, 4 MiB) and attach it with `POST /v1/mail/compose/send` `uploadIds: [id]` (the upload is deleted once the message is queued). Uploads expire after 24 h; at most 20 unfinished per mailbox. The total of a message's attachments is limited to 15 MB (`413 attachments_too_large`): share bigger files from Drive. requestBody: required: true content: application/json: schema: type: object required: - filename - size properties: filename: type: string maxLength: 255 contentType: type: string examples: - application/pdf size: type: integer minimum: 0 description: Exact size in bytes. responses: '201': description: 'Created (`Location`, `Upload-Offset: 0`, `Upload-Length`).' content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Upload' '400': $ref: '#/components/responses/MobileBadRequest' '401': $ref: '#/components/responses/MobileUnauthorized' '413': description: '`attachments_too_large` (`details.maxMb`).' content: application/json: schema: $ref: '#/components/schemas/Error' '429': description: '`too_many_uploads`.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/mail/compose/uploads/{id}: get: tags: - Uploads operationId: getUpload summary: Where an upload stands (resume from `offset`) description: '`HEAD` answers the same headers (`Upload-Offset`, `Upload-Length`, `Upload-Expires`) without a body.' parameters: - $ref: '#/components/parameters/UploadId' responses: '200': description: The upload. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/Upload' '401': $ref: '#/components/responses/MobileUnauthorized' '404': description: '`upload_not_found` (unknown, expired or another mailbox''s).' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] patch: tags: - Uploads operationId: appendUpload summary: Append a chunk description: | Body: the raw bytes, `Content-Type: application/offset+octet-stream`, header `Upload-Offset` = the current offset. A chunk at another offset is refused with `409 offset_mismatch` and the server's offset (header and `details.offset`): continue from there. parameters: - $ref: '#/components/parameters/UploadId' - name: Upload-Offset in: header required: true schema: type: integer minimum: 0 requestBody: required: true content: application/offset+octet-stream: schema: type: string format: binary responses: '204': description: Appended; `Upload-Offset` is the new offset (`= Upload-Length` → complete). '400': $ref: '#/components/responses/MobileBadRequest' '401': $ref: '#/components/responses/MobileUnauthorized' '404': description: '`upload_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' '409': description: '`offset_mismatch`.' content: application/json: schema: $ref: '#/components/schemas/Error' '413': description: '`chunk_too_large`, `upload_too_long`.' content: application/json: schema: $ref: '#/components/schemas/Error' '415': description: | The chunk was not sent as `application/offset+octet-stream`. An unparsed type answers `{error: bad_request, code: unsupported_media_type}`; a type the server parses as something else (e.g. JSON) `{error: unsupported_media_type, code: upload_content_type}`. Treat both as a client bug. content: application/json: schema: $ref: '#/components/schemas/Error' example: error: bad_request code: unsupported_media_type message: 'Unsupported Media Type: application/octet-stream' security: - bearerAuth: [] delete: tags: - Uploads operationId: deleteUpload summary: Abort an upload parameters: - $ref: '#/components/parameters/UploadId' responses: '204': description: Deleted (or did not exist). '401': $ref: '#/components/responses/MobileUnauthorized' security: - bearerAuth: [] /v1/account/deletion-request: get: tags: - Account deletion operationId: getDeletionRequest summary: The latest deletion request of this account responses: '200': description: The request, or `null`. content: application/json: schema: type: object required: - data properties: data: oneOf: - $ref: '#/components/schemas/DeletionRequest' - type: 'null' '401': $ref: '#/components/responses/MobileUnauthorized' '403': $ref: '#/components/responses/MobileDelegated' security: - bearerAuth: [] post: tags: - Account deletion operationId: requestAccountDeletion summary: Ask for this account to be deleted description: | A Zomail mailbox belongs to the user's **organisation**. Two outcomes (`kind`), show the matching text: * `admin_review` — the organisation's owners and administrators are asked (e-mail + Admin → Organisation → Account deletion requests). They delete the mailbox (it goes to the organisation's trash, then is purged) or decline. The user can cancel with `DELETE` while it is `open`. * `tenant_deletion` — the user is the sole owner of an organisation they signed up for themselves: the organisation's deletion is started (Zomail confirms it, then a grace period runs before data is purged; `tenantDeletionId` identifies it for support). Nothing is deleted immediately. Signing out of the app is a different action. requestBody: content: application/json: schema: type: object properties: reason: type: string maxLength: 1000 responses: '202': description: Request recorded. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DeletionRequest' '401': $ref: '#/components/responses/MobileUnauthorized' '403': $ref: '#/components/responses/MobileDelegated' '409': description: '`deletion_request_exists` (one is open), `deletion_exists` (the organisation''s deletion is already open).' content: application/json: schema: $ref: '#/components/schemas/Error' '503': description: '`deletion_unavailable` — organisation deletion is not configured on this server.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] delete: tags: - Account deletion operationId: cancelAccountDeletion summary: Withdraw an open `admin_review` request responses: '200': description: Cancelled. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/DeletionRequest' '401': $ref: '#/components/responses/MobileUnauthorized' '404': description: '`not_found` — nothing open that the user can cancel.' content: application/json: schema: $ref: '#/components/schemas/Error' security: - bearerAuth: [] /v1/meet/{code}/invite: post: tags: - Meet calls operationId: ringColleagues summary: Ring colleagues on their phones for a meeting description: | Sends a `meet` push (`kind: meet.invite`, `meetingCode`, `joinUrl`) to the devices of these addresses when they belong to the caller's organisation. Addresses outside it are returned in `notColleagues` (invite them by e-mail). 30 calls per 10 minutes per mailbox. The caller must belong to the meeting's organisation. parameters: - name: code in: path required: true schema: type: string pattern: ^[a-z]{3}-[a-z]{4}-[a-z]{3}$ requestBody: required: true content: application/json: schema: type: object required: - addresses properties: addresses: type: array minItems: 1 maxItems: 20 items: type: string format: email responses: '200': description: Who was rung. content: application/json: schema: type: object required: - data properties: data: type: object properties: invited: type: array items: type: string devicesNotified: type: integer notColleagues: type: array items: type: string '400': $ref: '#/components/responses/MobileBadRequest' '401': $ref: '#/components/responses/MobileUnauthorized' '404': description: '`meeting_not_found` (or not your organisation''s).' content: application/json: schema: $ref: '#/components/schemas/Error' '429': $ref: '#/components/responses/MobileRateLimited' security: - bearerAuth: [] components: securitySchemes: bearerAuth: type: http scheme: bearer description: | `Authorization: Bearer ` — a session token from `POST /v1/auth/login` (12 h), a delegated `dlg.` token from `POST /v1/account/delegation/{id}/open`, or an access token from the mobile device flow (see mobile spec). One token always acts on exactly one mailbox. Never log tokens. headers: RetryAfter: description: Seconds to wait before retrying. schema: type: integer minimum: 1 IdempotentReplayed: description: '`true` when the answer is the submission created earlier with the same idempotency key (nothing was sent now).' schema: type: string enum: - 'true' parameters: IdempotencyKey: name: Idempotency-Key in: header required: false description: | The send's idempotency key (16-128 chars `[A-Za-z0-9_-]`), instead of — or equal to — the body's `idempotencyKey`. Generate it once per message the user means to send and reuse it for every retry: a retry is answered with the first submission and never sends twice. schema: type: string pattern: ^[A-Za-z0-9_-]{16,128}$ example: 3f9d2c7e5b8a4c1d9e0f1a2b3c4d5e6f FolderQuery: name: folder in: query description: System folder. Default `inbox`. schema: $ref: '#/components/schemas/MailFolder' Uid: name: uid in: path required: true description: IMAP UID of the message in `folder` (positive integer; unique per folder and `uidValidity`). schema: type: integer minimum: 1 DeviceSessionId: name: id in: path required: true description: A device session id (`deviceSessionId`), or `current` for the caller's own device. schema: type: string pattern: ^([0-9a-fA-F-]{36}|current)$ UploadId: name: id in: path required: true schema: type: string format: uuid responses: BadRequest: description: | Invalid request. `error: validation_error` with zod issues in `details` (each issue has `path`, `message` and, for field rules, `params.code`), or a route-specific code such as `invalid_uid`, `invalid_folder`, `invalid_request`. Malformed JSON and other framework-level 4xx errors answer `error: bad_request` (or `validation_error`) with a `message`. content: application/json: schema: $ref: '#/components/schemas/Error' example: error: validation_error code: validation_error details: - code: invalid_format format: email path: - to - 0 message: Invalid email address Unauthorized: description: | `error: unauthorized` — no bearer token; `error: invalid_session` — the token expired, was revoked, or the delegated access ended. Drop the token (refresh it with the mobile refresh flow, or ask the user to sign in again). content: application/json: schema: $ref: '#/components/schemas/Error' examples: missing: value: error: unauthorized code: unauthorized expired: value: error: invalid_session code: invalid_session message: The session has expired DelegatedForbidden: description: | `error: forbidden` for a delegated (`dlg.`) session: `code` is `delegated_scope` (route not available in a delegated mailbox), `delegated_send_forbidden` (no send permission) or `delegated_delete_forbidden` (no delete permission). content: application/json: schema: $ref: '#/components/schemas/Error' example: error: forbidden code: delegated_scope message: Not available in a delegated mailbox ServiceUnavailable: description: | The feature is not configured on this server (e.g. `webmail_unavailable`, `account_unavailable`, `settings_unavailable`, `mfa_unavailable`, `sending_unavailable`, `import_unavailable`, `delegation_unavailable`, `drive_unavailable`) — hide it in the app for this account — or a temporary condition with `Retry-After` (`mail_backend_busy`, `mailbox_moving`). headers: Retry-After: $ref: '#/components/headers/RetryAfter' content: application/json: schema: $ref: '#/components/schemas/Error' MailUnavailable: description: | `error: mail_backend_busy` — the mailbox server is overloaded or restarting; retry after `Retry-After` (5 s). `error: mailbox_moving` (writes only) — the mailbox is being moved between servers; retry after 10 s. `error: webmail_unavailable` / `mail_unavailable` — mail reading is not configured on this server. headers: Retry-After: $ref: '#/components/headers/RetryAfter' content: application/json: schema: $ref: '#/components/schemas/Error' example: error: mail_backend_busy code: mail_backend_busy message: The mail server is busy; try again in a moment DelegationError404: description: '`error: delegation_error`, `code` one of `delegation_not_found`, `delegate_not_found`, `mailbox_not_found`, `shared_mailbox_not_found`, `tenant_not_found`.' content: application/json: schema: $ref: '#/components/schemas/Error' ImportSourceError: description: | `error: import_source_failed`, `code` one of `source_auth_failed`, `source_unreachable`, `source_tls_failed`, `source_host_blocked`, `source_insecure_not_allowed`, `source_port_not_allowed`, `source_throttled`, `source_error`; or `error`/`code` `source_host_required` (custom provider without host) or `import_test_rate_limited` (10 tests per 10 minutes — note: answered with 422, not 429). content: application/json: schema: $ref: '#/components/schemas/Error' RecoveryEmailStatus: description: Recovery e-mail state. content: application/json: schema: type: object required: - data properties: data: $ref: '#/components/schemas/RecoveryEmailStatus' MobileBadRequest: description: '`validation_error` (zod issues in `details`) or a route code such as `invalid_email`, `invalid_folder`, `invalid_push_token`.' content: application/json: schema: $ref: '#/components/schemas/Error' MobileUnauthorized: description: '`unauthorized` (no token) or `invalid_session` (expired/revoked access token: refresh it).' content: application/json: schema: $ref: '#/components/schemas/Error' MobileDelegated: description: '`forbidden` / `delegated_session` — not available with a delegated (`dlg.`) token.' content: application/json: schema: $ref: '#/components/schemas/Error' MobilePolicy: description: | The organisation's policy: `mobile_access_disabled`, `mfa_setup_required` (session kept on refresh — show a message and retry later), or `mailbox_disabled` (session ended). A billing-suspended organisation is not refused here (per-endpoint rules apply, like on the web). content: application/json: schema: $ref: '#/components/schemas/Error' MobileRateLimited: description: '`login_throttled` (`details.retryAfterSeconds`, `code: ip_blocked` for a blocked network) or `rate_limited`. Honour `Retry-After`.' headers: Retry-After: schema: type: integer minimum: 1 content: application/json: schema: $ref: '#/components/schemas/Error' MobileUnavailable: description: '`mobile_unavailable` (mobile API not enabled on this server), `sso_unavailable`, `webmail_unavailable`, `mail_backend_busy`, `calendar_unavailable` — retry later or hide the feature.' content: application/json: schema: $ref: '#/components/schemas/Error' schemas: WebPushSubscription: type: object required: - id - service - endpointHash - categories - preview - userAgent - createdAt - lastSeenAt properties: id: type: string format: uuid service: type: string description: Host of the push service (fcm.googleapis.com, web.push.apple.com…). endpointHash: type: string description: 'First 16 hex characters of SHA-256(endpoint): lets a browser find its own row.' categories: type: array items: type: string preview: type: string enum: - full - minimal description: Effective (the organisation's minimal wins). userAgent: type: string createdAt: type: string format: date-time lastSeenAt: type: string format: date-time Error: type: object description: | Error body. `error` is the route-level code (stable, snake_case). `code` narrows it to the specific situation (equal to `error` when there is nothing more specific; always present). `details` carries parameters for the message (limits, seconds, names) — or, for `validation_error`, the list of zod issues. `message` is English for developers/logs only. Clients: translate `code ?? error`; unknown codes → generic message for the HTTP status. required: - error - code properties: error: type: string examples: - validation_error - invalid_session - rule_violation code: type: string message: type: string details: oneOf: - type: object additionalProperties: true - type: array items: type: object additionalProperties: true retryAfterSeconds: type: integer description: Present on some 429 answers (login, sending). requestId: type: string description: 'On `500 internal_error`: quote it to support.' additionalProperties: true Mailbox: type: object description: The mailbox a session acts on. required: - id - primaryAddress - displayName - quotaBytes properties: id: type: string format: uuid primaryAddress: type: string format: email displayName: type: string quotaBytes: type: integer description: Mailbox storage quota in bytes. MailFolder: type: string description: The six system folders. Custom IMAP folders are not exposed; use labels instead. enum: - inbox - sent - drafts - junk - trash - archive Session: type: object required: - token - expiresAt - mailbox properties: token: type: string description: Opaque bearer token (43 chars base64url). Store in Keychain/Keystore. expiresAt: type: string format: date-time description: Absolute expiry (12 hours after sign-in). mailbox: $ref: '#/components/schemas/Mailbox' MfaChallenge: type: object required: - mfaRequired - challenge properties: mfaRequired: type: boolean const: true challenge: type: string description: Opaque; valid 5 minutes, 5 attempts. AppPassword: type: object required: - id - name - createdAt - lastUsedAt properties: id: type: string format: uuid name: type: string createdAt: type: string format: date-time lastUsedAt: type: - string - 'null' format: date-time WebSession: type: object required: - id - createdAt - lastUsedAt - expiresAt - current properties: id: type: string createdAt: type: string format: date-time lastUsedAt: type: string format: date-time expiresAt: type: string format: date-time current: type: boolean description: This is the caller's own session. DelegationParty: type: object required: - id - primaryAddress - displayName properties: id: type: string format: uuid primaryAddress: type: string format: email displayName: type: string DelegatedAccess: type: object description: Present on delegated sessions. The session's `mailbox` is the owner's; `delegate` is the signed-in person. required: - delegationId - kind - canSend - canDelete - delegate properties: delegationId: type: string format: uuid kind: type: string enum: - delegation - shared description: '`shared` = a shared mailbox (team inbox).' canSend: type: boolean canDelete: type: boolean delegate: $ref: '#/components/schemas/DelegationParty' Delegation: type: object required: - id - kind - status - canSend - canDelete - owner - delegate - invitedBy - createdAt - acceptedAt - revokedAt - revokedBy properties: id: type: string format: uuid kind: type: string enum: - delegation - shared status: type: string enum: - pending - active - declined - revoked canSend: type: boolean canDelete: type: boolean owner: $ref: '#/components/schemas/DelegationParty' delegate: $ref: '#/components/schemas/DelegationParty' invitedBy: type: string description: Address of who created it (owner or an administrator). createdAt: type: string format: date-time acceptedAt: type: - string - 'null' format: date-time revokedAt: type: - string - 'null' format: date-time revokedBy: type: - string - 'null' ForwardingAddress: type: object required: - address - status - createdAt - verifiedAt properties: address: type: string format: email status: type: string enum: - pending - verified createdAt: type: string format: date-time verifiedAt: type: - string - 'null' format: date-time RecoveryEmailStatus: type: object required: - address - status - verifiedAt - lastSentAt - unavailable properties: address: type: - string - 'null' format: email status: type: - string - 'null' enum: - pending - verified - null verifiedAt: type: - string - 'null' format: date-time lastSentAt: type: - string - 'null' format: date-time unavailable: type: - string - 'null' enum: - platform_staff - shared_mailbox - suspended - tenant_disabled - null description: Why self-service reset cannot be used for this mailbox (null = available). Vacation: type: object required: - enabled properties: enabled: type: boolean subject: type: string maxLength: 200 default: '' description: Empty = a default subject in the mailbox's language. body: type: string maxLength: 4000 default: '' startDate: type: - string - 'null' format: date default: null endDate: type: - string - 'null' format: date default: null description: Must be on or after startDate (`end_before_start`). days: type: integer minimum: 1 maximum: 30 default: 1 description: Reply to the same sender at most once per this many days. FilterRule: type: object description: | One condition + one action. `field`: from, to (to/cc), subject, any (from/to/cc/subject), list (List-Id), attachment (has attachment), size (`value` = KB, digits only). Actions: archive, trash, junk, read, star, forward (`forwardTo`, a verified address; keeps a copy), label (`labelId`, optional `labelArchive` = skip inbox), important, never_spam (only with `field: from`), delete (discard). `value` is required for from/to/subject/any/list. required: - id - field - action properties: id: type: string minLength: 1 maxLength: 40 description: Client-generated id. field: type: string enum: - from - to - subject - any - list - attachment - size operator: type: string enum: - contains - is default: contains value: type: string maxLength: 200 default: '' hasAttachment: type: boolean description: Extra condition. sizeOverKb: type: integer minimum: 1 maximum: 1000000 description: Extra condition. action: type: string enum: - archive - trash - junk - read - star - forward - label - important - never_spam - delete forwardTo: type: string format: email maxLength: 320 labelId: type: string format: uuid labelArchive: type: boolean stop: type: boolean default: true description: Stop evaluating later filters when this one matches. ForwardingSettings: type: object properties: enabled: type: boolean default: false address: type: string maxLength: 320 default: '' description: Required (an e-mail) when enabled; must be a verified forwarding address. keepCopy: type: boolean default: true MailSettings: type: object properties: signature: type: string maxLength: 5000 default: '' description: Plain text signature (no NUL characters). vacation: $ref: '#/components/schemas/Vacation' filters: type: array maxItems: 50 default: [] items: $ref: '#/components/schemas/FilterRule' forwarding: $ref: '#/components/schemas/ForwardingSettings' MailSettingsState: allOf: - $ref: '#/components/schemas/MailSettings' - type: object required: - signature - vacation - filters - forwarding - applied - applyError - updatedAt - revision properties: applied: type: boolean description: Filters/vacation are active on the mail server. applyError: type: - string - 'null' updatedAt: type: - string - 'null' format: date-time revision: type: integer minimum: 0 description: Counts the saves (0 = never saved); the ETag. Send it as If-Match on PUT (0.10.102). ImportSource: type: object required: - username - password properties: provider: type: string enum: - gmail - microsoft - yahoo - custom default: custom description: 'Presets: gmail = imap.gmail.com:993, microsoft = outlook.office365.com:993, yahoo = imap.mail.yahoo.com:993 (TLS).' host: type: string maxLength: 253 description: Required for `custom`. port: type: integer minimum: 1 maximum: 65535 description: 'Allowed: 143, 993 (or the server''s IMPORT_ALLOWED_PORTS).' security: type: string enum: - tls - starttls - none username: type: string minLength: 1 maxLength: 320 password: type: string minLength: 1 maxLength: 1000 description: Write-only. Google/Yahoo app passwords may contain the display spaces. includeAllMail: type: boolean description: Import Gmail "All Mail" into Archive. includeJunkAndTrash: type: boolean description: Default true. ImportFolder: type: object required: - source - target - skipReason - status - total - imported - skipped - failed properties: source: type: string target: type: - string - 'null' skipReason: type: - string - 'null' status: type: string total: type: integer imported: type: integer skipped: type: integer failed: type: integer ImportJob: type: object required: - id - tenantId - mailboxId - address - origin - createdBy - provider - sourceHost - sourcePort - sourceSecurity - sourceUsername - options - status - cancelRequested - errorCode - totalMessages - importedMessages - skippedMessages - failedMessages - importedBytes - attempts - nextAttemptAt - createdAt - startedAt - finishedAt - updatedAt properties: id: type: string format: uuid tenantId: type: string mailboxId: type: string address: type: string format: email origin: type: string description: '`user` for jobs started here.' createdBy: type: string provider: type: string sourceHost: type: string sourcePort: type: integer sourceSecurity: type: string sourceUsername: type: string options: type: object properties: includeAllMail: type: boolean includeJunkAndTrash: type: boolean status: type: string enum: - queued - running - completed - failed - cancelled cancelRequested: type: boolean errorCode: type: - string - 'null' description: One of the import error codes (source_auth_failed, quota_exceeded, ...). totalMessages: type: integer importedMessages: type: integer skippedMessages: type: integer failedMessages: type: integer importedBytes: type: integer attempts: type: integer nextAttemptAt: type: - string - 'null' format: date-time createdAt: type: string format: date-time startedAt: type: - string - 'null' format: date-time finishedAt: type: - string - 'null' format: date-time updatedAt: type: string format: date-time folders: type: array description: Only on the newest job of the list and on GET by id. items: $ref: '#/components/schemas/ImportFolder' FolderSummary: type: object required: - folder - total - unread properties: folder: $ref: '#/components/schemas/MailFolder' total: type: integer minimum: 0 unread: type: integer minimum: 0 StorageUsage: type: object required: - usedBytes - limitBytes properties: usedBytes: type: integer description: Bytes used by the whole mailbox (IMAP QUOTA STORAGE of the INBOX quota root; the server reports KiB, converted to bytes). limitBytes: type: - integer - 'null' description: The mailbox quota in bytes from the same QUOTA answer; null = no limit reported. description: The mailbox's storage usage. The `storage` field holding it is null when the mail server reports no quota (QUOTA unsupported or no quota root). MessageAttachment: type: object required: - part - filename - contentType - size properties: part: type: string description: MIME part number, e.g. "2" or "1.2". filename: type: string description: '"tep-dinh-kem" when the part has no name.' contentType: type: string size: type: integer description: Encoded size in bytes (base64 parts are ~4/3 of the file). MessageDetail: allOf: - $ref: '#/components/schemas/MessageSummary' - type: object required: - to - cc - bcc - messageId - references - text - html - blockedRemoteImages - attachments properties: to: type: array items: type: string description: Addresses (or names when no address). cc: type: array items: type: string bcc: type: array items: type: string description: Usually empty except for your own sent/draft messages. messageId: type: - string - 'null' description: Message-ID header including angle brackets; use for In-Reply-To when replying. references: type: array items: type: string text: type: string description: Plain-text body ("" when none). html: type: - string - 'null' description: Sanitized HTML body. blockedRemoteImages: type: integer minimum: 0 attachments: type: array items: $ref: '#/components/schemas/MessageAttachment' description: Full message. `unread` is false (reading marks it read) except with `peek=1`; `preview` = first 180 characters of the text. MessageDetails: type: object description: Gmail-style "show details" facts measured by Zomail's inbound gateway (never taken from the sender's own headers). required: - mailedBy - signedBy - tls - authChecked - dmarc properties: mailedBy: type: - string - 'null' description: Envelope sender (Return-Path) domain, else the From domain. signedBy: type: array items: type: string description: DKIM domains that passed. tls: type: - boolean - 'null' description: The last hop into Zomail used TLS (null = unknown). authChecked: type: boolean description: The gateway's authentication results are present. dmarc: type: - string - 'null' description: DMARC result (e.g. pass, fail, none) when authChecked. ThreadEntry: type: object required: - folder - uid - sender - senderAddress - subject - receivedAt - current - preview - unread - flagged - hasAttachments - messageId properties: folder: type: string enum: - inbox - sent - archive uid: type: integer sender: type: string senderAddress: type: string subject: type: string receivedAt: type: string format: date-time current: type: boolean preview: type: string unread: type: boolean flagged: type: boolean hasAttachments: type: boolean messageId: type: - string - 'null' description: 'Message-ID header (0.10.102). The Sent and Inbox copies of mail to yourself share it: show one.' MessageAction: oneOf: - type: object required: - type properties: type: type: string enum: - read - unread - star - unstar - delete - pin - unpin - important - unimportant - type: object required: - type - target properties: type: type: string const: move target: $ref: '#/components/schemas/MailFolder' description: 'Either a flag action `{type: read|unread|star|unstar|delete|pin|unpin|important|unimportant}` or `{type: move, target: }`.' MessageActionRequest: type: object required: - folder - uids - action properties: folder: $ref: '#/components/schemas/MailFolder' uids: type: array minItems: 1 maxItems: 500 items: type: integer minimum: 1 action: $ref: '#/components/schemas/MessageAction' SaveDraft: type: object properties: replaceUid: type: integer minimum: 1 description: UID of the previous version to replace. to: type: array maxItems: 100 default: [] items: type: string maxLength: 320 cc: type: array maxItems: 100 default: [] items: type: string maxLength: 320 bcc: type: array maxItems: 100 default: [] items: type: string maxLength: 320 subject: type: string maxLength: 998 default: '' description: Single line. text: type: string maxLength: 1000000 default: '' html: type: string maxLength: 2000000 inReplyTo: type: string maxLength: 998 references: type: array maxItems: 50 default: [] items: type: string maxLength: 998 ComposeAttachment: type: object required: - filename - contentBase64 properties: filename: type: string minLength: 1 maxLength: 255 description: No CR, LF, NUL, "/" or "\". contentType: type: string default: application/octet-stream pattern: ^[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*$ contentBase64: type: string contentEncoding: base64 description: Standard base64 without line breaks. ComposeMessage: type: object description: | At least one recipient in to/cc/bcc; at most 100 distinct recipients. `idempotencyKey` is required unless the `Idempotency-Key` header carries the key. properties: idempotencyKey: type: string pattern: ^[A-Za-z0-9_-]{16,128}$ description: Required unless sent as the `Idempotency-Key` header (then equal to it if both are given). to: type: array default: [] items: type: string format: email maxLength: 320 cc: type: array default: [] items: type: string format: email maxLength: 320 bcc: type: array default: [] items: type: string format: email maxLength: 320 subject: type: string maxLength: 998 default: '' description: Single line. text: type: string maxLength: 1000000 default: '' description: Plain-text body; generated from html when empty. html: type: string maxLength: 2000000 description: Rich body; re-sanitized by the server. inReplyTo: type: string maxLength: 998 description: Message-ID of the message replied to. references: type: array maxItems: 50 default: [] items: type: string maxLength: 998 draftUid: type: integer minimum: 1 description: Draft to delete once queued. attachments: type: array maxItems: 10 default: [] items: $ref: '#/components/schemas/ComposeAttachment' MessageSubmission: type: object required: - id - status - subject - recipientCount - rejectedRecipients - sizeBytes - attempts - errorCode - errorMessage - sentCopySaved - createdAt - acceptedAt - failedAt properties: id: type: string format: uuid status: type: string enum: - queued - sending - accepted - failed - scheduled description: '`accepted` = handed to the next server for every deliverable recipient.' subject: type: string recipientCount: type: integer rejectedRecipients: type: array items: type: string sizeBytes: type: integer attempts: type: integer errorCode: type: - string - 'null' errorMessage: type: - string - 'null' sentCopySaved: type: boolean createdAt: type: string format: date-time acceptedAt: type: - string - 'null' format: date-time failedAt: type: - string - 'null' format: date-time OutboxRecipient: type: object required: - recipient - state - route - code - detail - attempts - bounced - updatedAt properties: recipient: type: string state: type: string enum: - pending - delivered - deferred - failed - cancelled route: type: - string - 'null' enum: - local - relay - direct - null code: type: - string - 'null' description: SMTP status of the last attempt. detail: type: - string - 'null' attempts: type: integer bounced: type: boolean updatedAt: type: - string - 'null' format: date-time OutboxEntry: type: object required: - id - status - subject - messageId - createdAt - attempts - errorCode - nextRetryAt - canRetry - canCancel - recipients properties: id: type: string format: uuid status: type: string enum: - queued - sending - accepted - failed subject: type: string messageId: type: string createdAt: type: string format: date-time attempts: type: integer errorCode: type: - string - 'null' nextRetryAt: type: - string - 'null' format: date-time canRetry: type: boolean canCancel: type: boolean recipients: type: array items: $ref: '#/components/schemas/OutboxRecipient' LocalizedText: type: object required: - vi - en properties: vi: type: string en: type: string StatusLevel: type: string enum: - operational - degraded - partial_outage - major_outage - maintenance StatusIncident: type: object required: - id - kind - status - impact - title - components - startedAt - resolvedAt - scheduledStart - scheduledEnd - updatedAt - updates properties: id: type: string kind: type: string enum: - incident - maintenance status: type: string enum: - investigating - identified - monitoring - resolved - scheduled - in_progress - completed impact: type: string enum: - none - degraded - partial_outage - major_outage - maintenance title: $ref: '#/components/schemas/LocalizedText' components: type: array items: type: string enum: - webmail - sending - receiving - imap - calendar - drive - meet - admin startedAt: type: - string - 'null' format: date-time resolvedAt: type: - string - 'null' format: date-time scheduledStart: type: - string - 'null' format: date-time scheduledEnd: type: - string - 'null' format: date-time updatedAt: type: - string - 'null' format: date-time updates: type: array items: type: object required: - id - status - body - createdAt properties: id: type: string status: type: string body: $ref: '#/components/schemas/LocalizedText' createdAt: type: - string - 'null' format: date-time PublicStatus: type: object required: - page - generatedAt - overall - components - active - upcoming - history properties: page: type: object required: - title properties: title: type: string generatedAt: type: string format: date-time overall: $ref: '#/components/schemas/StatusLevel' components: type: array items: type: object required: - id - name - status - since - uptime - days properties: id: type: string enum: - webmail - sending - receiving - imap - calendar - drive - meet - admin name: $ref: '#/components/schemas/LocalizedText' status: $ref: '#/components/schemas/StatusLevel' since: type: string format: date-time uptime: type: - number - 'null' description: Percent over 90 days. days: type: array description: 90 UTC days, oldest first. items: type: object required: - day - status - uptime - minutes properties: day: type: string format: date status: oneOf: - $ref: '#/components/schemas/StatusLevel' - type: 'null' uptime: type: - number - 'null' minutes: type: - object - 'null' properties: degraded: type: integer partial_outage: type: integer major_outage: type: integer maintenance: type: integer active: type: array items: $ref: '#/components/schemas/StatusIncident' upcoming: type: array items: $ref: '#/components/schemas/StatusIncident' history: type: array items: $ref: '#/components/schemas/StatusIncident' ComposeSendRequest: type: object description: | Body of POST /v1/mail/compose/send: the regular compose message (contracts `composeMessageSchema`) plus compose extras (undo / schedule / carried-over draft attachments / Drive attachments / resumable uploads). Unknown properties are ignored. Total recipients (to + cc + bcc, de-duplicated) must be 1..100. `attachments` + kept draft attachments + Drive attachments + `uploadIds` together: at most 10 files and 15 MiB of decoded bytes; the built MIME message must not exceed 25 MiB. properties: idempotencyKey: type: string pattern: ^[A-Za-z0-9_-]{16,128}$ description: | Client-generated key, unique per message the user means to send (generate it when the compose screen opens, reuse it for every retry of the same send). Scoped to the mailbox. Required unless sent as the `Idempotency-Key` header. A request with a key that already has a submission returns that submission with HTTP 200 instead of creating a new one. After a successful recall (undo) the submission is deleted, so the same key can be used again. example: 3f0c9a6e5b7d4e21a8c0d9b2 to: type: array default: [] items: type: string format: email maxLength: 320 description: Trimmed and lower-cased by the server. cc: type: array default: [] items: type: string format: email maxLength: 320 bcc: type: array default: [] items: type: string format: email maxLength: 320 description: Envelope only; never written into the sent MIME. subject: type: string maxLength: 998 default: '' description: Single line (no CR/LF). text: type: string maxLength: 1000000 default: '' description: Plain-text body. When `html` is present and `text` is blank, the server generates the text alternative. html: type: string maxLength: 2000000 description: | Rich-text body. Re-sanitized by the server with a strict allow-list (p, br, b, strong, i, em, u, s, ul, ol, li, blockquote, a[href http/https/mailto], span, div; only the `color` style). The sanitized HTML must be at most 1,000,000 bytes (else 413 code html_too_large). inReplyTo: type: string maxLength: 998 description: Message-ID of the message being replied to (single line). references: type: array maxItems: 50 default: [] items: type: string maxLength: 998 attachments: type: array maxItems: 10 default: [] items: $ref: '#/components/schemas/ComposeAttachment' description: New files uploaded inline as base64. draftUid: type: integer minimum: 1 description: | UID (in Drafts) of the draft being sent. Needed to carry over `keepAttachments`. After the message is queued (202) the draft is deleted (best effort); Undo recreates it from the queued MIME. keepAttachments: type: array maxItems: 10 default: [] items: type: string maxLength: 600 description: | Attachments of the draft `draftUid` to include, identified by the key `"|"` (size in bytes, as listed by the message endpoint). Ignored without `draftUid`. If any key is not found the request fails with 422 compose_invalid / draft_attachment_missing. example: - report.pdf|48213 driveAttachments: type: array maxItems: 10 default: [] items: type: string format: uuid description: | Drive file IDs whose current version is copied into the message as real attachments at send time. Requires viewer access to the file. Not allowed in delegated sessions (422 delegated_drive_forbidden). uploadIds: type: array maxItems: 10 default: [] items: type: string format: uuid description: | Finished resumable uploads (`POST /v1/mail/compose/uploads`, then `PATCH` until complete) to attach, with their filename and content type. Each upload is deleted once the message is queued (an idempotent replay of the same key does not need them again). Unknown, expired or another mailbox's id → 404 `upload_not_found`; an unfinished upload → 409 `upload_incomplete` (details `{offset, size}`); the server has no resumable uploads → 422 `compose_invalid` / `uploads_unavailable`. Not accepted by the draft endpoints: attach the file to the draft inline (base64) or keep the upload id until send. example: - 5a0e7c1d-2b3f-4a5e-8c9d-0e1f2a3b4c5d sendAt: type: string format: date-time description: | Schedule send at this exact instant (ISO 8601 with offset or Z). Must be at least 60 seconds and at most 366 days ahead. Takes precedence over `sendAtLocal`. example: '2026-10-06T01:00:00Z' sendAtLocal: type: string maxLength: 20 pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$ description: | Schedule send at this wall-clock time ("YYYY-MM-DDTHH:mm", no seconds/offset) in `timeZone` (default: the user's saved compose time zone). Impossible dates (e.g. 31 Feb) are rejected; a local time skipped by a DST change resolves to the later instant. Same 1 minute .. 366 days bounds. example: 2026-10-06T08:00 timeZone: type: string maxLength: 64 description: IANA time zone for `sendAtLocal`, e.g. Asia/Ho_Chi_Minh. Unknown zone → 422 time_zone_invalid. example: idempotencyKey: 3f0c9a6e5b7d4e21a8c0d9b2 to: - lan@example.com subject: Báo giá tháng 10 html:

Chào chị Lan,

Em gửi báo giá ạ.

draftUid: 812 keepAttachments: - bao-gia.pdf|48213 ComposeSendResult: type: object required: - submission - sendAt - undoSeconds - undoUntil properties: submission: $ref: '#/components/schemas/MessageSubmission' sendAt: type: - string - 'null' format: date-time description: | Scheduled send time (UTC) when this request scheduled the message, else null. Note: on an idempotent replay (HTTP 200) this echoes the time computed from the replayed request, not a stored value. undoSeconds: type: integer enum: - 0 - 5 - 10 - 20 - 30 description: | Undo window applied to this send (the user's `undoSendSeconds` preference). 0 for scheduled sends, replays (200) and when undo is turned off. undoUntil: type: - string - 'null' format: date-time description: | Server time until which the message is held (now + undoSeconds); null when undoSeconds is 0. Call POST /v1/mail/compose/submissions/{id}/recall before then to undo. ComposeRecallResult: type: object required: - draftUid properties: draftUid: type: - integer - 'null' description: UID of the recreated draft in the Drafts folder (null if the IMAP server did not report it). ComposeScheduledMessage: type: object required: - id - subject - recipients - sendAt - createdAt - sizeBytes properties: id: type: string format: uuid description: Submission ID (use with send-now / recall). subject: type: string description: First 255 characters of the subject. recipients: type: array items: type: string description: All envelope recipients (To, Cc and Bcc, de-duplicated). sendAt: type: string format: date-time createdAt: type: string format: date-time sizeBytes: type: integer description: Size of the queued MIME message in bytes. ComposePreferences: type: object required: - undoSendSeconds - timeZone - saved properties: undoSendSeconds: type: integer enum: - 0 - 5 - 10 - 20 - 30 description: Undo send delay; 0 = off. Default 5. timeZone: type: string description: Saved IANA time zone used for `sendAtLocal` and presets. Default Asia/Ho_Chi_Minh. saved: type: boolean description: false while the user has never saved preferences (defaults are shown). ComposePreferencesInput: type: object required: - undoSendSeconds - timeZone properties: undoSendSeconds: type: integer enum: - 0 - 5 - 10 - 20 - 30 description: Other values → 400 validation_error (issue param code undo_send_invalid). timeZone: type: string maxLength: 64 description: IANA zone. Unknown → 400 validation_error (issue param code time_zone_invalid). example: undoSendSeconds: 10 timeZone: Asia/Ho_Chi_Minh ComposePreferencesView: allOf: - $ref: '#/components/schemas/ComposePreferences' - type: object required: - effectiveTimeZone - undoOptions - presets properties: effectiveTimeZone: type: string description: | Zone used for `presets`: the `tz` query hint while nothing is saved (and the hint is a valid zone), otherwise the saved/default `timeZone`. undoOptions: type: array items: type: integer description: Allowed undo delays in seconds (currently [0, 5, 10, 20, 30]). example: - 0 - 5 - 10 - 20 - 30 presets: type: object required: - tomorrowMorning - mondayMorning description: Schedule-send shortcuts computed for `effectiveTimeZone` (08:00 local), as UTC instants. properties: tomorrowMorning: type: string format: date-time mondayMorning: type: string format: date-time description: 08:00 on the next Monday (a week ahead if today is Monday). ComposeDraftRequest: type: object description: | Draft save (contracts `saveDraftSchema` + `keepAttachments`). Addresses are NOT validated as email addresses here (drafts may be incomplete); blank entries are dropped. New files are added with `uploadIds` (finished resumable uploads); attachments of the replaced draft are kept with `keepAttachments`. properties: replaceUid: type: integer minimum: 1 description: UID of the previous version of this draft in Drafts; it is deleted after the new version is appended. to: type: array maxItems: 100 default: [] items: type: string maxLength: 320 cc: type: array maxItems: 100 default: [] items: type: string maxLength: 320 bcc: type: array maxItems: 100 default: [] items: type: string maxLength: 320 description: Kept as a Bcc header in the draft. subject: type: string maxLength: 998 default: '' description: Single line (no CR/LF). text: type: string maxLength: 1000000 default: '' html: type: string maxLength: 2000000 description: Sanitized like a sent message's HTML (sanitized output ≤ 1,000,000 bytes, else 413 html_too_large). inReplyTo: type: string maxLength: 998 references: type: array maxItems: 50 default: [] items: type: string maxLength: 998 keepAttachments: type: array maxItems: 10 default: [] items: type: string maxLength: 600 description: | Attachments of `replaceUid` to carry into the new version, as `"|"` keys. Ignored without `replaceUid`. Max 15 MiB in total. uploadIds: type: array maxItems: 10 default: [] items: type: string format: uuid description: | Finished uploads (POST /v1/mail/compose/uploads) to attach to this version (0.10.102). Counted in the same 15 MiB / 10-attachment limit as `keepAttachments`. Not deleted by the save. example: replaceUid: 812 to: - lan@example.com subject: Báo giá tháng 10 html:

Chào chị Lan,

keepAttachments: - bao-gia.pdf|48213 ComposeDraftSaved: type: object required: - uid properties: uid: type: - integer - 'null' description: UID of the new draft version in Drafts (null if the IMAP server did not report it). Use it as the next `replaceUid`. attachments: type: array description: Present when the draft has attachments (0.10.102). `key` goes into `keepAttachments` on the next save or on send. items: allOf: - $ref: '#/components/schemas/MessageAttachment' - type: object required: - key properties: key: type: string description: '"|".' TemplateInput: type: object required: - name description: Full replacement on PUT (omitted `subject`/`html` become ""). properties: name: type: string minLength: 1 maxLength: 120 description: Trimmed. subject: type: string maxLength: 255 default: '' description: Single line (no CR/LF). html: type: string maxLength: 200000 default: '' description: Sanitized with the compose HTML allow-list before storing (blank stays ""). example: name: Gửi báo giá subject: Báo giá dịch vụ html:

Kính gửi anh/chị,

…

Template: type: object required: - id - name - subject - html - createdAt - updatedAt properties: id: type: string format: uuid name: type: string subject: type: string html: type: string description: Sanitized HTML. createdAt: type: string format: date-time updatedAt: type: string format: date-time AiTranslateTarget: type: string enum: - vi - en - zh - ja - ko - fr - de - es - th - id - ru - lo - km description: Target language (zh = Simplified Chinese). AiWriteRequest: type: object required: - prompt properties: prompt: type: string minLength: 3 maxLength: 2000 description: What the user wants to write, in their own words (trimmed). tone: type: string enum: - formal - friendly - short default: formal language: type: string enum: - auto - vi - en default: auto description: auto = the mailbox owner's UI language unless the prompt asks for another. example: prompt: Mời anh Minh họp review hợp đồng thứ Sáu 9h tone: formal AiWriteResult: type: object required: - subject - body properties: subject: type: string maxLength: 200 description: Single line. body: type: string description: Plain-text paragraphs separated by blank lines, no signature; unknown facts appear as [placeholders]. AiRewriteRequest: type: object required: - text - mode properties: text: type: string minLength: 1 maxLength: 30000 description: The draft text (plain text, trimmed). mode: type: string enum: - improve - shorten - formal - friendly AiRewriteResult: type: object required: - body properties: body: type: string description: The rewritten text (plain text paragraphs), in the draft's language. AiProofreadRequest: type: object required: - text properties: text: type: string minLength: 1 maxLength: 30000 AiProofreadResult: type: object required: - corrected - issues properties: corrected: type: string description: The whole text with only spelling/grammar/punctuation/diacritic fixes. issues: type: array maxItems: 200 description: Each change, in order (entries where original equals suggestion are dropped). Empty when the text is correct. items: type: object required: - original - suggestion - reason properties: original: type: string suggestion: type: string reason: type: string description: Short explanation in the owner's UI language. AiComposeTranslateRequest: type: object required: - text - target properties: text: type: string minLength: 1 maxLength: 30000 subject: type: string maxLength: 300 default: '' target: $ref: '#/components/schemas/AiTranslateTarget' AiComposeTranslateResult: type: object required: - subject - body properties: subject: type: string maxLength: 200 description: Translated subject, single line; "" when no subject was given. body: type: string AiStatus: type: object required: - available - enabled properties: available: type: boolean description: An AI provider is configured on this server. enabled: type: boolean description: available AND the organisation has opted in (Mail Pro plan). Hide AI UI when false. AiMessageRef: type: object required: - uid properties: folder: allOf: - $ref: '#/components/schemas/MailFolder' default: inbox uid: type: integer minimum: 1 AiSummary: type: object required: - summary - keyPoints - actionItems - priority - category - suspiciousReason description: Model output (schema-constrained); text fields are in the owner's UI language. properties: summary: type: string description: 2-4 sentences. keyPoints: type: array items: type: string description: Up to ~5 facts (amounts, dates, decisions); the count is not enforced server-side. actionItems: type: array items: type: object required: - task - owner - due properties: task: type: string owner: type: string enum: - me - sender - other description: Who should act, from the mailbox owner's point of view. due: type: - string - 'null' description: YYYY-MM-DD when a deadline is stated (model output, not validated as a date). priority: type: string enum: - urgent - normal - low category: type: string enum: - personal - work - transaction - newsletter - notification - suspicious suspiciousReason: type: - string - 'null' description: Why it looks like phishing/fraud, else null. AiDraftReplyRequest: allOf: - $ref: '#/components/schemas/AiMessageRef' - type: object properties: instruction: type: string maxLength: 1000 default: '' description: What the user wants to say (any language). Empty = a suitable reply. tone: type: string enum: - formal - friendly - short default: formal AiDraftReplyResult: type: object required: - body properties: body: type: string description: Reply body only (no subject, no signature), in the language of the last message. AiMessageTranslateRequest: allOf: - $ref: '#/components/schemas/AiMessageRef' - type: object required: - target properties: target: $ref: '#/components/schemas/AiTranslateTarget' AiMessageTranslation: type: object required: - subject - body - sourceLanguage description: Show as plain text, never as HTML. properties: subject: type: string body: type: string sourceLanguage: type: string description: ISO 639-1 code of the original language as reported by the model (may be ""). AiBrief: type: object required: - overview - allRead - items properties: overview: type: string description: 2-3 sentences in the owner's UI language; "" when allRead. allRead: type: boolean description: true when none of the 40 newest inbox messages is unread (no AI call was made; show your own "all caught up" text). items: type: array maxItems: 5 description: Most important unread messages first. items: type: object required: - uid - sender - subject - reason properties: uid: type: integer description: Inbox UID. sender: type: string description: Sender display name (or address) as listed. subject: type: string reason: type: string AiSmartReplies: type: object required: - suggestions - language - cached properties: suggestions: type: array maxItems: 3 items: type: string maxLength: 300 description: Up to 3 short ready-to-send replies. language: type: string enum: - vi - en description: Language detected from the message (Vietnamese letters → vi, otherwise en). cached: type: boolean description: true when served from the per-message cache (7 days); a cached answer costs no AI request. MessageSummary: type: object description: | One message in a folder listing (IMAP reader `toSummary`, imap-mail-reader.ts). Used by GET /v1/mail/messages and, extended, by the organized view (`ViewMessage`). Identified by `uid` within its folder; UIDs change when a message moves to another folder. required: - uid - sender - senderAddress - recipients - subject - preview - receivedAt - unread - flagged - pinned - important - size properties: uid: type: integer minimum: 1 description: IMAP UID of the message in its folder. sender: type: string description: Display name of the first From address, else its address, else "" (render your own localized placeholder for ""). senderAddress: type: string description: Address of the first From entry ("" when missing). Not lower-cased. recipients: type: array items: type: string description: To + Cc addresses (the display name when an entry has no address), in header order. subject: type: string description: Decoded subject; "" when missing (render your own placeholder). preview: type: string description: Plain-text snippet of the first text part; "" when it could not be fetched. receivedAt: type: string format: date-time description: IMAP INTERNALDATE (arrival time), ISO 8601 UTC. unread: type: boolean description: True when the message lacks \Seen. flagged: type: boolean description: Starred (\Flagged). pinned: type: boolean description: Has the `$Pinned` keyword (pinned above the inbox; at most 20 per folder). important: type: boolean description: Has `$Important` (or the server's `\Important`), set by the user or a filter. hasAttachments: type: boolean description: | At least one real attachment (0.10.102): from the BODYSTRUCTURE already fetched for the snippet, or Dovecot's `$HasAttachment` / `$HasNoAttachment` keyword. Inline images referenced by Content-ID (signature logos) do not count. Present on every list item (GET /v1/mail/messages, /v1/mail/view, /v1/mail/changes `added`). size: type: integer minimum: 0 description: RFC 822 size in bytes. example: uid: 4211 sender: Nguyễn Văn An senderAddress: an@acme.vn recipients: - me@example.vn - team@acme.vn subject: Báo cáo tuần 40 preview: Chào anh, em gửi báo cáo tuần này... receivedAt: '2026-10-03T02:15:00.000Z' unread: true flagged: false pinned: false important: true hasAttachments: true size: 48213 OrganizeCategory: type: string enum: - primary - promotions - social - updates - forums - purchases description: | Inbox category tab. Stored on the message as the IMAP keyword `$cat_`; computed automatically for inbox mail when tabs are enabled (or set by the user / a per-sender rule). OrganizeOptionalTab: type: string enum: - promotions - social - updates - forums - purchases description: A tab the user can enable besides Primary (Primary is always shown when any tab is on). OrganizeSort: type: string enum: - newest - unread_first - starred_first - priority description: | Inbox sort. `newest` and `priority` page with the UID cursor (`before`); `unread_first` and `starred_first` page with `offset`. `priority` adds "important & unread" and "starred" sections on the first inbox page. ViewMessage: description: A `MessageSummary` plus organize metadata (organize-service.ts `OrganizedMessage`). allOf: - $ref: '#/components/schemas/MessageSummary' - type: object required: - folder - labelIds - category - snoozedBack - hasAttachments properties: folder: $ref: '#/components/schemas/MailFolder' labelIds: type: array items: type: string format: uuid description: Ids of the user's labels on this message (keywords of deleted labels are ignored). category: description: Category tab keyword on the message, null when not classified yet (only inbox mail is classified, and only while tabs are on). oneOf: - $ref: '#/components/schemas/OrganizeCategory' - type: 'null' snoozedBack: type: boolean description: Came back from snooze (`$snoozed` keyword) and is still unread — show a "Snoozed" chip. Label: type: object required: - id - name - color - keyword - createdAt properties: id: type: string format: uuid name: type: string minLength: 1 maxLength: 80 description: Unique per mailbox (case-insensitive). color: type: string pattern: ^#[0-9a-f]{6}$ description: Lower-case hex colour. keyword: type: string pattern: ^\$label_[0-9a-f]{8}$ description: | IMAP keyword stored on labelled messages (`$label_` + first 8 hex chars of the id). Other IMAP clients and Sieve filters see/set the same keyword (`addflag "$label_3fa85f64"`). createdAt: type: string format: date-time example: id: 3fa85f64-5717-4562-b3fc-2c963f66afa6 name: Khách hàng color: '#5b7cfa' keyword: $label_3fa85f64 createdAt: '2026-09-30T08:00:00.000Z' LabelCreateRequest: type: object required: - name properties: name: type: string minLength: 1 maxLength: 80 description: Trimmed; must not contain CR, LF or TAB. color: type: string pattern: ^#[0-9a-fA-F]{6}$ default: '#5b7cfa' description: Hex colour (trimmed and lower-cased by the server). LabelUpdateRequest: type: object minProperties: 1 description: At least one of `name` / `color` is required (else 400 validation_error). properties: name: type: string minLength: 1 maxLength: 80 description: Trimmed; must not contain CR, LF or TAB. color: type: string pattern: ^#[0-9a-fA-F]{6}$ LabelApplyRequest: type: object required: - folder - uids - labelId properties: folder: $ref: '#/components/schemas/MailFolder' uids: type: array minItems: 1 maxItems: 500 items: type: integer minimum: 1 labelId: type: string format: uuid remove: type: boolean default: false description: true removes the label instead of adding it. LabelApplyResult: type: object required: - label - count - removed properties: label: $ref: '#/components/schemas/Label' count: type: integer description: Number of UIDs in the request (not a count of messages that actually changed; unknown UIDs are ignored by IMAP). removed: type: boolean LabelMessageLabels: type: object required: - labels - applied - category properties: labels: type: array description: All labels of the mailbox (sorted by name, case-insensitive) so the picker can be drawn from one call. items: $ref: '#/components/schemas/Label' applied: type: array description: Ids of the labels on this message. items: type: string format: uuid category: oneOf: - $ref: '#/components/schemas/OrganizeCategory' - type: 'null' OrganizePrefs: type: object required: - sort - tabs properties: sort: $ref: '#/components/schemas/OrganizeSort' tabs: type: array description: | Enabled optional tabs, in canonical order (promotions, social, updates, forums, purchases). Empty = tabs off (plain inbox). A mailbox that never saved preferences gets [promotions, social, updates, purchases] and sort `newest`. items: $ref: '#/components/schemas/OrganizeOptionalTab' OrganizePrefsUpdate: type: object description: Partial update; omitted fields keep their current value. `tabs` replaces the whole set ([] turns tabs off). properties: sort: $ref: '#/components/schemas/OrganizeSort' tabs: type: array maxItems: 5 items: $ref: '#/components/schemas/OrganizeOptionalTab' ViewSearchChip: type: object description: One parsed search term, for showing the query as chips. required: - key - value - negated properties: key: type: string description: | Operator: `text` (free text, also unknown `foo:bar` operators), from, to, cc, subject, filename, has, is, in, label, before, after, older_than, newer_than, older, newer, larger, smaller. value: type: string description: Value as typed (quotes removed). negated: type: boolean description: Term was prefixed with `-`. invalid: type: boolean description: Present (true) only when the operator value was not understood and the term was ignored (e.g. `is:foo`, `in:nowhere`, bad date or size). ViewSearch: type: object required: - chips - scope properties: chips: type: array items: $ref: '#/components/schemas/ViewSearchChip' scope: description: Folder scope from an `in:` operator (`anywhere` = all six folders); null when none. type: - string - 'null' enum: - inbox - sent - drafts - junk - trash - archive - anywhere - null ViewTab: type: object required: - category - unread properties: category: $ref: '#/components/schemas/OrganizeCategory' unread: type: integer minimum: 0 description: Unread inbox messages in this tab (Primary = unread inbox mail not in an enabled optional tab). ViewResponse: type: object description: The organized message list (organize-service.ts `OrganizedView`) plus the session mailbox. All fields are always present. required: - mailbox - folder - mode - folders - messages - sections - pinned - total - nextCursor - nextOffset - firstIndex - hasPrev - prevCursor - prevOffset - sort - prefs - tab - tabs - labels - label - search properties: mailbox: $ref: '#/components/schemas/Mailbox' folder: $ref: '#/components/schemas/MailFolder' mode: type: string enum: - folder - label - search description: '`label` when `label` was given (wins over search), `search` when `q` was given, else `folder`.' folders: type: array description: | Folders the view covers. One folder normally; a label view without `in:` covers [inbox, archive, sent, drafts]; `in:anywhere` covers all six. With more than one folder, items carry their own `folder`, sort is forced to `newest` by arrival date, paging uses `offset`, and at most 1000 newest matches per folder are considered. items: $ref: '#/components/schemas/MailFolder' messages: type: array description: The page, in display order. items: $ref: '#/components/schemas/ViewMessage' sections: description: | Only for sort `priority` on the first page of the plain inbox (Primary tab when tabs are on); else null. `important` = up to 25 unread messages that are marked important, or Primary mail from people you write to / colleagues writing to you directly; `starred` = up to 20 other starred messages. Messages in sections are excluded from `messages`. oneOf: - type: object required: - important - starred properties: important: type: array items: $ref: '#/components/schemas/ViewMessage' starred: type: array items: $ref: '#/components/schemas/ViewMessage' - type: 'null' pinned: type: array description: | Pinned messages (max 20, newest UID first), only on the first page of the plain inbox when the tab is Primary or tabs are off — regardless of their own category. They are excluded from `messages` on that page. Empty otherwise. items: $ref: '#/components/schemas/ViewMessage' total: type: integer minimum: 0 description: Matching messages in the whole list (single folder, first page includes pinned ones). nextCursor: type: - integer - 'null' description: Cursor paging (single folder, sort newest/priority) — pass as `before` for the next (older) page; null at the end or in offset mode. nextOffset: type: - integer - 'null' description: Offset paging (sort unread_first/starred_first, or multi-folder) — pass as `offset` for the next page; null at the end or in cursor mode. firstIndex: type: integer minimum: 0 description: 0-based index of this page's first message in the whole list (for "51–100 of 240"). hasPrev: type: boolean description: A newer page exists. Go there with `prevCursor` (as `before`) or `prevOffset` (as `offset`); when both are null, reload the first page (no before/offset). prevCursor: type: - integer - 'null' prevOffset: type: - integer - 'null' sort: $ref: '#/components/schemas/OrganizeSort' prefs: $ref: '#/components/schemas/OrganizePrefs' tab: description: Active tab — only for the plain inbox (no q, no label) with tabs enabled; null otherwise. An unknown/disabled `tab` falls back to `primary`. oneOf: - $ref: '#/components/schemas/OrganizeCategory' - type: 'null' tabs: type: array description: Primary + enabled tabs with unread counts, only when tabs are on for the plain inbox; else []. items: $ref: '#/components/schemas/ViewTab' labels: type: array description: All labels of the mailbox (sorted by name), to resolve `labelIds`. items: $ref: '#/components/schemas/Label' label: description: The label being viewed (mode `label`), else null. oneOf: - $ref: '#/components/schemas/Label' - type: 'null' search: description: Parsed `q` (mode `search`, or a label view that also has `q`), else null. oneOf: - $ref: '#/components/schemas/ViewSearch' - type: 'null' ViewUidsResponse: type: object required: - folder - uids - total properties: folder: $ref: '#/components/schemas/MailFolder' uids: type: array description: Matching UIDs, newest (highest) first, at most 10000. Includes pinned inbox mail. items: type: integer total: type: integer description: Total matches (may exceed the length of `uids`). OrganizeUidsRequest: type: object required: - folder - uids properties: folder: $ref: '#/components/schemas/MailFolder' uids: type: array minItems: 1 maxItems: 50 items: type: integer minimum: 1 OrganizeMuteResult: type: object required: - muted - archived properties: muted: type: integer description: Conversations (distinct thread roots) muted. archived: type: integer description: Inbox messages of those conversations moved to Archive now. OrganizeMutedThread: type: object required: - id - rootMessageId - subject - createdAt properties: id: type: string format: uuid rootMessageId: type: string description: Root Message-ID of the conversation, with angle brackets (first References id, else In-Reply-To, else the message's own Message-ID). subject: type: string maxLength: 300 createdAt: type: string format: date-time OrganizeSnoozeRequest: type: object required: - folder - uids - preset properties: folder: $ref: '#/components/schemas/MailFolder' uids: type: array minItems: 1 maxItems: 50 items: type: integer minimum: 1 preset: type: string enum: - later_today - tomorrow - next_week - custom description: | later_today = now + 3 h rounded up to the full hour; tomorrow = 08:00 tomorrow; next_week = next Monday 08:00; custom = `until`. All wall-clock times in `timezone`. until: type: string maxLength: 40 description: Required for `custom` — local wall-clock time `YYYY-MM-DDTHH:mm` in `timezone` (extra characters after the minutes are ignored; no offset is honoured). example: 2026-10-10T09:30 timezone: type: string maxLength: 64 description: IANA zone (e.g. Asia/Ho_Chi_Minh). Missing or invalid → Asia/Ho_Chi_Minh (silently). OrganizeSnoozeResult: type: object required: - count - wakeAt properties: count: type: integer description: Messages actually snoozed (UIDs not found in the folder are skipped; may be 0). wakeAt: type: string format: date-time snoozes: type: array description: | One per snoozed message (0.10.102): the snooze `id` (Undo = POST /v1/mail/organize/snoozed/wake with these ids; open = GET /v1/mail/organize/snoozed/{id}/message), the UID it had in the source folder and its UID in Snoozed (0 = unknown). items: type: object required: - id - sourceUid - snoozedUid properties: id: type: string format: uuid sourceUid: type: integer snoozedUid: type: integer target: type: string const: Snoozed description: IMAP folder the messages were moved to. uidValidity: type: string description: UIDVALIDITY of Snoozed ("" when unknown). uidMap: type: object additionalProperties: type: integer description: Source UID → Snoozed UID. OrganizeSnooze: type: object description: One snoozed message (row of mail_snoozes). Snoozed mail lives in the IMAP folder "Snoozed" until it wakes. required: - id - mailboxId - messageId - snoozedUid - uidValidity - subject - sender - wakeAt - attempts - lastError properties: id: type: string format: uuid mailboxId: type: string format: uuid messageId: type: - string - 'null' description: Message-ID header of the message. snoozedUid: type: integer description: UID in the Snoozed folder (0 when unknown). uidValidity: type: string description: UIDVALIDITY of the Snoozed folder at snooze time. subject: type: string sender: type: string description: From display name or address. wakeAt: type: string format: date-time attempts: type: integer description: Wake-up attempts by the background worker. lastError: type: - string - 'null' description: Last wake-up failure (retried with back-off), null when none. OrganizeWakeRequest: type: object required: - ids properties: ids: type: array minItems: 1 maxItems: 50 items: type: string format: uuid description: Snooze ids (OrganizeSnooze.id). OrganizeWakeResult: type: object required: - woken properties: woken: type: array items: type: object required: - id - uid properties: id: type: string format: uuid uid: type: integer description: New INBOX UID of the woken message (to open it); 0 when the message was gone from Snoozed or the new UID is unknown. OrganizeCategoryRequest: type: object required: - folder - uids - category properties: folder: $ref: '#/components/schemas/MailFolder' uids: type: array minItems: 1 maxItems: 100 items: type: integer minimum: 1 category: $ref: '#/components/schemas/OrganizeCategory' remember: type: boolean default: true description: Also store a per-sender rule (the From address of each message) and retag other inbox mail from those senders now. OrganizeCategoryResult: type: object required: - category - count - senders - retagged properties: category: $ref: '#/components/schemas/OrganizeCategory' count: type: integer description: Number of UIDs in the request. senders: type: array items: type: string description: Lower-cased From addresses of the messages. retagged: type: integer description: Other inbox messages retagged by the new rules (0 when remember=false; up to 500 per group of 20 senders). OrganizeCategoryRule: type: object required: - sender - category - createdAt properties: sender: type: string description: Lower-cased sender address (rules created by the API are always full addresses). category: $ref: '#/components/schemas/OrganizeCategory' createdAt: type: string format: date-time JunkReportRequest: type: object required: - folder - uids - verdict properties: folder: $ref: '#/components/schemas/MailFolder' uids: type: array minItems: 1 maxItems: 50 items: type: integer minimum: 1 description: Duplicates are ignored. verdict: type: string enum: - spam - ham description: | `spam` = Report spam (move to Junk; not allowed from junk, drafts, sent). `ham` = Not spam (move Junk → Inbox; only from junk). There is no separate phishing verdict. rememberSender: type: boolean default: false description: spam → add the senders to the block list; ham → add them to the allow list (source `report`). Your own address is never added. JunkReportResult: type: object required: - moved - learning - senders properties: moved: type: integer description: Number of distinct UIDs in the request (the move is done for all of them). learning: type: string enum: - queued - 'off' - skipped description: | Spam-filter training: `queued` (the messages are fed to the Bayes classifier in the background), `off` (no classifier configured on this server), `skipped` (nothing learnable: too large > 2 MB, unreadable, or the learning queue is full). senders: type: array items: type: string description: Addresses added to the allow/block list (empty unless rememberSender). target: type: string enum: - junk - inbox description: Folder the messages were moved to (0.10.102). uidValidity: type: string description: UIDVALIDITY of `target` ("" when the server did not report it). uidMap: type: object additionalProperties: type: integer description: Old UID → UID in `target` (COPYUID); Undo moves these back. `{}` when not reported. MoveResult: type: object required: - target - uidValidity - uidMap description: Where a move put the messages (IMAP MOVE + COPYUID), 0.10.102. properties: target: description: Target folder; null for flag actions and permanent delete. oneOf: - $ref: '#/components/schemas/MailFolder' - type: 'null' uidValidity: type: string description: UIDVALIDITY of the target folder ("" when unknown). UIDs are only valid together with it. uidMap: type: object additionalProperties: type: integer description: Old UID (as a string key) → new UID in the target. Messages that were already gone are absent. LinkVerdict: type: object required: - href - shown - host - verdict description: | One link of the message (0.10.102), judged with the same rules as the message-level link signals: only in mail from outside the organisation, text/target mismatch not in authenticated bulk mail (click trackers). properties: href: type: string description: The link target as written in the original HTML. shown: type: string description: The visible link text. host: type: - string - 'null' description: 'Host of an http(s) link; null for mailto:, tel: and others.' verdict: type: string enum: - ok - text_mismatch - ip_address description: '`text_mismatch` = the text names another domain than the target; `ip_address` = the target is a raw IP.' FrequentRecipient: type: object required: - address - name - count - lastSentAt properties: address: type: string description: Lower-cased address. name: type: string description: Display name from the latest message that had one ("" when none). count: type: integer description: Sent messages (of the latest 500) that had this recipient. lastSentAt: type: string format: date-time SenderRule: type: object required: - sender - kind - source - createdAt properties: sender: type: string description: Lower-case address (`an@acme.vn`) or domain with leading @ (`@acme.vn`). kind: type: string enum: - allow - block description: allow = never file into Junk; block = always file into Junk. An exact address wins over a domain; at the same level allow wins. source: type: string enum: - manual - report description: manual = added in settings; report = added by a spam / not-spam report with rememberSender. createdAt: type: string format: date-time SenderRuleList: type: object required: - rules - applied - applyError properties: rules: type: array description: Ordered by kind (allow first), then sender. At most 1000 per mailbox. items: $ref: '#/components/schemas/SenderRule' applied: type: boolean description: Rules are enforced at delivery time; currently always true. applyError: type: - string - 'null' description: Currently always null. SenderRuleCreateRequest: type: object required: - sender - kind properties: sender: type: string maxLength: 320 description: | An address (`an@acme.vn`, `mailto:` prefix allowed) or a domain (`acme.vn` or `@acme.vn`). Normalised to lower case; domains are stored with a leading @. Invalid → 400 validation_error with an issue whose `params.code` is `invalid_sender`. kind: type: string enum: - allow - block SenderRuleCreated: type: object required: - sender - kind description: The normalised rule as stored (upsert — an existing rule for the sender is switched to this kind). properties: sender: type: string kind: type: string enum: - allow - block UnsubscribeRequest: type: object required: - uid properties: folder: $ref: '#/components/schemas/MailFolder' uid: type: integer minimum: 1 description: UID of the message whose List-Unsubscribe header is used. `folder` defaults to inbox. UnsubscribeResult: type: object required: - method - status - target - url properties: method: type: string enum: - one_click - mailto - link status: type: string enum: - done - sent - opened description: | one_click → `done` (the server POSTed RFC 8058 one-click to the list's https URL); mailto → `sent` (an unsubscribe email was sent from this mailbox); link → `opened` (nothing was done server-side: the client must open `url` in a browser after the user confirmed). target: type: string description: Host of the URL (one_click / link) or the mailto address. url: type: - string - 'null' description: The web page to open — only for method `link`, else null. example: method: one_click status: done target: list.example.com url: null UnsubscribeRecord: type: object required: - sender - listId - method - target - status - updatedAt properties: sender: type: string description: From address of the message the user unsubscribed from. listId: type: - string - 'null' description: List-Id header, when present. method: type: string enum: - one_click - mailto - link target: type: string description: Host or mailto address (max 500 chars). status: type: string enum: - done - sent - opened - failed updatedAt: type: string format: date-time UnsubscribeSafetySignal: type: object required: - code - severity properties: code: type: string description: | Stable code to localise. danger: spoofed_own_domain, auth_dmarc_fail, display_name_impersonation, homograph_domain, lookalike_domain (also warning for brands / near matches), dangerous_attachment, multiple_warnings (3+ distinct warnings). warning: auth_spf_fail, auth_dkim_fail, auth_unverified, gateway_spam, display_name_address_mismatch, mixed_script_domain, reply_to_mismatch, link_text_mismatch, link_ip_address, macro_attachment, disk_image_attachment, html_attachment, encrypted_archive, ai_suspicious. info: first_time_sender, external_sender. severity: type: string enum: - danger - warning - info details: type: object description: | Parameters for the message text (strings or numbers), e.g. {domain}, {name, address}, {shown, address}, {domain, target, kind, distance?}, {replyTo, address}, {shown, actual, count}, {host}, {filename}, {count}, {address}. Absent for gateway_spam and ai_suspicious. additionalProperties: type: - string - number ReaderExtras: type: object description: | Fields that ReaderService.decorate merges into the `data` object of GET /v1/mail/messages/{uid} (next to `mailbox`, `folder`, `message`, `details`). For messages in sent/drafts all three are null. The three keys are ABSENT entirely when the reader service is not configured on the server or computing them failed (logged, the message is still returned) — treat missing like null. properties: safety: description: Phishing/safety verdict for the banner; null for sent/drafts or when the message has no safety facts. oneOf: - type: object required: - level - external - firstTime - signals properties: level: type: string enum: - danger - warning - info - none description: Highest signal severity (3 or more distinct warnings escalate to danger; external or first-time sender alone = info). external: type: boolean description: Sender domain is not one of the organisation's domains. firstTime: type: boolean description: External sender never seen before, never written to, and not in contacts. signals: type: array description: Sorted by severity (danger first). items: $ref: '#/components/schemas/UnsubscribeSafetySignal' - type: 'null' unsubscribe: description: | Unsubscribe option from List-Unsubscribe (RFC 2369/8058); null when there is none or when `safety.level` is danger (never offered for likely phishing). Perform it with POST /v1/mail/messages/unsubscribe. oneOf: - type: object required: - method - target - url - previous properties: method: type: string enum: - one_click - mailto - link description: one_click requires an https URL, List-Unsubscribe-Post and DKIM/DMARC pass; else mailto; else a web link. target: type: string description: Host (one_click / link) or mailto address — show it in the confirmation dialog. url: type: - string - 'null' description: Web page URL for method `link`, else null. previous: description: Last unsubscribe recorded for this sender, null when none. oneOf: - type: object required: - status - at properties: status: type: string enum: - done - sent - opened - failed at: type: string format: date-time - type: 'null' - type: 'null' smartReplies: description: Cached Smart Reply suggestions (generated earlier via POST /v1/mail/ai/smart-replies, kept 7 days); null when none cached. Never generated by this read. oneOf: - type: object required: - suggestions - language properties: suggestions: type: array maxItems: 3 items: type: string maxLength: 300 language: type: string description: Language of the suggestions (`vi` or `en`). - type: 'null' CalendarAttendee: type: object description: One ATTENDEE line of the event (iCalendar), `mailto:` stripped. required: - email - name - status properties: email: type: string description: Attendee address as written in the event (case not normalised). example: lan@partner.vn name: type: string description: The attendee's CN parameter; empty string when absent. status: type: string description: | iCalendar PARTSTAT, uppercase. Usually one of NEEDS-ACTION (default when absent), ACCEPTED, DECLINED, TENTATIVE, DELEGATED; events written by other CalDAV clients may carry any other value, so treat unknown values as NEEDS-ACTION. On events you organise it follows the attendees' answers: an iMIP REPLY received by e-mail updates it when the sender is that attendee and is authenticated (DMARC pass or DKIM/SPF aligned with the From domain, not Junk; on this platform: the signed-in sender), the REPLY names you as organiser and its SEQUENCE is not older than the event's (a delayed older answer never overwrites a newer one). Attendees on this platform who answer through POST /v1/calendar/invitation/respond (or decline by deleting) update it at once. example: NEEDS-ACTION CalendarEvent: type: object description: | One event (or one occurrence of a recurring series) from the user's default calendar. Time conventions: - `start` / `end` are always UTC instants (`toISOString()`, millisecond precision, `Z`). Events written by other clients with a TZID are converted to UTC; display them in the device's (or the user's chosen) zone. - `end` is exclusive. For events without DTEND, `end` equals `start`. - All-day events (`allDay: true`) are stored as iCalendar DATE values. Their `start`/`end` are midnight of the date *in the API server's process time zone* (UTC on standard deployments), with `end` = the day after the last day. To recover the calendar date robustly, round the instant to the nearest UTC midnight and take its UTC date (this is what the Zomail web client does); never shift all-day dates into the device zone. required: - uid - title - start - end - allDay - location - description - organizer - attendees - recurring - reminders - showAs - color properties: uid: type: string description: | The iCalendar UID. All occurrences of a recurring series share the same `uid`. Events created through this API have UIDs of the form `@novamail`; events from other clients or accepted invitations keep the organiser's UID. Every listed `uid` can be passed (percent-encoded) to PATCH/DELETE /v1/calendar/events/{uid}. example: 3f0c6c1e-6a39-4c55-9a8e-0f6a2b1c9d10@novamail title: type: string description: SUMMARY; empty string when the event has no title (show your own localised placeholder). start: type: string format: date-time example: '2026-10-01T02:00:00.000Z' end: type: string format: date-time example: '2026-10-01T03:00:00.000Z' allDay: type: boolean location: type: string description: Empty string when absent. description: type: string description: Empty string when absent. organizer: type: - string - 'null' description: Organiser address (`mailto:` stripped); null when the event has no ORGANIZER (a private event). attendees: type: array items: $ref: '#/components/schemas/CalendarAttendee' recurring: type: boolean description: True when the event is (an occurrence of) a recurring series (RRULE or RECURRENCE-ID). Organiser fields of recurring events cannot be edited through this API (personal fields can). reminders: type: array items: type: integer minimum: 0 description: Personal. Reminders as minutes before the start (DISPLAY/AUDIO VALARMs with a relative trigger), ascending; [] = none. Push reminders (mobile) use them. example: - 10 - 60 showAs: type: string enum: - busy - free description: Personal. TRANSP — `free` (TRANSPARENT) does not block time in free/busy; default busy. color: type: - string - 'null' description: Personal. RFC 7986 COLOR (CSS colour name or `#rrggbb`, lowercase); null = calendar default. CalendarEventUpdated: allOf: - $ref: '#/components/schemas/CalendarEvent' - type: object required: - notified properties: notified: type: - boolean - 'null' description: | null = nobody to notify (no attendees, or the event has no organiser); true = attendees were sent an updated invitation (iMIP REQUEST with a higher SEQUENCE); false = the change was saved but sending the update failed (best effort; the change is NOT rolled back). CalendarEventCreateRequest: type: object required: - title - start - end properties: title: type: string minLength: 1 maxLength: 200 description: Trimmed; must not be blank. start: type: string format: date-time description: | ISO 8601 date-time `YYYY-MM-DDTHH:MM:SS[.fff…]` WITH an offset (`Z` or `±HH:MM`), e.g. `2026-10-02T09:00:00+07:00`. Seconds are required; a value without an offset, without seconds, or with a `+0700` / `+07` style offset is rejected (400 validation_error). The instant is stored as UTC (no TZID is kept), so a client should convert the user's local wall-clock time to an instant itself. For all-day events see `allDay`. end: type: string format: date-time description: Same format as `start`; must be strictly after `start` (400 validation_error with params code end_before_start). allDay: type: boolean default: false description: | When true, `start` and `end` are reduced to their **UTC calendar date** and stored as DATE values. Send UTC midnight of the first day and UTC midnight of the day AFTER the last day, e.g. a single-day event on 1 Oct 2026: `start: 2026-10-01T00:00:00.000Z`, `end: 2026-10-02T00:00:00.000Z`. location: type: string maxLength: 300 default: '' description: type: string maxLength: 5000 default: '' attendees: type: array maxItems: 100 default: [] description: | Email addresses to invite (trimmed, lowercased, max 320 chars each). Your own address and duplicates are dropped. When at least one attendee remains you become the ORGANIZER and each attendee is emailed an iMIP invitation (METHOD:REQUEST), written in the recipient's language when they are on this platform. items: type: string format: email maxLength: 320 reminders: type: array maxItems: 5 items: type: integer minimum: 0 maximum: 40320 description: Personal (never sent to attendees). Reminder minutes before the start (0 = at the start, at most 4 weeks); omitted or [] = none. example: - 10 showAs: type: string enum: - busy - free description: Personal. `free` stores TRANSP:TRANSPARENT; default busy. color: type: - string - 'null' pattern: ^(#[0-9a-fA-F]{6}|[a-zA-Z]{3,20})$ description: Personal. RFC 7986 COLOR — CSS colour name or `#rrggbb`; omitted or null = calendar default. example: title: Weekly sync start: '2026-10-02T09:00:00+07:00' end: '2026-10-02T10:00:00+07:00' location: Room 1 attendees: - lan@partner.vn reminders: - 10 showAs: busy CalendarEventCreated: type: object required: - uid - invited properties: uid: type: string description: UID of the new event (`@novamail`); use it for PATCH/DELETE. example: 3f0c6c1e-6a39-4c55-9a8e-0f6a2b1c9d10@novamail invited: type: integer minimum: 0 description: Number of attendees an invitation was sent to (0 when none). CalendarEventUpdateRequest: type: object additionalProperties: false minProperties: 1 description: | Every field optional, at least one required; unknown fields (e.g. `attendees`, `uid`) are rejected with 400. The attendee list cannot be changed through this API. `title`, `start`, `end`, `allDay`, `location`, `description` are organiser fields (403 `not_organizer` on someone else's invitation); `reminders`, `showAs`, `color` are personal and allowed on every event. properties: title: type: string minLength: 1 maxLength: 200 start: type: string format: date-time description: Same format as in create (offset required). When only one of start/end is sent the other keeps its stored value; the merged pair must satisfy end > start (else 422 calendar_rule / end_before_start). end: type: string format: date-time allDay: type: boolean description: Same all-day conventions as in create. Toggling it without times re-writes the stored start/end as DATE (or DATE-TIME) values. location: type: string maxLength: 300 description: Empty string removes the location. description: type: string maxLength: 5000 description: Empty string removes the description. reminders: type: array maxItems: 5 items: type: integer minimum: 0 maximum: 40320 description: Personal. Replaces every reminder — minutes before the start (0 = at the start, at most 4 weeks); duplicates merged; [] removes all. showAs: type: string enum: - busy - free description: Personal. Busy/free (TRANSP). color: type: - string - 'null' pattern: ^(#[0-9a-fA-F]{6}|[a-zA-Z]{3,20})$ description: Personal. `#rrggbb` or a CSS colour name; null removes it. examples: - start: '2026-10-02T11:00:00+07:00' end: '2026-10-02T12:30:00+07:00' - reminders: - 10 - 60 showAs: free color: '#1a73e8' CalendarInvitation: type: object description: | The text/calendar part (or `.ics` attachment) of a received message, parsed. Only the first VEVENT is read (attachments larger than ~1 MB are truncated). required: - method - uid - title - start - end - location - organizer - myStatus properties: method: type: string description: iTIP METHOD, uppercase (PUBLISH when absent). Only `REQUEST` can be answered with /v1/calendar/invitation/respond; `CANCEL` means the organiser cancelled; `REPLY` is someone's answer to your invitation. example: REQUEST uid: type: string description: The organiser's event UID. title: type: string description: Empty string when untitled. start: type: string format: date-time description: UTC instant (same conventions as CalendarEvent.start, including all-day DATE values). end: type: string format: date-time location: type: string organizer: type: string description: Organiser address (`mailto:` stripped); empty string when absent. myStatus: type: - string - 'null' description: | Your PARTSTAT (NEEDS-ACTION, ACCEPTED, DECLINED, TENTATIVE…), taken from your saved answer when you already responded, else from the invitation. null when your address is not among the attendees. CalendarInvitationRespondRequest: type: object required: - uid - response properties: folder: $ref: '#/components/schemas/MailFolder' uid: type: integer minimum: 1 description: IMAP UID of the message carrying the invitation (in `folder`, default inbox). response: type: string enum: - accepted - declined - tentative example: folder: inbox uid: 4211 response: accepted CalendarInvitationRespondResult: type: object required: - status properties: status: type: string enum: - ACCEPTED - DECLINED - TENTATIVE description: The PARTSTAT now stored in your calendar copy and sent to the organiser. Contact: type: object description: One vCard from the user's default address book (also visible to CardDAV clients). required: - uid - name - emails - phones - organization - note properties: uid: type: string description: vCard UID (a UUID for contacts created here). example: 0b8c1f61-2d7e-4c5a-9d43-2f5f8e3f7a10 name: type: string description: FN (formatted name). emails: type: array items: type: string description: EMAIL values (`mailto:` stripped), empty values dropped. phones: type: array items: type: string description: TEL values (`tel:` stripped). organization: type: string description: ORG; empty string when absent. note: type: string description: NOTE; empty string when absent. etag: type: string description: The stored vCard's ETag (when the address book server reports one) — send it as `If-Match` to update only if unchanged. ContactCreateRequest: type: object required: - name properties: name: type: string minLength: 1 maxLength: 200 description: Trimmed; must not be blank. emails: type: array maxItems: 10 default: [] items: type: string format: email maxLength: 320 description: Trimmed and lowercased. phones: type: array maxItems: 10 default: [] items: type: string maxLength: 40 pattern: ^[+\d\s().-]+$ description: Trimmed; only digits, spaces, `+ ( ) . -`. organization: type: string maxLength: 200 default: '' note: type: string maxLength: 2000 default: '' example: name: Nguyễn Lan emails: - lan@partner.vn phones: - +84 90 123 4567 organization: Partner Co. ContactPatchRequest: type: object additionalProperties: false minProperties: 1 description: Fields of ContactCreateRequest, every one optional (at least one); unknown fields are rejected (400). properties: name: type: string minLength: 1 maxLength: 200 emails: type: array maxItems: 10 items: type: string format: email maxLength: 320 phones: type: array maxItems: 10 items: type: string maxLength: 40 pattern: ^[+\d\s().-]+$ organization: type: string maxLength: 200 description: Empty string removes it. note: type: string maxLength: 2000 description: Empty string removes it. ContactCreated: type: object required: - uid properties: uid: type: string description: UID of the new contact (UUID). Task: type: object description: | One task (iCalendar VTODO in the user's CalDAV `tasks/` collection, shared with iOS Reminders, Thunderbird, DAVx5 + Tasks.org etc.). Properties set by other clients (alarms, priority, categories…) are preserved by PATCH but not exposed here. required: - id - uid - title - notes - due - done - completedAt - url - messageId - createdAt - updatedAt properties: id: type: string description: | Resource name inside the collection (the `.ics` file name without extension) — the handle for PATCH/DELETE. A UUID for tasks created here; tasks created by other clients may use any name of 1–255 characters except `/`, `\` and control characters (percent-encode it in the path). example: 7d7a3c3e-55f0-4c1f-9a2f-1b3c4d5e6f70 uid: type: string description: iCalendar UID (equals `id` for tasks created here). title: type: string description: SUMMARY; may be empty for tasks created by other clients. notes: type: string description: DESCRIPTION; empty string when absent. due: type: - string - 'null' format: date description: Due day (YYYY-MM-DD), no time and no time zone. Date-time DUEs written by other clients are reduced to their own wall-clock calendar day. example: '2026-10-05' done: type: boolean description: True when STATUS is COMPLETED or a COMPLETED timestamp exists. completedAt: type: - string - 'null' format: date-time url: type: - string - 'null' description: Link back to the source, e.g. the webmail URL of the message a task was made from (`/mail/[?folder=…]`). messageId: type: - string - 'null' description: Message-ID of the source email when the task was made from one (used to avoid duplicates). createdAt: type: - string - 'null' format: date-time updatedAt: type: - string - 'null' format: date-time description: LAST-MODIFIED, else DTSTAMP. TaskCreateRequest: type: object required: - title properties: title: type: string minLength: 1 maxLength: 300 description: Trimmed; must not be blank. notes: type: string maxLength: 10000 default: '' due: type: - string - 'null' format: date default: null description: YYYY-MM-DD, must be a real calendar date (400 with params code invalid_date otherwise). example: title: Send the quote due: '2026-10-05' TaskUpdateRequest: type: object minProperties: 1 description: Partial update; at least one known field (unknown fields are ignored, so a body with only unknown fields is a 400 with params code empty_update). properties: title: type: string minLength: 1 maxLength: 300 notes: type: string maxLength: 10000 description: Empty string removes the notes. due: type: - string - 'null' format: date description: YYYY-MM-DD, or null to clear the due day. done: type: boolean description: true marks it completed (STATUS COMPLETED, PERCENT-COMPLETE 100, COMPLETED timestamp kept if already set); false reopens it. example: done: true TaskFromMessageRequest: type: object required: - uid properties: folder: $ref: '#/components/schemas/MailFolder' uid: type: integer minimum: 1 description: IMAP UID of the message in `folder` (default inbox). due: type: - string - 'null' format: date default: null example: folder: inbox uid: 4211 due: '2026-10-05' TaskFromMessageResult: type: object required: - data - existing description: Note that `existing` sits next to `data`, not inside it. properties: data: $ref: '#/components/schemas/Task' existing: type: boolean description: true when an open task for the same message already existed (returned unchanged, HTTP 200); false when a new task was created (HTTP 201). MeetMeeting: type: object required: - id - code - tenantId - title - allowGuests - guestLobby - createdBy - createdAt properties: id: type: string format: uuid code: type: string pattern: ^[a-z]{3}-[a-z]{4}-[a-z]{3}$ description: Public meeting code (letters a–z without l and o); also the LiveKit room name and the last part of the web join link `/meet/`. example: kqz-mbtr-wpx tenantId: type: string format: uuid description: The organisation that owns the meeting. title: type: string maxLength: 200 allowGuests: type: boolean description: Whether people outside the organisation (anonymous guests and users of other organisations) may join. guestLobby: type: boolean description: Whether outsiders wait in a lobby until a member of the organisation admits them. createdBy: type: - string - 'null' format: uuid description: Mailbox id of the creator (null if that mailbox was deleted). createdAt: type: string format: date-time MeetCreateRequest: type: object properties: title: type: string maxLength: 200 default: '' description: Trimmed; empty means "use the creator's display name". allowGuests: type: boolean default: true guestLobby: type: boolean default: true example: title: Weekly sync allowGuests: true guestLobby: true MeetMeetingRef: type: object required: - code - title properties: code: type: string example: kqz-mbtr-wpx title: type: string MeetJoinReady: type: object required: - status - serverUrl - token - meeting properties: status: type: string const: ready serverUrl: type: string description: LiveKit signalling URL (wss://…) to pass to the LiveKit client SDK's `connect`. example: wss://meet.example.com token: type: string description: | LiveKit access token (JWT, HS256). Grants: room = meeting code, roomJoin, canPublish, canSubscribe, canPublishData, and roomAdmin for the meeting's creator. Identity = your mailbox id, name = your display name. Participant metadata is JSON `{"guest": bool, "host": bool, "member": bool}` (label people in your own UI language from these flags). Valid for 6 hours by default (server setting). meeting: $ref: '#/components/schemas/MeetMeetingRef' MeetJoinWaiting: type: object required: - status - requestId - secret - meeting properties: status: type: string const: waiting requestId: type: string format: uuid description: Your entry in the meeting's lobby. secret: type: string description: Opaque secret proving you own the lobby entry (only its hash is stored). Keep it in memory; it is needed to learn the decision. meeting: $ref: '#/components/schemas/MeetMeetingRef' MeetJoinResult: oneOf: - $ref: '#/components/schemas/MeetJoinReady' - $ref: '#/components/schemas/MeetJoinWaiting' discriminator: propertyName: status mapping: ready: '#/components/schemas/MeetJoinReady' waiting: '#/components/schemas/MeetJoinWaiting' MeetLobbyStatus: oneOf: - $ref: '#/components/schemas/MeetJoinReady' - $ref: '#/components/schemas/MeetLobbyPending' MeetLobbyPending: type: object required: - status properties: status: type: string enum: - waiting - denied description: '`waiting` — keep polling; `denied` — turned away, stop.' MeetLobbyEntry: type: object required: - id - name - address - since properties: id: type: string format: uuid description: The join request id (use as `requestId` to admit or deny). name: type: string maxLength: 60 description: Name the person gave (guests) or their display name (users of other organisations). address: type: - string - 'null' description: Email address when the person is signed in to another organisation; null for anonymous guests. since: type: string format: date-time description: When they asked to join. MeetLobbyDecisionRequest: type: object required: - admit properties: admit: type: boolean description: true lets the person in, false turns them away. example: admit: true MeetLobbyDecisionResult: type: object required: - admitted properties: admitted: type: boolean description: Echo of `admit`. DriveRole: type: string description: Role granted by a share. `viewer` can list/preview/download; `editor` can also upload new versions, rename, create sub-folders and upload into a shared folder. enum: - viewer - editor DriveAccessLevel: type: string description: The caller's effective access to an item. `owner` = the item belongs to the caller's own tree; otherwise the strongest share role on the item or any ancestor folder. enum: - owner - viewer - editor DriveScanStatus: type: string description: Virus scan state of a file version. `pending` = not scanned yet; `skipped` = the server has no scanner (or the file was not scanned). Infected versions cannot be downloaded, previewed or opened in Office (403 `infected`). enum: - pending - clean - infected - skipped - error DriveCrumb: type: object description: One breadcrumb entry (folder). required: - id - name properties: id: type: string format: uuid name: type: string DriveItem: type: object description: A Drive file or folder. Sizes are bytes of the current version (0 for folders). required: - id - tenantId - ownerId - ownerAddress - parentId - kind - name - mimeType - sizeBytes - currentVersion - createdAt - updatedAt - trashedAt - shared - scanStatus properties: id: type: string format: uuid tenantId: type: string format: uuid description: Organisation (tenant) the item belongs to. ownerId: type: string format: uuid description: Mailbox id of the owner. ownerAddress: type: string format: email description: Primary address of the owner mailbox. parentId: type: - string - 'null' format: uuid description: Parent folder id; `null` = the owner's Drive root ("My Drive"). kind: type: string enum: - folder - file name: type: string minLength: 1 maxLength: 255 description: NFC-normalised name; no `/`, `\` or control characters, never `.` or `..`. Names are unique per folder (case-insensitive) among non-trashed items. mimeType: type: - string - 'null' description: MIME type of the current version (`null` for folders). Derived from `x-file-type` / `contentType`, falling back to the file extension, else `application/octet-stream`. sizeBytes: type: integer format: int64 minimum: 0 currentVersion: type: integer minimum: 0 description: Number of the current version (files start at 1; folders stay 0). createdAt: type: string format: date-time updatedAt: type: string format: date-time trashedAt: type: - string - 'null' format: date-time description: When the item was moved to the trash; `null` when not trashed. shared: type: boolean description: True when the item has at least one live (non-revoked) share — user share or public link. scanStatus: description: Scan status of the current version; `null` for folders (and files without a version row). oneOf: - $ref: '#/components/schemas/DriveScanStatus' - type: 'null' example: id: 6f1c2a9e-3b4d-4c5e-8f60-718293a4b5c6 tenantId: 0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d ownerId: 9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d ownerAddress: an@example.vn parentId: null kind: file name: Bao cao Q3.pdf mimeType: application/pdf sizeBytes: 482113 currentVersion: 2 createdAt: '2026-09-30T08:12:45.120Z' updatedAt: '2026-10-01T02:03:04.000Z' trashedAt: null shared: false scanStatus: clean DriveSharedItem: description: An item another member of the organisation shared directly with the caller, plus the role granted. allOf: - $ref: '#/components/schemas/DriveItem' - type: object required: - role properties: role: $ref: '#/components/schemas/DriveRole' DriveVersion: type: object description: One stored version of a file (newest first in lists). At most `maxVersions` (default 25) are kept; older ones are pruned automatically. required: - version - sizeBytes - mimeType - sha256 - createdAt - createdBy - scanStatus - scanResult properties: version: type: integer minimum: 1 sizeBytes: type: integer format: int64 minimum: 0 mimeType: type: string sha256: type: - string - 'null' description: Hex SHA-256 of the bytes; `null` for versions created by a multipart (direct) upload. createdAt: type: string format: date-time createdBy: type: - string - 'null' format: email description: Primary address of the mailbox that created the version; `null` if that mailbox no longer exists. scanStatus: $ref: '#/components/schemas/DriveScanStatus' scanResult: type: - string - 'null' description: Scanner verdict detail (e.g. signature name) when available. DriveShare: type: object description: A live share of an item. `kind=user` is a direct share with a colleague; `kind=link` is an anonymous public link (its token is never shown again after creation). required: - id - kind - role - grantee - expiresAt - createdAt properties: id: type: string format: uuid description: Share id — pass to `DELETE /v1/drive/items/{itemId}/shares/{shareId}` to revoke (works for links too). kind: type: string enum: - user - link role: $ref: '#/components/schemas/DriveRole' grantee: description: The colleague (for `kind=user`); `null` for links. oneOf: - type: object required: - id - address - displayName properties: id: type: string format: uuid address: type: string format: email displayName: type: string - type: 'null' expiresAt: type: - string - 'null' format: date-time createdAt: type: string format: date-time DriveFolderListing: type: object description: Contents of the root or of one folder (non-trashed children only, folders first then by name, case-insensitive). required: - folder - level - path - items properties: folder: description: The listed folder; `null` for the root ("My Drive"). oneOf: - $ref: '#/components/schemas/DriveItem' - type: 'null' level: $ref: '#/components/schemas/DriveAccessLevel' path: type: array description: Breadcrumbs from the outermost visible folder down to the listed folder's parent (excludes the folder itself). For shared folders it starts at the outermost folder shared with the caller. Empty for the root. items: $ref: '#/components/schemas/DriveCrumb' items: type: array maxItems: 1000 description: At most 1000 children (no pagination). items: $ref: '#/components/schemas/DriveItem' DriveItemDetails: type: object required: - item - level - path - versions - shares properties: item: $ref: '#/components/schemas/DriveItem' level: $ref: '#/components/schemas/DriveAccessLevel' path: type: array description: Breadcrumbs (ancestors visible to the caller, outermost first; excludes the item). items: $ref: '#/components/schemas/DriveCrumb' versions: type: array description: File versions, newest first. Always empty for folders. items: $ref: '#/components/schemas/DriveVersion' shares: type: array description: Live shares and links. Only filled for the owner (`level=owner`); empty for everyone else. items: $ref: '#/components/schemas/DriveShare' DriveUsage: type: object description: Storage usage and limits. The quota is per organisation (tenant), shared by all its mailboxes. required: - quotaBytes - usedBytes - externalSharing - suspended - maxFileBytes - maxVersions - trashDays - directUpload properties: quotaBytes: type: integer format: int64 description: Organisation Drive quota in bytes. usedBytes: type: integer format: int64 description: Bytes used by the whole organisation (all versions count). externalSharing: type: boolean description: Whether public (anonymous) links may be created. When false, `POST .../links` returns 403 `external_sharing_disabled`. suspended: type: boolean description: The organisation is suspended by billing — Drive is read-only (list/preview/download work; writes return 423 `tenant_suspended`). maxFileBytes: type: integer format: int64 description: Maximum size of one file (server default 5 GiB = 5368709120; configurable per server). maxVersions: type: integer description: Versions kept per file (default 25). trashDays: type: integer description: Days trashed items are kept before automatic permanent deletion (default 30). directUpload: type: boolean description: True when multipart direct-to-storage uploads (`POST /v1/drive/uploads`) are available (S3-compatible storage). When false, use `PUT /v1/drive/upload` for every file. example: quotaBytes: 107374182400 usedBytes: 2147483648 externalSharing: true suspended: false maxFileBytes: 5368709120 maxVersions: 25 trashDays: 30 directUpload: true DriveCreateFolderRequest: type: object required: - name properties: name: type: string minLength: 1 maxLength: 255 description: Folder name (trimmed, NFC). No `/`, `\`, control characters, `.` or `..` — else 400 `invalid_name`. parentId: type: - string - 'null' format: uuid description: Parent folder (must be a folder the caller owns or can edit). Omit / `null` / empty = the caller's root. DriveRenameRequest: type: object required: - name properties: name: type: string minLength: 1 maxLength: 255 description: New name (same rules as folder names; renaming a file does not change its stored MIME type). DriveMoveRequest: type: object required: - parentId properties: parentId: type: - string - 'null' format: uuid description: Target folder (one of the caller's own folders) or `null` for the root. The key is required. DriveShareRequest: type: object required: - email properties: email: type: string format: email description: Address of an active mailbox in the SAME organisation (trimmed, case-insensitive). Outside addresses or the caller themself give 422 `invalid_grantee` — use a public link instead. role: $ref: '#/components/schemas/DriveRole' notify: type: boolean default: true description: Send the grantee a notification email (in the grantee's language). DriveShareCreated: type: object required: - id properties: id: type: string format: uuid description: Share id. Sharing again with the same colleague updates the role of the existing share and returns its id. DriveLinkRequest: type: object properties: expiresInDays: type: - integer - 'null' minimum: 1 maximum: 365 default: 30 description: Link lifetime in days; `null` = never expires. The server may cap it (DRIVE_LINK_MAX_DAYS), which also turns `null` into that cap. DriveLink: type: object required: - id - token - expiresAt properties: id: type: string format: uuid description: Share id of the link (revoke with `DELETE /v1/drive/items/{itemId}/shares/{id}`). token: type: string description: Raw link token (24 random bytes = 32 base64url characters). Returned ONLY here — the server stores just its SHA-256. The shareable page is `https:///s/{token}` (the web app's public link page). expiresAt: type: - string - 'null' format: date-time DriveDirectUploadStartRequest: type: object required: - size description: Either `name` (+ optional `parentId`) for a new file, or `itemId` to add a new version to an existing file. properties: name: type: string description: File name for a new file (ignored when `itemId` is given). Same rules as item names. If a non-trashed file with the same name (case-insensitive) exists in the target folder, completion adds a new version to it. parentId: type: - string - 'null' format: uuid description: Target folder; omit / `null` = root. itemId: type: string format: uuid description: Existing file to receive a new version (needs editor access). size: type: integer format: int64 minimum: 1 description: Exact total size in bytes. Completion fails with 422 `upload_incomplete` if the assembled object differs. contentType: type: string maxLength: 200 description: MIME type of the file (falls back to the extension). DriveDirectUploadStart: type: object required: - uploadId - partSize - parts properties: uploadId: type: string format: uuid description: Upload session id, valid for 24 hours. partSize: type: integer description: Bytes per part (16 MiB = 16777216 by default; larger for files over ~156 GiB so that there are at most 10,000 parts). Every part except the last must be exactly this size. parts: type: integer minimum: 1 maximum: 10000 description: Number of parts = ceil(size / partSize). Parts are numbered 1..parts. example: uploadId: 2d4e6f80-1a3b-4c5d-8e9f-0a1b2c3d4e5f partSize: 16777216 parts: 7 DrivePresignPartsRequest: type: object required: - partNumbers properties: partNumbers: type: array minItems: 1 maxItems: 100 description: Part numbers to sign (1-based, each ≤ `parts` of the upload, else 422 `upload_incomplete`). items: type: integer minimum: 1 maximum: 10000 DrivePresignedParts: type: object required: - urls properties: urls: type: array items: type: object required: - partNumber - url properties: partNumber: type: integer url: type: string format: uri description: Presigned object-storage URL, valid for 1 hour. `PUT` the raw part bytes to it with NO Authorization header; read the `ETag` response header. DriveCompleteUploadRequest: type: object required: - parts properties: parts: type: array minItems: 1 maxItems: 10000 description: One entry for EVERY part 1..parts (any order; the server sorts). Missing / extra parts give 422 `upload_incomplete`. items: type: object required: - partNumber - etag properties: partNumber: type: integer minimum: 1 etag: type: string minLength: 1 maxLength: 200 description: The `ETag` header value returned by storage for that part, verbatim (including the surrounding double quotes). DriveOfficeCapabilities: type: object required: - enabled - edit - view properties: enabled: type: boolean description: Whether an Office server (Collabora / ONLYOFFICE over WOPI) is configured. edit: type: array description: Lower-case file extensions that can be edited (subset of docx, xlsx, pptx, odt, ods, odp, doc, xls, ppt, rtf, csv, txt the Office server supports). items: type: string view: type: array description: Lower-case file extensions the Office server can display read-only. items: type: string example: enabled: true edit: - docx - xlsx - pptx - odt view: - docx - xlsx - pptx - pdf - odt DriveOfficeSession: type: object description: |- WOPI editor session. To open it, load `url` in a web view by an HTML form POST (target = the web view / iframe) with form fields `access_token` = `accessToken` and `access_token_ttl` = `accessTokenTtl`. The editor then talks to the server's /wopi endpoints itself. `origin` is the Office server origin (use it to accept its postMessage events). required: - url - accessToken - accessTokenTtl - mode - origin properties: url: type: string format: uri description: Editor URL (already contains `WOPISrc` and `lang`). accessToken: type: string description: Opaque WOPI access token for this user + file. accessTokenTtl: type: integer format: int64 description: Token expiry as Unix time in MILLISECONDS (WOPI convention; default ~10 hours from now). mode: type: string enum: - edit - view description: '`edit` when the caller may edit (not a viewer) and the extension is editable; otherwise `view`.' origin: type: string format: uri description: Origin of the Office server (scheme://host[:port]). DriveOfficeNewRequest: type: object required: - kind properties: kind: type: string enum: - docx - xlsx - pptx name: type: string description: Base name; the `.{kind}` extension is appended when missing. Empty / missing = "Untitled". parentId: type: - string - 'null' format: uuid description: Target folder; omit / `null` = root. DriveSaveToDriveRequest: type: object properties: parentId: type: - string - 'null' format: uuid description: Target Drive folder; omit / `null` (or an empty body `{}`) = the Drive root. QrScanRequest: type: object required: - id - challenge properties: id: type: string format: uuid description: The `` of the QR URL. challenge: type: string pattern: ^[A-Za-z0-9_-]{43}$ description: The fragment of the QR URL (256 bits, base64url). QrScanInfo: type: object required: - id - status - account - requestedAt - expiresAt - browser - os - userAgent - ip - location - sameNetwork - webHost properties: id: type: string format: uuid status: type: string enum: - scanned account: type: string format: email description: The mailbox that will be signed in (the scanning account). requestedAt: type: string format: date-time description: When the browser created the code. expiresAt: type: string format: date-time description: Decide before this (scan time + 120 s). browser: type: object required: - name - version properties: name: type: string examples: - Chrome - Edge - Firefox - Safari - Opera - Samsung Internet - Cốc Cốc - Brave - Unknown version: type: string description: Major version, may be empty. os: type: object required: - name - version properties: name: type: string examples: - Windows - macOS - iOS - iPadOS - Android - ChromeOS - Linux - Unknown version: type: string userAgent: type: string maxLength: 512 ip: type: - string - 'null' description: The browser's IP as seen by the server. location: description: Approximate location when the server has a GeoIP database; otherwise null (show the IP only). oneOf: - type: 'null' - type: object required: - country - countryCode - city properties: country: type: string countryCode: type: string city: type: - string - 'null' sameNetwork: type: boolean description: The phone and the browser share a public IP (a hint; warn when false). webHost: type: string description: The host the QR came from. DeviceInfo: type: object required: - id - platform properties: id: type: string pattern: ^[A-Za-z0-9-]{8,64}$ description: Installation id (UUID generated once per app install; the same for every account). platform: type: string enum: - ios - android model: type: string maxLength: 80 examples: - iPhone15,2 - SM-S918B osVersion: type: string maxLength: 40 appVersion: type: string maxLength: 40 name: type: string maxLength: 120 description: The device name the user knows (shown in the device list). MobileTokens: type: object required: - accessToken - accessTokenExpiresAt - refreshToken - refreshTokenExpiresAt - deviceSessionId - mailbox properties: accessToken: type: string accessTokenExpiresAt: type: string format: date-time refreshToken: type: string description: '`zmr.`-prefixed, single use.' refreshTokenExpiresAt: type: string format: date-time description: Slides with every refresh. deviceSessionId: type: string format: uuid mailbox: $ref: '#/components/schemas/Mailbox' MobileMfaChallenge: type: object required: - mfaRequired - challenge properties: mfaRequired: type: boolean const: true challenge: type: string PushCategory: type: string enum: - newMail - calendar - meet - security PushRegistration: type: object required: - provider - token properties: provider: type: string enum: - apns - fcm token: type: string description: APNs device token (hex) or FCM registration token. environment: type: string enum: - sandbox - production default: production description: 'APNs: `sandbox` for development builds.' locale: type: string examples: - vi - en-US description: Language of the notification text (vi, en; default vi). enabledCategories: type: array items: $ref: '#/components/schemas/PushCategory' default: - newMail - calendar - meet - security preview: type: string enum: - full - minimal default: full description: '`minimal`: no sender/subject/event details.' calendarReminderMinutes: type: - integer - 'null' minimum: 0 maximum: 1440 default: 10 description: Reminder for timed events without their own alarm; null = only the events' alarms. Device: type: object required: - id - deviceId - platform - createdAt - lastUsedAt - expiresAt - current - push properties: id: type: string format: uuid description: Device session id. deviceId: type: string description: Installation id. platform: type: string enum: - ios - android model: type: string osVersion: type: string appVersion: type: string name: type: string createdAt: type: string format: date-time lastUsedAt: type: string format: date-time lastIp: type: - string - 'null' expiresAt: type: string format: date-time current: type: boolean push: type: object properties: enabled: type: boolean provider: type: - string - 'null' enum: - apns - fcm - null environment: type: - string - 'null' categories: type: array items: $ref: '#/components/schemas/PushCategory' preview: type: string enum: - full - minimal registeredAt: type: - string - 'null' format: date-time ServerConfiguration: type: object required: - version - product - serverName - deployment - apiBaseUrl - webUrl - features - push properties: version: type: integer const: 1 product: type: string const: zomail serverName: type: string examples: - Zomail Cloud - mail.company.vn deployment: type: string enum: - cloud - private apiBaseUrl: type: string format: uri description: Base URL of the API for every account on this server. webUrl: type: string format: uri features: type: object description: What this server offers (an organisation may still have a feature off, e.g. AI or Meet by plan). properties: ai: type: boolean meet: type: boolean drive: type: boolean calendar: type: boolean contacts: type: boolean tasks: type: boolean office: type: boolean delegation: type: boolean push: type: object description: Whether this server can reach APNs / FCM (own keys or the Zomail relay). properties: apns: type: boolean fcm: type: boolean mail: type: object description: IMAP/SMTP hosts for a fallback or for other mail clients. properties: imap: type: object properties: host: type: string port: type: integer security: type: string enum: - tls smtp: type: object properties: host: type: string port: type: integer security: type: string enum: - starttls dav: type: - object - 'null' properties: caldav: type: string format: uri carddav: type: string format: uri auth: type: object properties: login: type: string mfa: type: string refresh: type: string logout: type: string passwordReset: type: - string - 'null' format: uri description: Web page for a forgotten password. sso: type: object description: Single sign-on endpoints and the exact app redirect URIs this server accepts. properties: start: type: string examples: - /v1/auth/mobile/sso/start complete: type: string examples: - /v1/auth/mobile/sso/complete redirectUris: type: array items: type: string examples: - - zomail://sso - https://mail.example.com/sso/mobile-callback mobileConfig: type: string examples: - /v1/public/mobile/config MobileConfig: type: object properties: minVersion: type: object properties: ios: type: string android: type: string description: Older apps must update (block the app with a store link). latestVersion: type: object properties: ios: type: string android: type: string storeUrls: type: object properties: ios: type: string android: type: string links: type: object properties: privacy: type: string terms: type: string support: type: string message: type: object properties: vi: type: string en: type: string description: Optional banner text set by the administrator. features: type: object additionalProperties: type: boolean description: Server features merged with the administrator's flags. push: type: object properties: apns: type: boolean fcm: type: boolean server: type: object properties: apiBaseUrl: type: string webUrl: type: string serverName: type: string deployment: type: string enum: - cloud - private update: type: - object - 'null' properties: required: type: boolean available: type: boolean MailFlagChange: type: object required: - uid - unread - flagged - pinned - important - keywords properties: uid: type: integer unread: type: boolean flagged: type: boolean pinned: type: boolean important: type: boolean hasAttachments: type: boolean description: Paperclip (0.10.102; see MessageSummary.hasAttachments). keywords: type: array items: type: string description: IMAP keywords (labels are keywords too). MailChanges: type: object required: - folder - cursor - reset - added - updated - removed - hasMore - uidValidity properties: folder: $ref: '#/components/schemas/MailFolder' cursor: type: string reset: type: boolean added: type: array items: allOf: - $ref: '#/components/schemas/MessageSummary' - type: object required: - keywords properties: keywords: type: array items: type: string description: 'IMAP keywords (0.10.102): labels, category tab `$cat_*`, `$snoozed`…' updated: type: array items: $ref: '#/components/schemas/MailFlagChange' removed: type: array items: type: integer present: type: string description: 'Only without QRESYNC: every UID of the folder as an IMAP sequence set.' hasMore: type: boolean uidValidity: type: string DavChanges: type: object required: - cursor - reset - changed - removed properties: cursor: type: - string - 'null' reset: type: boolean changed: type: array items: type: object additionalProperties: true description: | Calendar: uid, title, start, end, allDay, location, description, organizer, attendees, recurring, rrule, exceptions, reminders, showAs, color, resource, etag. Contacts: the contact + resource + etag. `resource` is the object's name in the collection — equal to `uid` except for accepted invitations and cards created by other clients. Keep it with the item: `removed` lists resources. Address the item in the REST routes (PATCH/DELETE /v1/calendar/events/{uid}, /v1/contacts/{uid}) by its `uid`. removed: type: array items: type: string description: Resources (object names, see `changed[].resource`) of removed items; for events and contacts created through the API these equal their UIDs. Upload: type: object required: - id - filename - contentType - size - offset - complete - expiresAt - chunkSize properties: id: type: string format: uuid filename: type: string contentType: type: string size: type: integer offset: type: integer complete: type: boolean expiresAt: type: string format: date-time chunkSize: type: integer description: Maximum bytes per PATCH. DeletionRequest: type: object required: - id - kind - status - address - createdAt properties: id: type: string format: uuid kind: type: string enum: - admin_review - tenant_deletion status: type: string enum: - open - done - dismissed - canceled address: type: string reason: type: string createdAt: type: string format: date-time resolvedAt: type: - string - 'null' format: date-time resolvedBy: type: - string - 'null' tenantDeletionId: type: - string - 'null' format: uuid mailboxId: type: - string - 'null' format: uuid x-tagGroups: - name: Zomail API (user-facing) tags: - Auth - Account - Account security - Mail settings - Delegation & forwarding - Mail import - Mail - Messages - Drafts - Sending - Compose - Templates - Labels - Organize - Safety & unsubscribe - AI - Calendar - Contacts - Tasks - Drive - Meet - Public - name: Zomail Mobile API tags: - Mobile sign-in - Devices & push - Discovery & app config - Delta sync - Uploads - Account deletion - Meet calls - QR sign-in