From eaf060174234cae9a3ad426404aba56e4324f9fc Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:35:06 +0200 Subject: [PATCH] Include OAuth public endpoint in PACC responses --- api/v1/openapi.yml | 3112 +++-------------- crates/common/src/cache/invalidate.rs | 5 + crates/common/src/config/network.rs | 20 +- crates/common/src/network/dns/records.rs | 18 +- crates/common/src/network/dns/update.rs | 13 +- crates/common/src/storage/quota.rs | 11 +- crates/directory/src/backend/oidc/config.rs | 11 +- crates/directory/src/backend/oidc/lookup.rs | 6 +- crates/directory/src/backend/oidc/mod.rs | 11 +- crates/directory/src/core/dispatch.rs | 4 +- crates/directory/src/lib.rs | 1 + crates/http/src/auth/oauth/auth.rs | 4 +- crates/http/src/request.rs | 10 +- crates/jmap/src/registry/mapping/mod.rs | 1 + crates/jmap/src/registry/mapping/principal.rs | 13 + .../src/registry/mapping/queued_message.rs | 10 + crates/jmap/src/registry/mapping/sieve.rs | 49 + crates/jmap/src/registry/query.rs | 2 +- crates/jmap/src/registry/set.rs | 37 +- .../services/src/task_manager/maintenance.rs | 65 +- crates/store/src/registry/write.rs | 14 + tests/src/directory/oidc.rs | 4 +- 22 files changed, 791 insertions(+), 2630 deletions(-) create mode 100644 crates/jmap/src/registry/mapping/sieve.rs diff --git a/api/v1/openapi.yml b/api/v1/openapi.yml index d33bbc19..7de8b445 100644 --- a/api/v1/openapi.yml +++ b/api/v1/openapi.yml @@ -1,2622 +1,586 @@ -# SPDX-FileCopyrightText: 2025 Stalwart Labs LLC -# -# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - -openapi: 3.0.0 +openapi: 3.0.3 info: - title: Stalwart API - version: 1.0.0 + title: Stalwart Management API + description: | + REST Management API for Stalwart server. These endpoints are helpers + that complement the JMAP API — most of the server's configuration and data + is managed via JMAP (see `POST /jmap/`). The endpoints documented here cover + interactive login, account introspection, configuration schema retrieval and + live (Server-Sent Events) telemetry streams. + version: "1.0" + license: + name: AGPL-3.0-only OR LicenseRef-SEL servers: - - url: https://mail.example.org/api - description: Sample server + - url: https://{host} + description: Stalwart server + variables: + host: + default: mail.example.com + description: The hostname of Stalwart server +security: + - bearerAuth: [] + - basicAuth: [] paths: - /oauth: + /api/auth: post: - summary: Obtain OAuth token - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - code: - type: string - permissions: - type: array - items: - type: string - version: - type: string - isEnterprise: - type: boolean - example: - data: - code: 4YmRFLu9Df1t4JO7Iffnuney4B8tVLAxjimdRxEg - permissions: - - webadmin-update - - spam-filter-update - - dkim-signature-get - - dkim-signature-create - - undelete - - fts-reindex - - purge-account - - purge-in-memory-store - - purge-data-store - - purge-blob-store - version: 0.11.0 - isEnterprise: true - "401": - description: Unauthorized - content: - application/json: - schema: - type: object - properties: - type: - type: string - status: - type: number - title: - type: string - detail: - type: string - example: - type: about:blank - status: 401 - title: Unauthorized - detail: You have to authenticate first. + operationId: login + summary: Authenticate a user and obtain an authorization code + description: | + Anonymous endpoint used by the web UI and device-flow clients to exchange + user credentials (plus optional MFA token and PKCE challenge) for an + OAuth authorization `client_code` that can then be exchanged for an + access token via `POST /auth/token`. Rate-limited as an anonymous request. + security: [] + tags: [Authentication] requestBody: + required: true content: application/json: schema: - type: object - properties: - type: - type: string - client_id: - type: string - redirect_uri: - type: string - nonce: - type: string - example: - type: code - client_id: webadmin - redirect_uri: stalwart://auth - nonce: ttsaXca3qx - /telemetry/metrics: - get: - summary: Fetch Telemetry Metrics + $ref: '#/components/schemas/LoginRequest' + examples: + authCode: + summary: Authorization-code flow (web UI) + value: + type: authCode + accountName: jane@example.com + accountSecret: s3cret + clientId: webadmin + redirectUri: https://mail.example.com/login + codeChallenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + codeChallengeMethod: S256 + authDevice: + summary: Device-flow completion + value: + type: authDevice + accountName: jane@example.com + accountSecret: s3cret + code: BDWP-HQPK responses: - "200": - description: OK + '200': + description: Result of the authentication attempt content: application/json: schema: - type: object - properties: - error: - type: string - details: - type: string - reason: - type: string - example: - error: other - details: No metrics store has been defined - reason: - You need to configure a metrics store in order to use this - feature. - parameters: - - name: after - in: query - required: false - schema: - type: string - /telemetry/live/metrics-token: - get: - summary: Obtain Metrics Telemetry token - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: string - example: - data: 2GO4RahIkSAms6S00R9BRsroo97ZdYTz4QVxFCOwGrGkr7zguP0AVyTMA/iha3Vz/////w8DhZi1+ALBmLX4AndlYg== - /telemetry/metrics/live: - get: - summary: Live Metrics - responses: - "200": - description: OK - content: {} - parameters: - - name: metrics - in: query - required: false - schema: - type: string - - name: interval - in: query - required: false - schema: - type: number - - name: token - in: query - required: false - schema: - type: string - /principal: - get: - summary: List Principals - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - items: - type: array - items: {} - total: - type: number - example: - data: - items: [] - total: 0 - parameters: - - name: page - in: query - required: false - schema: - type: number - - name: limit - in: query - required: false - schema: - type: number - - name: types - in: query - required: false - schema: - type: string - post: - summary: Create Principal - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: number - example: - data: 50 - requestBody: - content: - application/json: - schema: - type: object - properties: - type: - type: string - quota: - type: number - name: - type: string - description: - type: string - secrets: - type: array - items: {} - emails: - type: array - items: {} - urls: - type: array - items: {} - memberOf: - type: array - items: {} - roles: - type: array - items: {} - lists: - type: array - items: {} - members: - type: array - items: {} - enabledPermissions: - type: array - items: {} - disabledPermissions: - type: array - items: {} - externalMembers: - type: array - items: {} - example: - type: domain - quota: 0 - name: example.org - description: Example domain - secrets: [] - emails: [] - urls: [] - memberOf: [] - roles: [] - lists: [] - members: [] - enabledPermissions: [] - disabledPermissions: [] - externalMembers: [] - /dkim: - post: - summary: Create DKIM Signature - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - requestBody: - content: - application/json: - schema: - type: object - properties: - id: - type: object - nullable: true - algorithm: - type: string - domain: - type: string - selector: - type: object - nullable: true - example: - id: - algorithm: Ed25519 - domain: example.org - selector: - /principal/{principal_id}: - get: - summary: Fetch Principal - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - id: - type: number - type: - type: string - secrets: - type: string - name: - type: string - quota: - type: number - description: - type: string - emails: - type: string - roles: - type: array - items: - type: string - lists: - type: array - items: - type: string - example: - data: - id: 90 - type: individual - secrets: $6$ONjGT6nQtmPNaxw0$NNF5DXtPfOay2mfVnPJ0uQ77C.L3LNxXO/QMyphP/DzpODqbDBBGd4/gCnckYPQj3st6pqwY8/KeBsCJ.oe1Y1 - name: jane - quota: 0 - description: Jane Doe - emails: jane@example.org - roles: - - user - lists: - - all - parameters: - - name: principal_id - in: path - required: true - schema: - type: string - patch: - summary: Update Principal - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - parameters: - - name: principal_id - in: path - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - type: array - items: - type: object - properties: - action: - type: string - field: - type: string + $ref: '#/components/schemas/LoginResponse' + examples: + authenticated: value: - type: string - example: - - action: set - field: name - value: jane.doe - - action: set - field: description - value: Jane Mary Doe - - action: addItem - field: emails - value: jane-doe@example.org - - action: removeItem - field: emails - value: jane@example.org - delete: - summary: Delete Principal - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: + type: authenticated + clientCode: 3F7A9C1E4B2D8E6F + mfaRequired: + value: + type: mfaRequired + failure: + value: + type: failure + '400': + $ref: '#/components/responses/BadRequest' + '429': + $ref: '#/components/responses/TooManyRequests' + + /api/discover/{email}: + get: + operationId: discoverOidc + summary: Discover the OpenID Connect provider for an email address + description: | + Returns the OpenID Connect discovery document for the directory that + owns the domain part of `email`. If the domain is not bound to an + external OIDC directory, the server's own OIDC discovery document + (equivalent to `/.well-known/openid-configuration`) is returned. + Anonymous endpoint, rate-limited. + security: [] + tags: [Authentication] parameters: - - name: principal_id + - name: email in: path required: true + description: Email address or account name schema: type: string - /queue/messages: + format: email + responses: + '200': + description: OpenID Connect discovery document + content: + application/json: + schema: + type: object + description: OIDC discovery metadata (RFC 8414) + additionalProperties: true + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + + /api/account: get: - summary: List Queued Messages + operationId: getAccount + summary: Return the authenticated account's permissions, edition and locale + tags: [Account] responses: - "200": - description: OK + '200': + description: Account descriptor content: application/json: schema: - type: object - properties: - data: - type: object - properties: - items: - type: array - items: {} - total: - type: number - status: - type: boolean - example: - data: - items: [] - total: 0 - status: true - parameters: - - name: page - in: query - required: false - schema: - type: number - - name: max-total - in: query - required: false - schema: - type: number - - name: limit - in: query - required: false - schema: - type: number - - name: values - in: query - required: false - schema: - type: number - patch: - summary: Reschedule Queued Messages - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: boolean - example: - data: true - parameters: - - name: filter - in: query - required: false - schema: - type: string - delete: - summary: Delete Queued Messages - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: boolean - example: - data: true - parameters: - - name: text - in: query - required: false - schema: - type: string - /queue/reports: + $ref: '#/components/schemas/Account' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/schema: get: - summary: List Queued Reports + operationId: getSchemaRedirect + summary: Redirect to the versioned configuration schema URL + description: | + Redirects (302) to `/api/schema/{hash}` where `{hash}` is the SHA-256 + of the current configuration schema. Use this when you do not yet know + the hash; once you have cached a schema at a given hash the immutable + cache will never require re-download. + tags: [Schema] responses: - "200": - description: OK - content: - application/json: + '302': + description: Redirect to the hashed schema URL + headers: + Location: schema: - type: object - properties: - data: - type: object - properties: - items: - type: array - items: {} - total: - type: number - example: - data: - items: [] - total: 0 - parameters: - - name: max-total - in: query - required: false - schema: - type: number - - name: limit - in: query - required: false - schema: - type: number - - name: page - in: query - required: false - schema: - type: number - /reports/dmarc: + type: string + example: /api/schema/a1b2c3d4e5f6... + '401': + $ref: '#/components/responses/Unauthorized' + + /api/schema/{hash}: get: - summary: List Incoming DMARC Reports - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - items: - type: array - items: {} - total: - type: number - example: - data: - items: [] - total: 0 + operationId: getSchema + summary: Return the configuration schema at a specific hash + description: | + Returns the JSON Schema describing the full Stalwart configuration tree. + The response is always gzip-encoded (`Content-Encoding: gzip`) and served + with an immutable cache policy — the schema for a given hash never + changes. If the hash does not match the server's current schema, the + server redirects to the correct URL. + tags: [Schema] parameters: - - name: max-total - in: query - required: false - schema: - type: number - - name: limit - in: query - required: false - schema: - type: number - - name: page - in: query - required: false - schema: - type: number - /reports/tls: - get: - summary: List Incoming TLS Reports - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - items: - type: array - items: {} - total: - type: number - example: - data: - items: [] - total: 0 - parameters: - - name: limit - in: query - required: false - schema: - type: number - - name: max-total - in: query - required: false - schema: - type: number - - name: page - in: query - required: false - schema: - type: number - /reports/arf: - get: - summary: List Incoming ARF Reports - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - items: - type: array - items: {} - total: - type: number - example: - data: - items: [] - total: 0 - parameters: - - name: page - in: query - required: false - schema: - type: number - - name: limit - in: query - required: false - schema: - type: number - - name: max-total - in: query - required: false - schema: - type: number - /telemetry/traces: - get: - summary: List Stored Traces - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - error: - type: string - details: - type: string - example: - error: unsupported - details: No tracing store has been configured - parameters: - - name: type - in: query - required: false - schema: - type: string - - name: page - in: query - required: false - schema: - type: number - - name: limit - in: query - required: false - schema: - type: number - - name: values - in: query - required: false - schema: - type: number - /logs: - get: - summary: Quere Log Files - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - items: - type: array - items: - type: object - properties: - timestamp: - type: string - level: - type: string - event: - type: string - event_id: - type: string - details: - type: string - total: - type: number - example: - data: - items: - - timestamp: "2025-01-05T14:06:29Z" - level: TRACE - event: HTTP request body - event_id: http.request-body - details: - listenerId = "http", localPort = 1443, remoteIp = ::1, - remotePort = 57223, contents = "", size = 0 - - timestamp: "2025-01-05T14:06:29Z" - level: TRACE - event: Write batch operation - event_id: store.data-write - details: elapsed = 0ms, total = 2 - - timestamp: "2025-01-05T14:06:29Z" - level: TRACE - event: Expression evaluation result - event_id: eval.result - details: - listenerId = "http", localPort = 1443, remoteIp = ::1, - remotePort = 57223, id = "server.http.allowed-endpoint", result - = "Integer(200)" - - timestamp: "2025-01-05T14:06:29Z" - level: DEBUG - event: HTTP request URL - event_id: http.request-url - details: - listenerId = "http", localPort = 1443, remoteIp = ::1, - remotePort = 57223, url = "/api/logs?page=1&limit=50&" - - timestamp: "2025-01-05T14:06:23Z" - level: TRACE - event: HTTP response body - event_id: http.response-body - details: - listenerId = "http", localPort = 1443, remoteIp = ::1, - remotePort = 57223, contents = "{"error":"unsupported","details":"No - tracing store has been configured"}", code = 200, size = 72 - - timestamp: "2025-01-05T14:06:23Z" - level: DEBUG - event: Management operation not supported - event_id: manage.not-supported - details: - listenerId = "http", localPort = 1443, remoteIp = ::1, - remotePort = 57223, details = No tracing store has been configured - - timestamp: "2025-01-05T14:06:23Z" - level: TRACE - event: HTTP request body - event_id: http.request-body - details: - listenerId = "http", localPort = 1443, remoteIp = ::1, - remotePort = 57223, contents = "", size = 0 - - timestamp: "2025-01-05T14:06:23Z" - level: TRACE - event: Write batch operation - event_id: store.data-write - details: elapsed = 0ms, total = 2 - - timestamp: "2025-01-05T14:06:23Z" - level: TRACE - event: Expression evaluation result - event_id: eval.result - details: - listenerId = "http", localPort = 1443, remoteIp = ::1, - remotePort = 57223, id = "server.http.allowed-endpoint", result - = "Integer(200)" - - timestamp: "2025-01-05T14:06:23Z" - level: DEBUG - event: HTTP request URL - event_id: http.request-url - details: - listenerId = "http", localPort = 1443, remoteIp = ::1, - remotePort = 57223, url = "/api/telemetry/traces?page=1&type=delivery.attempt-start&limit=10&values=1&" - total: 100 - parameters: - - name: page - in: query - required: false - schema: - type: number - - name: limit - in: query - required: false - schema: - type: number - /spam-filter/train/spam: - post: - summary: Train Spam Filter as Spam - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - requestBody: - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - ? "From: john@example.org\nTo: list@example.org\nSubject: Testing, - please ignore\nContent-Type: text/plain; charset" - : type: string - example: - ? "From: john@example.org\nTo: list@example.org\nSubject: Testing, please - ignore\nContent-Type: text/plain; charset" - : "\"utf-8\"\nContent-Transfer-Encoding: 8bit\n\nTesting 1, 2, 3\n" - /spam-filter/train/ham: - post: - summary: Train Spam Filter as Ham - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - requestBody: - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - ? "From: john@example.org\nTo: list@example.org\nSubject: Testing, - please ignore\nContent-Type: text/plain; charset" - : type: string - example: - ? "From: john@example.org\nTo: list@example.org\nSubject: Testing, please - ignore\nContent-Type: text/plain; charset" - : "\"utf-8\"\nContent-Transfer-Encoding: 8bit\n\nTesting 1, 2, 3\n" - /spam-filter/train/spam/{account_id}: - post: - summary: Train Account's Spam Filter as Spam - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - parameters: - - name: account_id + - name: hash in: path required: true + description: SHA-256 hex digest of the configuration schema schema: type: string - requestBody: - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - ? "From: john@example.org\nTo: list@example.org\nSubject: Testing, - please ignore\nContent-Type: text/plain; charset" - : type: string - example: - ? "From: john@example.org\nTo: list@example.org\nSubject: Testing, please - ignore\nContent-Type: text/plain; charset" - : "\"utf-8\"\nContent-Transfer-Encoding: 8bit\n\nTesting 1, 2, 3\n" - /spam-filter/train/ham/{account_id}: - post: - summary: Train Account's Spam Filter as Ham responses: - "200": - description: OK + '200': + description: Gzipped JSON Schema document + headers: + Content-Encoding: + schema: + type: string + example: gzip + Cache-Control: + schema: + type: string + example: public, max-age=31536000, immutable content: application/json: schema: type: object - properties: - data: - type: object - nullable: true - example: - data: + description: JSON Schema document describing Stalwart config + additionalProperties: true + '302': + description: Redirect to the current schema URL when the hash is stale + '401': + $ref: '#/components/responses/Unauthorized' + + /api/token/delivery: + get: + operationId: issueDeliveryToken + summary: Issue a short-lived token for live delivery diagnostics + description: | + Returns a plain-text bearer token, valid for 60 seconds, that authorises + connecting to `/api/live/delivery/{target}` as a query parameter + (`?token=...`). Useful for EventSource clients that cannot send + `Authorization` headers. Requires `LiveDeliveryTest` permission. + tags: [Live Telemetry] + responses: + '200': + description: Short-lived delivery token + content: + text/plain: + schema: + type: string + example: eyJhbGciOi... + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/token/tracing: + get: + operationId: issueTracingToken + summary: Issue a short-lived token for live tracing (Enterprise) + description: | + Returns a plain-text bearer token, valid for 60 seconds, that authorises + connecting to `/api/live/tracing` as a query parameter. Requires the + `LiveTracing` permission. Available only in the Enterprise edition. + tags: [Live Telemetry] + responses: + '200': + description: Short-lived tracing token + content: + text/plain: + schema: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Enterprise feature not available in this edition + + /api/token/metrics: + get: + operationId: issueMetricsToken + summary: Issue a short-lived token for live metrics (Enterprise) + description: | + Returns a plain-text bearer token, valid for 60 seconds, that authorises + connecting to `/api/live/metrics` as a query parameter. Requires the + `LiveMetrics` permission. Available only in the Enterprise edition. + tags: [Live Telemetry] + responses: + '200': + description: Short-lived metrics token + content: + text/plain: + schema: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Enterprise feature not available in this edition + + /api/live/delivery/{target}: + get: + operationId: liveDelivery + summary: Stream outbound-delivery diagnostics as Server-Sent Events + description: | + Opens a `text/event-stream` connection that streams each stage of an + outbound delivery attempt to `target` (a domain or email address): MX + lookup, MTA-STS fetch, TLSA/DANE validation, SMTP conversation, and so + on. Each SSE frame carries a JSON-encoded `DeliveryStage` wrapped in a + single-element array (`data: [{...}]`). The stream ends with a final + `completed` event. Requires `LiveDeliveryTest` permission; may also be + authenticated via the `?token=` query parameter obtained from + `/api/token/delivery`. + tags: [Live Telemetry] + security: + - bearerAuth: [] + - basicAuth: [] + - liveToken: [] parameters: - - name: account_id + - name: target in: path required: true + description: Target domain or email address to diagnose schema: type: string - requestBody: - content: - application/x-www-form-urlencoded: - schema: - type: object - properties: - ? "From: john@example.org\nTo: list@example.org\nSubject: Testing, - please ignore\nContent-Type: text/plain; charset" - : type: string - example: - ? "From: john@example.org\nTo: list@example.org\nSubject: Testing, please - ignore\nContent-Type: text/plain; charset" - : "\"utf-8\"\nContent-Transfer-Encoding: 8bit\n\nTesting 1, 2, 3\n" - /spam-filter/classify: - post: - summary: Test Spam Filter Classification - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - score: - type: number - tags: - type: object - properties: - FROM_NO_DN: - type: object - properties: - action: - type: string - value: - type: number - SOURCE_ASN_15169: - type: object - properties: - action: - type: string - value: - type: number - SOURCE_COUNTRY_US: - type: object - properties: - action: - type: string - value: - type: number - MISSING_DATE: - type: object - properties: - action: - type: string - value: - type: number - FROMHOST_NORES_A_OR_MX: - type: object - properties: - action: - type: string - value: - type: number - MISSING_MIME_VERSION: - type: object - properties: - action: - type: string - value: - type: number - FORGED_SENDER: - type: object - properties: - action: - type: string - value: - type: number - SPF_NA: - type: object - properties: - action: - type: string - value: - type: number - X_HDR_TO: - type: object - properties: - action: - type: string - value: - type: number - HELO_IPREV_MISMATCH: - type: object - properties: - action: - type: string - value: - type: number - X_HDR_CONTENT_TYPE: - type: object - properties: - action: - type: string - value: - type: number - AUTH_NA: - type: object - properties: - action: - type: string - value: - type: number - FORGED_RECIPIENTS: - type: object - properties: - action: - type: string - value: - type: number - RBL_SENDERSCORE_REPUT_BLOCKED: - type: object - properties: - action: - type: string - value: - type: number - RCVD_COUNT_ZERO: - type: object - properties: - action: - type: string - value: - type: number - X_HDR_SUBJECT: - type: object - properties: - action: - type: string - value: - type: number - X_HDR_FROM: - type: object - properties: - action: - type: string - value: - type: number - RCPT_COUNT_ONE: - type: object - properties: - action: - type: string - value: - type: number - MISSING_MID: - type: object - properties: - action: - type: string - value: - type: number - TO_DOM_EQ_FROM_DOM: - type: object - properties: - action: - type: string - value: - type: number - ARC_NA: - type: object - properties: - action: - type: string - value: - type: number - RCVD_TLS_LAST: - type: object - properties: - action: - type: string - value: - type: number - X_HDR_CONTENT_TRANSFER_ENCODING: - type: object - properties: - action: - type: string - value: - type: number - HELO_NORES_A_OR_MX: - type: object - properties: - action: - type: string - value: - type: number - TO_DN_NONE: - type: object - properties: - action: - type: string - value: - type: number - FROM_NEQ_ENV_FROM: - type: object - properties: - action: - type: string - value: - type: number - DMARC_NA: - type: object - properties: - action: - type: string - value: - type: number - SINGLE_SHORT_PART: - type: object - properties: - action: - type: string - value: - type: number - DKIM_NA: - type: object - properties: - action: - type: string - value: - type: number - disposition: - type: object - properties: - action: - type: string - value: - type: string - example: - data: - score: 12.7 - tags: - FROM_NO_DN: - action: allow - value: 0.0 - SOURCE_ASN_15169: - action: allow - value: 0.0 - SOURCE_COUNTRY_US: - action: allow - value: 0.0 - MISSING_DATE: - action: allow - value: 1.0 - FROMHOST_NORES_A_OR_MX: - action: allow - value: 1.5 - MISSING_MIME_VERSION: - action: allow - value: 2.0 - FORGED_SENDER: - action: allow - value: 0.3 - SPF_NA: - action: allow - value: 0.0 - X_HDR_TO: - action: allow - value: 0.0 - HELO_IPREV_MISMATCH: - action: allow - value: 1.0 - X_HDR_CONTENT_TYPE: - action: allow - value: 0.0 - AUTH_NA: - action: allow - value: 1.0 - FORGED_RECIPIENTS: - action: allow - value: 2.0 - RBL_SENDERSCORE_REPUT_BLOCKED: - action: allow - value: 0.0 - RCVD_COUNT_ZERO: - action: allow - value: 0.1 - X_HDR_SUBJECT: - action: allow - value: 0.0 - X_HDR_FROM: - action: allow - value: 0.0 - RCPT_COUNT_ONE: - action: allow - value: 0.0 - MISSING_MID: - action: allow - value: 2.5 - TO_DOM_EQ_FROM_DOM: - action: allow - value: 0.0 - ARC_NA: - action: allow - value: 0.0 - RCVD_TLS_LAST: - action: allow - value: 0.0 - X_HDR_CONTENT_TRANSFER_ENCODING: - action: allow - value: 0.0 - HELO_NORES_A_OR_MX: - action: allow - value: 0.3 - TO_DN_NONE: - action: allow - value: 0.0 - FROM_NEQ_ENV_FROM: - action: allow - value: 0.0 - DMARC_NA: - action: allow - value: 1.0 - SINGLE_SHORT_PART: - action: allow - value: 0.0 - DKIM_NA: - action: allow - value: 0.0 - disposition: - action: allow - value: - "X-Spam-Result: ARC_NA (0.00),\r\n\tDKIM_NA (0.00),\r\n - \tFROM_NEQ_ENV_FROM (0.00),\r\n\tFROM_NO_DN (0.00),\r\n\tRBL_SENDERSCORE_REPUT_BLOCKED - (0.00),\r\n\tRCPT_COUNT_ONE (0.00),\r\n\tRCVD_TLS_LAST (0.00),\r - \n\tSINGLE_SHORT_PART (0.00),\r\n\tSPF_NA (0.00),\r\n\tTO_DN_NONE - (0.00),\r\n\tTO_DOM_EQ_FROM_DOM (0.00),\r\n\tRCVD_COUNT_ZERO - (0.10),\r\n\tFORGED_SENDER (0.30),\r\n\tHELO_NORES_A_OR_MX (0.30),\r - \n\tAUTH_NA (1.00),\r\n\tDMARC_NA (1.00),\r\n\tHELO_IPREV_MISMATCH - (1.00),\r\n\tMISSING_DATE (1.00),\r\n\tFROMHOST_NORES_A_OR_MX - (1.50),\r\n\tFORGED_RECIPIENTS (2.00),\r\n\tMISSING_MIME_VERSION - (2.00),\r\n\tMISSING_MID (2.50)\r\nX-Spam-Status: Yes, score=12.70\r\ - \n" - requestBody: - content: - application/json: - schema: - type: object - properties: - message: - type: string - remoteIp: - type: string - ehloDomain: - type: string - authenticatedAs: - type: object - nullable: true - isTls: - type: boolean - envFrom: - type: string - envFromFlags: - type: number - envRcptTo: - type: array - items: - type: string - example: - message: - "From: john@example.org\nTo: list@example.org\nSubject: Testing, - please ignore\nContent-Type: text/plain; charset=\"utf-8\"\nContent-Transfer-Encoding: - 8bit\n\nTesting 1, 2, 3\n" - remoteIp: 8.8.8.8 - ehloDomain: foo.org - authenticatedAs: - isTls: true - envFrom: bill@foo.org - envFromFlags: 0 - envRcptTo: - - john@example.org - /troubleshoot/token: - get: - summary: Obtain a Troubleshooting Token - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: string - example: - data: +bS1rCUcrjoEtl9f7Vz1P6daqVs4nywxa56bHltPIASijRFrj1JrwvHxJCWphPKs/////w8E8p21+AKunrX4AndlYg== - /troubleshoot/delivery/{recipient}: - get: - summary: Run Delivery Troubleshooting - responses: - "200": - description: OK - content: {} - parameters: - - name: recipient - in: path - required: true - schema: - type: string - - name: token + - name: timeout in: query required: false + description: Maximum stream lifetime in seconds (minimum 1, default 30) + schema: + type: integer + minimum: 1 + default: 30 + responses: + '200': + description: Server-Sent Events stream of delivery-diagnose stages + content: + text/event-stream: + schema: + type: string + description: | + Series of SSE frames. Each `event: event` frame carries + `data: []`; the final frame's stage is + `{"type":"completed"}`. + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/live/tracing: + get: + operationId: liveTracing + summary: Stream live tracing events (Enterprise) + description: | + Opens a `text/event-stream` connection streaming server trace events in + real time. Requires `LiveTracing` permission; may be authenticated via + the `?token=` query parameter from `/api/token/tracing`. Enterprise only. + tags: [Live Telemetry] + security: + - bearerAuth: [] + - basicAuth: [] + - liveToken: [] + responses: + '200': + description: Server-Sent Events stream of tracing events + content: + text/event-stream: + schema: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Enterprise feature not available in this edition + + /api/live/metrics: + get: + operationId: liveMetrics + summary: Stream live metrics events (Enterprise) + description: | + Opens a `text/event-stream` connection streaming server metrics in real + time. Requires `LiveMetrics` permission; may be authenticated via the + `?token=` query parameter from `/api/token/metrics`. Enterprise only. + tags: [Live Telemetry] + security: + - bearerAuth: [] + - basicAuth: [] + - liveToken: [] + responses: + '200': + description: Server-Sent Events stream of metric events + content: + text/event-stream: + schema: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Enterprise feature not available in this edition + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: OAuth2 access token issued by `POST /auth/token`. + basicAuth: + type: http + scheme: basic + description: HTTP Basic authentication using account name and secret. + liveToken: + type: apiKey + in: query + name: token + description: | + Short-lived token (60s lifetime) issued by `/api/token/{kind}` and used + to authorise Server-Sent Events streams where an `Authorization` header + cannot be set (e.g. browser `EventSource`). + + responses: + BadRequest: + description: Request payload is malformed or fails validation + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + Unauthorized: + description: Missing or invalid credentials + headers: + WWW-Authenticate: schema: type: string - /troubleshoot/dmarc: - post: - summary: Run DMARC Troubleshooting - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - spfEhloDomain: - type: string - spfEhloResult: - type: object - properties: - type: - type: string - spfMailFromDomain: - type: string - spfMailFromResult: - type: object - properties: - type: - type: string - details: - type: object - nullable: true - ipRevResult: - type: object - properties: - type: - type: string - ipRevPtr: - type: array - items: - type: string - dkimResults: - type: array - items: {} - dkimPass: - type: boolean - arcResult: - type: object - properties: - type: - type: string - dmarcResult: - type: object - properties: - type: - type: string - dmarcPass: - type: boolean - dmarcPolicy: - type: string - elapsed: - type: number - example: - data: - spfEhloDomain: mx.google.com - spfEhloResult: - type: none - spfMailFromDomain: google.com - spfMailFromResult: - type: softFail - details: - ipRevResult: - type: pass - ipRevPtr: - - dns.google. - dkimResults: [] - dkimPass: false - arcResult: - type: none - dmarcResult: - type: none - dmarcPass: false - dmarcPolicy: reject - elapsed: 200 - requestBody: - content: - application/json: - schema: - type: object - properties: - remoteIp: - type: string - ehloDomain: - type: string - mailFrom: - type: string - body: - type: string - example: - remoteIp: 8.8.8.8 - ehloDomain: mx.google.com - mailFrom: john@google.com - body: - "From: john@example.org\nTo: list@example.org\nSubject: Testing, - please ignore\nContent-Type: text/plain; charset=\"utf-8\"\nContent-Transfer-Encoding: - 8bit\n\nTesting 1, 2, 3\n" - /reload: - get: - summary: Reload Settings - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - warnings: - type: object - properties: {} - errors: - type: object - properties: {} - example: - data: - warnings: {} - errors: {} - parameters: - - name: dry-run - in: query - required: false - schema: - type: string - /update/spam-filter: - get: - summary: Update Spam Filter - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - /update/webadmin: - get: - summary: Update WebAdmin - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - /store/reindex: - get: - summary: Request FTS Reindex - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - /store/purge/in-memory/default/bayes-global: - get: - summary: Delete Global Bayes Model - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - /settings/keys: - get: - summary: List Settings by Key - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - lookup.default.hostname: - type: string - example: - data: - lookup.default.hostname: mx.fr.email - parameters: - - name: prefixes - in: query - required: false - schema: - type: string - - name: keys - in: query - required: false - schema: - type: string - /settings/group: - get: - summary: List Settings by Group - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - total: - type: number - items: - type: array - items: - type: object - properties: - _id: - type: string - bind: - type: string - protocol: - type: string - example: - data: - total: 11 - items: - - _id: http - bind: "[::]:1443" - protocol: http - - bind: "[::]:443" - _id: https - protocol: http - tls.implicit: "true" - - protocol: imap - bind: "[::]:143" - _id: imap - - bind: "[::]:1143" - tls.implicit: "false" - _id: imapnotls - protocol: imap - proxy.override: "false" - tls.override: "false" - tls.enable: "false" - socket.override: "false" - - bind: "[::]:993" - tls.implicit: "true" - protocol: imap - _id: imaptls - - bind: "[::]:110" - protocol: pop3 - _id: pop3 - - tls.implicit: "true" - _id: pop3s - protocol: pop3 - bind: "[::]:995" - - protocol: managesieve - _id: sieve - bind: "[::]:4190" - - bind: "[::]:25" - _id: smtp - protocol: smtp - - _id: submission - bind: "[::]:587" - protocol: smtp - parameters: - - name: limit - in: query - required: false - schema: - type: number - - name: page - in: query - required: false - schema: - type: number - - name: suffix - in: query - required: false - schema: - type: string - - name: prefix - in: query - required: false - schema: - type: string - /settings/list: - get: - summary: List Settings - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - total: - type: number - items: - type: object - properties: - enable: - type: string - format: - type: string - limits.entries: - type: string - limits.entry-size: - type: string - limits.size: - type: string - refresh: - type: string - retry: - type: string - timeout: - type: string - url: - type: string - example: - data: - total: 9 - items: - enable: "true" - format: list - limits.entries: "100000" - limits.entry-size: "512" - limits.size: "104857600" - refresh: 12h - retry: 1h - timeout: 30s - url: https://openphish.com/feed.txt - parameters: - - name: prefix - in: query - required: false - schema: - type: string - /settings: - post: - summary: Update Settings - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - requestBody: - content: - application/json: - schema: - type: array - items: - type: object - properties: - type: - type: string - prefix: - type: string - example: - - type: clear - prefix: spam-filter.rule.stwt_arc_signed. - /account/crypto: - get: - summary: Obtain Encryption-at-Rest Settings - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - type: - type: string - example: - data: - type: disabled - post: - summary: Update Encryption-at-Rest Settings - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: number - example: - data: 1 - requestBody: - content: - application/json: - schema: - type: object - properties: - type: - type: string - algo: - type: string - certs: - type: string - example: - type: pGP - algo: Aes256 - certs: - "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxsFNBGTGHwkBEADRB5EEtfsnUwgF2ZRg6h1fp2E8LNhv4lb9AWersI8KNFoWM6qx\n - Bk/MfEpgILSPdW3g7PWHOxPV/hxjtStFHfbU/Ye5VvfbkU49faIPiw1V3MQJJ171\n - cN6kgMnABfdixNiutDkHP4f34ABrEqexX2myOP+btxL24gI/N9UpOD5PiKTyKR7i\n - GwNpi+O022rs/KvjlWR7iSJ4vk7bGFfTNHvWI6dZworey1tZoTIZ0CgvgMeB/F1q\n - OOa0FvrJdNYR227RpHmICqFqTptNZ2EfdkJ6QUXW7bZ9dWgL36ds9QPJOGcG3c5i\n - JebeX5YdJnniBefiWjfZElcqh/N6SqVuEwoTLyMCnMZ6gjNMn6tddwPH24kavZhT\n - p6+vhTHmyq8XBqK/XEt9r+clSfg2hi5s7GO7hQV+W26xRjX7sQJY41PfzkgYJ0BM\n - 6+w09X1ZO/iMjEp44t2rd3xSudwGYhlbazXbdB+OJaa3RtyjOAeFgY8OyNlODx3V\n - xXLtF+104HGSL7nkpBsu6LLighSgEEF2Vok43grr0omyb1NPhWoAZhM8sT5iv5gW\n - fKvB1O13c+hDc/iGTAvcrtdLLnF2Cs+6HD7r7zPPM4L6DrD1+oQt510H/oOEE5NZ\n - wIS9CmBf0txqwk7n1U5V95lonaCK9nfoKeQ1fKl/tu01dCeERRbMXG2nCQARAQAB\n - zRtKb2huIERvZSA8am9obkBleGFtcGxlLm9yZz7CwYcEEwEIADEWIQQWwx1eM+Aa\n - o8okGzL45grMTSggxQUCZMYfCQIbAwQLCQgHBRUICQoLBRYCAwEAAAoJEPjmCsxN\n - KCDFWP4QAI3eS5nPxmU0AC9/h8jeKNgjgpENroNQZKeWZQ8x4PfncDRkcbsJfT7Y\n - IVZl4zw6gFKY5EoB1s1KkYJxPgYsqicmKNiR7Tnzabb3mzomU48FKaIyVCBzFUnJ\n - YMroL/rm7QhoW2WWLvT+CPCPway/tA3By8Be/YOjhavJ8mf1W3rPzt87/4Vo6erf\n - yzL0lN+FQmmhKfT4j42jF4SMSyyC2yzvfC7PT49u+KUKQm/LpQsfKHpwXZ/VI6+X\n - GtZjTqsc+uglJYRo69oosImLzieA/ST1ltjmUutZQOSvlQFpDUEFrMej8XZ0qsrf\n - 0gP2iwxyl0vkhV8c6wO6CacDHPivvQEHed9H1PNGn3DBfKb7Mq/jado2DapRtJg3\n - 2OH0F0HTvQ0uNKl30xMUcwGQB0cKOlaFtksZT1LsosQPhtPLpFy1TuWaXOInpQLq\n - JmNVcTbydOsCKq0mb6bgGcvhElC1q39tclKP3rOEDOnJ8hE6wYNaMGrt6WSKr3Tt\n - h52M6KwTXOuMAecMvpDBSS3UFEVQ+T5puzInDTkjINxmj23ip+swA1x3HH2IgNrO\n - VJ7O20oEf0+qC47R5rTRUxrvh/U0U3DRE5xt2J2T3xetFDT2mnQv0jcyMg/UlXXv\n - GpGVfwNkvN0Cxmb1tFiBNLKCcPVizxq4MLrwx+MVfQBaRCwjJrUszsFNBGTGHwoB\n - EACr5lA+j5pH0Er6Q76btbS4q9JgNjDNrjKJwX9brdBY1oXIUeBqCW9ekoqDTFpn\n - xA5EFGJvPO++/0ZCa+zXE4IAcXS9+I9HVBouenPYBLETnXK0Phws+OCLoe0cAIvG\n - e9Xo9VrHcGXCs9tJruVSAW3NF04YejHmnHNfEuD8mbaUdxVn5zc23w/2gLaY/ABL\n - ZfNV8XZw0jBVBm3YXS3Ob3uIO+RvsNqBgnhGYN/C51QI9hdxXWUDlD1vdRacXmcI\n - LDCYC3w6u8caxL0ktXTS4zwN+hEu7jHxBNiKcovCeIF5VZ5NcPpp6+6Y+vNdmmXw\n - +lWNwAzj3ah6iu+y25LKSsz+7IkCh5liOwwYohO+YI7SjtTD+gL9HiHYAIO+PtBh\n - 7GudmUwFoARu/q54hE4ThpzkeOzJzPqGkM/CzmwdKKM3u81ze+72ptJOqVKbFEsQ\n - 3+RURrIAfyYyeJj4VVCfHNzrRRVpARZc9hJm1AXefxPnDN9dxbikjQgbg5UxrKaJ\n - cjVU+go5CH5lg2D1LRGfKqTJtfiWFPjtztNgMp/SeslkhhFXsyJ0RJDcU8VfRBrO\n - DBnZvPnZi4nLaWCL1LdHA8Y9EJgSwVOsfdRqL/Xk9qxqgl5R8m8lsNKZN2EYkfMN\n - 4Vd+/8UBbmibHYoGIQi7UlNSPthc0XQcRzFen+3H4sg5kQARAQABwsF2BBgBCAAg\n - FiEEFsMdXjPgGqPKJBsy+OYKzE0oIMUFAmTGHwsCGwwACgkQ+OYKzE0oIMXn4hAA\n - lUWeF7tDdyENsOYyhsbtLIuLipYe6orHFY5m68NNOoLWwqEeTvutJgFeDT4WxYi0\n - PJaNQYFPyGVyg7N0hCx5cGwajdnwGpb5zpSNyvG2Yes9I1O/u7+FFrbSwOuo61t1\n - scGa8YlgTKoyGc9cwxl5U8krrlEwXTWQ/qF1Gq2wHG23wm1D2d2PXFDRvw3gPxJn\n - yWkrx5k26ru1kguM7XFVyRi7B+uG4vdvMlxMBXM3jpH1CJRr82VvzYPv7f05Z5To\n - C7XDqHpWKx3+AQvh/ZsSBpBhzK8qaixysMwnawe05rOPydWvsLlnMCGManKVnq9Y\n - Wek1P2dwYT9zuroBR5nmrECY+xVWk7vhsDasKsYlQ/LdDyzSL7qh0Vq3DjcoHxLI\n - uL7qQ3O0YRcKGfmQibpKdDzvIqA+48Nfh2nDnTxvfuwOxb41zdLTZQftaSXc0Xwd\n - HgquBAFbRDr5TyWlUUc8iACowKkk01pEPc8coxPCp6F/hz6kgmebRevzs7sxwrS7\n - aUWycSls783JC7WO267DRD30FNx+9S7SY4ECzhDGjLdne6wIoib1L9SFkk1AAKb3\n - m2+6BB/HxCXtMqi95pFeCjV99bp+PBqoifx9SlFYZq9qcGDr/jyrdG8V2Wf/HF4n\n - K8RIPxB+daAPMLTpj4WBhNquSE6mRQvABEf0GPi2eLA=\n=0TDv\n-----END PGP - PUBLIC KEY BLOCK-----\n\n\n" - /account/auth: - get: - summary: Obtain Account Authentication Settings - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - otpEnabled: - type: boolean - appPasswords: - type: array - items: {} - example: - data: - otpEnabled: false - appPasswords: [] - post: - summary: Update Account Authentication Settings - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - error: - type: string - details: - type: string - reason: - type: object - nullable: true - example: - error: other - details: Fallback administrator accounts do not support 2FA or AppPasswords - reason: - "401": - description: Unauthorized - content: - application/json: - schema: - type: object - properties: - type: - type: string - status: - type: number - title: - type: string - detail: - type: string - example: - type: about:blank - status: 401 - title: Unauthorized - detail: You have to authenticate first. - requestBody: - content: - application/json: - schema: - type: array - items: - type: object - properties: - type: - type: string - name: - type: string - password: - type: string - example: - - type: addAppPassword - name: dGVzdCQyMDI1LTAxLTA1VDE0OjEyOjUxLjg0NyswMDowMA== - password: $6$4M/5LmG7b13r0cdE$6zb.i6wJ3pAQHA2MRHkKg0t8bgSYb2IeqiIU115t.NugwW6VXifE0VKI5n2BQUNwdeDMUzaX82TmhuVVgC0Gx1 - /reload/: - get: - summary: Reload Settings - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - warnings: - type: object - properties: {} - errors: - type: object - properties: {} - example: - data: - warnings: {} - errors: {} - /queue/status/stop: - patch: - summary: Stop Queue Processing - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: boolean - example: - data: true - /queue/status/start: - patch: - summary: Resume Queue Processing - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: boolean - example: - data: false - /queue/messages/{message_id}: - get: - summary: Obtain Queued Message Details - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - id: - type: number - return_path: - type: string - domains: - type: array - items: - type: object - properties: - name: - type: string - status: - type: string - recipients: - type: array - items: - type: object - properties: - address: - type: string - status: - type: string - retry_num: - type: number - next_retry: - type: string - next_notify: - type: string - expires: - type: string - created: - type: string - size: - type: number - blob_hash: - type: string - example: - data: - id: 217700302698266624 - return_path: pepe@pepe.com - domains: - - name: example.org - status: scheduled - recipients: - - address: john@example.org - status: scheduled - retry_num: 0 - next_retry: "2025-01-05T14:33:15Z" - next_notify: "2025-01-06T14:33:15Z" - expires: "2025-01-10T14:33:15Z" - created: "2025-01-05T14:33:15Z" - size: 1451 - blob_hash: ykrZ_KghvdG2AdjH4AZajkSvZvcsxP_oI2HEZvw-tS0 - "404": - description: Not Found - content: - application/json: - schema: - type: object - properties: - type: - type: string - status: - type: number - title: - type: string - detail: - type: string - example: - type: about:blank - status: 404 - title: Not Found - detail: The requested resource does not exist on this server. - parameters: - - name: message_id - in: path - required: true - schema: - type: string - patch: - summary: Reschedule Delivery of Queued Message - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: boolean - example: - data: true - parameters: - - name: message_id - in: path - required: true - schema: - type: string - delete: - summary: Cancel Delivery of Queued Message - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: boolean - example: - data: true - parameters: - - name: message_id - in: path - required: true - schema: - type: string - /store/blobs/{blob_id}: - get: - summary: Fetch Blob by ID - responses: - "200": - description: OK - content: {} - parameters: - - name: blob_id - in: path - required: true - schema: - type: string - - name: limit - in: query - required: false - schema: - type: number - /telemetry/trace/{trace_id}: - get: - summary: Obtain Trace Details - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - type: object - properties: - text: - type: string - details: - type: string - createdAt: - type: string - type: - type: string - data: - type: object - properties: - listenerId: - type: string - localPort: - type: number - remoteIp: - type: string - remotePort: - type: number - example: - data: - - text: SMTP connection started - details: A new SMTP connection was started - createdAt: "2025-01-05T14:34:50Z" - type: smtp.connection-start - data: - listenerId: smtp - localPort: 25 - remoteIp: ::1 - remotePort: 57513 - - text: SMTP EHLO command - details: The remote server sent an EHLO command - createdAt: "2025-01-05T14:34:50Z" - type: smtp.ehlo - data: - domain: test.eml - - text: SPF EHLO check failed - details: EHLO identity failed SPF check - createdAt: "2025-01-05T14:34:50Z" - type: smtp.spf-ehlo-fail - data: - domain: test.eml - result: - type: spf.none - text: No SPF record - details: No SPF record was found - data: {} - elapsed: 24 - - text: IPREV check passed - details: Reverse IP check passed - createdAt: "2025-01-05T14:34:50Z" - type: smtp.iprev-pass - data: - domain: test.eml - result: - type: iprev.pass - text: IPREV check passed - details: The IPREV check has passed - data: - details: - - localhost. - elapsed: 0 - - text: SPF From check failed - details: MAIL FROM identity failed SPF check - createdAt: "2025-01-05T14:34:50Z" - type: smtp.spf-from-fail - data: - domain: test.eml - from: pepe@pepe.com - result: - type: spf.none - text: No SPF record - details: No SPF record was found - data: {} - elapsed: 18 - - text: SMTP MAIL FROM command - details: The remote client sent a MAIL FROM command - createdAt: "2025-01-05T14:34:50Z" - type: smtp.mail-from - data: - from: pepe@pepe.com - - text: SMTP RCPT TO command - details: The remote client sent an RCPT TO command - createdAt: "2025-01-05T14:34:50Z" - type: smtp.rcpt-to - data: - to: john@example.org - - text: DKIM verification failed - details: Failed to verify DKIM signature - createdAt: "2025-01-05T14:34:50Z" - type: smtp.dkim-fail - data: - strict: false - result: [] - elapsed: 0 - - text: ARC verification passed - details: Successful ARC verification - createdAt: "2025-01-05T14:34:50Z" - type: smtp.arc-pass - data: - strict: false - result: - type: dkim.none - text: No DKIM signature - details: No DKIM signature was found - data: {} - elapsed: 0 - - text: DMARC check failed - details: Failed to verify DMARC policy - createdAt: "2025-01-05T14:34:50Z" - type: smtp.dmarc-fail - data: - strict: false - domain: example.org - policy: reject - result: - type: dmarc.none - text: No DMARC record - details: No DMARC record was found - data: {} - elapsed: 0 - parameters: - - name: trace_id - in: path - required: true - schema: - type: string - /telemetry/live/tracing-token: - get: - summary: Request a Tracing Token - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: string - example: - data: VLxkixOwgDF8Frj0wi8kPhx3SpzKqtsDvbo25wgKw2tBIz/O8La0dwioQw9pN11c/////w8Ctau1+ALxq7X4AndlYg== - /telemetry/traces/live: - get: - summary: Start Live Tracing - responses: - "200": - description: OK - content: {} - parameters: - - name: filter - in: query - required: false - schema: - type: string - - name: token - in: query - required: false - schema: - type: string - /dns/records/{domain}: - get: - summary: Obtain DNS Records for Domain - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - type: object - properties: - type: - type: string - name: - type: string - content: - type: string - example: - data: - - type: MX - name: example.org. - content: 10 mx.fr.email. - - type: CNAME - name: mail.example.org. - content: mx.fr.email. - - type: TXT - name: 202501e._domainkey.example.org. - content: v=DKIM1; k=ed25519; h=sha256; p=82LqzMGRHEBI2HGDogjojWGz+Crrv0TAi8pcaOBd1vw= - - type: TXT - name: 202501r._domainkey.example.org. - content: v=DKIM1; k=rsa; h=sha256; - p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1qtCbIlrZffIqm7gHqpihPUlxOq1zD6K3j1RO/enhkZRp5dEdCqcLbyFk5d+rqRsVIWwUZiU4HXHWqMTN1hlKojUlzmU1JYtlHRMwtM5vN4mzG4x1KA0i8ZHxkahE8ITsP+kPByDF9x0vAySHXpyErNXq3BeFyu/VW+6X+fmUW6x39PfWq7kQQTcwU0Ogo447oJfmAX9H4Z+/cD5WJVNiLgvLY6faVgoXm0mJJjRU5xoEStXoUcKwrwbl7G3K7JfxtmWsgEn97auV6v4he2LRRfTxbY9smkqUtcJs61E9iyyYroJv0iRda2pv71qg8e4wTb2sqBloZv/F2FZQhM+wIDAQAB - - type: TXT - name: example.org. - content: v=spf1 mx ra=postmaster -all - - type: SRV - name: _jmap._tcp.example.org. - content: 0 1 443 mx.fr.email. - - type: SRV - name: _imaps._tcp.example.org. - content: 0 1 993 mx.fr.email. - - type: SRV - name: _imap._tcp.example.org. - content: 0 1 143 mx.fr.email. - - type: SRV - name: _imap._tcp.example.org. - content: 0 1 1143 mx.fr.email. - - type: SRV - name: _pop3s._tcp.example.org. - content: 0 1 995 mx.fr.email. - parameters: - - name: domain - in: path - required: true - schema: - type: string - /store/purge/account/{account_id}: - get: - summary: Purge Account - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - parameters: - - name: account_id - in: path - required: true - schema: - type: string - /store/purge/in-memory/default/bayes-account/{account_id}: - get: - summary: Delete Bayes Model for Account - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - parameters: - - name: account_id - in: path - required: true - schema: - type: string - /store/undelete/{account_id}: - get: - summary: List Deleted Messages - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - properties: - items: - type: array - items: {} - total: - type: number - example: - data: - items: [] - total: 0 - parameters: - - name: account_id - in: path - required: true - schema: - type: string - - name: limit - in: query - required: false - schema: - type: number - - name: page - in: query - required: false - schema: - type: number - post: - summary: Undelete Messages - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - type: object - properties: - type: - type: string - example: - data: - - type: success - parameters: - - name: account_id - in: path - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - type: array - items: - type: object - properties: - hash: - type: string - collection: - type: string - restoreTime: - type: string - cancelDeletion: - type: string - example: - - hash: 9pDYGrkDlLYuBNl062qhi0wStnDYyq4ZWalnj2vXbLY - collection: email - restoreTime: "2025-01-05T14:50:13Z" - cancelDeletion: "2025-02-04T14:50:13Z" - /queue/status: - get: - summary: Obtain Queue Status - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: boolean - example: - data: true - /store/purge/blob: - get: - summary: Purge Blob Store - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - /store/purge/data: - get: - summary: Purge Data Store - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - /store/purge/in-memory: - get: - summary: Purge In-Memory Store - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - /store/purge/account: - get: - summary: Purge All Accounts - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: object - nullable: true - example: - data: - /store/uids/{account_id}: - delete: - summary: Reset IMAP UIDs for Account - responses: - "200": - description: OK - content: - application/json: - schema: - type: object - properties: - data: - type: array - items: - type: number - example: - data: - - 0 - - 0 - parameters: - - name: account_id - in: path - required: true + example: Bearer realm="Stalwart Server" + content: + application/problem+json: schema: + $ref: '#/components/schemas/ProblemDetails' + Forbidden: + description: Authenticated principal lacks the required permission + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + NotFound: + description: Resource not found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + TooManyRequests: + description: Anonymous-request rate limit exceeded + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ProblemDetails' + + schemas: + LoginRequest: + type: object + description: | + Tagged union discriminated by `type`. Use `authCode` for the standard + OAuth authorization-code flow (optionally with PKCE) and `authDevice` + to complete an OAuth device-authorization flow. + oneOf: + - $ref: '#/components/schemas/LoginRequestAuthCode' + - $ref: '#/components/schemas/LoginRequestAuthDevice' + discriminator: + propertyName: type + mapping: + authCode: '#/components/schemas/LoginRequestAuthCode' + authDevice: '#/components/schemas/LoginRequestAuthDevice' + + LoginRequestAuthCode: + type: object + required: [type, accountName, accountSecret, clientId] + properties: + type: + type: string + enum: [authCode] + accountName: + type: string + accountSecret: + type: string + format: password + mfaToken: + type: string + nullable: true + description: MFA token returned by a previous `mfaRequired` response + clientId: + type: string + description: OAuth client identifier + redirectUri: + type: string + format: uri + nullable: true + description: Must use `https://` unless the server is in recovery or dev mode + nonce: + type: string + nullable: true + scope: + type: string + nullable: true + codeChallenge: + type: string + nullable: true + description: PKCE code challenge (RFC 7636) + codeChallengeMethod: + type: string + enum: [plain, S256] + nullable: true + description: Defaults to `plain` when a `codeChallenge` is present + state: + type: string + nullable: true + + LoginRequestAuthDevice: + type: object + required: [type, accountName, accountSecret, code] + properties: + type: + type: string + enum: [authDevice] + accountName: + type: string + accountSecret: + type: string + format: password + mfaToken: + type: string + nullable: true + code: + type: string + description: User-facing device code issued by `POST /auth/device` + + LoginResponse: + type: object + description: Tagged union discriminated by `type`. + oneOf: + - $ref: '#/components/schemas/LoginResponseAuthenticated' + - $ref: '#/components/schemas/LoginResponseVerified' + - $ref: '#/components/schemas/LoginResponseMfaRequired' + - $ref: '#/components/schemas/LoginResponseFailure' + discriminator: + propertyName: type + mapping: + authenticated: '#/components/schemas/LoginResponseAuthenticated' + verified: '#/components/schemas/LoginResponseVerified' + mfaRequired: '#/components/schemas/LoginResponseMfaRequired' + failure: '#/components/schemas/LoginResponseFailure' + + LoginResponseAuthenticated: + type: object + required: [type, clientCode] + properties: + type: + type: string + enum: [authenticated] + clientCode: + type: string + description: Authorization code to exchange at `POST /auth/token` + + LoginResponseVerified: + type: object + required: [type] + properties: + type: + type: string + enum: [verified] + + LoginResponseMfaRequired: + type: object + required: [type] + properties: + type: + type: string + enum: [mfaRequired] + + LoginResponseFailure: + type: object + required: [type] + properties: + type: + type: string + enum: [failure] + + Account: + type: object + required: [permissions, edition, locale] + properties: + permissions: + type: array + description: | + Effective permissions for the authenticated principal, filtered to + exclude internal/system-only permissions. Values are from the + `Permission` enum (e.g. `authenticate`, `jmap-email-get`, + `sys-account-settings-get`). + items: type: string + edition: + type: string + enum: [oss, community, enterprise] + description: Server edition + locale: + type: string + description: Preferred locale for the account (IETF BCP 47-style tag) + + ProblemDetails: + type: object + description: RFC 7807 problem details document + properties: + type: + type: string + format: uri + title: + type: string + status: + type: integer + detail: + type: string + instance: + type: string diff --git a/crates/common/src/cache/invalidate.rs b/crates/common/src/cache/invalidate.rs index d60d365a..bb2ffaf5 100644 --- a/crates/common/src/cache/invalidate.rs +++ b/crates/common/src/cache/invalidate.rs @@ -238,6 +238,10 @@ impl Server { linked_object.id().document_id(), )); } + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + // SPDX-License-Identifier: LicenseRef-SEL + #[cfg(feature = "enterprise")] ObjectType::Tenant => { // Invalidate all accounts of the tenant let tenant_id = linked_object.id().document_id(); @@ -253,6 +257,7 @@ impl Server { changes.insert(CacheInvalidation::AccessToken(account_id)); } } + // SPDX-SnippetEnd ObjectType::Role => { role_ids.push(linked_object.id().document_id()); } diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index fa896fca..23c4ce4d 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -43,11 +43,17 @@ pub struct Network { #[derive(Clone)] pub struct NetworkInfo { - pub pacc: String, + pub pacc: Pacc, pub mxs: Vec, pub services: VecMap, } +#[derive(Clone)] +pub struct Pacc { + pub prefix: String, + pub suffix: String, +} + #[derive(Clone)] pub struct Http { pub rate_authenticated: Option, @@ -172,10 +178,13 @@ impl Network { has_acme_tls_challenge = true; } + const SPLIT_HERE: &str = "$$__SPLIT_HERE__$$"; let mut pacc = Configuration { protocols: Protocols::default(), authentication: Some(Authentication { - oauth_public: None, + oauth_public: Some(OAuthPublic { + issuer: SPLIT_HERE.to_string(), + }), password: true, }), info: Info { @@ -307,6 +316,11 @@ impl Network { } } + let (prefix, suffix) = serde_json::to_string(&pacc) + .unwrap_or_default() + .rsplit_once(SPLIT_HERE) + .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) + .unwrap(); let mut network = Network { node_id: bp.node_id() as u64, server_name: system.default_hostname, @@ -321,7 +335,7 @@ impl Network { info: NetworkInfo { mxs: system.mail_exchangers.into_iter().collect(), services: system.services, - pacc: serde_json::to_string(&pacc).unwrap_or_default(), + pacc: Pacc { prefix, suffix }, }, }; diff --git a/crates/common/src/network/dns/records.rs b/crates/common/src/network/dns/records.rs index dcd5094b..1b672726 100644 --- a/crates/common/src/network/dns/records.rs +++ b/crates/common/src/network/dns/records.rs @@ -19,6 +19,7 @@ use registry::schema::{ use reqwest::Url; use sha2::{Digest, Sha256}; use store::registry::RegistryQuery; +use trc::AddContext; use types::id::Id; use x509_parser::parse_x509_certificate; @@ -132,7 +133,7 @@ impl Server { } } DnsRecordType::AutoConfig => { - let pacc_digest = Sha256::digest(&network.info.pacc); + let pacc_digest = Sha256::digest(&self.get_pacc_for_fomain(domain_name).await?); let pacc_digest_encoded = general_purpose::STANDARD.encode(pacc_digest); records.push(NamedDnsRecord { @@ -377,6 +378,21 @@ impl Server { .await .map(|records| BindSerializer::serialize(&records)) } + + pub async fn get_pacc_for_fomain(&self, domain_name: &str) -> trc::Result { + self.get_directory_for_domain(domain_name) + .await + .caused_by(trc::location!()) + .map(|directory| { + directory + .and_then(|directory| { + directory + .oidc_discovery_document() + .map(|doc| doc.url.to_string()) + }) + .unwrap_or_else(|| self.core.network.http.url_https.clone()) + }) + } } #[inline(always)] diff --git a/crates/common/src/network/dns/update.rs b/crates/common/src/network/dns/update.rs index abad6f1a..27d7d202 100644 --- a/crates/common/src/network/dns/update.rs +++ b/crates/common/src/network/dns/update.rs @@ -102,6 +102,7 @@ impl DnsUpdater { } }; + #[allow(deprecated)] Ok(DnsUpdater { polling_interval: server.polling_interval.into_inner(), propagation_timeout: server.propagation_timeout.into_inner(), @@ -266,13 +267,8 @@ impl DnsUpdater { .map_err(|err| format!("Failed to build DNS updater: {}", err))?, }), DnsServer::Route53(server) => { - let secret_access_key = - server.secret_access_key.secret().await?.into_owned(); - let session_token = server - .session_token - .secret() - .await? - .map(|c| c.into_owned()); + let secret_access_key = server.secret_access_key.secret().await?.into_owned(); + let session_token = server.session_token.secret().await?.map(|c| c.into_owned()); let config = dns_update::providers::route53::Route53Config { access_key_id: server.access_key_id, secret_access_key, @@ -292,8 +288,7 @@ impl DnsUpdater { }) } DnsServer::GoogleCloudDns(server) => { - let service_account_json = - server.service_account_json.secret().await?.into_owned(); + let service_account_json = server.service_account_json.secret().await?.into_owned(); let config = dns_update::providers::google_cloud_dns::GoogleCloudDnsConfig { service_account_json, project_id: server.project_id, diff --git a/crates/common/src/storage/quota.rs b/crates/common/src/storage/quota.rs index abebe93d..2b9af10d 100644 --- a/crates/common/src/storage/quota.rs +++ b/crates/common/src/storage/quota.rs @@ -30,7 +30,10 @@ impl Server { .await .add_context(|err| err.caused_by(trc::location!()).account_id(account_id)) } - + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + // SPDX-License-Identifier: LicenseRef-SEL + #[cfg(feature = "enterprise")] pub async fn get_used_quota_tenant(&self, tenant_id: u32) -> trc::Result { self.core .storage @@ -39,6 +42,12 @@ impl Server { .await .add_context(|err| err.caused_by(trc::location!())) } + // SPDX-SnippetEnd + + #[cfg(not(feature = "enterprise"))] + pub async fn get_used_quota_tenant(&self, _tenant_id: u32) -> trc::Result { + Ok(0) + } pub async fn has_available_quota( &self, diff --git a/crates/directory/src/backend/oidc/config.rs b/crates/directory/src/backend/oidc/config.rs index b0d6cec2..ebcd191e 100644 --- a/crates/directory/src/backend/oidc/config.rs +++ b/crates/directory/src/backend/oidc/config.rs @@ -7,7 +7,7 @@ use crate::Directory; use crate::backend::oidc::lookup::fetch_jwks_keys; use crate::backend::oidc::{ - DiscoveryDocument, JwksCache, OidcError, OpenIdConfig, OpenIdDirectory, + DiscoveryDocument, JwksCache, OidcConfig, OidcDiscovery, OidcError, OpenIdDirectory, }; use registry::schema::structs; use reqwest::Client; @@ -17,7 +17,7 @@ use trc::AuthEvent; impl OpenIdDirectory { pub async fn open(config: structs::OidcDirectory) -> Result { - Self::new(OpenIdConfig { + Self::new(OidcConfig { issue_url: config.issuer_url, require_aud: config.require_audience, require_scopes: config.require_scopes.into_inner(), @@ -31,7 +31,7 @@ impl OpenIdDirectory { .map_err(|err| err.to_string()) } - pub async fn new(config: OpenIdConfig) -> Result { + pub async fn new(config: OidcConfig) -> Result { let http = Client::builder() .user_agent("Stalwart/1.0") .timeout(Duration::from_secs(30)) @@ -133,8 +133,11 @@ impl OpenIdDirectory { }); Ok(Self { + discovery: OidcDiscovery { + url: config.issue_url.clone(), + document: discovery, + }, config, - discovery, http, cache, }) diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index 02bdd945..30b90954 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -63,7 +63,7 @@ impl OpenIdDirectory { validation.validate_aud = false; } - validation.set_issuer(&[&self.discovery.issuer]); + validation.set_issuer(&[&self.discovery.document.issuer]); validation.leeway = 60; match decode::(token, dk, &validation) { @@ -115,7 +115,7 @@ impl OpenIdDirectory { } } - let new_keys = fetch_jwks_keys(&self.http, &self.discovery.jwks_uri).await?; + let new_keys = fetch_jwks_keys(&self.http, &self.discovery.document.jwks_uri).await?; { let mut guard = self.cache.write().await; guard.keys = new_keys; @@ -146,7 +146,7 @@ impl OpenIdDirectory { async fn fetch_userinfo(&self, token: &str) -> Result { let resp = self .http - .get(&self.discovery.userinfo_endpoint) + .get(&self.discovery.document.userinfo_endpoint) .bearer_auth(token) .send() .await diff --git a/crates/directory/src/backend/oidc/mod.rs b/crates/directory/src/backend/oidc/mod.rs index 5e88ea70..03ad48c0 100644 --- a/crates/directory/src/backend/oidc/mod.rs +++ b/crates/directory/src/backend/oidc/mod.rs @@ -14,7 +14,7 @@ use utils::Client; pub mod config; pub mod lookup; -pub struct OpenIdConfig { +pub struct OidcConfig { pub issue_url: String, pub require_aud: Option, pub require_scopes: Vec, @@ -24,6 +24,11 @@ pub struct OpenIdConfig { pub default_domain: Option, } +pub struct OidcDiscovery { + pub url: String, + pub document: DiscoveryDocument, +} + #[derive(Deserialize, Serialize)] pub struct DiscoveryDocument { pub issuer: String, @@ -52,8 +57,8 @@ struct JwksCache { } pub struct OpenIdDirectory { - config: OpenIdConfig, - pub discovery: DiscoveryDocument, + config: OidcConfig, + pub discovery: OidcDiscovery, http: Client, cache: RwLock, } diff --git a/crates/directory/src/core/dispatch.rs b/crates/directory/src/core/dispatch.rs index 1b8c028f..21e47cd6 100644 --- a/crates/directory/src/core/dispatch.rs +++ b/crates/directory/src/core/dispatch.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{Account, Credentials, Directory, Recipient, backend::oidc::DiscoveryDocument}; +use crate::{Account, Credentials, Directory, Recipient, backend::oidc::OidcDiscovery}; use trc::AddContext; impl Directory { @@ -34,7 +34,7 @@ impl Directory { !matches!(self, Directory::OpenId(_)) } - pub fn oidc_discovery_document(&self) -> Option<&DiscoveryDocument> { + pub fn oidc_discovery_document(&self) -> Option<&OidcDiscovery> { match &self { Directory::OpenId(directory) => Some(&directory.discovery), _ => None, diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index 1e4450c9..3c576bde 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -28,6 +28,7 @@ pub enum Credentials { }, } +#[allow(clippy::large_enum_variant)] pub enum Directory { Ldap(LdapDirectory), Sql(SqlDirectory), diff --git a/crates/http/src/auth/oauth/auth.rs b/crates/http/src/auth/oauth/auth.rs index 9e8d5f27..25ea6247 100644 --- a/crates/http/src/auth/oauth/auth.rs +++ b/crates/http/src/auth/oauth/auth.rs @@ -133,7 +133,9 @@ impl OAuthApiHandler for Server { .await? .and_then(|directory| directory.oidc_discovery_document()) { - Ok(JsonResponse::new(endpoint).no_cache().into_http_response()) + Ok(JsonResponse::new(&endpoint.document) + .no_cache() + .into_http_response()) } else { self.handle_oidc_metadata(req, session).await } diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index ef8496ed..ad859a22 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -311,7 +311,15 @@ impl ParseHttp for Server { .await?; return Ok(Resource::new( "application/json", - self.core.network.info.pacc.clone().into_bytes(), + self.get_pacc_for_fomain( + req.headers() + .get(header::HOST) + .and_then(|h| h.to_str().ok()) + .map(|h| h.rsplit_once(':').map_or(h, |(h, _)| h)) + .unwrap_or_default(), + ) + .await? + .into_bytes(), ) .into_http_response()); } diff --git a/crates/jmap/src/registry/mapping/mod.rs b/crates/jmap/src/registry/mapping/mod.rs index 6a92b99f..556f7a0a 100644 --- a/crates/jmap/src/registry/mapping/mod.rs +++ b/crates/jmap/src/registry/mapping/mod.rs @@ -32,6 +32,7 @@ pub mod principal; pub mod public_key; pub mod queued_message; pub mod report; +pub mod sieve; pub mod spam_sample; pub mod task; pub mod tls; diff --git a/crates/jmap/src/registry/mapping/principal.rs b/crates/jmap/src/registry/mapping/principal.rs index 69ecd48a..5bab6856 100644 --- a/crates/jmap/src/registry/mapping/principal.rs +++ b/crates/jmap/src/registry/mapping/principal.rs @@ -350,6 +350,10 @@ pub(crate) async fn validate_role( } } +// SPDX-SnippetBegin +// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC +// SPDX-License-Identifier: LicenseRef-SEL +#[cfg(feature = "enterprise")] pub(crate) async fn validate_tenant_quota( set: &RegistrySetResponse<'_>, quota: TenantStorageQuota, @@ -411,6 +415,15 @@ pub(crate) async fn validate_tenant_quota( Ok(Ok(ObjectResponse::default())) } +// SPDX-SnippetEnd + +#[cfg(not(feature = "enterprise"))] +pub(crate) async fn validate_tenant_quota( + _set: &RegistrySetResponse<'_>, + _quota: TenantStorageQuota, +) -> ValidationResult { + ValidationResult::Ok(Ok(ObjectResponse::default())) +} pub(crate) async fn schedule_account_destruction( server: &Server, diff --git a/crates/jmap/src/registry/mapping/queued_message.rs b/crates/jmap/src/registry/mapping/queued_message.rs index f1271c38..1f3501c5 100644 --- a/crates/jmap/src/registry/mapping/queued_message.rs +++ b/crates/jmap/src/registry/mapping/queued_message.rs @@ -537,6 +537,10 @@ pub(crate) async fn queued_message_query( } } +// SPDX-SnippetBegin +// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC +// SPDX-License-Identifier: LicenseRef-SEL +#[cfg(feature = "enterprise")] async fn tenant_domains(server: &Server, tenant_id: u32) -> trc::Result> { let domain_ids = server .registry() @@ -553,6 +557,12 @@ async fn tenant_domains(server: &Server, tenant_id: u32) -> trc::Result trc::Result> { + Ok(AHashSet::new()) +} fn map_message(message_in: &ArchivedMessage) -> QueuedMessage { let mut message_out = QueuedMessage { diff --git a/crates/jmap/src/registry/mapping/sieve.rs b/crates/jmap/src/registry/mapping/sieve.rs new file mode 100644 index 00000000..04aae17e --- /dev/null +++ b/crates/jmap/src/registry/mapping/sieve.rs @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::registry::mapping::{ObjectResponse, ValidationResult}; +use common::Server; +use jmap_proto::error::set::SetError; +use registry::schema::prelude::Property; + +pub(crate) async fn validate_sieve_script( + server: &Server, + script: &str, + old_script: Option<&str>, + is_system_script: bool, +) -> ValidationResult { + if old_script.is_none_or(|old_script| old_script != script) { + if is_system_script { + if let Err(err) = server + .core + .sieve + .untrusted_compiler + .compile(script.as_bytes()) + { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Contents) + .with_description(format!( + "Failed to compile system Sieve script: {err}" + )))); + } + } else { + if let Err(err) = server + .core + .sieve + .untrusted_compiler + .compile(script.as_bytes()) + { + return Ok(Err(SetError::invalid_properties() + .with_property(Property::Contents) + .with_description(format!( + "Failed to compile user Sieve script: {err}" + )))); + } + } + } + + Ok(Ok(ObjectResponse::default())) +} diff --git a/crates/jmap/src/registry/query.rs b/crates/jmap/src/registry/query.rs index 457ee576..732fa42c 100644 --- a/crates/jmap/src/registry/query.rs +++ b/crates/jmap/src/registry/query.rs @@ -403,7 +403,7 @@ impl RegistryQueryFilters for QueryRequest { .unwrap_or_default() .into_iter() .next() - .unwrap_or_else(|| Comparator::ascending(RegistryComparator::Property(Property::Id))); + .unwrap_or_else(|| Comparator::descending(RegistryComparator::Property(Property::Id))); match comparator.property { RegistryComparator::Property(property) => { diff --git a/crates/jmap/src/registry/set.rs b/crates/jmap/src/registry/set.rs index da61ab5d..31549509 100644 --- a/crates/jmap/src/registry/set.rs +++ b/crates/jmap/src/registry/set.rs @@ -21,6 +21,7 @@ use crate::registry::{ public_key::validate_public_key, queued_message::queued_message_set, report::report_set, + sieve::validate_sieve_script, spam_sample::spam_sample_set, task::task_set, tls::{validate_acme_provider, validate_certificate}, @@ -47,7 +48,10 @@ use registry::{ OBJ_FILTER_ACCOUNT, OBJ_FILTER_TENANT, OBJ_SINGLETON, Object, ObjectInner, ObjectType, Property, }, - structs::{Certificate, DkimSignature, DnsServer, Domain, PublicKey, Role, Task}, + structs::{ + Certificate, DkimSignature, DnsServer, Domain, PublicKey, Role, SieveSystemScript, + SieveUserScript, Task, + }, }, types::id::ObjectId, }; @@ -467,6 +471,24 @@ impl RegistrySet for Server { ObjectInner::Certificate(cert) => { validate_certificate(cert, modification.as_certificate()).await? } + ObjectInner::SieveUserScript(SieveUserScript { contents, .. }) => { + validate_sieve_script( + set.server, + contents, + modification.as_sieve_script(), + false, + ) + .await? + } + ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => { + validate_sieve_script( + set.server, + contents, + modification.as_sieve_script(), + true, + ) + .await? + } _ => Ok(ObjectResponse::default()), }; @@ -815,6 +837,19 @@ impl Modification { }, } } + + fn as_sieve_script(&self) -> Option<&str> { + match self { + Modification::Create { .. } => None, + Modification::Update { object, .. } => match &object.inner { + ObjectInner::SieveUserScript(SieveUserScript { contents, .. }) + | ObjectInner::SieveSystemScript(SieveSystemScript { contents, .. }) => { + Some(contents.as_str()) + } + _ => None, + }, + } + } } pub(crate) fn map_write_error(err: RegistryWriteResult) -> SetError { diff --git a/crates/services/src/task_manager/maintenance.rs b/crates/services/src/task_manager/maintenance.rs index 8a534297..66b833aa 100644 --- a/crates/services/src/task_manager/maintenance.rs +++ b/crates/services/src/task_manager/maintenance.rs @@ -359,38 +359,45 @@ async fn store_maintenance( } } TaskStoreMaintenanceType::ResetTenantQuotas => { - let mut batch = BatchBuilder::new(); - let now = now() as i64; - - for tenant_id in server - .registry() - .query::(RegistryQuery::new(ObjectType::Tenant)) - .await? + // SPDX-SnippetBegin + // SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + // SPDX-License-Identifier: LicenseRef-SEL + #[cfg(feature = "enterprise")] { - #[cfg(feature = "test_mode")] - let status = TaskStatus::at(now); + let mut batch = BatchBuilder::new(); + let now = now() as i64; - #[cfg(not(feature = "test_mode"))] - let status = - TaskStatus::at(now + rand::Rng::random_range(&mut rand::rng(), 0..=300)); + for tenant_id in server + .registry() + .query::(RegistryQuery::new(ObjectType::Tenant)) + .await? + { + #[cfg(feature = "test_mode")] + let status = TaskStatus::at(now); - batch.schedule_task(Task::TenantMaintenance(TaskTenantMaintenance { - tenant_id: tenant_id.into(), - maintenance_type: TaskTenantMaintenanceType::RecalculateQuota, - status, - })); + #[cfg(not(feature = "test_mode"))] + let status = + TaskStatus::at(now + rand::Rng::random_range(&mut rand::rng(), 0..=300)); - if batch.is_large_batch() { + batch.schedule_task(Task::TenantMaintenance(TaskTenantMaintenance { + tenant_id: tenant_id.into(), + maintenance_type: TaskTenantMaintenanceType::RecalculateQuota, + status, + })); + + if batch.is_large_batch() { + server.core.storage.data.write(batch.build_all()).await?; + server.notify_task_queue(); + batch = BatchBuilder::new(); + } + } + + if !batch.is_empty() { server.core.storage.data.write(batch.build_all()).await?; server.notify_task_queue(); - batch = BatchBuilder::new(); } } - - if !batch.is_empty() { - server.core.storage.data.write(batch.build_all()).await?; - server.notify_task_queue(); - } + // SPDX-SnippetEnd } } @@ -489,6 +496,10 @@ async fn recalculate_quota(server: &Server, account_id: u32) -> trc::Result<()> .map(|_| ()) } +// SPDX-SnippetBegin +// SPDX-FileCopyrightText: 2020 Stalwart Labs LLC +// SPDX-License-Identifier: LicenseRef-SEL +#[cfg(feature = "enterprise")] async fn recalculate_tenant_quota(server: &Server, tenant_id: u32) -> trc::Result<()> { let mut quota = 0; for account_id in server @@ -515,6 +526,12 @@ async fn recalculate_tenant_quota(server: &Server, tenant_id: u32) -> trc::Resul .caused_by(trc::location!()) .map(|_| ()) } +// SPDX-SnippetEnd + +#[cfg(not(feature = "enterprise"))] +async fn recalculate_tenant_quota(_server: &Server, _tenant_id: u32) -> trc::Result<()> { + Ok(()) +} async fn reset_imap_uids(server: &Server, account_id: u32) -> trc::Result<(u32, u32)> { let mut mailbox_count = 0; diff --git a/crates/store/src/registry/write.rs b/crates/store/src/registry/write.rs index 90a530a0..61f2c630 100644 --- a/crates/store/src/registry/write.rs +++ b/crates/store/src/registry/write.rs @@ -28,6 +28,8 @@ use std::{borrow::Cow, fmt::Display}; use trc::AddContext; use types::id::Id; +const MAX_OBJECT_PAYLOAD_SIZE: usize = 200_000; + #[derive(Debug, PartialEq, Eq)] pub enum RegistryWriteResult { Success(Id), @@ -307,6 +309,18 @@ impl RegistryStore { // It's pickle time! let out = object.inner.to_pickled_vec(); + if out.len() > MAX_OBJECT_PAYLOAD_SIZE { + return Ok(RegistryWriteResult::ValidationError { + errors: vec![ValidationError::Invalid { + property: Property::Id, + value: format!( + "Object size {} exceeds maximum of {}", + out.len(), + MAX_OBJECT_PAYLOAD_SIZE + ), + }], + }); + } // Build batch if write_id { diff --git a/tests/src/directory/oidc.rs b/tests/src/directory/oidc.rs index 3b1c50e2..6c5b3883 100644 --- a/tests/src/directory/oidc.rs +++ b/tests/src/directory/oidc.rs @@ -49,7 +49,7 @@ pub async fn test() { // Make sure the userinfo endpoint is not being used if let Directory::OpenId(directory) = &mut oidc { - directory.discovery.userinfo_endpoint = "http://invalid".to_string(); + directory.discovery.document.userinfo_endpoint = "http://invalid".to_string(); } // JWT authentication should still work without the userinfo endpoint @@ -108,7 +108,7 @@ pub async fn test() { assert_eq!( oidc.oidc_discovery_document() .as_ref() - .map(|oidc| oidc.authorization_endpoint.as_str()), + .map(|oidc| oidc.document.authorization_endpoint.as_str()), Some("http://localhost:9080/realms/stalwart/protocol/openid-connect/auth") ); }