From 2418da031fd68c29e40d04bcbb8c335f14fe54de Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:04:05 +0100 Subject: [PATCH] [Web] API: complete the OpenAPI docs, add route tests for json_api.php --- data/web/api/openapi.yaml | 22114 ++++++++++------ tests/php/Api/Admin/AdminRoutesTest.php | 82 + tests/php/Api/Admin/ApiKeyRoutesTest.php | 76 + tests/php/Api/Admin/DomainAdminRoutesTest.php | 111 + tests/php/Api/Admin/LogRoutesTest.php | 82 + .../php/Api/Admin/OAuth2ClientRoutesTest.php | 62 + tests/php/Api/Admin/OwnAccountRoutesTest.php | 58 + tests/php/Api/Admin/SettingsRoutesTest.php | 94 + tests/php/Api/Admin/StatusRoutesTest.php | 120 + tests/php/Api/ApiResult.php | 43 + tests/php/Api/ApiTestCase.php | 150 + tests/php/Api/Domains/AliasDomainTest.php | 71 + tests/php/Api/Domains/DkimTest.php | 62 + tests/php/Api/Domains/DomainPolicyTest.php | 57 + tests/php/Api/Domains/DomainRatelimitTest.php | 68 + tests/php/Api/Domains/DomainSearchTest.php | 115 + tests/php/Api/Domains/DomainTemplateTest.php | 66 + tests/php/Api/Domains/DomainTest.php | 152 + tests/php/Api/Domains/MtaStsTest.php | 34 + tests/php/Api/Filtering/Fail2BanTest.php | 70 + tests/php/Api/Filtering/GlobalFilterTest.php | 56 + tests/php/Api/Filtering/QuarantineTest.php | 59 + tests/php/Api/Filtering/QueueTest.php | 78 + .../Api/Filtering/QuotaNotificationTest.php | 31 + tests/php/Api/Filtering/RspamdTest.php | 118 + tests/php/Api/Harness/runner.php | 266 + .../Api/Harness/webroot/inc/lib/ssp.class.php | 3 + .../Harness/webroot/inc/prerequisites.inc.php | 11 + .../Api/Mailboxes/AppPasswordRoutesTest.php | 52 + .../Mailboxes/FilterAndPolicyRoutesTest.php | 84 + tests/php/Api/Mailboxes/MailboxRoutesTest.php | 193 + tests/php/Api/Mailboxes/MailboxSearchTest.php | 91 + .../Mailboxes/MailboxSettingsRoutesTest.php | 78 + .../php/Api/Mailboxes/RatelimitRoutesTest.php | 52 + .../php/Api/Mailboxes/ResourceRoutesTest.php | 49 + tests/php/Api/Modules/IdentityRoutesTest.php | 136 + .../Api/Modules/MailboxGroupRoutesTest.php | 111 + tests/php/Api/Modules/SyncJobRoutesTest.php | 194 + tests/php/Api/OpenApiSpecTest.php | 155 + tests/php/Api/RequestHandlingTest.php | 131 + tests/php/Api/RouteMap.php | 115 + tests/php/Api/Routing/AliasRoutesTest.php | 116 + .../Api/Routing/ForwardingHostRoutesTest.php | 62 + tests/php/Api/Routing/MapRoutesTest.php | 86 + tests/php/Api/Routing/TransportRoutesTest.php | 85 + tests/php/composer.json | 3 +- tests/php/composer.lock | 237 +- 47 files changed, 18848 insertions(+), 7491 deletions(-) create mode 100644 tests/php/Api/Admin/AdminRoutesTest.php create mode 100644 tests/php/Api/Admin/ApiKeyRoutesTest.php create mode 100644 tests/php/Api/Admin/DomainAdminRoutesTest.php create mode 100644 tests/php/Api/Admin/LogRoutesTest.php create mode 100644 tests/php/Api/Admin/OAuth2ClientRoutesTest.php create mode 100644 tests/php/Api/Admin/OwnAccountRoutesTest.php create mode 100644 tests/php/Api/Admin/SettingsRoutesTest.php create mode 100644 tests/php/Api/Admin/StatusRoutesTest.php create mode 100644 tests/php/Api/ApiResult.php create mode 100644 tests/php/Api/ApiTestCase.php create mode 100644 tests/php/Api/Domains/AliasDomainTest.php create mode 100644 tests/php/Api/Domains/DkimTest.php create mode 100644 tests/php/Api/Domains/DomainPolicyTest.php create mode 100644 tests/php/Api/Domains/DomainRatelimitTest.php create mode 100644 tests/php/Api/Domains/DomainSearchTest.php create mode 100644 tests/php/Api/Domains/DomainTemplateTest.php create mode 100644 tests/php/Api/Domains/DomainTest.php create mode 100644 tests/php/Api/Domains/MtaStsTest.php create mode 100644 tests/php/Api/Filtering/Fail2BanTest.php create mode 100644 tests/php/Api/Filtering/GlobalFilterTest.php create mode 100644 tests/php/Api/Filtering/QuarantineTest.php create mode 100644 tests/php/Api/Filtering/QueueTest.php create mode 100644 tests/php/Api/Filtering/QuotaNotificationTest.php create mode 100644 tests/php/Api/Filtering/RspamdTest.php create mode 100644 tests/php/Api/Harness/runner.php create mode 100644 tests/php/Api/Harness/webroot/inc/lib/ssp.class.php create mode 100644 tests/php/Api/Harness/webroot/inc/prerequisites.inc.php create mode 100644 tests/php/Api/Mailboxes/AppPasswordRoutesTest.php create mode 100644 tests/php/Api/Mailboxes/FilterAndPolicyRoutesTest.php create mode 100644 tests/php/Api/Mailboxes/MailboxRoutesTest.php create mode 100644 tests/php/Api/Mailboxes/MailboxSearchTest.php create mode 100644 tests/php/Api/Mailboxes/MailboxSettingsRoutesTest.php create mode 100644 tests/php/Api/Mailboxes/RatelimitRoutesTest.php create mode 100644 tests/php/Api/Mailboxes/ResourceRoutesTest.php create mode 100644 tests/php/Api/Modules/IdentityRoutesTest.php create mode 100644 tests/php/Api/Modules/MailboxGroupRoutesTest.php create mode 100644 tests/php/Api/Modules/SyncJobRoutesTest.php create mode 100644 tests/php/Api/OpenApiSpecTest.php create mode 100644 tests/php/Api/RequestHandlingTest.php create mode 100644 tests/php/Api/RouteMap.php create mode 100644 tests/php/Api/Routing/AliasRoutesTest.php create mode 100644 tests/php/Api/Routing/ForwardingHostRoutesTest.php create mode 100644 tests/php/Api/Routing/MapRoutesTest.php create mode 100644 tests/php/Api/Routing/TransportRoutesTest.php diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index b08747722..97bf903eb 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -19,6 +19,29 @@ info: defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) are shown in the UI, but can only be changed in mailcow.conf. + + **Requests** + + - `get` routes use `GET`, all other routes use `POST` (else `405`). + - `add` routes take the new object as the JSON body. + - `edit` routes take `{"items": [...], "attr": {...}}`: `items` names the + objects to change, `attr` holds the values to set. + - `delete` routes take the JSON array of the objects to delete. + - Send `Content-Type: application/json`. A body that is no valid JSON + is answered with `400`. + + **Answers** + + - Write routes answer with the list of messages of the call (`type` + `success`, `danger`, `warning` or `info`; `msg` is a key of the mailcow + language files, alone or with its values). A call without messages + answers `{"type": "success", "msg": "Task completed"}` or + `{"type": "error", "msg": "Cannot add item"}`. The HTTP status is 200 + in both cases: check `type`. + - Read routes answer with the object or list, `{}` or `[]` when there is + nothing. + - Unknown routes answer `404` with `{"type": "error", "msg": "route not found"}`. + title: mailcow API version: "1.0.0" @@ -31,23 +54,4032 @@ components: type: apiKey in: header name: X-API-Key + schemas: + Message: + type: object + description: >- + One message of a write call, as the mailcow UI shows it. `msg` is a key + of the mailcow language files, alone or as a list with the values of its + placeholders. + properties: + type: + type: string + enum: + - success + - danger + - warning + - info + log: + type: array + description: >- + The call that produced the message (function, action, object, + data), secrets replaced by `*` + items: {} + msg: + oneOf: + - type: string + - type: array + items: {} + required: + - type + - msg + Messages: + type: array + items: + $ref: "#/components/schemas/Message" + TaskResult: + type: object + description: Answer of a write call that produced no message + properties: + type: + type: string + enum: + - success + - error + msg: + type: string + examples: + - Task completed + - Cannot add item + required: + - type + - msg + Error: + type: object + properties: + type: + type: string + const: error + msg: + type: string + required: + - type + - msg + Domain: + type: object + description: >- + A domain. Quotas and sizes are in bytes; the `*_int` fields repeat the + value before them. + properties: + domain_name: + type: string + description: Domain name (IDN as punycode) + domain_h_name: + type: string + description: Domain name in Unicode + description: + type: string + description: Description + max_new_mailbox_quota: + type: integer + description: >- + Largest quota a new mailbox can get: the free domain quota, at most + the max. mailbox quota + def_new_mailbox_quota: + type: integer + description: Quota a new mailbox gets by default + quota_used_in_domain: + type: [string, integer] + description: Sum of the quotas of the mailboxes + bytes_total: + type: [string, integer] + description: Storage used by the mailboxes + msgs_total: + type: [string, integer] + description: Number of messages in the mailboxes + mboxes_in_domain: + type: integer + description: Number of mailboxes + mboxes_left: + type: integer + description: Mailboxes that can still be added + max_num_aliases_for_domain: + type: integer + description: Max. number of aliases + max_num_mboxes_for_domain: + type: integer + description: Max. number of mailboxes + def_quota_for_mbox: + type: integer + description: Default quota of new mailboxes + max_quota_for_mbox: + type: integer + description: Max. quota per mailbox + max_quota_for_domain: + type: integer + description: Quota of the whole domain + relayhost: + type: [string, integer] + description: ID of the sender-dependent transport, "0" = none + backupmx: + type: integer + enum: [0, 1] + description: Relay domain (backup MX) + backupmx_int: + type: integer + enum: [0, 1] + gal: + type: integer + enum: [0, 1] + description: Global address list in SOGo + gal_int: + type: integer + enum: [0, 1] + rl: + description: Rate limit, `false` if none + oneOf: + - $ref: "#/components/schemas/DomainRatelimit" + - type: boolean + const: false + active: + type: integer + enum: [0, 1] + description: Domain active + active_int: + type: integer + enum: [0, 1] + relay_all_recipients: + type: integer + enum: [0, 1] + description: Relay all recipients + relay_all_recipients_int: + type: integer + enum: [0, 1] + relay_unknown_only: + type: integer + enum: [0, 1] + description: Relay non-existing mailboxes only + relay_unknown_only_int: + type: integer + enum: [0, 1] + created: + type: string + description: Creation time (`YYYY-MM-DD hh:mm:ss`) + modified: + type: [string, "null"] + description: Time of the last change + aliases_in_domain: + type: [integer, string] + description: Number of aliases, including those of its alias domains + aliases_left: + type: integer + description: Aliases that can still be added + domain_admins: + type: string + description: >- + Comma-separated domain admins, `-` if none; only for admins (and + API keys) + tags: + type: array + items: + type: string + description: Tags; missing when the domain has none + DomainRatelimit: + type: object + description: Rate limit of a domain or alias domain + properties: + value: + type: string + description: Messages per time frame + frame: + type: string + enum: [s, m, h, d] + description: Time frame (second, minute, hour, day) + DomainTemplate: + type: object + properties: + id: + type: integer + description: Template ID + template: + type: string + description: Template name + type: + type: string + const: domain + attributes: + $ref: "#/components/schemas/DomainTemplateAttributes" + created: + type: string + description: Creation time + modified: + type: [string, "null"] + description: Time of the last change + DomainTemplateAttributes: + type: object + description: Values of a domain template as stored; quotas in bytes + properties: + tags: + type: array + items: + type: string + max_num_aliases_for_domain: + type: integer + max_num_mboxes_for_domain: + type: integer + def_quota_for_mbox: + type: integer + max_quota_for_mbox: + type: integer + max_quota_for_domain: + type: integer + rl_frame: + type: string + enum: [s, m, h, d] + rl_value: + type: [string, integer] + description: Rate limit, "" = none + active: + type: integer + enum: [0, 1] + gal: + type: integer + enum: [0, 1] + backupmx: + type: integer + enum: [0, 1] + relay_all_recipients: + type: integer + enum: [0, 1] + relay_unknown_only: + type: integer + enum: [0, 1] + dkim_selector: + type: string + key_size: + type: integer + DomainTemplateInput: + type: object + description: >- + Values of a domain template, quotas in MiB. The defaults apply when a + template is added; for edit/domain/template see there. + properties: + template: + type: string + description: Template name, unique + tags: + type: array + items: + type: string + default: [] + description: Tags of new domains + max_num_aliases_for_domain: + type: [integer, string] + default: 400 + description: Max. number of aliases + max_num_mboxes_for_domain: + type: [integer, string] + default: 10 + description: Max. number of mailboxes + def_quota_for_mbox: + type: [integer, string] + default: 3072 + description: Default quota of new mailboxes in MiB + max_quota_for_mbox: + type: [integer, string] + default: 10240 + description: Max. quota per mailbox in MiB + max_quota_for_domain: + type: [integer, string] + default: 10240 + description: Quota of the whole domain in MiB + rl_value: + type: [integer, string] + default: "" + description: Rate limit, messages per `rl_frame`; empty = none + rl_frame: + type: string + enum: [s, m, h, d] + default: s + description: Rate limit time frame + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 1 + gal: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 1 + description: Global address list in SOGo + backupmx: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + description: Relay domain (backup MX) + relay_all_recipients: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + relay_unknown_only: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + dkim_selector: + type: string + default: dkim + description: Selector of the DKIM key of new domains + key_size: + type: [integer, string] + default: 2048 + description: DKIM key size in bits of new domains + AliasDomain: + type: object + properties: + alias_domain: + type: string + description: Alias domain name + target_domain: + type: string + description: Domain the alias domain points to + parent_is_backupmx: + type: integer + enum: [0, 1] + description: The target domain is a relay domain + active: + type: integer + enum: [0, 1] + description: Alias domain active + active_int: + type: integer + enum: [0, 1] + rl: + description: Rate limit, `false` if none + oneOf: + - $ref: "#/components/schemas/DomainRatelimit" + - type: boolean + const: false + created: + type: string + description: Creation time + modified: + type: [string, "null"] + description: Time of the last change + DomainPolicyEntry: + type: object + properties: + object: + type: string + description: The domain or mailbox the entry belongs to + value: + type: string + description: Sender address or pattern + prefid: + type: integer + description: Entry ID (for delete/domain-policy) + DkimKey: + type: object + properties: + pubkey: + type: string + description: Public key (base64, without PEM lines) + length: + type: string + enum: ["1024", "2048", "3072", "4096", ">= 8192"] + description: Key size in bits, estimated from the public key + dkim_txt: + type: string + description: >- + Content of the DNS TXT record; split into quoted strings of 255 + characters when `SPLIT_DKIM_255` is enabled + dkim_selector: + type: string + description: Selector + privkey: + type: string + description: >- + Private key in PEM format, base64-encoded, when + `SHOW_DKIM_PRIV_KEYS` is enabled, else empty + DomainSearchRequest: + type: object + description: >- + Server-side processing parameters of DataTables + (https://datatables.net/manual/server-side). Without `start` and + `length` all matching domains are answered. + properties: + draw: + type: integer + description: Request counter, answered as it is + start: + type: integer + description: Offset of the first domain + length: + type: integer + description: Number of domains, -1 = all + search: + type: object + properties: + value: + type: string + description: Searched in all columns with `searchable` true + order: + type: array + items: + type: object + properties: + column: + type: integer + description: Position in `columns` (see the description of the route) + dir: + type: string + enum: [asc, desc] + columns: + type: array + description: >- + The columns of the table; the position gives the database column + (see the description of the route) + items: + type: object + properties: + data: + type: [string, integer] + description: >- + Column name of the UI; a column position makes + `search.value` of this entry filter that column + searchable: + type: [boolean, string] + description: Column takes part in the global search + orderable: + type: [boolean, string] + description: Column may be sorted by + search: + type: object + properties: + value: + type: string + description: Filter of this column + DomainSearchResult: + type: object + properties: + draw: + type: integer + description: "`draw` of the request" + recordsTotal: + type: integer + description: Number of domains the caller can see + recordsFiltered: + type: integer + description: Number of domains that match the search + data: + type: array + description: The domains of the page + items: + $ref: "#/components/schemas/Domain" + MailboxItems: + description: One mailbox address or a list of them + oneOf: + - type: string + - type: array + items: + type: string + UserAclName: + type: string + description: >- + A user ACL: what the mailbox user may do in the UI (time-limited + aliases, TLS policy, spam score, allow/deny lists, tagged mail, sync + jobs, ActiveSync and SOGo reset, Pushover, quarantine actions, + attachments, notification interval and category, app passwords, + password reset) + enum: + - spam_alias + - tls_policy + - spam_score + - spam_policy + - delimiter_action + - syncjobs + - eas_reset + - sogo_profile_reset + - pushover + - quarantine + - quarantine_attachments + - quarantine_notification + - quarantine_category + - app_passwds + - pw_reset + ProtocolAccessName: + type: string + enum: + - imap + - pop3 + - smtp + - sieve + - eas + - dav + MailboxAttributes: + type: object + description: Settings of a mailbox; flags are "0" or "1" + properties: + force_pw_update: + type: [string, integer] + description: The user must change the password at the next login + force_tfa: + type: [string, integer] + description: The user must set up two-factor authentication at the next login + tls_enforce_in: + type: [string, integer] + tls_enforce_out: + type: [string, integer] + sogo_access: + type: [string, integer] + description: Login to SOGo from the UI + imap_access: + type: [string, integer] + pop3_access: + type: [string, integer] + smtp_access: + type: [string, integer] + sieve_access: + type: [string, integer] + eas_access: + type: [string, integer] + dav_access: + type: [string, integer] + relayhost: + type: [string, integer] + description: ID of the sender-dependent transport, "0" for none + shared: + type: [string, integer] + description: Shared mailbox without a login of its own + passwd_update: + type: [string, integer] + description: Time of the last password change + mailbox_format: + type: string + quarantine_notification: + type: string + enum: + - never + - hourly + - daily + - weekly + quarantine_category: + type: string + enum: + - reject + - add_header + - all + attribute_hash: + type: string + description: Hash of the template the settings came from, cleared by manual changes + recovery_email: + type: string + description: Address for password reset mails (present after it was set) + template: + type: string + description: Name of the template the mailbox was created from (if any) + Mailbox: + type: object + description: >- + Details of a mailbox. `max_new_quota`, `spam_aliases`, + `pushover_active`, `rl`, `rl_scope`, `is_relayed` and `sender_acl` are + missing in the reduced form; `tags` is missing when the mailbox has no + tags. + properties: + username: + type: string + description: Address of the mailbox + active: + type: integer + description: 1 active, 0 inactive, 2 login disabled (mail is still accepted) + active_int: + type: integer + description: Same as `active` + domain: + type: string + name: + type: string + description: Full name + local_part: + type: string + quota: + type: integer + description: Quota in bytes, 0 = unlimited + messages: + type: integer + description: Number of messages + attributes: + $ref: "#/components/schemas/MailboxAttributes" + custom_attributes: + type: [object, array] + description: Custom attributes (an empty list when there are none) + additionalProperties: + type: string + quota_used: + type: integer + description: Used bytes + percent_in_use: + type: [integer, string] + description: Used part of the quota in percent, "- " for unlimited quota + created: + type: string + modified: + type: [string, "null"] + authsource: + type: string + enum: + - mailcow + - keycloak + - generic-oidc + - ldap + percent_class: + type: string + enum: + - info + - success + - warning + - danger + description: info (unlimited), success, warning (≥ 75 %), danger (≥ 90 %) + last_imap_login: + type: integer + description: Unix time of the last IMAP login, 0 = none + last_smtp_login: + type: integer + last_pop3_login: + type: integer + last_sso_login: + type: integer + last_sogo_login: + type: integer + max_new_quota: + type: integer + description: Largest quota in bytes the mailbox may get (domain limits) + spam_aliases: + type: integer + description: Number of valid time-limited aliases + pushover_active: + type: integer + enum: [0, 1] + rl: + description: Effective rate limit, `false` if there is none + oneOf: + - $ref: "#/components/schemas/MailboxRatelimit" + - type: boolean + const: false + rl_scope: + type: string + enum: + - mailbox + - domain + description: Whether `rl` is the mailbox's own limit or the domain limit + is_relayed: + type: integer + description: 1 if the domain is relayed (backup MX) + sender_acl: + type: array + description: Addresses and @domains the mailbox may send as (internal, set by `sender_acl`) + items: + type: string + tags: + type: array + items: + type: string + MailboxAdd: + type: object + required: + - local_part + - domain + properties: + local_part: + type: string + description: Part before the @ + domain: + type: string + description: Domain of the mailbox + name: + type: string + description: Full name (`<` and `>` at the ends are removed) + authsource: + type: string + enum: + - mailcow + - keycloak + - generic-oidc + - ldap + default: mailcow + description: >- + Where the user logs in; an external source is accepted only if it is + the configured identity provider, else mailcow is used + password: + type: string + format: password + description: >- + Password (authsource mailcow), checked against the password policy; + may be left empty together with `password2` for a shared mailbox + password2: + type: string + format: password + description: The password again + template: + type: [integer, string] + description: ID of the mailbox template that provides the defaults (default the first template) + quota: + type: [integer, string] + description: Quota in MiB, 0 = unlimited + active: + type: [integer, string] + enum: [0, 1, 2, "0", "1", "2"] + description: 1 active, 0 inactive, 2 login disabled (mail is accepted) + tags: + type: array + items: + type: string + description: Tags (the first 26 are saved) + protocol_access: + type: array + description: Allowed protocols; when sent it replaces the single `*_access` fields + items: + $ref: "#/components/schemas/ProtocolAccessName" + imap_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + pop3_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + smtp_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + sieve_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + eas_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + dav_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + sogo_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Login to SOGo from the UI + force_pw_update: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Password change at the first login (always 0 for external sources) + force_tfa: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Two-factor setup at the first login (always 0 for generic-oidc) + tls_enforce_in: + type: [integer, string] + enum: [0, 1, "0", "1"] + tls_enforce_out: + type: [integer, string] + enum: [0, 1, "0", "1"] + relayhost: + type: [integer, string] + default: 0 + description: ID of a sender-dependent transport, 0 = none + shared: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + description: Shared mailbox without a login of its own; members of groups open it + quarantine_notification: + type: string + enum: + - never + - hourly + - daily + - weekly + quarantine_category: + type: string + enum: + - reject + - add_header + - all + tagged_mail_handler: + type: string + enum: + - subfolder + - subject + - none + description: Handling of mail to address+tag, see `edit/delimiter_action` + acl: + type: array + description: User ACLs to grant; when sent, all others are off + items: + $ref: "#/components/schemas/UserAclName" + rl_value: + type: [integer, string] + description: Rate limit, messages per `rl_frame` (0 or empty = none) + rl_frame: + type: string + enum: + - s + - m + - h + - d + attribute_hash: + type: string + description: Internal, the hash of the template the values come from + MailboxEdit: + type: object + properties: + active: + type: [integer, string] + enum: [0, 1, 2, "0", "1", "2"] + description: 1 active, 0 inactive, 2 login disabled (mail is accepted) + name: + type: string + description: Full name; empty keeps the current name + quota: + type: [integer, string] + description: Quota in MiB, 0 = unlimited; empty keeps the current quota + password: + type: string + format: password + description: New password (authsource mailcow only), checked against the password policy + password2: + type: string + format: password + authsource: + type: string + enum: + - mailcow + - keycloak + - generic-oidc + - ldap + description: An external source is accepted only if it is the configured identity provider + pw_recovery_email: + type: string + description: Address for password reset mails (authsource mailcow only) + force_pw_update: + type: [integer, string] + enum: [0, 1, "0", "1"] + force_tfa: + type: [integer, string] + enum: [0, 1, "0", "1"] + sogo_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + protocol_access: + type: array + description: Allowed protocols; when sent it replaces the single `*_access` fields + items: + $ref: "#/components/schemas/ProtocolAccessName" + imap_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + pop3_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + smtp_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + sieve_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + eas_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + dav_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + relayhost: + type: [integer, string] + description: ID of a sender-dependent transport, 0 = none + shared: + type: [integer, string] + enum: [0, 1, "0", "1"] + tags: + type: array + items: + type: string + description: Tags to add (existing tags stay; remove them with `delete/mailbox/tag/{mailbox}`) + tagged_mail_handler: + type: string + enum: + - subfolder + - subject + - none + sender_acl: + description: >- + Replaces the internal addresses and @domains the mailbox may send + as; `*` (admins only) allows every address, `["default"]` removes + all + oneOf: + - type: string + - type: array + items: + type: string + extended_sender_acl: + type: string + description: >- + Replaces the external sender addresses or domains (not hosted on + this server), separated by space, comma or semicolon + attribute_hash: + type: string + description: Internal; cleared when not sent + MailboxTemplateInput: + type: object + properties: + quota: + type: [integer, string] + default: 0 + description: Quota in MiB, 0 = unlimited + tags: + type: array + items: + type: string + tagged_mail_handler: + type: string + enum: + - subfolder + - subject + - none + default: none + quarantine_notification: + type: string + enum: + - never + - hourly + - daily + - weekly + default: hourly + quarantine_category: + type: string + enum: + - reject + - add_header + - all + default: reject + rl_value: + type: [integer, string] + description: Rate limit, messages per `rl_frame` + rl_frame: + type: string + enum: + - s + - m + - h + - d + default: s + force_pw_update: + type: [integer, string] + enum: [0, 1, "0", "1"] + force_tfa: + type: [integer, string] + enum: [0, 1, "0", "1"] + sogo_access: + type: [integer, string] + enum: [0, 1, "0", "1"] + active: + type: [integer, string] + enum: [0, 1, 2, "0", "1", "2"] + default: 1 + tls_enforce_in: + type: [integer, string] + enum: [0, 1, "0", "1"] + tls_enforce_out: + type: [integer, string] + enum: [0, 1, "0", "1"] + protocol_access: + type: array + items: + $ref: "#/components/schemas/ProtocolAccessName" + description: Allowed protocols (default all) + acl: + type: array + items: + $ref: "#/components/schemas/UserAclName" + description: User ACLs granted to new mailboxes + MailboxTemplate: + type: object + properties: + id: + type: integer + template: + type: string + description: Name + type: + type: string + const: mailbox + attributes: + type: object + description: >- + The settings: quota in bytes, tags, tagged_mail_handler, + quarantine_notification, quarantine_category, rl_value, rl_frame, + the flags force_pw_update, force_tfa, sogo_access, active, + tls_enforce_in, tls_enforce_out, imap_access ... dav_access and one + acl_ flag per user ACL + additionalProperties: true + created: + type: string + modified: + type: [string, "null"] + Resource: + type: object + properties: + name: + type: string + description: Address of the resource + kind: + type: string + enum: + - location + - group + - thing + multiple_bookings: + type: integer + description: 0 always free, -1 unlimited but busy when booked, n ≥ 1 at most n bookings + description: + type: string + active: + type: integer + active_int: + type: integer + domain: + type: string + local_part: + type: string + AppPassword: + type: object + properties: + id: + type: integer + name: + type: string + mailbox: + type: string + domain: + type: string + password: + type: string + description: Hash of the app password + created: + type: string + modified: + type: [string, "null"] + imap_access: + type: integer + smtp_access: + type: integer + dav_access: + type: integer + eas_access: + type: integer + pop3_access: + type: integer + sieve_access: + type: integer + active: + type: integer + SieveFilter: + type: object + properties: + active: + type: integer + enum: [0, 1] + id: + type: integer + username: + type: string + description: Mailbox + filter_type: + type: string + enum: + - prefilter + - postfilter + script_data: + type: string + description: The Sieve script + script_desc: + type: string + TimeLimitedAlias: + type: object + properties: + address: + type: string + goto: + type: string + description: Mailbox the alias delivers to + description: + type: string + validity: + type: integer + description: Unix time of expiry (0 for permanent aliases set by an edit) + created: + type: string + modified: + type: [string, "null"] + permanent: + type: integer + enum: [0, 1] + SpamPolicyEntry: + type: object + properties: + object: + type: string + description: Mailbox or domain the entry belongs to + value: + type: string + description: Sender address, domain or pattern + prefid: + type: integer + description: ID for `delete/mailbox-policy` + MailboxRatelimit: + type: object + properties: + value: + type: string + description: Number of messages + frame: + type: string + enum: + - s + - m + - h + - d + description: Per second, minute, hour or day + SpamScore: + type: object + properties: + score: + type: string + description: '"low,high": junk folder from low, rejected from high' + LastLogin: + type: object + properties: + ui: + description: Previous successful UI login, `false` if there is none, `[]` for domain admins + oneOf: + - type: object + properties: + remote: + type: string + description: IP address + time: + type: integer + description: Unix time + - type: boolean + const: false + - type: array + maxItems: 0 + sasl: + type: array + items: + type: object + properties: + real_rip: + type: string + description: Client IP; private and internal addresses as "Web/EAS/Internal (address)" + datetime: + type: string + service: + type: string + description: e.g. imap, smtp, pop3, SOGO, SSO, MAILCOWUI + app_password: + type: integer + description: ID of the app password used, 0 = none + app_password_name: + type: [string, "null"] + location: + type: string + description: Country code of public addresses (if known) + MailboxSearchRequest: + type: object + description: DataTables server-side request + properties: + draw: + type: integer + description: Counter, returned unchanged + start: + type: integer + description: Offset of the first row; without it all rows are returned + length: + type: integer + description: Rows per page, -1 = all + search: + type: object + properties: + value: + type: string + description: Searched in all searchable columns (substring) + order: + type: array + items: + type: object + properties: + column: + type: integer + description: Column number (index in `columns`) + dir: + type: string + enum: + - asc + - desc + columns: + type: array + description: Column n of the table is `columns[n]` + items: + type: object + properties: + data: + type: [string, integer] + description: >- + Name of the column in the UI; for a filter by + `search.value` of this column only a column number selects + the column, other values filter the address + searchable: + type: [boolean, string] + description: true or "true" includes the column in the search + orderable: + type: [boolean, string] + description: true or "true" allows sorting by the column + search: + type: object + properties: + value: + type: string + description: Filter for this column only (substring) + MailboxSearchResult: + type: object + properties: + draw: + type: integer + recordsTotal: + type: integer + recordsFiltered: + type: integer + data: + type: array + items: + $ref: "#/components/schemas/Mailbox" + Alias: + type: object + properties: + in_primary_domain: + type: string + description: Target domain if `domain` is an alias domain, else empty + id: + type: integer + domain: + type: string + description: Domain of the address, a domain or alias domain + public_comment: + type: [string, "null"] + description: Comment shown to the users + private_comment: + type: [string, "null"] + description: Comment for administrators + goto: + type: string + description: >- + Comma-separated destination addresses, or `null@localhost` + (discard), `spam@localhost` (learn as spam), `ham@localhost` (learn + as ham) + address: + type: string + description: Alias address, `@example.org` for a catch-all + is_catch_all: + type: integer + description: 1 = catch-all of the domain + internal: + type: integer + description: 1 = only reachable from its own domain and its alias domains + active: + type: integer + description: 1 = active, 0 = inactive + active_int: + type: integer + description: Same as `active` + sogo_visible: + type: integer + description: 1 = selectable sender in SOGo + sogo_visible_int: + type: integer + description: Same as `sogo_visible` + sender_allowed: + type: integer + description: 1 = the destination mailboxes may send as this alias + group_id: + type: [integer, "null"] + description: >- + Mailbox group whose distribution address this is; such aliases can + only be changed in the group + group_name: + type: [string, "null"] + description: Name of that mailbox group + created: + type: string + description: Creation time, `YYYY-MM-DD hh:mm:ss` + modified: + type: [string, "null"] + description: Time of the last change + BccMap: + type: object + properties: + id: + type: integer + local_dest: + type: string + description: Domain as `@example.org` or the address of a mailbox or alias + bcc_dest: + type: string + description: Address that gets the copies + active: + type: integer + description: 1 = active, 0 = inactive + type: + type: string + enum: + - sender + - rcpt + description: "`sender`: mail sent by `local_dest`; `rcpt`: mail sent to it" + created: + type: string + domain: + type: string + description: Domain of `local_dest` + modified: + type: [string, "null"] + RecipientMap: + type: object + properties: + id: + type: integer + recipient_map_old: + type: string + description: Original recipient, an address or `@domain` + recipient_map_new: + type: string + description: New recipient, an address or `@domain` + active: + type: integer + description: 1 = active, 0 = inactive + created: + type: string + modified: + type: [string, "null"] + TlsPolicyMap: + type: object + properties: + id: + type: integer + dest: + type: string + description: Destination + policy: + type: string + enum: + - none + - may + - encrypt + - dane + - dane-only + - fingerprint + - verify + - secure + parameters: + type: [string, "null"] + description: Space-separated `name=value` attributes, may be empty + active: + type: integer + description: 1 = active, 0 = inactive + created: + type: string + modified: + type: [string, "null"] + Relayhost: + type: object + description: A sender-dependent transport + properties: + id: + type: integer + description: The value domains and mailboxes use as `relayhost` + hostname: + type: string + description: Next hop + username: + type: string + description: SMTP user name, empty without authentication + password: + type: string + description: SMTP password in clear text + active: + type: integer + description: 1 = active, 0 = inactive + password_short: + type: string + description: First three characters of the password and `...` + used_by_domains: + type: string + description: Comma-separated domains that use the transport, may be empty + used_by_mailboxes: + type: string + description: Comma-separated mailboxes that use the transport, may be empty + Transport: + type: object + description: A transport map entry + properties: + id: + type: integer + is_mx_based: + type: integer + description: 1 = `destination` is a regular expression for MX names + destination: + type: string + nexthop: + type: string + username: + type: string + description: SMTP user name, empty without authentication + password: + type: string + description: SMTP password in clear text + active: + type: integer + description: 1 = active, 0 = inactive + password_short: + type: string + description: First three characters of the password and `...` + ForwardingHost: + type: object + properties: + host: + type: string + description: IP address or network + source: + type: string + description: What was added (host name, domain or the address itself) + keep_spam: + type: string + enum: + - "yes" + - "no" + description: "`yes` = the mail of the host is not scanned for spam" + RspamdSettingAttributes: + type: object + properties: + desc: + type: string + description: Short description + content: + type: string + description: Rule content in Rspamd settings syntax + active: + type: [boolean, integer, string] + description: 1 = the rule is handed to Rspamd + default: 0 + required: + - desc + - content + RspamdSetting: + type: object + properties: + id: + type: integer + desc: + type: string + description: Short description + content: + type: string + description: Rule content in Rspamd settings syntax + active: + type: integer + enum: + - 0 + - 1 + RspamdRegexMap: + type: string + description: File name of a global Rspamd regex map + enum: + - global_mime_from_blacklist.map + - global_mime_from_whitelist.map + - global_smtp_from_blacklist.map + - global_smtp_from_whitelist.map + - global_rcpt_blacklist.map + - global_rcpt_whitelist.map + - fishy_tlds.map + - bad_words.map + - bad_words_de.map + - bad_languages.map + - bulk_header.map + - bad_header.map + - monitoring_nolog.map + Preset: + type: object + properties: + name: + type: string + description: File name of the preset without `.yml` + headline: + type: string + description: Title, translated + content: + type: string + description: Rspamd settings rule or Sieve script + GlobalFilters: + type: object + properties: + prefilter: + type: string + description: Sieve script that runs before the filters of the mailboxes + postfilter: + type: string + description: Sieve script that runs after the filters of the mailboxes + MailQueueItem: + type: object + description: One message of `postqueue -j` + properties: + queue_name: + type: string + description: Queue, e.g. `active`, `deferred`, `hold`, `incoming` + queue_id: + type: string + arrival_time: + type: integer + description: Unix time + message_size: + type: integer + description: Size in bytes + forced_expire: + type: boolean + sender: + type: string + recipients: + type: array + description: Recipient addresses, with the reason of the delay in brackets if there is one + items: + type: string + QuarantineItem: + type: object + properties: + id: + type: integer + qid: + type: string + description: Postfix queue ID of the message + subject: + type: string + virus_flag: + type: integer + description: Greater than 0 when Rspamd found a virus (`VIRUS_FOUND`) + score: + type: number + description: Rspamd score + rcpt: + type: string + description: Mailbox the item belongs to + sender: + type: string + action: + type: string + description: Rspamd action, e.g. `reject` or `add header` + created: + type: integer + description: Unix time + notified: + type: integer + description: 1 when the recipient got a notification + QuarantineItemDetails: + type: object + properties: + id: + type: integer + qid: + type: string + description: Postfix queue ID of the message + subject: + type: string + score: + type: number + description: Rspamd score + ip: + type: string + description: IP of the sending server + action: + type: string + description: Rspamd action + symbols: + type: string + description: Rspamd symbols as JSON text + fuzzy_hashes: + type: string + description: Fuzzy hashes as JSON text + sender: + type: string + rcpt: + type: string + description: Mailbox the item belongs to + msg: + type: string + description: The raw message + domain: + type: [string, "null"] + notified: + type: integer + description: 1 when the recipient got a notification + created: + type: string + description: Time of quarantining (`YYYY-MM-DD HH:MM:SS`) + user: + type: string + description: Authenticated sender, `unknown` if none + qhash: + type: string + description: Secret of the release and delete links in the notification mails + Fail2Ban: + type: object + properties: + ban_time: + type: integer + description: Ban time in seconds + ban_time_increment: + type: boolean + description: The ban time grows with every ban of a network + max_ban_time: + type: integer + description: Maximum ban time in seconds + netban_ipv4: + type: integer + description: IPv4 prefix length that is banned + netban_ipv6: + type: integer + description: IPv6 prefix length that is banned + max_attempts: + type: integer + description: Failed logins within `retry_window` that lead to a ban + retry_window: + type: integer + description: Window in seconds for `max_attempts` + banlist_id: + type: string + description: ID of the banlist URL `/f2b-banlist?id=` + manage_external: + type: integer + description: 1 = netfilter sets no rules, the banlist is for an external firewall + regex: + type: object + description: Regex filters by number + additionalProperties: + type: string + whitelist: + type: string + description: Allowlisted networks and hosts, one per line + blacklist: + type: string + description: Denylisted networks, one per line + perm_bans: + type: array + description: Permanent bans (only present when there are any) + items: + type: object + properties: + network: + type: string + ip: + type: string + active_bans: + type: array + description: Active bans (only present when there are any) + items: + type: object + properties: + queued_for_unban: + type: integer + description: 1 when the ban is about to be lifted + network: + type: string + ip: + type: string + banned_until: + type: string + description: Remaining ban time, e.g. `00h 27m 08s` + Admin: + type: object + description: An administrator account + properties: + username: + type: string + tfa_active: + type: integer + enum: [0, 1] + description: 1 = two-factor authentication is active + tfa_active_int: + type: integer + enum: [0, 1] + description: Same as `tfa_active` + active: + type: integer + enum: [0, 1] + description: 1 = the account can log in + active_int: + type: integer + enum: [0, 1] + description: Same as `active` + created: + type: string + description: Creation time (`YYYY-MM-DD hh:mm:ss`) + attributes: + type: object + properties: + force_tfa: + type: string + enum: ["0", "1"] + description: "\"1\" = two-factor authentication must be set up at the next login" + force_pw_update: + type: string + enum: ["0", "1"] + description: "\"1\" = the password must be changed at the next login" + DomainAdmin: + type: object + description: A domain administrator account + properties: + username: + type: string + tfa_active: + type: integer + enum: [0, 1] + description: 1 = two-factor authentication is active + tfa_active_int: + type: integer + enum: [0, 1] + description: Same as `tfa_active` + active: + type: integer + enum: [0, 1] + description: 1 = the account can log in + active_int: + type: integer + enum: [0, 1] + description: Same as `active` + created: + type: string + description: Creation time (`YYYY-MM-DD hh:mm:ss`) + attributes: + type: object + properties: + force_tfa: + type: string + enum: ["0", "1"] + description: "\"1\" = two-factor authentication must be set up at the next login" + force_pw_update: + type: string + enum: ["0", "1"] + description: "\"1\" = the password must be changed at the next login" + selected_domains: + type: array + description: The domains the account administers + items: + type: string + unselected_domains: + description: All other domains, `""` if there are none + oneOf: + - type: array + items: + type: string + - type: string + const: "" + DomainAdminAcl: + type: string + description: >- + ACL of a domain administrator (default in brackets): `syncjobs` sync + jobs (on), `quarantine` quarantine (on), `login_as` log in as mailbox + user (on), `sogo_access` direct SOGo access of users (on), + `app_passwds` app passwords (on), `bcc_maps` BCC maps (on), `pushover` + Pushover (off), `filters` filters (on), `ratelimit` rate limits (on), + `spam_policy` spam policy (on), `extend_sender_acl` sender ACL with + external addresses (off), `unlimited_quota` unlimited mailbox quota + (off), `protocol_access` protocol access of mailboxes (on), + `smtp_ip_access` SMTP IP restrictions (on), `alias_domains` alias + domains (off), `mailbox_relayhost` relay host of mailboxes (on), + `domain_relayhost` relay host of domains (on), `domain_desc` domain + description (off), `alias_external_goto` aliases with external + destinations (on), `mailbox_groups` mailbox groups (on) + enum: + - syncjobs + - quarantine + - login_as + - sogo_access + - app_passwds + - bcc_maps + - pushover + - filters + - ratelimit + - spam_policy + - extend_sender_acl + - unlimited_quota + - protocol_access + - smtp_ip_access + - alias_domains + - mailbox_relayhost + - domain_relayhost + - domain_desc + - alias_external_goto + - mailbox_groups + ApiKey: + type: object + description: An API key + properties: + id: + type: integer + description: + type: string + description: Free text; `API_KEY` or `API_KEY_READ_ONLY` for the service keys + access: + type: string + enum: + - ro + - rw + description: "`ro`: read-only (get routes only), `rw`: read/write" + api_key: + type: string + description: The key value, sent in the `X-API-Key` header + allow_from: + type: array + description: IP addresses and networks the key may be used from + items: + type: string + skip_ip_check: + type: integer + enum: [0, 1] + description: 1 = the key may be used from any address + service: + type: integer + enum: [0, 1] + description: 1 = service key from mailcow.conf, can only be changed there + active: + type: integer + enum: [0, 1] + created: + type: [string, "null"] + modified: + type: [string, "null"] + ApiKeyAttributes: + type: object + description: Attributes of an API key for add and edit + properties: + description: + type: string + description: Free text, at most 255 characters + default: "" + access: + type: string + enum: + - ro + - rw + description: "`ro`: read-only (get routes only), `rw`: read/write. Required for a new key." + allow_from: + description: >- + IP addresses and CIDR networks the key may be used from: a list or + a string separated by spaces, commas, semicolons or newlines. An + edit replaces the list. + oneOf: + - type: string + - type: array + items: + type: string + skip_ip_check: + type: [integer, string, boolean] + description: 1/true = allow the key from any address + default: 0 + active: + type: [integer, string, boolean] + description: 1/true = active + default: 1 + OAuth2Client: + type: object + description: An OAuth2 client of the mailcow OAuth2 server + properties: + id: + type: integer + description: ID used by the API + client_id: + type: string + description: OAuth2 client ID + client_secret: + type: string + description: OAuth2 client secret + redirect_uri: + type: string + description: Redirect (callback) URL + grant_types: + type: [string, "null"] + description: Not used + scope: + type: string + description: Always `profile` + user_id: + type: [string, "null"] + description: Not used + SyslogLogEntry: + type: object + description: A log line of a container + properties: + time: + type: string + description: Unix time + program: + type: string + description: Process that wrote the line + priority: + type: string + description: Syslog priority (`info`, `warning`, `err`, ...) + message: + type: string + AcmeLogEntry: + type: object + properties: + time: + type: string + description: Unix time + message: + type: string + ApiLogEntry: + type: object + properties: + time: + type: integer + description: Unix time + uri: + type: string + description: Request URI + method: + type: string + description: HTTP method + remote: + type: string + description: Client address + data: + type: string + description: Form data of the request (`attr`, `items`), secrets replaced by `*` + AutodiscoverLogEntry: + type: object + properties: + time: + type: integer + description: Unix time + ua: + type: string + description: User agent of the client + user: + type: string + description: Requested e-mail address + ip: + type: string + description: Client address + service: + type: string + description: Offered service (`activesync`, `imap`) or an error + CronLogEntry: + type: object + properties: + time: + type: integer + description: Unix time + priority: + type: string + task: + type: string + description: Name of the task + message: + type: string + NetfilterLogEntry: + type: object + properties: + time: + type: integer + description: Unix time + priority: + type: string + description: "`info`, `warn` or `crit` (bans)" + message: + type: string + RatelimitLogEntry: + type: object + properties: + time: + type: integer + description: Unix time + rcpt: + type: string + description: Recipients, comma separated + from: + type: string + description: Envelope sender + user: + type: string + description: Authenticated user + rl_info: + type: string + description: Rate limit that was hit, `name(hash)` + rl_name: + type: string + description: Name of the rate limit (mailbox or domain) + rl_hash: + type: string + description: Hash of the rate limit bucket (see `delete/rlhash`) + qid: + type: string + description: Postfix queue ID + ip: + type: string + description: Client address + message_id: + type: string + header_subject: + type: string + header_from: + type: string + RspamdHistoryEntry: + type: object + description: A message scanned by Rspamd (fields of Rspamd's history) + properties: + unix_time: + type: integer + time_real: + type: number + description: Scan time in seconds + score: + type: number + required_score: + type: number + action: + type: string + description: "`no action`, `greylist`, `add header`, `rewrite subject`, `soft reject` or `reject`" + thresholds: + type: object + additionalProperties: + type: number + ip: + type: string + user: + type: string + description: Authenticated user, `unknown` for incoming mail + sender_smtp: + type: string + sender_mime: + type: string + rcpt_smtp: + type: array + items: + type: string + rcpt_mime: + type: array + items: + type: string + subject: + type: string + message-id: + type: string + size: + type: integer + is_skipped: + type: boolean + symbols: + type: object + description: Matched symbols by name + additionalProperties: + type: object + properties: + name: + type: string + score: + type: number + metric_score: + type: number + description: + type: string + options: + type: array + items: + type: string + additionalProperties: true + RspamdStats: + type: object + description: Statistics of Rspamd's controller (`/stat`) + properties: + version: + type: string + config_id: + type: string + uptime: + type: integer + description: Seconds + read_only: + type: boolean + scanned: + type: integer + learned: + type: integer + actions: + type: object + description: Number of messages per action + additionalProperties: + type: integer + scan_times: + type: array + items: + type: [number, "null"] + spam_count: + type: integer + ham_count: + type: integer + connections: + type: integer + control_connections: + type: integer + total_learns: + type: integer + statfiles: + type: array + description: Bayes statistics + items: + type: object + fuzzy_hashes: + type: object + additionalProperties: + type: integer + additionalProperties: true + SaslLogEntry: + type: object + properties: + service: + type: string + description: "`imap`, `pop3`, `smtp`, `sieve`, `SSO` (SOGo), ..." + app_password: + type: integer + description: ID of the app password used, 0 = the mailbox password + username: + type: string + real_rip: + type: string + description: Client address + datetime: + type: string + description: Time of the last login (`YYYY-MM-DD hh:mm:ss`) + UiLogEntry: + type: object + properties: + id: + type: integer + task: + type: string + description: ID of the request + type: + type: string + description: "`success`, `danger`, `warning` or `info`" + msg: + type: string + description: The message (JSON) + call: + type: string + description: The call that produced it (JSON) + user: + type: string + description: Account (`API` for API keys) + role: + type: string + remote: + type: string + description: Client address + time: + type: integer + description: Unix time + WatchdogLogEntry: + type: object + properties: + time: + type: string + description: Unix time + service: + type: string + description: Checked service + lvl: + type: string + description: Health in percent + hpnow: + type: string + description: Current health points + hptotal: + type: string + description: Maximum health points + hpdiff: + type: string + description: Change since the last check + ContainerStatus: + type: object + properties: + type: + type: string + const: info + container: + type: string + description: Service name + state: + type: string + description: Docker state (`running`, `exited`, `restarting`, ...) + started_at: + type: string + description: Start time (RFC 3339) + image: + type: string + HostStats: + type: object + properties: + cpu: + type: object + properties: + cores: + type: integer + usage: + type: number + description: CPU usage in percent + memory: + type: object + properties: + total: + type: integer + description: Bytes + usage: + type: number + description: Memory usage in percent + swap: + type: array + description: Swap total, used, free (bytes), used percent, swapped in, swapped out (bytes) + items: + type: number + uptime: + type: number + description: Seconds since boot + system_time: + type: string + description: Host time (`DD.MM.YYYY hh:mm:ss`) + architecture: + type: string + PasswordPolicy: + type: object + description: Password policy; the values are strings, `false` if never saved + properties: + length: + type: [string, boolean] + description: Minimum length + chars: + type: [string, boolean] + description: "\"1\": at least one letter" + special_chars: + type: [string, boolean] + description: "\"1\": at least one character that is no letter or digit" + lowerupper: + type: [string, boolean] + description: "\"1\": lowercase and uppercase letters" + numbers: + type: [string, boolean] + description: "\"1\": at least one digit" + MailboxGroupAttributes: + type: object + description: >- + Attributes of a mailbox group. Flags take `1`, `true`, `on` or `yes` + for on, anything else is off. Lists take a JSON array or a string + separated by spaces, commas, semicolons or newlines; entries are + lowercased. + properties: + name: + type: string + description: 1-100 characters, unique in the domain + description: + type: string + description: Max. 255 characters + default: "" + address: + type: string + description: >- + Distribution address in the domain of the group (must not be used + by a mailbox, alias or spam alias); empty = no address. Wins over + `local_part`. + local_part: + type: string + description: Local part of the address in the domain of the group; empty = no address + members: + type: + - array + - string + items: + type: string + description: >- + Mailboxes (regular mailboxes, any domain the caller manages); + replaces the manual members the caller may access + add_members: + type: + - array + - string + items: + type: string + description: Mailboxes to add + remove_members: + type: + - array + - string + items: + type: string + description: Manual members to remove + send_as: + type: + - array + - string + items: + type: string + description: >- + Replaces the addresses the members may send as: existing mailbox or + alias addresses (not the group address, see `sender_allowed`) or + `@domain` for a domain or alias domain + sender_allowed: + type: + - boolean + - integer + - string + description: Members may send as the group address + default: 0 + sogo_visible: + type: + - boolean + - integer + - string + description: Offer the group address as identity in SOGo (only with `sender_allowed`) + default: 0 + internal: + type: + - boolean + - integer + - string + description: Only senders of the domain may write to the group address + default: 0 + active: + type: + - boolean + - integer + - string + default: 1 + imap_grants: + description: >- + Replaces the mailboxes the members may open (Dovecot ACL on every + folder, shown as Shared/); the caller needs access to every + owner. `subscribe` = a cron subscribes the folders for every member + once (within 15 minutes). A JSON string of the list is accepted too. + type: + - array + - string + items: + type: object + required: + - owner + properties: + owner: + type: string + description: Mailbox to share + rights: + type: string + description: >- + read = lookup, read, write-seen; write = also write, + write-deleted, insert, post, expunge, create; full = also delete + enum: + - read + - write + - full + default: read + subscribe: + type: + - boolean + - integer + - string + default: 0 + sogo_grants: + description: >- + Replaces the SOGo folders shared with the members; the caller needs + access to every owner. Pending until the owner opened SOGo once. + `subscribe` = a cron subscribes every member once. A JSON string of + the list is accepted too. + type: + - array + - string + items: + type: object + required: + - owner + properties: + owner: + type: string + description: Mailbox whose folder is shared + type: + type: string + enum: + - Calendar + - Contacts + default: Calendar + folder: + type: string + description: SOGo folder id (`A-Za-z0-9._-`, max. 100 characters) + default: personal + access: + type: string + enum: + - read + - write + default: read + subscribe: + type: + - boolean + - integer + - string + default: 0 + template_id: + type: + - integer + - string + - "null" + description: >- + Admins only: id of a mailbox template applied to the members; + null, empty or 0 = none + priority: + type: + - integer + - string + description: >- + Admins only: -1000000 to 1000000; of several groups with a + template the one with the highest priority wins + default: 0 + idp_ref: + type: string + description: >- + Admins only: id of the Identity Provider group whose members the + identity sync keeps in the group (max. 255 characters); empty = + none + MailboxGroup: + type: object + properties: + id: + type: integer + domain: + type: string + name: + type: string + description: + type: string + address: + type: + - string + - "null" + description: Distribution address, null = none + sender_allowed: + type: integer + enum: + - 0 + - 1 + sogo_visible: + type: integer + enum: + - 0 + - 1 + internal: + type: integer + enum: + - 0 + - 1 + template_id: + type: + - integer + - "null" + template: + type: + - string + - "null" + description: Name of the template + priority: + type: integer + idp_ref: + type: + - string + - "null" + description: Identity Provider group id, set = members are synced + members: + type: array + items: + type: object + properties: + username: + type: string + source: + type: string + description: manual = added in mailcow, idp = added by the identity sync + enum: + - manual + - idp + send_as: + type: array + description: Addresses and `@domain` entries the members may send as + items: + type: string + imap_grants: + type: array + items: + type: object + properties: + owner: + type: string + rights: + type: string + enum: + - read + - write + - full + subscribe: + type: integer + enum: + - 0 + - 1 + state: + type: string + description: pending until Dovecot has the ACL, failed when setting it failed (retried by a cron) + enum: + - pending + - applied + - failed + last_error: + type: + - string + - "null" + sogo_grants: + type: array + items: + type: object + properties: + owner: + type: string + type: + type: string + enum: + - Calendar + - Contacts + folder: + type: string + access: + type: string + enum: + - read + - write + subscribe: + type: integer + enum: + - 0 + - 1 + state: + type: string + description: pending (`last_error` folder_missing) until the SOGo folder of the owner exists + enum: + - pending + - applied + - failed + last_error: + type: + - string + - "null" + active: + type: integer + enum: + - 0 + - 1 + created: + type: + - string + - "null" + modified: + type: + - string + - "null" + can_edit: + type: boolean + description: The caller may change the group (false for read-only API keys) + MailboxGroupMemberships: + type: object + properties: + groups: + type: array + items: + type: object + properties: + id: + type: integer + domain: + type: string + name: + type: string + address: + type: + - string + - "null" + sender_allowed: + type: integer + active: + type: integer + priority: + type: integer + template: + type: + - string + - "null" + source: + type: string + enum: + - manual + - idp + template: + type: + - string + - "null" + description: Effective group template of the mailbox + send_as: + type: array + description: Addresses the mailbox may send as through its active groups + items: + type: string + SyncJobAttributes: + type: object + description: >- + Attributes of a sync job. Flags are cast to integers: `1`, `"1"` and + `true` are on, `0`, `"0"`, `false` (and the string `"true"`) are off; + on add they default to off. The former connection attributes `host1`, + `port1`, `enc1` and `authmech1` are ignored (see `source_id`). + properties: + source_id: + type: + - integer + - string + description: >- + Id of an active sync source the caller may see + (`GET /api/v1/get/syncjob_source/all`) + user1: + type: string + description: Remote username at the source, max. 255 characters + password1: + type: string + format: password + description: >- + Remote password; required unless the source uses XOAUTH2 (ignored + then); empty on edit = keep + mins_interval: + type: + - integer + - string + description: Minutes between runs, 1-43800 + subfolder2: + type: string + description: Folder in the local mailbox to sync into, empty = none + default: "" + exclude: + type: string + description: Regular expression of remote folders to skip + default: "" + maxage: + type: + - integer + - string + description: Only sync messages up to this age in days (1-32000), 0 = all + default: 0 + maxbytespersecond: + type: + - integer + - string + description: >- + Bandwidth limit in bytes/s (1-125000000), 0 = unlimited; the global + limit of `edit/imapsync_settings` caps it + default: 0 + timeout1: + type: + - integer + - string + description: Timeout of the remote connection in seconds (1-32000) + default: 600 + timeout2: + type: + - integer + - string + description: Timeout of the local connection in seconds (1-32000) + default: 600 + custom_params: + type: array + description: >- + Extra imapsync options, replaces the stored list; empty list, empty + string or null = none. Each option must be allowed by + `GET /api/v1/get/syncjob_options`: `true` needs a value, `false` + must have none, a string is the regular expression the value must + match. Max. 100 options, values max. 1000 characters, each value is + passed as one argument. + items: + $ref: "#/components/schemas/SyncJobCustomParam" + delete2duplicates: + type: + - integer + - string + - boolean + description: Delete duplicates in the local mailbox (--delete2duplicates) + delete1: + type: + - integer + - string + - boolean + description: Delete messages at the source after they were synced (--delete1) + delete2: + type: + - integer + - string + - boolean + description: Delete local messages that are not at the source (--delete2) + automap: + type: + - integer + - string + - boolean + description: Map special folders like "Sent Items" to their local counterparts (--automap) + skipcrossduplicates: + type: + - integer + - string + - boolean + description: Skip duplicates across folders, first come first served (--skipcrossduplicates) + subscribeall: + type: + - integer + - string + - boolean + description: Subscribe all folders (--subscribeall) + dry: + type: + - integer + - string + - boolean + description: Dry run, change nothing (--dry) + active: + type: + - integer + - string + - boolean + description: The job runs; a new job without `active` is inactive + default: 0 + SyncJobCustomParam: + type: object + required: + - o + properties: + o: + type: string + description: Option name, case-insensitive, leading dashes are removed + example: folder + v: + type: string + description: Value, empty for flags + default: "" + example: INBOX + SyncJob: + type: object + description: A sync job; the remote password is never returned. + properties: + id: + type: integer + user2: + type: string + description: Local mailbox + user1: + type: string + description: Remote username + source_id: + type: integer + subfolder2: + type: string + exclude: + type: string + maxage: + type: integer + mins_interval: + type: integer + maxbytespersecond: + type: string + description: Bytes/s as text, "0" = unlimited + timeout1: + type: integer + timeout2: + type: integer + custom_params: + type: + - string + - "null" + description: 'JSON text of the option list, e.g. `[{"o":"folder","v":"INBOX"}]`' + custom_params_legacy: + type: + - string + - "null" + description: Free-text options of jobs from before the allowlist, not used by the runner + delete2duplicates: + type: integer + delete1: + type: integer + delete2: + type: integer + automap: + type: integer + skipcrossduplicates: + type: integer + subscribeall: + type: integer + dry: + type: integer + active: + type: integer + regextrans2: + type: + - string + - "null" + description: Legacy column, read-only + authmd51: + type: integer + description: Legacy column, read-only + domain2: + type: string + description: Legacy column, read-only + is_running: + type: integer + last_run: + type: + - string + - "null" + success: + type: + - integer + - "null" + description: Result of the last run (1 = imapsync exited with 0), null = not run yet + exit_status: + type: + - string + - "null" + description: Exit status name of imapsync, e.g. EX_OK + prio: + type: integer + description: Queue priority, higher runs first + created: + type: + - string + - "null" + modified: + type: + - string + - "null" + source_name: + type: + - string + - "null" + source_description: + type: + - string + - "null" + source_host: + type: + - string + - "null" + source_port: + type: + - integer + - "null" + source_enc: + type: + - string + - "null" + source_auth_type: + type: + - string + - "null" + source_created_by: + type: + - string + - "null" + source_scope: + type: + - string + - "null" + source_oauth_flow: + type: + - string + - "null" + source_active: + type: + - integer + - "null" + source_oauth_token_expires: + type: + - integer + - "null" + description: Expiry (Unix time) of the token of a client_credentials source + source_oauth_last_refresh_error: + type: + - string + - "null" + user_token_expires: + type: + - integer + - "null" + description: Expiry (Unix time) of the sign-in token of an authorization_code source + user_token_error: + type: + - string + - "null" + user_token_exists: + type: integer + description: 1 = a sign-in token exists for (source, local mailbox, user1) + log: + type: string + description: Output of the last run; empty with `no_log` + SyncJobSourceAttributes: + type: object + description: >- + Attributes of a sync source; on edit, attributes that are not sent keep + their value. + properties: + name: + type: string + description: 1-100 characters, unique per creator + description: + type: string + description: Max. 255 characters + default: "" + host: + type: string + description: IMAP server, host name or IP address (`host1` is accepted too) + port: + type: + - integer + - string + description: 1-65535 (`port1` is accepted too) + enc: + type: string + description: TLS = STARTTLS (`enc1` is accepted too) + enum: + - TLS + - SSL + - PLAIN + default: TLS + verify_cert: + type: + - integer + - string + - boolean + description: Verify the server certificate; `0`, `"0"`, `false` or empty = no + default: 1 + auth_type: + type: string + description: >- + PLAIN, LOGIN and CRAM-MD5 use the password of each job, XOAUTH2 an + OAuth2 token. Users cannot create or edit XOAUTH2 sources, but may + use the ones they see. + enum: + - PLAIN + - LOGIN + - CRAM-MD5 + - XOAUTH2 + default: PLAIN + active: + type: + - integer + - string + description: 1 = usable for jobs (cast to integer) + default: 1 + scope: + type: string + description: >- + Who may use the source: `all` (admins only), `domain` (the + `domains`), `user` (the `users`; none = only the creator). Sources + of users and client_credentials sources are always `user` without + targets. On edit the targets only change when `scope` is sent. + enum: + - all + - domain + - user + default: user + domains: + type: array + description: Domains for scope `domain` (at least one), the caller must manage them + items: + type: string + users: + type: array + description: Mailboxes for scope `user`, the caller must have access to them + items: + type: string + oauth_flow: + type: string + description: >- + XOAUTH2 grant: client_credentials = one token of the source for all + mailboxes of the tenant; authorization_code = every mailbox owner + signs in once in the mailcow UI (redirect URI + `https:///syncjob-oauth`) + enum: + - client_credentials + - authorization_code + default: client_credentials + oauth_token_endpoint: + type: string + description: XOAUTH2, required; https:// URL + oauth_authorize_endpoint: + type: string + description: XOAUTH2 authorization_code, required; https:// URL + oauth_userinfo_endpoint: + type: string + description: >- + XOAUTH2 authorization_code, optional: identity endpoint used when + the token response has no usable id_token; https:// URL + oauth_client_id: + type: string + description: XOAUTH2, required, max. 500 characters + oauth_client_secret: + type: string + format: password + description: >- + XOAUTH2, required, max. 500 characters; empty on edit = keep. Never + returned. + oauth_scope: + type: string + description: XOAUTH2, required, max. 500 characters + oauth_extra_params: + description: >- + XOAUTH2, optional: extra OAuth2 parameters as JSON object with + scalar values, or as JSON text (max. 4000 characters); empty = + none. `prompt`, `access_type`, `login_hint`, `domain_hint` and `hd` + go to the sign-in URL, all others to the token request; they never + replace client id, secret, scope or grant fields. + type: + - object + - string + - "null" + additionalProperties: + type: + - string + - number + - boolean + - "null" + SyncJobSource: + type: object + description: A sync source; secrets are never returned. + properties: + id: + type: integer + name: + type: string + description: + type: string + created_by: + type: string + description: Creator; `API` for API keys, empty = handed over to the admins (creator deleted) + scope: + type: string + enum: + - all + - domain + - user + domains: + type: array + items: + type: string + users: + type: array + items: + type: string + host: + type: string + port: + type: integer + enc: + type: + - string + - "null" + host1: + type: string + description: Same as `host` + port1: + type: integer + description: Same as `port` + enc1: + type: + - string + - "null" + description: Same as `enc` + verify_cert: + type: integer + auth_type: + type: + - string + - "null" + active: + type: integer + oauth_flow: + type: string + oauth_token_endpoint: + type: + - string + - "null" + oauth_authorize_endpoint: + type: + - string + - "null" + oauth_userinfo_endpoint: + type: + - string + - "null" + oauth_client_id: + type: + - string + - "null" + oauth_scope: + type: + - string + - "null" + oauth_extra_params: + type: + - string + - "null" + description: JSON text, null for callers who may not edit the source + oauth_token_expires: + type: + - integer + - "null" + description: Expiry (Unix time) of the token of a client_credentials source + oauth_last_refresh_error: + type: + - string + - "null" + oauth_last_refresh_attempt: + type: + - string + - "null" + oauth_redirect_uri: + type: string + description: Only for authorization_code sources, register it at the provider + has_client_secret: + type: boolean + has_access_token: + type: boolean + can_edit: + type: boolean + description: The caller may change the source (false for read-only API keys) + created: + type: + - string + - "null" + modified: + type: + - string + - "null" + SyncJobSettings: + type: object + properties: + max_parallel: + type: integer + description: Sync jobs running at the same time + max_bytes_per_second: + type: integer + description: Bandwidth limit per sync job process in bytes/s, 0 = unlimited + max_kb_per_second: + type: integer + description: The same in KB/s + IdentityProviderAttributes: + type: object + required: + - authsource + description: >- + Configuration of the Identity Provider. Flags marked (boolean) are on + for any non-empty value except `0`/`"0"`/`false`; flags marked (0/1) are + cast to integers (the string `"true"` is 0). + properties: + authsource: + type: string + description: Provider type (case-insensitive) + enum: + - keycloak + - generic-oidc + - ldap + client_id: + type: string + description: Keycloak, generic-oidc + client_secret: + type: string + format: password + description: Keycloak, generic-oidc + redirect_url: + type: string + description: Keycloak, generic-oidc; URL of mailcow the provider redirects to after the login + redirect_url_extra: + type: + - array + - string + items: + type: string + description: Keycloak, generic-oidc; more allowed redirect URLs, only changed when sent + server_url: + type: string + description: Keycloak base URL + realm: + type: string + description: Keycloak realm + version: + type: string + description: Keycloak version + mailpassword_flow: + type: + - integer + - string + - boolean + description: (0/1) Keycloak; check mail client passwords through the Keycloak admin API + default: 0 + group_paths: + type: string + description: >- + Keycloak group sync: comma-separated group paths, e.g. `/Sales` or + `/mailcow/*` (all groups below /mailcow); needs the `query-groups` + permission + default: "" + authorize_url: + type: string + description: generic-oidc authorization endpoint + token_url: + type: string + description: generic-oidc token endpoint + userinfo_url: + type: string + description: generic-oidc userinfo endpoint + client_scopes: + type: string + description: generic-oidc scopes + default: openid profile email mailcow_template + host: + type: string + description: LDAP server, several separated by commas (spaces are removed) + default: "" + port: + type: + - integer + - string + description: LDAP port + default: 389 + use_ssl: + type: + - boolean + - integer + - string + description: (boolean) LDAP over SSL + default: false + use_tls: + type: + - boolean + - integer + - string + description: (boolean) LDAP StartTLS, ignored with `use_ssl` + default: false + basedn: + type: string + description: LDAP base DN of the user search + binddn: + type: string + description: LDAP bind DN + bindpass: + type: string + format: password + description: LDAP bind password + username_field: + type: string + description: LDAP attribute with the mail address of the user + default: mail + filter: + type: string + description: LDAP filter of the users that may log in + default: "" + attribute_field: + type: string + description: LDAP attribute whose value is matched against `mappers` + default: "" + id_attribute: + type: string + description: >- + LDAP attribute with a stable user id; empty = objectGUID, entryUUID, + nsUniqueId or ipaUniqueID, else the DN + default: "" + disabled_filter: + type: string + description: >- + LDAP filter of disabled users (Active Directory accounts with + ACCOUNTDISABLE are always disabled) + default: "" + group_basedn: + type: string + description: LDAP base DN of the group search, empty = `basedn` + default: "" + group_filter: + type: string + description: LDAP filter of the synced groups, required for the group sync + default: "" + group_name_attribute: + type: string + default: cn + group_member_mode: + type: string + description: >- + LDAP source of the memberships: memberOf of the users, member + attribute of the groups, or Active Directory incl. nested groups + enum: + - memberof + - member + - ad_chain + default: memberof + group_member_attribute: + type: string + description: LDAP member attribute for `group_member_mode` member + default: member + ignore_ssl_error: + type: + - boolean + - integer + - string + description: (boolean) Do not verify TLS certificates + default: false + login_provisioning: + type: + - boolean + - integer + - string + description: (boolean) Create mailboxes at their first login + default: false + periodic_sync: + type: + - integer + - string + - boolean + description: (0/1) Keycloak, LDAP; update mailboxes from the provider every `sync_interval` + default: 0 + import_users: + type: + - integer + - string + - boolean + description: (0/1) Keycloak, LDAP; create mailboxes for new users of the provider + default: 0 + sync_interval: + type: + - integer + - string + description: Keycloak, LDAP; minutes between sync runs (at least 1) + default: 15 + offboarding: + type: + - integer + - string + - boolean + description: >- + (0/1) Keycloak, LDAP; users disabled at the provider, or missing in + two complete sync runs, get login disabled (mailbox active = 2, mail + is still received); only mailboxes disabled by the sync are enabled + again + default: 0 + offboarding_limit: + type: + - integer + - string + description: >- + Keycloak, LDAP; runs that would disable more mailboxes or remove + more group memberships than this share in percent (0-100, at least + 5 mailboxes) are blocked + default: 10 + group_sync: + type: + - integer + - string + - boolean + description: >- + (0/1) Keycloak, LDAP; sync the members of provider groups to mailbox + groups (members added in mailcow stay) + default: 0 + group_autocreate: + type: + - integer + - string + - boolean + description: >- + (0/1) Keycloak, LDAP; create a mailbox group for every provider + group without one + default: 0 + group_default_domain: + type: string + description: Keycloak, LDAP; domain of new groups whose mail is not in a mailcow domain + default: "" + default_template: + type: string + description: >- + Mailbox template for new mailboxes without a matching mapping (not + used for updates); only changed when sent + mappers: + type: array + items: + type: string + description: >- + Attribute values, each mapped to the template at the same position + in `templates`; only changed when both lists are sent, pairs with an + empty value are dropped + templates: + type: array + items: + type: string + description: Mailbox templates for `mappers` + IdentityProviderSettings: + type: object + description: >- + Stored settings (see `IdentityProviderAttributes`). Values are strings + as stored, except `redirect_url_extra`, `mappers` and `templates` + (lists) and `use_ssl`, `use_tls`, `login_provisioning`, + `ignore_ssl_error` (booleans; `login_provisioning` is `1` when never + stored). Secrets are `*` for read-only API keys. + properties: + authsource: + type: string + client_secret: + type: string + bindpass: + type: string + access_token: + type: string + description: Cached admin API token of Keycloak + redirect_url_extra: + type: array + items: + type: string + mappers: + type: array + items: + type: string + templates: + type: array + items: + type: string + use_ssl: + type: boolean + use_tls: + type: boolean + ignore_ssl_error: + type: boolean + login_provisioning: + type: + - boolean + - integer + additionalProperties: + type: string + IdentitySyncStatus: + type: object + properties: + provider: + type: string + description: Configured provider, empty = none + can_sync: + type: boolean + description: The provider can be synced (Keycloak, LDAP) + sync_enabled: + type: boolean + description: Scheduled runs are on (periodic sync, import, offboarding or group sync) + counts: + type: object + description: Mailboxes of the provider (no resources) + properties: + accounts: + type: integer + linked: + type: integer + description: Linked to a user of the provider + idp_disabled: + type: integer + description: Login disabled by the sync + missing: + type: integer + description: Not found at the provider by the last runs + runs: + type: array + description: The last 10 runs, newest first + items: + $ref: "#/components/schemas/IdentitySyncRun" + IdentitySyncRun: + type: object + properties: + id: + type: integer + provider: + type: string + mode: + type: string + enum: + - run + - preview + - force + status: + type: string + enum: + - pending + - running + - ok + - blocked + - error + requested_by: + type: string + description: Who requested the run, empty for scheduled runs + created: + type: string + started: + type: + - string + - "null" + finished: + type: + - string + - "null" + summary: + type: + - object + - "null" + description: >- + Result, null while pending: `counts` per kind of change, `details` + (up to 50 entries per list), `blocked`, `errors` (up to 50), `mode`; + failed runs only `error` and `mode` + properties: + counts: + type: object + additionalProperties: + type: integer + details: + type: object + additionalProperties: + type: array + items: + type: string + blocked: + type: boolean + errors: + type: array + items: + type: string + error: + type: string + mode: + type: string + IdentityLink: + type: object + properties: + provider: + type: string + external_id: + type: + - string + - "null" + description: User id at the provider + idp_disabled: + type: integer + description: 1 = login disabled by the identity sync + missing_since: + type: + - string + - "null" + description: Since when the sync does not find the user at the provider responses: Unauthorized: - description: Unauthorized + description: >- + The API key is unknown or inactive (`authentication failed`), or the + request comes from an address the key does not allow + (`api access denied for ip
`) content: application/json: schema: - type: object - properties: - type: - type: string - example: error - msg: - type: string - example: authentication failed - required: - - type - - msg + $ref: "#/components/schemas/Error" + example: + type: error + msg: authentication failed + Forbidden: + description: The API key is read-only + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + type: error + msg: API read/write access denied + WriteResult: + description: >- + The messages of the call; `{"type": "success", "msg": "Task completed"}` + or `{"type": "error", "msg": "Cannot ... item"}` if it produced none. + Check `type`: the status is 200 for failures too. + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/Messages" + - $ref: "#/components/schemas/TaskResult" + examples: + messages: + summary: Messages of the call + value: + - type: success + log: + - mailbox + - add + - alias + - address: alias@example.org + goto: user@example.org + msg: + - alias_added + - alias@example.org + failed: + summary: Failed without a message + value: + type: error + msg: Cannot add item + AcmeLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AcmeLogEntry" + example: + - time: "1794308452" + message: mail.example.org - Certificates were successfully validated, no changes or renewals required, sleeping for another day. + ApiLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ApiLogEntry" + example: + - time: 1794334338 + uri: /api/v1/edit/domain-admin + method: POST + remote: 192.0.2.10 + data: "attr='{\"active\":\"1\",\"password\":\"*\",\"password2\":\"*\"}', items='[\"domainadmin1\"]'" + - time: 1794334330 + uri: /api/v1/get/status/version + method: GET + remote: 192.0.2.10 + data: "" + AutodiscoverLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AutodiscoverLogEntry" + example: + - time: 1794311051 + ua: Microsoft Office/16.0 (Windows NT 10.0; Microsoft Outlook 16.0.17928; Pro) + user: john.doe@example.org + ip: 198.51.100.7 + service: activesync + CronLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/CronLogEntry" + example: + - time: 1794096901 + priority: warning + task: Group Shares + message: "group_imap_grant_failed: info@example.org" + DovecotLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SyslogLogEntry" + example: + - time: "1794334332" + program: dovecot + priority: info + message: "imap-login: Login: user=, method=PLAIN, rip=198.51.100.7, lip=172.22.1.250, mpid=310, TLS, session=" + NetfilterLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/NetfilterLogEntry" + example: + - time: 1794334242 + priority: crit + message: Banning 203.0.113.5/32 for 30 minutes + - time: 1794334242 + priority: warn + message: "203.0.113.5 matched rule id 3 (warning: unknown[203.0.113.5]: SASL LOGIN authentication failed: ...)" + PostfixLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SyslogLogEntry" + example: + - time: "1794334301" + program: postfix/smtpd + priority: info + message: "connect from mail.example.com[198.51.100.20]" + RatelimitLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RatelimitLogEntry" + example: + - time: 1794334001 + rcpt: jane@example.com + from: john.doe@example.org + user: john.doe@example.org + rl_info: john.doe@example.org(RLsdz3tuabozgd4oacbdh8kc78) + rl_name: john.doe@example.org + rl_hash: RLsdz3tuabozgd4oacbdh8kc78 + qid: E3CF91500458 + ip: 172.22.1.248 + message_id: 6a-5d892500-7-240abd80@example.org + header_subject: Newsletter + header_from: "\"John Doe\" " + RspamdHistory: + description: The scanned messages, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RspamdHistoryEntry" + example: + - unix_time: 1794333685 + time_real: 0.510136 + score: -1.229459 + required_score: 15 + action: no action + thresholds: + reject: 15 + add header: 8 + greylist: 7 + ip: 198.51.100.20 + user: unknown + sender_smtp: jane@example.com + sender_mime: jane@example.com + rcpt_smtp: + - john.doe@example.org + rcpt_mime: + - john.doe@example.org + subject: Meeting tomorrow + message-id: 20261110190100.428319@mail.example.com + size: 1015 + is_skipped: false + symbols: + DMARC_POLICY_ALLOW: + name: DMARC_POLICY_ALLOW + score: -0.5 + metric_score: -0.5 + description: DMARC permit policy + options: + - example.com + - none + SaslLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SaslLogEntry" + example: + - service: imap + app_password: 0 + username: john.doe@example.org + real_rip: 198.51.100.7 + datetime: "2026-11-10 19:07:01" + SogoLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SyslogLogEntry" + example: + - time: "1794334303" + program: sogod + priority: notice + message: "[52]: 198.51.100.7 \"GET /SOGo/so/john.doe@example.org/Mail/view HTTP/1.1\" 200 2578/0 0.002 - - 0 - 12" + UiLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/UiLogEntry" + example: + - id: 1819 + task: 97D254 + type: success + msg: "[\"domain_admin_modified\",\"domainadmin1\"]" + call: "[\"domain_admin\",\"edit\",{\"username\":[\"domainadmin1\"],\"active\":\"1\"}]" + user: API + role: admin + remote: 192.0.2.10 + time: 1794319670 + WatchdogLogs: + description: The log entries, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/WatchdogLogEntry" + example: + - time: "1794334334" + service: Dovecot + lvl: "100" + hpnow: "12" + hptotal: "12" + hpdiff: "0" + parameters: + MailboxTagsQuery: + name: tags + in: query + required: false + description: >- + Comma-separated tags: only mailboxes with a tag that contains one of + them (substring match) + schema: + type: string + example: sales,support + examples: + MailboxDetails: + summary: A mailbox + value: + username: john.doe@example.org + active: 1 + active_int: 1 + domain: example.org + name: John Doe + local_part: john.doe + quota: 3221225472 + messages: 1207 + attributes: + force_pw_update: "0" + force_tfa: "0" + tls_enforce_in: "0" + tls_enforce_out: "0" + sogo_access: "1" + imap_access: "1" + pop3_access: "0" + smtp_access: "1" + sieve_access: "1" + eas_access: "1" + dav_access: "1" + relayhost: "0" + shared: "0" + passwd_update: "2026-11-03 09:37:37" + mailbox_format: "maildir:" + quarantine_notification: hourly + quarantine_category: reject + attribute_hash: "" + recovery_email: "" + custom_attributes: + department: Sales + quota_used: 418219315 + percent_in_use: 13 + created: "2026-11-03 09:37:37" + modified: "2026-11-06 20:20:46" + authsource: mailcow + percent_class: success + last_imap_login: 1794052021 + last_smtp_login: 1793788402 + last_pop3_login: 0 + last_sso_login: 1794005152 + last_sogo_login: 1794005160 + max_new_quota: 10737418240 + spam_aliases: 1 + pushover_active: 0 + rl: + value: "100" + frame: h + rl_scope: mailbox + is_relayed: 0 + sender_acl: + - sales@example.org + tags: + - sales + MailboxList: + summary: Mailboxes + value: + - username: john.doe@example.org + active: 1 + active_int: 1 + domain: example.org + name: John Doe + local_part: john.doe + quota: 3221225472 + messages: 1207 + attributes: + force_pw_update: "0" + force_tfa: "0" + tls_enforce_in: "0" + tls_enforce_out: "0" + sogo_access: "1" + imap_access: "1" + pop3_access: "0" + smtp_access: "1" + sieve_access: "1" + eas_access: "1" + dav_access: "1" + relayhost: "0" + shared: "0" + passwd_update: "2026-11-03 09:37:37" + mailbox_format: "maildir:" + quarantine_notification: hourly + quarantine_category: reject + attribute_hash: "" + custom_attributes: [] + quota_used: 418219315 + percent_in_use: 13 + created: "2026-11-03 09:37:37" + modified: "2026-11-06 20:20:46" + authsource: mailcow + percent_class: success + last_imap_login: 1794052021 + last_smtp_login: 1793788402 + last_pop3_login: 0 + last_sso_login: 0 + last_sogo_login: 1794005160 + max_new_quota: 10737418240 + spam_aliases: 0 + pushover_active: 0 + rl: false + rl_scope: domain + is_relayed: 0 + sender_acl: [] + MailboxSearchPage: + summary: One page of mailboxes + value: + draw: 1 + recordsTotal: 1 + recordsFiltered: 1 + data: + - username: john.doe@example.org + active: 1 + active_int: 1 + domain: example.org + name: John Doe + local_part: john.doe + quota: 3221225472 + messages: 1207 + attributes: + force_pw_update: "0" + force_tfa: "0" + tls_enforce_in: "0" + tls_enforce_out: "0" + sogo_access: "1" + imap_access: "1" + pop3_access: "0" + smtp_access: "1" + sieve_access: "1" + eas_access: "1" + dav_access: "1" + relayhost: "0" + shared: "0" + passwd_update: "2026-11-03 09:37:37" + mailbox_format: "maildir:" + quarantine_notification: hourly + quarantine_category: reject + attribute_hash: "" + custom_attributes: [] + quota_used: 418219315 + percent_in_use: 13 + created: "2026-11-03 09:37:37" + modified: "2026-11-06 20:20:46" + authsource: mailcow + percent_class: success + last_imap_login: 1794052021 + last_smtp_login: 1793788402 + last_pop3_login: 0 + last_sso_login: 0 + last_sogo_login: 1794005160 + max_new_quota: 10737418240 + spam_aliases: 0 + pushover_active: 0 + rl: false + rl_scope: domain + is_relayed: 0 + sender_acl: [] + tags: + - sales security: - ApiKeyAuth: [] @@ -55,3065 +4087,2143 @@ security: paths: /api/v1/add/alias: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - add - - alias - - active: "1" - address: alias@domain.tld - goto: destination@domain.tld - - null - msg: - - alias_added - - alias@domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} tags: - Aliases + summary: Create aliases + operationId: addAlias description: >- - You may create your own mailbox alias using this action. It takes a JSON - object containing a domain informations. + Creates an alias for every address in `address`, all with the same + destinations. Administrators and API keys may use every domain, domain + administrators their own domains. - Only one `goto*` option can be used, for ex. if you want learn as spam, - then send just `goto_spam = 1` in request body. - operationId: Create alias + + Every address is checked on its own and gets its own message: + `alias_added` (address, new ID) on success, else `is_alias_or_mailbox`, + `domain_not_found`, `is_spam_alias`, `alias_invalid` or + `access_denied`. An address that is also one of the destinations is + skipped without message. `max_alias_exceeded` (the domain has no + aliases left) stops the call at that address. Nothing is created with + `comment_too_long`, `alias_empty`, `goto_empty` or when no destination + is valid (`goto_invalid`: no e-mail address or a resource; + `external_goto_denied`: not a mailcow domain while external + destinations are disabled by `ALIAS_DISABLE_EXTERNAL_DOMAINS` or by the + ACL of the domain administrator). + + + Flags that are left out are 0: send `active`, `sogo_visible` and + `sender_allowed` as 1 to get an alias like the UI creates it. requestBody: content: application/json: schema: - example: - active: "1" - address: alias@domain.tld - goto: destination@domain.tld - properties: - active: - description: is alias active or not - type: boolean - address: - description: 'alias address, for catchall use "@domain.tld"' - type: string - goto: - description: "destination address, comma separated" - type: string - goto_ham: - description: learn as ham - type: boolean - goto_null: - description: silently ignore - type: boolean - goto_spam: - description: learn as spam - type: boolean - sogo_visible: - description: toggle visibility as selectable sender in SOGo - type: boolean type: object - summary: Create alias + properties: + address: + type: string + description: >- + Alias address, `@example.org` for a catch-all of the + domain. Several addresses can be separated by spaces, + commas, semicolons or line breaks. The domain must be a + mailcow domain or alias domain (IDN domains are stored in + punycode). + goto: + type: string + description: >- + Destination addresses, separated by spaces, commas, + semicolons or line breaks. Required unless `goto_null`, + `goto_spam` or `goto_ham` is 1; ignored then. + goto_null: + type: [boolean, integer, string] + description: >- + 1 = discard the mail silently (destination + `null@localhost`). Wins over `goto_spam` and `goto_ham`. + default: 0 + goto_spam: + type: [boolean, integer, string] + description: >- + 1 = learn the mail as spam (destination `spam@localhost`). + Wins over `goto_ham`. + default: 0 + goto_ham: + type: [boolean, integer, string] + description: 1 = learn the mail as ham (destination `ham@localhost`) + default: 0 + active: + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + default: 0 + sogo_visible: + type: [boolean, integer, string] + description: >- + 1 = the alias is a selectable sender in SOGo (aliases that + point to at least one local mailbox) + default: 0 + internal: + type: [boolean, integer, string] + description: >- + 1 = internal alias: only reachable from its own domain and + the alias domains of it + default: 0 + sender_allowed: + type: [boolean, integer, string] + description: 1 = the destination mailboxes may send as this alias + default: 0 + public_comment: + type: string + description: Comment shown to the users, at most 160 characters + maxLength: 160 + private_comment: + type: string + description: Comment for administrators, at most 160 characters + maxLength: 160 + required: + - address + example: + address: sales@example.org + goto: anna@example.org,ben@example.org + active: "1" + sogo_visible: "1" + sender_allowed: "1" + internal: "0" + public_comment: Sales team + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/add/time_limited_alias: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - add - - time_limited_alias - - address: info@domain.tld - domain: domain.tld - - null - msg: - - mailbox_modified - - info@domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} tags: - - Aliases + - Mailbox user settings + summary: Create a time-limited alias + operationId: addTimeLimitedAlias description: >- - You may create a time limited alias using this action. It takes a JSON - object containing a domain and mailbox informations. - Mailcow will generate a random alias. - operationId: Create time limited alias + Creates a random alias (e.g. `kahd.osuwe@example.org`) that delivers to + the mailbox until it expires. Needs the user ACL `spam_alias`. API keys + must send `username` (without it the session user "API" is used and + the call fails with `domain_invalid`); a mailbox user may leave it out + (own mailbox) and gets `access_denied` for other mailboxes. `domain` + must be the domain of the mailbox or one of its alias domains + (`domain_invalid`). Errors: `validity_missing` (validity out of range). + Success: `mailbox_modified`. requestBody: + required: true content: application/json: schema: - example: - username: info@domain.tld - domain: domain.tld - description: my time limited alias - validity: 8760 - permanent: false + type: object + required: + - domain properties: username: - description: 'the mailbox an alias should be created for' type: string + description: Mailbox the alias delivers to (required for API keys) domain: - description: "the domain" type: string + description: Domain of the alias description: - description: "a description for the alias, defaults to an empty string" type: string + default: "" + description: Note for the alias validity: - description: "how many hours the alias stays valid, 1 to 87600, defaults to 8760 (one year)" - type: integer + type: [integer, string] + minimum: 1 + maximum: 87600 + default: 8760 + description: Hours until the alias expires permanent: - description: "keep the alias after it expired, defaults to false" - type: boolean - type: object - summary: Create time limited alias + type: [boolean, integer, string] + default: false + description: true keeps the alias after it expired + example: + username: john.doe@example.org + domain: example.org + description: Online shop registration + validity: 720 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/add/api-key: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - api - - add - - key - - description: provisioning - access: rw - allow_from: "192.0.2.10, 2001:db8::/64" - skip_ip_check: "0" - active: "1" - msg: - - api_key_added - - provisioning - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + summary: Create API key + description: >- + Creates an API key with a random value. The answer does not contain the + value: read it with `get/api-key/all`. Without `skip_ip_check` the key + needs at least one IP address or network in `allow_from`. + + + Success: `api_key_added` (with the description, else the ID). Errors: + `api_key_access_invalid`, `api_key_description_invalid`, + `api_key_network_invalid`, `ip_list_empty`. + operationId: addApiKey tags: - API Keys - description: >- - Create an API key with a random value (admin only, not with a read-only - API key). Get the key value with the GET method. Without - `skip_ip_check` at least one IP/network in `allow_from` is required. - operationId: Create API key requestBody: + required: true content: application/json: schema: - example: - description: provisioning - access: rw - allow_from: "192.0.2.10, 2001:db8::/64" - skip_ip_check: "0" - active: "1" - properties: - description: - description: free text, max. 255 characters - type: string - access: - description: "ro (read-only, GET endpoints only) or rw (read-write), required" - enum: - - ro - - rw - type: string - allow_from: - description: >- - IPs/CIDR networks the key may be used from, separated by - commas, spaces or newlines (or an array) - type: string - skip_ip_check: - description: allow the key from any IP - type: boolean - active: - description: is key active or not - type: boolean - type: object - summary: Create API key + allOf: + - $ref: "#/components/schemas/ApiKeyAttributes" + - required: + - access + example: + description: provisioning + access: rw + allow_from: "192.0.2.10, 2001:db8::/64" + skip_ip_check: 0 + active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/add/app-passwd: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - app_passwd - - add - - active: "1" - username: info@domain.tld - app_name: wordpress - app_passwd: keyleudecticidechothistishownsan31 - app_passwd2: keyleudecticidechothistishownsan31 - protocols: - - imap_access - - dav_access - - smtp_access - - eas_access - - pop3_access - - sieve_access - msg: app_passwd_added - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} tags: - App Passwords + summary: Create an app password + operationId: addAppPassword description: >- - Using this endpoint you can create a new app password for a specific - mailbox. - operationId: Create App Password + Adds an app password to a mailbox: a separate password for a client, + valid only for the protocols listed. Needs the user ACL `app_passwds`. + API keys must send `username` (without it the session user "API" is + used and the call fails with `access_denied`); a mailbox user may leave + it out (own mailbox). Errors: `access_denied`, `password_complexity`, + `password_mismatch`, `app_name_empty`. Success: `app_passwd_added`. requestBody: + required: true content: application/json: schema: - example: - active: "1" - username: info@domain.tld - app_name: wordpress - app_passwd: keyleudecticidechothistishownsan31 - app_passwd2: keyleudecticidechothistishownsan31 - protocols: - - imap_access - - dav_access - - smtp_access - - eas_access - - pop3_access - - sieve_access + type: object + required: + - app_name + - app_passwd + - app_passwd2 properties: - active: - description: is alias active or not - type: boolean username: - description: mailbox for which the app password should be created type: string + description: Mailbox the app password belongs to (required for API keys) app_name: - description: name of your app password type: string + description: Name of the app password app_passwd: - description: your app password type: string + format: password + description: The password, checked against the password policy app_passwd2: - description: your app password type: string - type: object - summary: Create App Password - /api/v1/add/bcc: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - bcc - - add - - active: "1" - bcc_dest: bcc@awesomecow.tld - local_dest: mailcow.tld - type: sender - - null - msg: bcc_saved - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Address Rewriting - description: >- - Using this endpoint you can create a BCC map to forward all mails via a - bcc for a given domain. - operationId: Create BCC Map - requestBody: - content: - application/json: - schema: - example: - active: "1" - bcc_dest: bcc@awesomecow.tld - local_dest: mailcow.tld - type: sender - properties: - active: - description: 1 for a active user account 0 for a disabled user account - type: number - bcc_dest: - description: the email address where all mails should be send to - type: string - local_dest: - description: the domain which emails should be forwarded - type: string - type: - description: the type of bcc map can be `sender` or `rcpt` - enum: [sender, rcpt] - type: string - type: object - summary: Create BCC Map - /api/v1/add/dkim: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - dkim - - add - - dkim_selector: dkim - domains: hanspeterlol.de - key_size: "2048" - msg: - - dkim_added - - hanspeterlol.de - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - DKIM - description: Using this endpoint you can generate new DKIM keys. - operationId: Generate DKIM Key - requestBody: - content: - application/json: - schema: - example: - dkim_selector: dkim - domains: mailcow.tld - key_size: "2048" - properties: - dkim_selector: - description: the DKIM selector default dkim - type: string - domains: - description: a list of domains for which a dkim key should be generated - type: string - key_size: - description: the key size (1024, 2048, 3072 or 4096) - type: number - type: object - summary: Generate DKIM Key - /api/v1/add/dkim_duplicate: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - dkim - - duplicate - - from_domain: mailcow.tld - to_domain: awesomecow.tld - msg: - - dkim_duplicated - - mailcow.tld - - awesomecow.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - DKIM - description: Using this endpoint you can duplicate the DKIM Key of one domain. - operationId: Duplicate DKIM Key - requestBody: - content: - application/json: - schema: - example: - from_domain: mailcow.tld - to_domain: awesomecow.tld - properties: - fron_domain: - description: the domain where the dkim key should be copied from - type: string - to_domain: - description: the domain where the dkim key should be copied to - type: string - type: object - summary: Duplicate DKIM Key - /api/v1/add/domain: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - ratelimit - - edit - - domain - - object: domain.tld - rl_frame: s - rl_value: "10" - msg: - - rl_saved - - domain.tld - type: success - - log: - - mailbox - - add - - domain - - active: "1" - aliases: "400" - restart_sogo: "1" - backupmx: "0" - defquota: "3072" - description: some decsription - domain: domain.tld - mailboxes: "10" - maxquota: "10240" - quota: "10240" - relay_all_recipients: "0" - rl_frame: s - rl_value: "10" - tags: ["tag1", "tag2"] - - null - msg: - - domain_added - - domain.tld - type: success - schema: - type: array - items: - type: object - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - description: OK - headers: {} - tags: - - Domains - description: >- - You may create your own domain using this action. It takes a JSON object - containing a domain informations. - operationId: Create domain - requestBody: - content: - application/json: - schema: - example: - active: "1" - aliases: "400" - backupmx: "0" - defquota: "3072" - description: some decsription - domain: domain.tld - mailboxes: "10" - maxquota: "10240" - quota: "10240" - relay_all_recipients: "0" - rl_frame: s - rl_value: "10" - restart_sogo: "10" - tags: ["tag1", "tag2"] - properties: - active: - description: is domain active or not - type: boolean - aliases: - description: limit count of aliases associated with this domain - type: number - backupmx: - description: relay domain or not - type: boolean - defquota: - description: predefined mailbox quota in `add mailbox` form - type: number - description: - description: Description of domain - type: string - domain: - description: Fully qualified domain name - type: string - gal: - description: >- - is domain global address list active or not, it enables - shared contacts accross domain in SOGo webmail - type: boolean - mailboxes: - description: limit count of mailboxes associated with this domain - type: number - maxquota: - description: maximum quota per mailbox - type: number - quota: - description: maximum quota for this domain (for all mailboxes in sum) - type: number - restart_sogo: - description: restart SOGo to activate the domain in SOGo - type: number - relay_all_recipients: - description: >- - if not, them you have to create "dummy" mailbox for each - address to relay - type: boolean - relay_unknown_only: - description: Relay non-existing mailboxes only. Existing mailboxes will be delivered locally. - type: boolean - rl_frame: - enum: - - s - - m - - h - - d - type: string - rl_value: - description: rate limit value - type: number - tags: - description: tags for this Domain + format: password + description: The password again + protocols: + description: Protocols the password is valid for; not listed = not allowed type: array items: type: string - type: object - summary: Create domain - /api/v1/add/domain-admin: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - domain_admin - - add - - active: "1" - domains: mailcow.tld - password: "*" - password2: "*" - username: testadmin - msg: - - domain_admin_added - - testadmin - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Domain admin - description: >- - Using this endpoint you can create a new Domain Admin user. This user - has full control over a domain, and can create new mailboxes and - aliases. - operationId: Create Domain Admin user - requestBody: - content: - application/json: - schema: - example: - active: "1" - domains: mailcow.tld - password: supersecurepw - password2: supersecurepw - username: testadmin - properties: + - imap_access + - smtp_access + - pop3_access + - sieve_access + - dav_access + - eas_access active: - description: 1 for a active user account 0 for a disabled user account - type: number - domains: - description: the domains the user should be a admin of - type: string - password: - description: domain admin user password - type: string - password2: - description: domain admin user password - type: string - username: - description: the username for the admin user - type: string - type: object - summary: Create Domain Admin user - /api/v1/add/sso/domain-admin: - post: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + description: 1 active, 0 inactive + example: + username: john.doe@example.org + app_name: Thunderbird + app_passwd: eiQu4ahz8iePh + app_passwd2: eiQu4ahz8iePh + protocols: + - imap_access + - smtp_access + - sieve_access + active: 1 responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - token: "591F6D-5C3DD2-7455CD-DAF1C1-AA4FCC" - description: OK - headers: { } - tags: - - Single Sign-On - description: >- - Using this endpoint you can issue a token for Domain Admin user. This token can be used for - autologin Domain Admin user by using query_string var sso_token={token}. Token expiration time is 30s - operationId: Issue Domain Admin SSO token - requestBody: - content: - application/json: - schema: - example: - username: testadmin - properties: - username: - description: the username for the admin user - type: object - type: object - summary: Issue Domain Admin SSO token - /api/v1/edit/da-acl: + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/bcc: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - type: success - log: - - acl - - edit - - testadmin - - username: - - testadmin - da_acl: - - syncjobs - - quarantine - - login_as - - sogo_access - - app_passwds - - bcc_maps - - pushover - - filters - - ratelimit - - spam_policy - - extend_sender_acl - - unlimited_quota - - protocol_access - - smtp_ip_access - - alias_domains - - domain_desc - msg: - - acl_saved - - testadmin - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Domain admin - description: >- - Using this endpoint you can edit the ACLs of a Domain Admin user. This user - has full control over a domain, and can create new mailboxes and - aliases. - operationId: Edit Domain Admin ACL - requestBody: - content: - application/json: - schema: - example: - items: - - testadmin - attr: - da_acl: - - syncjobs - - quarantine - - login_as - - sogo_access - - app_passwds - - bcc_maps - - pushover - - filters - - ratelimit - - spam_policy - - extend_sender_acl - - unlimited_quota - - protocol_access - - smtp_ip_access - - alias_domains - - domain_desc - properties: - items: - description: contains the domain admin username you want to edit - type: object - attr: - properties: - da_acl: - description: contains the list of acl names that are active for this user - type: object - type: object - summary: Edit Domain Admin ACL - /api/v1/edit/domain-admin: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - type: success - log: - - domain_admin - - edit - - username: testadmin - active: ["0","1"] - username_new: testadmin - domains: ["domain.tld"] - password: "*" - password2: "*" - msg: - - domain_admin_modified - - testadmin - schema: - properties: - type: - enum: - - success - - danger - - error - type: string - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: object - description: OK - headers: {} - tags: - - Domain admin - description: >- - Using this endpoint you can edit a existing Domain Admin user. This user - has full control over a domain, and can create new mailboxes and - aliases. - operationId: Edit Domain Admin user - requestBody: - content: - application/json: - schema: - example: - items: - - testadmin - attr: - active: - - '0' - - '1' - username_new: testadmin - domains: ["domain.tld"] - password: supersecurepassword - password2: supersecurepassword - properties: - attr: - properties: - active: - description: is the domain admin active or not - type: boolean - username_new: - description: the username of the domain admin, change this to change the username - type: string - domains: - description: a list of all domains managed by this domain admin - type: array - items: - type: string - password: - description: the new domain admin user password - type: string - password2: - description: the new domain admin user password for confirmation - type: string - type: object - items: - description: contains the domain admin username you want to edit - type: object - summary: Edit Domain Admin user - /api/v1/add/domain-policy: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - policy - - add - - domain - - domain: domain.tld - object_from: "*@baddomain.tld" - object_list: bl - msg: - - domain_modified - - domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Domain antispam policies - description: >- - You may create your own domain policy using this action. It takes a JSON - object containing a domain informations. - operationId: Create domain policy - requestBody: - content: - application/json: - schema: - example: - domain: domain.tld - object_from: "*@baddomain.tld" - object_list: bl - properties: - domain: - description: domain name to which policy is associated to - type: string - object_from: - description: exact address or use wildcard to match whole domain - type: string - object_list: - enum: - - wl - - bl - type: string - type: object - summary: Create domain policy - /api/v1/add/fwdhost: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - fwdhost - - add - - filter_spam: "0" - hostname: hosted.mailcow.de - msg: - - forwarding_host_added - - "5.1.76.202, 2a00:f820:417::202" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Fordwarding Hosts - description: >- - Add a new Forwarding host to mailcow. You can chose to enable or disable - spam filtering of incoming emails by specifing `filter_spam` 0 = - inactive, 1 = active. - operationId: Add Forward Host - requestBody: - content: - application/json: - schema: - example: - filter_spam: "0" - hostname: hosted.mailcow.de - properties: - filter_spam: - description: "1 to enable spam filter, 0 to disable spam filter" - type: number - hostname: - description: contains the hostname you want to add - type: string - type: object - summary: Add Forward Host - /api/v1/add/mailbox: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - add - - mailbox - - active: "1" - domain: domain.tld - local_part: info - name: Full name - password: "*" - password2: "*" - quota: "3072" - force_pw_update: "1" - force_tfa: "1" - tls_enforce_in: "1" - tls_enforce_out: "1" - tags: ["tag1", "tag2"] - - null - msg: - - mailbox_added - - info@domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Mailboxes - description: >- - You may create your own mailbox using this action. It takes a JSON - object containing a domain informations. - operationId: Create mailbox - requestBody: - content: - application/json: - schema: - example: - active: "1" - domain: domain.tld - local_part: info - name: Full name - authsource: mailcow - password: atedismonsin - password2: atedismonsin - quota: "3072" - force_pw_update: "1" - force_tfa: "1" - tls_enforce_in: "1" - tls_enforce_out: "1" - tags: ["tag1", "tag2"] - properties: - active: - description: is mailbox active or not - type: boolean - domain: - description: domain name - type: string - local_part: - description: left part of email address - type: string - name: - description: Full name of the mailbox user - type: string - authsource: - description: Specifies the authentication source for the mailbox. - type: string - enum: [mailcow, ldap, keycloak, generic-oidc] - default: mailcow - password2: - description: mailbox password for confirmation - type: string - password: - description: mailbox password when using `mailcow` as the authentication source. - type: string - quota: - description: mailbox quota - type: number - force_pw_update: - description: forces the user to update its password on first login - type: boolean - force_tfa: - description: force 2FA enrollment at login - type: boolean - tls_enforce_in: - description: force inbound email tls encryption - type: boolean - tls_enforce_out: - description: force oubound tmail tls encryption - type: boolean - shared: - description: >- - shared mailbox: no login of its own, members of groups open it through shares; - password and password2 may be empty (a random password is set) - type: boolean - type: object - summary: Create mailbox - - /api/v1/add/mailbox-group: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox_group - - add - - group - - domain: example.org - name: Sales - local_part: sales - members: - - alice@example.org - msg: - - group_added - - Sales - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - warning - - error - type: string - type: object - description: OK - headers: {} - tags: - - Groups - description: >- - Create a mailbox group (admins; domain admins with the "mailbox_groups" - ACL for their domains). With an address the group is a distribution - list; its alias is maintained by the group and read-only in the alias - API. - operationId: Create group - requestBody: - content: - application/json: - schema: - example: - domain: example.org - name: Sales - local_part: sales - members: - - alice@example.org - - bob@example.org - send_as: - - shared@example.org - sender_allowed: "1" - active: "1" - properties: - domain: - description: domain of the group (required) - type: string - name: - description: unique per domain, max. 100 characters - type: string - description: - description: max. 255 characters - type: string - local_part: - description: local part of the group address (domain of the group), empty = no address - type: string - address: - description: full group address instead of local_part, empty = no address - type: string - members: - description: >- - mailboxes (array or list separated by commas, spaces or - newlines); replaces the manual members you may access - type: array - send_as: - description: addresses (mailbox or alias) or @domain the members may send as - type: array - sender_allowed: - description: members may send as the group address - type: boolean - sogo_visible: - description: show the group address as SOGo identity (needs sender_allowed) - type: boolean - internal: - description: only accept mail from senders of the domain - type: boolean - template_id: - description: mailbox template applied to the members (admin only), empty = none - type: number - priority: - description: highest priority wins if a mailbox is in several groups with a template (admin only) - type: number - active: - type: boolean - type: object - summary: Create group - /api/v1/add/oauth2-client: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - oauth2 - - add - - client - - redirect_uri: "https://mailcow.tld" - msg: Added client access - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - oAuth Clients - description: Using this endpoint you can create a oAuth clients. - operationId: Create oAuth Client - requestBody: - content: - application/json: - schema: - example: - redirect_uri: "https://mailcow.tld" - properties: - redirect_uri: - description: the uri where you should be redirected after oAuth - type: string - type: object - summary: Create oAuth Client - /api/v1/add/recipient_map: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - recipient_map - - add - - active: "1" - recipient_map_new: target@mailcow.tld - recipient_map_old: recipient@mailcow.tld - - null - msg: - - recipient_map_entry_saved - - recipient@mailcow.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} tags: - Address Rewriting + summary: Create a BCC map + operationId: addBccMap description: >- - Using this endpoint you can create a recipient map to forward all mails - from one email address to another. - operationId: Create Recipient Map + Sends a hidden copy of the mail of a domain or address to `bcc_dest`. + Administrators, API keys and domain administrators with the BCC maps + ACL may call it (domain administrators for their domains). The local + destination is not informed when the copy cannot be delivered. + + + Messages: `bcc_saved`; `invalid_bcc_map_type`, `bcc_empty`, + `bcc_must_be_email`, `bcc_exists` (same `local_dest` and `type`), + `access_denied` (no ACL, a domain or address the caller does not + manage, or an address that is no mailbox or alias). A `local_dest` that + is neither a domain nor an e-mail address answers `Cannot add item`. requestBody: content: application/json: schema: - example: - active: "1" - recipient_map_new: target@mailcow.tld - recipient_map_old: recipient@mailcow.tld - properties: - active: - description: 1 for a active user account 0 for a disabled user account - type: number - recipient_map_new: - description: the email address that should receive the forwarded emails - type: string - recipient_map_old: - description: the email address which emails should be forwarded - type: string type: object - summary: Create Recipient Map - /api/v1/add/relayhost: - post: + properties: + local_dest: + type: string + description: >- + A mailcow domain (stored as `@example.org`, IDN in + punycode) or the address of a mailbox or alias + bcc_dest: + type: string + description: >- + One e-mail address that gets the copies (use an alias for + several) + type: + type: string + enum: + - sender + - rcpt + description: >- + `sender`: copy the mail sent by `local_dest`; `rcpt`: copy + the mail sent to it + active: + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + default: 0 + required: + - local_dest + - bcc_dest + - type + example: + local_dest: example.org + bcc_dest: archive@example.com + type: rcpt + active: "1" responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/dkim: + post: + summary: Generate DKIM keys + description: >- + Generates an RSA DKIM key for each domain. Every domain must be a + domain or alias domain the caller has access to and must not have a + key yet. Success `dkim_added`, errors `dkim_domain_or_sel_invalid` + (invalid domain or selector, key exists, key generation failed), + `access_denied`. + operationId: addDkimKey + tags: + - DKIM + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - domains + - key_size + properties: + domains: + type: string + description: >- + Domain names, separated by spaces, commas, semicolons or + line breaks + dkim_selector: + type: string + default: dkim + description: Selector (letters, digits, `-`, `_`, `.`) + key_size: + type: [integer, string] + description: RSA key size in bits (the UI offers 1024, 2048, 3072, 4096) + example: + domains: example.org, example.net + dkim_selector: dkim + key_size: 2048 + responses: "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/dkim_duplicate: + post: + summary: Copy a DKIM key to other domains + description: >- + Copies the DKIM key and selector of `from_domain` to the domains in + `to_domain`, replacing their keys. Admins only. The target names are + not checked. Success `dkim_duplicated` per target, errors + `access_denied`, `dkim_domain_or_sel_invalid` (`from_domain` has no + key). + operationId: duplicateDkimKey + tags: + - DKIM + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - from_domain + - to_domain + properties: + from_domain: + type: string + description: Domain whose key is copied + to_domain: + description: Domain name or list of domain names that get the key + oneOf: + - type: string + - type: array + items: + type: string + example: + from_domain: example.org + to_domain: + - example.net + - example.com + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/domain: + post: + summary: Add a domain + description: >- + Adds a domain. Admins only (API keys are admins). Quotas are in MiB. + Values that are not sent come from the domain template given in + `template`, else from the first domain template (`Default`). + `relay_all_recipients` = 1 sets `backupmx`, `relay_unknown_only` = 1 + sets both. Besides the domain this sets the rate limit (`rl_value`, + `rl_frame`) and creates a DKIM key (`key_size`, `dkim_selector`; the + Default template creates a 2048 bit key with selector `dkim`), so the + answer can hold `rl_saved` and `dkim_added` (or + `domain_add_dkim_available` when a key exists already) before + `domain_added`. Errors: `access_denied`, + `mailbox_defquota_exceeds_mailbox_maxquota`, + `mailbox_quota_exceeds_domain_quota`, `defquota_empty`, + `maxquota_empty`, `domain_invalid`, `object_is_not_numeric`, + `domain_exists` (also for an alias domain of that name), + `domain_cannot_match_hostname`. SOGo knows the domain only after a + restart (`restart_sogo`); a failed restart answers + `domain_added_sogo_failed` (warning) although the domain was added. + operationId: addDomain + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - domain + properties: + domain: + type: string + description: Domain name (stored lowercase, IDN as punycode) + description: + type: string + description: Description, default the domain name + template: + type: [integer, string] + description: >- + ID of the domain template that gives the values not sent + (see get/domain/template/all); default the first template + aliases: + type: [integer, string] + description: Max. number of aliases + mailboxes: + type: [integer, string] + description: Max. number of mailboxes + defquota: + type: [integer, string] + description: Default quota of new mailboxes in MiB, must not be 0 + maxquota: + type: [integer, string] + description: Max. quota per mailbox in MiB, must not be 0 + quota: + type: [integer, string] + description: Quota of the whole domain in MiB + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Domain active + gal: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: >- + Global address list in SOGo (all objects of the domain) + backupmx: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Relay this domain (backup MX) + relay_all_recipients: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: >- + Relay all recipients; else a mailbox is needed for every + recipient to relay. 1 sets `backupmx`. + relay_unknown_only: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: >- + Relay non-existing mailboxes only, existing mailboxes are + delivered locally. 1 sets `backupmx` and + `relay_all_recipients`. + tags: + type: array + items: + type: string + description: >- + Tags of the domain; tags beyond the tagging limit + (`TAGGING_LIMIT`, 25) are dropped with the warning + `tag_limit_exceeded` + rl_value: + type: [integer, string] + description: Rate limit, messages per `rl_frame`; empty or 0 = none + rl_frame: + type: string + enum: [s, m, h, d] + description: Rate limit time frame + dkim_selector: + type: string + description: Selector of the DKIM key that is created + key_size: + type: [integer, string] + description: >- + RSA key size in bits of the DKIM key that is created (the + UI offers 1024, 2048, 3072, 4096); empty or 0 = no key + restart_sogo: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + description: Restart SOGo afterwards + example: + domain: example.org + description: Example Org + aliases: 400 + mailboxes: 10 + defquota: 3072 + maxquota: 10240 + quota: 10240 + active: 1 + gal: 1 + backupmx: 0 + relay_all_recipients: 0 + relay_unknown_only: 0 + tags: + - customer + rl_value: 100 + rl_frame: h + dkim_selector: dkim + key_size: 2048 + restart_sogo: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/domain-admin: + post: + summary: Create domain administrator + description: >- + Creates a domain administrator, who manages mailboxes, aliases and + the other objects of the given domains. The username is converted to + lowercase; it may only contain letters, digits, `_`, `.` and `-` and + must not be used by a mailbox, an administrator or a domain + administrator. The new account gets the default ACLs (see + `edit/da-acl`). + + + Success: `domain_admin_added`. Errors: `domain_invalid` (no domain or an + unknown domain), `username_invalid`, `object_exists`, + `password_complexity`, `password_mismatch`. Unknown domains are skipped + with `domain_invalid`; if none of the domains exists, the account is not + created although `domain_admin_added` is reported too. + operationId: addDomainAdmin + tags: + - Domain admin + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + description: Username (letters, digits, `_`, `.`, `-`; stored in lowercase) + password: + type: string + description: Password, must match the password policy + password2: + type: string + description: The password again + domains: + description: Domain or list of domains the account administers + oneOf: + - type: string + - type: array + items: + type: string + active: + type: [integer, string, boolean] + description: >- + 1 = the account can log in, 0 = disabled. Missing counts as + 0: send `1` for a usable account. + default: 0 + required: + - username + - password + - password2 + - domains + example: + username: domainadmin1 + password: "Rand0m-Passw0rd!" + password2: "Rand0m-Passw0rd!" + domains: + - example.org + - example.com + active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/sso/domain-admin: + post: + summary: Issue domain administrator login token + description: >- + Issues a one-time login token for a domain administrator. Opening + `https:///domainadmin/mailbox?sso_token=` in a + browser logs in as this domain administrator without password or + two-factor authentication. The token is valid for 30 seconds, can be + used once and only works for an active domain administrator. + + + Errors: `access_denied`, `object_doesnt_exist` (no domain + administrator of this name). + operationId: issueDomainAdminSsoToken + tags: + - Single Sign-On + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + description: Username of the domain administrator + required: + - username + example: + username: domainadmin1 + responses: + "200": + description: The token, or the error messages content: application/json: - examples: - response: - value: - - log: - - relayhost - - add - - hostname: "mailcow.tld:25" - password: supersecurepassword - username: testuser - msg: - - relayhost_added - - "" - type: success schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + oneOf: + - type: object + properties: + token: + type: string + description: Login token (five groups of six hex digits) + required: + - token + - $ref: "#/components/schemas/Messages" + example: + token: 591F6D-5C3DD2-7455CD-DAF1C1-AA4FCC + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/da-acl: + post: + summary: Edit domain administrator ACLs + description: >- + Sets the ACLs (permissions) of domain administrators: the ACLs in + `da_acl` are granted, all others are revoked. An empty or missing + `da_acl` revokes every ACL. + + + One message per account. Success: `acl_saved`. Error: `Cannot + determine current ACL` (no domain administrator of this name). + operationId: editDomainAdminAcl tags: - - Routing - description: Using this endpoint you can create Sender-Dependent Transports. - operationId: Create Sender-Dependent Transports + - Domain admin + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + items: + description: Username of the domain administrator, or a list of usernames + oneOf: + - type: string + - type: array + items: + type: string + attr: + type: object + properties: + da_acl: + description: The ACLs to grant (a single name or a list) + oneOf: + - $ref: "#/components/schemas/DomainAdminAcl" + - type: array + items: + $ref: "#/components/schemas/DomainAdminAcl" + required: + - items + - attr + example: + items: + - domainadmin1 + attr: + da_acl: + - syncjobs + - quarantine + - login_as + - sogo_access + - app_passwds + - bcc_maps + - filters + - ratelimit + - spam_policy + - protocol_access + - smtp_ip_access + - mailbox_relayhost + - domain_relayhost + - alias_external_goto + - mailbox_groups + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/domain-admin: + post: + summary: Edit domain administrators + description: >- + Changes domain administrators. Attributes that are not sent keep their + value, except `force_tfa` and `force_pw_update`, which are set to 0 when + they are missing. `domains` replaces the list of domains; an empty or + missing list keeps it. The password is only changed when `password` is + not empty. Renaming an account moves its ACLs, two-factor + authentication and the sync job sources it created to the new name + (FIDO2 keys stay with the old name). + + + One message per account. Success: `domain_admin_modified`. Errors: + `access_denied` (no domain administrator of this name), + `domain_invalid`, `username_invalid` (invalid `username_new` or the + name of another domain administrator). A password that does not match + `password2` or the policy (`password_mismatch`, `password_complexity`) + stops the call, after the domains were already saved. + operationId: editDomainAdmin + tags: + - Domain admin + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + items: + description: Username of the domain administrator, or a list of usernames + oneOf: + - type: string + - type: array + items: + type: string + attr: + type: object + properties: + active: + type: [integer, string, boolean] + description: 1 = active, 0 = disabled + username_new: + type: string + description: New username (letters, digits, `_`, `.`, `-`) + domains: + description: The domains the account administers from now on + oneOf: + - type: string + - type: array + items: + type: string + password: + type: string + description: New password; empty or missing keeps the password + password2: + type: string + description: The new password again + force_tfa: + type: [integer, string, boolean] + description: 1 = two-factor authentication must be set up at the next login + default: 0 + force_pw_update: + type: [integer, string, boolean] + description: 1 = the password must be changed at the next login + default: 0 + disable_tfa: + type: [integer, string, boolean] + description: >- + Present (with any value) = deactivate the two-factor + authentication of the account + required: + - items + - attr + example: + items: + - domainadmin1 + attr: + active: 1 + domains: + - example.org + password: "N3w-Passw0rd!" + password2: "N3w-Passw0rd!" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/domain-policy: + post: + summary: Add a sender to a domain allow or deny list + description: >- + Adds an entry to the allow list (`wl`) or deny list (`bl`) of a domain. + Needs the ACL `spam_policy` (API keys have it). Success + `domain_modified`, errors `access_denied` (also for an invalid domain or + list), `policy_list_from_invalid`, `policy_list_from_exists` (the sender + is on one of the lists already). + operationId: addDomainPolicy + tags: + - Domain antispam policies + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - domain + - object_list + - object_from + properties: + domain: + type: string + description: Domain the entry belongs to + object_list: + type: string + enum: [wl, bl] + description: "`wl` allow list (whitelist), `bl` deny list (blacklist)" + object_from: + type: string + description: >- + Sender address or pattern, `*` as wildcard (e.g. + `*@example.com`); letters, digits and `@ _ . - *` + example: + domain: example.org + object_list: wl + object_from: "*@partner.example.com" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/fwdhost: + post: + tags: + - Forwarding Hosts + summary: Add forwarding hosts + operationId: addForwardingHost + description: >- + Adds hosts whose incoming mail is accepted unconditionally: no DNSBL + checks, no greylisting, spam is never rejected. A host or domain name + is resolved to the addresses of its SPF record, else its MX hosts, else + its A/AAAA records; every address becomes an entry with the name as + `source`. Administrators and API keys only. + + + Messages: `forwarding_host_added` (the addresses), `invalid_host` + (nothing resolved), `access_denied`, `redis_error`. requestBody: content: application/json: schema: - example: - hostname: "mailcow.tld:25" - password: supersecurepassword - username: testuser + type: object properties: hostname: - description: the hostname of the smtp server with port type: string - password: - description: the password for the smtp user - type: string - username: - description: the username used to authenticate - type: string - type: object - summary: Create Sender-Dependent Transports - /api/v1/add/resource: - post: + description: >- + IPv4 or IPv6 address, network in CIDR notation, host name + or domain name + filter_spam: + type: [boolean, integer, string] + description: >- + 1 = scan the mail of the hosts for spam (it can be filed + into the junk folder, never rejected); anything else = do + not scan it + default: 0 + required: + - hostname + example: + hostname: relay.example.com + filter_spam: "1" responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - add - - resource - - active: "1" - description: test - domain: mailcow.tld - kind: location - multiple_bookings: "0" - multiple_bookings_custom: "" - multiple_bookings_select: "0" - - null - msg: - - resource_added - - mailcow.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/fwdhost: + post: tags: - - Resources - description: Using this endpoint you can create Resources. - operationId: Create Resources + - Forwarding Hosts + summary: Update forwarding hosts + operationId: editForwardingHost + description: >- + Turns the spam filter of forwarding hosts on or off. Administrators and + API keys only. Per host: `object_modified`, `access_denied` (unknown + host), `redis_error`. Without `keep_spam` the spam filter is turned on. requestBody: content: application/json: schema: - example: - active: "1" - description: test - domain: mailcow.tld - kind: location - multiple_bookings: "0" - multiple_bookings_custom: "" - multiple_bookings_select: "0" + type: object properties: + items: + description: >- + Hosts as get/fwdhost lists them (`host`), a single value or + a list + oneOf: + - type: array + items: + type: string + - type: string + attr: + type: object + properties: + keep_spam: + type: [boolean, integer, string] + description: >- + 1 = do not scan the mail of the host for spam; anything + else = scan it + required: + - items + - attr + example: + items: + - 192.0.2.25 + - "2001:db8::25" + attr: + keep_spam: "0" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/mailbox: + post: + tags: + - Mailboxes + summary: Create a mailbox + operationId: addMailbox + description: >- + Creates the mailbox `local_part@domain` with its alias entry, quota + record and user ACL. Fields that are not sent take the values of the + mailbox template given in `template` (by default the first template, + "Default"). + + + Admins and domain admins (for their domains) may call it, mailbox users + get `access_denied`. Checks: valid address (`mailbox_invalid`), domain + exists (`domain_not_found`), address not in use by a mailbox, alias or + time-limited alias (`object_exists`, `is_alias`, `is_spam_alias`), + password policy (`password_complexity`, `password_mismatch`), mailbox + count and quota limits of the domain (`max_mailbox_exceeded`, + `mailbox_quota_exceeded`, `mailbox_quota_left_exceeded`), quota 0 + (unlimited) needs the `unlimited_quota` ACL (`unlimited_quota_acl`). + Success: `mailbox_added`. Tags after the 26th are dropped with the + warning `tag_limit_exceeded`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MailboxAdd" + example: + local_part: john.doe + domain: example.org + name: John Doe + password: Moo2Shoh5aiXu + password2: Moo2Shoh5aiXu + quota: 3072 + active: 1 + force_pw_update: 1 + tags: + - sales + acl: + - spam_alias + - spam_score + - delimiter_action + - app_passwds + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/mailbox-group: + post: + summary: Create mailbox group + description: >- + Creates a mailbox group in a domain. With an address the group is a + distribution list: mail to the address goes to all members (the group + keeps an alias row for it that the alias API shows read-only, and it + counts against the alias limit of the domain; it is inactive while the + group is inactive or has no members). A group can also let + its members send as other addresses (`send_as`, `sender_allowed`), open + other mailboxes (`imap_grants`), see shared SOGo folders (`sogo_grants`) + and get a mailbox template (`template_id`). + + + Admins (every read/write API key) may create groups in every domain and + set `template_id`, `priority` and `idp_ref`; domain admins need the + `mailbox_groups` ACL and may only use their domains and mailboxes. + Members must be regular mailboxes (no resources). + + + Answers `group_added` plus warnings of the follow-up work + (`group_too_many_members` when a group with an address has more than + 1000 members, `group_template_failed`, `group_imap_grant_failed`, + `group_subscription_failed`, `group_sogo_init_failed`; failed steps + are retried by a cron). Errors (nothing is written): `domain_not_found`, + `access_denied`, `group_name_invalid`, `group_description_invalid`, + `object_exists` (name exists in the domain), `group_address_invalid`, + `is_alias_or_mailbox`, `max_alias_exceeded`, `group_member_invalid`, + `group_member_idp`, `group_send_as_invalid`, `group_grant_invalid`, + `template_id_invalid`, `group_priority_invalid`. + operationId: addMailboxGroup + tags: + - Groups + requestBody: + content: + application/json: + schema: + allOf: + - type: object + required: + - domain + - name + properties: + domain: + type: string + description: Domain of the group (no alias domain), case-insensitive + - $ref: "#/components/schemas/MailboxGroupAttributes" + example: + domain: example.org + name: Sales + description: Sales team + local_part: sales + members: + - alice@example.org + - bob@example.org + send_as: + - orders@example.org + sender_allowed: 1 + imap_grants: + - owner: shared@example.org + rights: write + subscribe: 1 + template_id: 3 + priority: 10 + active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/oauth2-client: + post: + summary: Create OAuth2 client + description: >- + Creates an OAuth2 client with a random client ID and secret and the + scope `profile`. The answer does not contain them: read them with + `get/oauth2-client/all`. The redirect URI is not validated. + + + Success: `Added client access`. + operationId: addOAuth2Client + tags: + - oAuth Clients + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + redirect_uri: + type: string + description: Redirect (callback) URL of the client application + required: + - redirect_uri + example: + redirect_uri: https://app.example.org/oauth/callback + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/recipient_map: + post: + tags: + - Address Rewriting + summary: Create a recipient map + operationId: addRecipientMap + description: >- + Replaces the recipient address of mail before delivery: mail to + `recipient_map_old` goes to `recipient_map_new`. Administrators and API + keys only (other callers get `Cannot add item`). + + + Messages: `recipient_map_entry_saved`; `invalid_recipient_map_old`, + `invalid_recipient_map_new`, `recipient_map_entry_exists`. + requestBody: + content: + application/json: + schema: + type: object + properties: + recipient_map_old: + type: string + description: >- + Original recipient: an e-mail address or a domain (stored + as `@example.org`, IDN in punycode; a leading `@` is + optional) + recipient_map_new: + type: string + description: New recipient, an e-mail address or a domain active: - description: 1 for a active transport map 0 for a disabled transport map - type: number - description: - description: a description of the resource + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + default: 0 + required: + - recipient_map_old + - recipient_map_new + example: + recipient_map_old: jane.doe@example.org + recipient_map_new: jane.smith@example.org + active: "1" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/relayhost: + post: + tags: + - Routing + summary: Create a sender-dependent transport + operationId: addRelayhost + description: >- + Adds a relay host for outgoing mail. Domains and mailboxes use it when + their `relayhost` attribute is set to its ID (edit/domain, + edit/mailbox). The service is always `smtp:` (TLS when offered, no + SMTPS). The new entry is always active. Colons in `username` and + `password` are stored as `\:` (in the password as `\\:`); edit/relayhost + stores both unchanged. Administrators and API keys only. + + + Messages: `relayhost_added` (without the host name), + `invalid_host` (empty `hostname`), `access_denied`, `mysql_error`. + requestBody: + content: + application/json: + schema: + type: object + properties: + hostname: type: string + description: >- + Next hop: `host`, `host:25`, `[host]:587`, `[192.0.2.1]` + or `[192.0.2.1]:25` + username: + type: string + description: SMTP user name, empty for no authentication + default: "" + password: + type: string + description: SMTP password + default: "" + required: + - hostname + example: + hostname: "[smtp.example.com]:587" + username: relay-user@example.com + password: Secret-Passphrase-42 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/resource: + post: + tags: + - Resources + summary: Create a resource + operationId: addResource + description: >- + Creates a bookable resource (room, equipment, group) for SOGo. Its + address is made of the letters and digits of the description and the + domain (description "Meeting Room 1" → `MeetingRoom1@example.org`). + Admins and domain admins (for their domains). Errors: + `resource_invalid` (address or kind invalid), `description_invalid`, + `domain_invalid`, `access_denied`, `object_exists`, `is_alias`, + `is_spam_alias`, `domain_not_found`. Success: `resource_added`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - domain + - description + - kind + properties: domain: - description: the domain for which the resource should be type: string + description: Domain of the resource + description: + type: string + description: Name of the resource, also the source of its address kind: - description: the kind of recouse + type: string enum: - location - group - thing - type: string + description: Type of resource multiple_bookings: - enum: - - "-1" - - "1" - - custom - type: string - multiple_bookings_custom: - description: always empty - type: number - multiple_bookings_select: - enum: - - "-1" - - "1" - - custom - type: string - type: object - summary: Create Resources + type: [integer, string] + default: 0 + description: >- + Booking rule: 0 always free (bookings are not limited and + do not block), -1 unlimited but shown as busy when booked, + n ≥ 1 at most n overlapping bookings; values below -1 mean -1 + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + description: 1 active, 0 inactive + example: + domain: example.org + description: Meeting Room 1 + kind: location + multiple_bookings: 0 + active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/add/syncjob: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - syncjob - - add - - job - - username: mailbox@domain.tld - source_id: "3" - user1: username - password1: "*" - mins_interval: "20" - active: "1" - msg: - - mailbox_modified - - mailbox@domain.tld - type: success - schema: - properties: - log: - description: contains request object (secrets are masked as "*") - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + summary: Create sync job + description: >- + Creates a sync job that copies the remote account `user1` of a sync + source into a local mailbox. Host, port, encryption and authentication + come from the source (`source_id`); the job only carries the remote + login. Needs the `syncjobs` ACL (admins and API keys have it); the + caller must have access to the local mailbox. An API key must name the + mailbox in `username` (`no_user_defined` otherwise), mailbox users + always create jobs for their own mailbox. + + + Answers `mailbox_modified` with the local mailbox. Errors: + `access_denied` (no access to the mailbox, `mins_interval` missing or + not 1-43800, `exclude` no valid regular expression), `no_user_defined`, + `imapsync_user1_invalid`, `imapsync_source_unavailable` (source unknown, + invisible to the caller or inactive), `password_empty`, + `imapsync_source_oauth_connect_required` (XOAUTH2 authorization_code + source without a sign-in of the mailbox for `user1`), `object_exists` + (the mailbox already syncs `user1` from this source), + `imapsync_custom_params_format`, `imapsync_custom_param_invalid`, + `imapsync_custom_param_value`. Passwords are logged as `*`. + operationId: addSyncJob tags: - Sync jobs - description: >- - Create a sync job for a local mailbox. Connection data (host, port, - encryption, authentication) comes from the sync source referenced by - `source_id`. Requires the `syncjobs` ACL (user ACL for mailbox users, - domain admin ACL for domain admins). - operationId: Create sync job - summary: Create sync job requestBody: content: application/json: schema: - example: - username: lisa@mailcow.tld - source_id: "3" - user1: demo@remote.tld - password1: supersecretpw - mins_interval: "20" - subfolder2: "/SyncIntoSubfolder" - maxage: "0" - maxbytespersecond: "0" - timeout1: "600" - timeout2: "600" - exclude: "(?i)spam|(?i)junk" - custom_params: - - {o: folder, v: INBOX} - - {o: syncinternaldates, v: ""} - delete2duplicates: "1" - delete1: "0" - delete2: "0" - automap: "1" - skipcrossduplicates: "0" - subscribeall: "1" - dry: "0" - active: "1" - properties: - username: - description: local mailcow mailbox (destination, user2); users always create jobs for their own mailbox - type: string - source_id: - description: >- - id of a sync source visible to the caller (`GET /api/v1/get/syncjob_source/all`). - type: integer - user1: - description: >- - remote username at the source. For XOAUTH2 authorization_code sources - a token for (source_id, local mailbox, user1) must exist, i.e. the - mailbox owner must have signed in via `/syncjob-oauth` first - (`imapsync_source_oauth_connect_required` otherwise). - type: string - password1: - description: >- - remote password, required unless the source uses XOAUTH2 (ignored then) - type: string - mins_interval: - description: sync interval in minutes (1-43800) - type: number - subfolder2: - description: sync into subfolder on destination (empty = do not use subfolder) - type: string - maxage: - description: only sync messages up to this age in days (0 = ignore age) - type: number - maxbytespersecond: - description: per-job bandwidth limit in bytes/s (0 = unlimited, capped by the global limit) - type: number - timeout1: - description: timeout for connection to remote host - type: number - timeout2: - description: timeout for connection to local host - type: number - exclude: - description: exclude objects (regex) - type: string - custom_params: - description: >- - Allowlisted imapsync options as a list of option/value pairs (must be - an array). - `o` is the option name without leading dashes (see `GET - /api/v1/get/syncjob_options`), `v` the value. The allowlist decides - per option: `true` = a non-empty value is required, `false` = flag - without value (`v` must be empty), string = regular expression the - whole value must match. Values are passed to imapsync as a single argument. - type: array - items: - type: object - properties: - o: - type: string - v: - type: string - delete2duplicates: - description: delete duplicates on destination (--delete2duplicates) - type: boolean - delete1: - description: delete from source when completed (--delete1) - type: boolean - delete2: - description: delete messages on destination that are not on source (--delete2) - type: boolean - automap: - description: try to automap folders ("Sent items", "Sent" => "Sent" etc.) (--automap) - type: boolean - skipcrossduplicates: - description: skip duplicate messages across folders (first come, first serve) (--skipcrossduplicates) - type: boolean - subscribeall: - description: subscribe all folders (--subscribeall) - type: boolean - dry: - description: dry run, nothing is changed (--dry) - type: boolean - active: - description: enables or disables the sync job - type: boolean - host1: - description: >- - Removed. Host, port, encryption and authentication now come from - the sync source (`source_id`, see `/api/v1/add/syncjob_source`). - The attribute is ignored. - deprecated: true - type: string - port1: - description: >- - Removed. Host, port, encryption and authentication now come from - the sync source (`source_id`, see `/api/v1/add/syncjob_source`). - The attribute is ignored. - deprecated: true - type: string - enc1: - description: >- - Removed. Host, port, encryption and authentication now come from - the sync source (`source_id`, see `/api/v1/add/syncjob_source`). - The attribute is ignored. - deprecated: true - type: string - authmech1: - description: >- - Removed. Host, port, encryption and authentication now come from - the sync source (`source_id`, see `/api/v1/add/syncjob_source`). - The attribute is ignored. - deprecated: true - type: string - type: object + allOf: + - type: object + required: + - username + - source_id + - user1 + - mins_interval + properties: + username: + type: string + description: >- + Local mailbox the job syncs into (`user2`); cannot be + changed later. Optional for mailbox users (own mailbox). + - $ref: "#/components/schemas/SyncJobAttributes" + example: + username: alice@example.org + source_id: 3 + user1: alice@old-provider.example.com + password1: remote-password + mins_interval: 20 + subfolder2: Imported + exclude: "(?i)spam|(?i)junk" + custom_params: + - o: folder + v: INBOX + delete2duplicates: 1 + automap: 1 + subscribeall: 1 + active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/add/syncjob_source: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - syncjob - - add - - source - - name: Example import - host: imap.example.org - port: "993" - enc: SSL - auth_type: PLAIN - scope: all - msg: - - imapsync_source_added - - Example import - type: success - schema: - properties: - log: - description: contains request object (secrets are masked as "*") - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + summary: Create sync source + description: >- + Creates a reusable IMAP sync source: server, encryption and + authentication of a remote system that sync jobs use by `source_id`. + Password sources (PLAIN, LOGIN, CRAM-MD5) take the password from each + job, XOAUTH2 sources carry an OAuth2 client (client_credentials: one + token of the source for every mailbox of the tenant; authorization_code: + every mailbox owner signs in once in the mailcow UI). The creator is + stored in `created_by` (`API` for API keys); names are unique per + creator. Needs the `syncjobs` ACL. Only admins create `all` sources; + XOAUTH2 sources are created by admins and domain admins only. OAuth2 + endpoints must be https:// URLs with a valid host name; for sources + not created by a superadmin or an API key every resolved address must + also be public. + + + A client_credentials source fetches its token while it is saved: the + source is saved even when that fails, the answer then also has + `imapsync_source_token_refresh_failed`. Answers + `imapsync_source_added`. Errors (nothing is written): + `imapsync_source_name_invalid`, `imapsync_source_description_invalid`, + `imapsync_source_host_invalid`, `imapsync_source_port_invalid`, + `imapsync_source_enc_invalid`, `imapsync_source_auth_invalid`, + `imapsync_source_oauth_required`, `imapsync_source_oauth_invalid`, + `imapsync_source_oauth_endpoint_invalid`, + `imapsync_source_oauth_extra_invalid`, `imapsync_source_scope_empty`, + `access_denied` (scope or targets not allowed), `object_exists`. + Secrets are logged as `*`. + operationId: addSyncJobSource tags: - Sync jobs - description: >- - Create a reusable IMAP sync source. A sync job binds to a source by id; - the source carries host/port/encryption and either static credentials - (PLAIN/LOGIN/CRAM-MD5 — the password is supplied per job) or an OAuth2 - configuration (XOAUTH2). Requires the `syncjobs` ACL. Validation errors - are returned as translated `danger` messages, nothing is written then. - operationId: Create sync job source - summary: Create sync job source requestBody: content: application/json: schema: - example: - name: Example import - description: "" - host: imap.example.org - port: "993" - enc: SSL - auth_type: PLAIN - active: "1" - scope: domain - domains: - - example.org - properties: - name: - description: >- - source name, max. 100 characters, unique per creator - type: string - description: - description: >- - free text shown in the UI, max. 255 characters - type: string - host: - description: >- - remote IMAP host (hostname or IP, max. 253 characters). - `host1` is accepted as an alias. - type: string - port: - description: >- - remote IMAP port (1-65535). `port1` is accepted as an alias. - type: integer - enc: - description: >- - TLS (STARTTLS) / SSL / PLAIN. `enc1` is accepted as an alias. - type: string - enum: - - TLS - - SSL - - PLAIN - verify_cert: - description: >- - 1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check - it (self-signed, expired or not matching the host). - type: integer - enum: - - 0 - - 1 - auth_type: - description: >- - authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job - password, XOAUTH2 uses OAuth2 tokens. XOAUTH2 sources can only be created - and edited by admins and domain admins (`access_denied` for users); users - can use XOAUTH2 sources visible to them in their sync jobs. - type: string - enum: - - PLAIN - - LOGIN - - CRAM-MD5 - - XOAUTH2 - oauth_flow: - description: >- - XOAUTH2 grant type (default client_credentials). client_credentials - sources are always private to their creator (scope is forced to `user` - without targets). - type: string - enum: - - client_credentials - - authorization_code - oauth_authorize_endpoint: - description: >- - authorize URL, required for authorization_code. Must be an https:// URL - without credentials and with a valid hostname - type: string - oauth_token_endpoint: - description: >- - token URL, required for XOAUTH2. Must be an https:// URL without credentials and with a valid hostname - type: string - oauth_userinfo_endpoint: - description: >- - optional identity endpoint for authorization_code (used when the token - response has no usable id_token). Must be an https:// URL without credentials and with a valid hostname - type: string - oauth_client_id: - description: >- - OAuth2 client id, required for XOAUTH2 - type: string - oauth_client_secret: - description: >- - OAuth2 client secret, required for XOAUTH2. Never returned by GET. - type: string - oauth_scope: - description: >- - OAuth2 scope, required for XOAUTH2 - type: string - oauth_extra_params: - description: >- - optional extra token request parameters: a JSON object with scalar values, - sent either as object or as JSON string (empty = none). They never - override the grant fields. - oneOf: - - type: object - additionalProperties: true - - type: string - scope: - description: >- - visibility: `all` (admins only), `domain` (needs `domains`) or `user` - (listed `users`; no targets = private to the creator). Users always - create private sources. - type: string - enum: - - all - - domain - - user - domains: - description: target domains when scope is domain - type: array - items: - type: string - users: - description: target mailboxes when scope is user - type: array - items: - type: string - active: - description: >- - enables or disables the source - type: boolean - type: object - /api/v1/add/tls-policy-map: - post: + allOf: + - type: object + required: + - name + - host + - port + - $ref: "#/components/schemas/SyncJobSourceAttributes" + examples: + password: + summary: Password source for a domain + value: + name: Old provider + description: Migration from the old provider + host: imap.example.com + port: 993 + enc: SSL + auth_type: PLAIN + scope: domain + domains: + - example.org + active: 1 + client_credentials: + summary: XOAUTH2 source with an app token (Microsoft 365) + value: + name: M365 tenant + host: outlook.office365.com + port: 993 + enc: SSL + auth_type: XOAUTH2 + oauth_flow: client_credentials + oauth_token_endpoint: https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/token + oauth_client_id: 11111111-2222-3333-4444-555555555555 + oauth_client_secret: client-secret-value + oauth_scope: https://outlook.office365.com/.default + active: 1 responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - tls_policy_maps - - add - - parameters: "" - active: "1" - dest: mailcow.tld - policy: encrypt - - null - msg: - - tls_policy_map_entry_saved - - mailcow.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/tls-policy-map: + post: tags: - Outgoing TLS Policy Map Overrides - description: Using this endpoint you can create a TLS policy map override. - operationId: Create TLS Policy Map + summary: Create a TLS policy map + operationId: addTlsPolicyMap + description: >- + Sets the TLS policy Postfix uses for outgoing connections to a + destination (`smtp_tls_policy_maps`). Administrators and API keys only + (other callers get `Cannot add item`). + + + Messages: `tls_policy_map_entry_saved`; `tls_policy_map_dest_invalid` + (empty, `.`, `*` or `@`), `tls_policy_map_parameter_invalid`, + `tls_policy_map_entry_exists`. The backend does not check `policy`; the + database only takes the listed values. requestBody: content: application/json: schema: - example: - parameters: "" - active: "1" - dest: mailcow.tld - policy: encrypt + type: object properties: - parameters: - description: >- - custom parameters you find out more about them - [here](http://www.postfix.org/postconf.5.html#smtp_tls_policy_maps) - type: string - active: - description: 1 for a active user account 0 for a disabled user account - type: number dest: - description: the target domain or email address type: string + description: >- + Destination, e.g. `example.org`, `.example.org` (its + subdomains) or `[mail.example.org]:25`; IDN in punycode policy: - description: the policy + type: string + description: >- + Postfix TLS security level (see + [smtp_tls_policy_maps](https://www.postfix.org/TLS_README.html#client_tls_policy)) enum: - none - may - encrypt - dane - - "'dane" + - dane-only - fingerprint - verify - secure + parameters: type: string - type: object - summary: Create TLS Policy Map + description: >- + Empty or space-separated `name=value` attributes, e.g. + `protocols=!SSLv2 ciphers=medium exclude=3DES` + default: "" + active: + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + default: 0 + required: + - dest + - policy + example: + dest: example.com + policy: encrypt + parameters: protocols=>=TLSv1.2 + active: "1" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/add/transport: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - transport - - add - - active: "1" - destination: example2.org - nexthop: "host:25" - password: supersecurepw - username: testuser - msg: - - relayhost_added - - "" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} tags: - Routing - description: Using this endpoint you can create Sender-Dependent Transports. - operationId: Create Transport Maps + summary: Create transport maps + operationId: addTransport + description: >- + Routes mail for the destinations over a next hop. A transport map + entry overrules a sender-dependent transport. The service is always + `smtp:` (TLS when offered, no SMTPS); addresses matching `/localhost$/` + are always delivered locally. Administrators and API keys only. + + + One entry is created per destination. All entries with the same next + hop share one set of credentials: `username` and `password` are + written to every existing entry of `nexthop`. + + + Messages: `relayhost_added` (without names); `invalid_nexthop` + (empty), `invalid_nexthop_authenticated` (the next hop exists with + another user name), `next_hop_interferes`, `next_hop_interferes_any` + (`host` and `[host]:port` cannot be used side by side) stop the call; + `transport_dest_exists` and `invalid_destination` drop the + destination. The destinations are only checked when other transport + maps exist. Without any valid destination the answer is the messages + or `Cannot add item`. requestBody: content: application/json: schema: - example: - active: "1" - destination: example.org - nexthop: "host:25" - password: supersecurepw - username: testuser + type: object properties: - active: - description: 1 for a active transport map 0 for a disabled transport map - type: number destination: type: string + description: >- + Destinations, separated by spaces, commas, semicolons or + line breaks: `example.org`, `.example.org` (its + subdomains), `*` or `box@example.org`. With `is_mx_based` + a regular expression matched against the MX name of the + recipient domain, e.g. `.*\.google\.com`. nexthop: type: string - password: - description: the password for the smtp user - type: string + description: >- + `host`, `host:25`, `[host]:25` or an IP address (put in + brackets automatically) username: - description: the username used to authenticate type: string - type: object - summary: Create Transport Maps + description: SMTP user name, empty for no authentication + default: "" + password: + type: string + description: SMTP password + default: "" + is_mx_based: + type: [boolean, integer, string] + description: 1 = `destination` is a regular expression for MX names + default: 0 + active: + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + default: 0 + required: + - destination + - nexthop + example: + destination: example.com, .example.com + nexthop: "[mx.example.com]:25" + username: "" + password: "" + is_mx_based: "0" + active: "1" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/delete/alias: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - delete - - alias - - id: - - "6" - - "9" - - null - msg: - - alias_removed - - alias@domain.tld - type: success - - log: - - mailbox - - delete - - alias - - id: - - "6" - - "9" - - null - msg: - - alias_removed - - alias2@domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} tags: - Aliases - description: You can delete one or more aliases. - operationId: Delete alias + summary: Delete aliases + operationId: deleteAlias + description: >- + Deletes the aliases and the send-as rights granted for their addresses + (`alias_removed` per alias). Unknown aliases and aliases of domains the + caller does not manage answer `access_denied`; distribution addresses + of mailbox groups `alias_group_managed`. requestBody: content: application/json: schema: - items: - example: "6" - type: string type: array - summary: Delete alias + description: IDs or addresses of the aliases (a single value works too) + items: + type: [integer, string] + example: + - 12 + - info@example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/delete/api-key: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - api - - delete - - key - - id: - - "3" - msg: - - api_key_deleted - - provisioning - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + summary: Delete API keys + description: >- + Deletes API keys. Service keys from mailcow.conf cannot be deleted here. + + + One message per key. Success: `api_key_deleted`. Errors: + `access_denied` (no key with this ID), `api_key_service_locked`. + operationId: deleteApiKey tags: - API Keys - description: >- - Delete API keys by ID. Service keys from mailcow.conf cannot be - deleted (`api_key_service_locked`). - operationId: Delete API keys requestBody: + required: true content: application/json: schema: - example: - - "3" - properties: - items: - description: contains list of API key IDs you want to delete - type: object - type: object - summary: Delete API keys + type: array + description: IDs of the API keys + items: + type: [integer, string] + example: + - 3 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/delete/app-passwd: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - app_passwd - - delete - - id: - - "2" - msg: - - app_passwd_removed - - "2" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} tags: - App Passwords - description: Using this endpoint you can delete a single app password. - operationId: Delete App Password + summary: Delete app passwords + operationId: deleteAppPassword + description: >- + Deletes app passwords by ID. Needs the user ACL `app_passwds` and + access to the owning mailbox (API keys: any). Messages: + `app_passwd_removed` per ID; the first unknown ID + (`app_passwd_id_invalid`) or foreign ID (`access_denied`) stops the + call. requestBody: + required: true content: application/json: schema: - example: - - "1" - properties: - items: - description: contains list of app passwords you want to delete - type: object - type: object - summary: Delete App Password + type: array + description: App password IDs + items: + type: integer + example: + - 23 + - 24 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/delete/bcc: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - bcc - - delete - - id: - - "4" - - null - msg: - - bcc_deleted - - "4" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} tags: - Address Rewriting + summary: Delete BCC maps + operationId: deleteBccMap description: >- - Using this endpoint you can delete a BCC map, for this you have to know - its ID. You can get the ID using the GET method. - operationId: Delete BCC Map + Deletes BCC maps by ID (`bcc_deleted` per map, `access_denied` for a + map of a domain the caller does not manage). A non-numeric ID stops the + call. Same callers as add/bcc. requestBody: content: application/json: schema: - example: - - "3" - properties: - items: - description: contains list of bcc maps you want to delete - type: object - type: object - summary: Delete BCC Map + type: array + description: IDs of the BCC maps (a single value works too) + items: + type: [integer, string] + example: + - 3 + - 4 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/delete/dkim: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - dkim - - delete - - domains: - - mailcow.tld - msg: - - dkim_removed - - mailcow.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + summary: Delete DKIM keys + description: >- + Deletes the DKIM keys of domains. Admins only. Success `dkim_removed` + per domain (also when it had no key), errors `access_denied`, + `dkim_domain_or_sel_invalid`. + operationId: deleteDkimKeys tags: - DKIM - description: Using this endpoint a existing DKIM Key can be deleted - operationId: Delete DKIM Key requestBody: + required: true content: application/json: schema: - items: - example: - - mailcow.tld - type: string type: array - summary: Delete DKIM Key + description: Domain names + items: + type: string + example: + - example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/delete/domain: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - delete - - domain - - domain: - - domain.tld - - domain2.tld - - null - msg: - - domain_removed - - domain.tld - type: success - - log: - - mailbox - - delete - - domain - - domain: - - domain.tld - - domain2.tld - - null - msg: - - domain_removed - - domain2.tld - type: success - schema: - type: array - items: - type: object - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - description: OK - headers: {} + summary: Delete domains + description: >- + Deletes domains. Admins only. A domain that still has mailboxes or + resources is kept (`domain_not_empty`). With the domain go its aliases, + alias domains, domain admin assignments, BCC maps, MTA-STS policy, spam + policies, mailbox group data and rate limit; domain admins left without + any domain are deleted. Its mail storage is moved to the garbage + collector of Dovecot (a failure is a warning). The DKIM key is kept. Success `domain_removed`, errors `access_denied`, + `domain_invalid`. + operationId: deleteDomain tags: - Domains - description: You can delete one or more domains. - operationId: Delete domain requestBody: + required: true content: application/json: schema: - type: object - example: - - domain.tld - - domain2.tld - properties: - items: - type: array - items: - type: string - summary: Delete domain - /api/v1/delete/domain-admin: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - domain_admin - - delete - - username: - - testadmin - msg: - - domain_admin_removed - - testadmin - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Domain admin - description: Using this endpoint a existing Domain Admin user can be deleted. - operationId: Delete Domain Admin - requestBody: - content: - application/json: - schema: - example: - - testadmin - properties: - items: - description: contains list of usernames of the users you want to delete - type: object - type: object - summary: Delete Domain Admin - /api/v1/delete/domain-policy: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - policy - - delete - - domain - - prefid: - - "1" - - "2" - msg: - - item_deleted - - "1" - type: success - - log: - - policy - - delete - - domain - - prefid: - - "1" - - "2" - msg: - - item_deleted - - "2" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Domain antispam policies - description: You can delete one o more domain policies. - operationId: Delete domain policy - requestBody: - content: - application/json: - schema: - example: - - "1" - - "2" - properties: - items: - description: contains list of domain policys you want to delete - type: object - type: object - summary: Delete domain policy - /api/v1/delete/fwdhost: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - fwdhost - - delete - - forwardinghost: - - 5.1.76.202 - - "2a00:f820:417::202" - msg: - - forwarding_host_removed - - 5.1.76.202 - type: success - - log: - - fwdhost - - delete - - forwardinghost: - - 5.1.76.202 - - "2a00:f820:417::202" - msg: - - forwarding_host_removed - - "2a00:f820:417::202" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Fordwarding Hosts - description: >- - Using this endpoint you can delete a forwarding host, in order to do so - you need to know the IP of the host. - operationId: Delete Forward Host - requestBody: - content: - application/json: - schema: - example: - - 5.1.76.202 - - "2a00:f820:417::202" - properties: - ip: - description: contains the ip of the fowarding host you want to delete - type: string - type: object - summary: Delete Forward Host - /api/v1/delete/mailbox: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - delete - - mailbox - - username: - - info@domain.tld - - sales@domain.tld - - null - msg: - - mailbox_removed - - info@domain.tld - type: success - - log: - - mailbox - - delete - - mailbox - - username: - - info@domain.tld - - sales@domain.tld - - null - msg: - - mailbox_removed - - sales@domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Mailboxes - description: You can delete one or more mailboxes. - operationId: Delete mailbox - requestBody: - content: - application/json: - schema: - example: - - info@domain.tld - - sales@domain.tld - properties: - items: - description: contains list of mailboxes you want to delete - type: object - type: object - summary: Delete mailbox - /api/v1/delete/mailbox-group: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox_group - - delete - - group - - id: - - "7" - msg: - - group_removed - - Sales - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - warning - - error - type: string - type: object - description: OK - headers: {} - tags: - - Groups - description: >- - Delete groups by ID. Members keep their settings; their group alias and - send-as rights of the group are removed. - operationId: Delete groups - requestBody: - content: - application/json: - schema: - example: - - "7" - properties: - items: - description: contains list of group IDs you want to delete - type: object - type: object - summary: Delete groups - /api/v1/delete/identity-link: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - identity_provider - - delete - - link - - - alice@example.org - msg: - - iam_link_removed - - alice@example.org - type: success - description: OK - headers: {} - tags: - - Identity Provider - description: >- - Removes the link between mailboxes and their Identity Provider users (admins only). The mailbox is - linked again by email on the next sync run or login, e.g. after its address was given to another - Identity Provider user. - operationId: Delete identity links - requestBody: - content: - application/json: - schema: - example: - - alice@example.org + type: array + description: Domain names items: type: string + example: + - example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/domain-admin: + post: + summary: Delete domain administrators + description: >- + Deletes domain administrators with their ACLs, two-factor + authentication and FIDO2 keys; sync job sources they created are + released. The domains and their mailboxes stay. + + + One message per account. Success: `domain_admin_removed`. Error: + `username_invalid` (no domain administrator of this name). + operationId: deleteDomainAdmin + tags: + - Domain admin + requestBody: + required: true + content: + application/json: + schema: type: array - summary: Delete identity links + description: Usernames of the domain administrators + items: + type: string + example: + - domainadmin1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/domain-policy: + post: + summary: Delete entries of domain allow and deny lists + description: >- + Deletes allow or deny list entries of domains by their `prefid` (see + get/policy_wl_domain and get/policy_bl_domain). Needs the ACL + `spam_policy`. Success `item_deleted` per entry, error `access_denied` + (non-numeric ID, entry of a mailbox or unknown entry). + operationId: deleteDomainPolicy + tags: + - Domain antispam policies + requestBody: + required: true + content: + application/json: + schema: + type: array + description: Entry IDs (`prefid`) + items: + type: [integer, string] + example: + - 12 + - 13 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/fwdhost: + post: + tags: + - Forwarding Hosts + summary: Delete forwarding hosts + operationId: deleteForwardingHost + description: >- + Removes forwarding hosts (`forwarding_host_removed` per host, also for + hosts that do not exist). Administrators and API keys only. + requestBody: + content: + application/json: + schema: + type: array + description: Hosts as get/fwdhost lists them (`host`; a single value works too) + items: + type: string + example: + - 192.0.2.25 + - "2001:db8::25" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/fwdhost/{host}: + get: + tags: + - Forwarding Hosts + summary: Get forwarding hosts + operationId: getForwardingHosts + description: >- + `all` lists the forwarding hosts (administrators and API keys), a host + address returns that entry (every logged-in role). `{}` if there is + none. Networks (`198.51.100.0/24`) cannot be read one by one: the path + ends at the slash. + parameters: + - name: host + in: path + required: true + description: "`all` or the address of a forwarding host" + schema: + type: string + example: all + responses: + "200": + description: The list of forwarding hosts (`all`) or one host + content: + application/json: + schema: + anyOf: + - type: array + items: + $ref: "#/components/schemas/ForwardingHost" + - $ref: "#/components/schemas/ForwardingHost" + examples: + all: + summary: get/fwdhost/all + value: + - host: 192.0.2.25 + source: relay.example.com + keep_spam: "no" + - host: "2001:db8::25" + source: relay.example.com + keep_spam: "no" + - host: 198.51.100.0/24 + source: 198.51.100.0/24 + keep_spam: "yes" + one: + summary: get/fwdhost/192.0.2.25 + value: + host: 192.0.2.25 + source: relay.example.com + keep_spam: "no" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/delete/mailbox: + post: + tags: + - Mailboxes + summary: Delete mailboxes + operationId: deleteMailbox + description: >- + Deletes mailboxes with everything that belongs to them: maildir (moved + to the garbage collector), aliases pointing only to them, ACLs, + quarantine, filters, policies, time-limited aliases, sync jobs, SOGo + data, BCC maps, OAuth tokens, 2FA keys, rate limit; the address is + removed from the targets of other aliases. Admins and domain admins + (for their domains); a mailbox user session passes the check for its + own mailbox and can delete it. Messages per mailbox: `mailbox_removed` + or `access_denied`; a warning if the maildir could not be moved. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MailboxItems" + example: + - john.doe@example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/mailbox-group: + post: + summary: Delete mailbox groups + description: >- + Deletes groups. The group alias, the send-as rights and the grants of + the group are removed; members keep their mailbox settings (another + group's template may become effective). Same permissions as edit. + Answers one message per id: `group_removed` or `access_denied` (unknown + id, id that is no number, no permission). + operationId: deleteMailboxGroups + tags: + - Groups + requestBody: + content: + application/json: + schema: + type: array + description: Group ids + items: + type: + - integer + - string + example: + - 7 + - 8 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/identity-link: + post: + summary: Remove identity links + description: >- + Admins only (every read/write API key). Removes the links of mailboxes + to their Identity Provider users; the next sync run or login links them + again by email. Use it to resolve conflicts, e.g. when an address was + given to another user of the provider. Answers `iam_link_removed` per + mailbox (also for mailboxes without a link), or `access_denied`. + operationId: deleteIdentityLinks + tags: + - Identity Provider + requestBody: + content: + application/json: + schema: + type: array + description: Mailboxes + items: + type: string + example: + - alice@example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/delete/mailq: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - msg: Task completed - type: success - description: OK - headers: {} + summary: Delete queued messages + description: >- + Deletes messages from the Postfix queue (`postsuper -d `; admins + only, an API key may). To empty the whole queue use `super_delete` of + `POST /api/v1/edit/mailq`. Queue IDs that are not hexadecimal are + skipped. + + + Messages: `queue_command_success`; `Error: command failed: ...` + (danger); `unknown` (danger) when the command gave no answer; + `access_denied` for non-admins. + operationId: deleteQueuedMessages tags: - Queue Manager - description: >- - Using this API you can delete the current mail queue. This will delete - all mails in it. - - This API uses the command: `postsuper -d` - operationId: Delete Queue requestBody: content: application/json: schema: - example: - action: super_delete - properties: - action: - description: use super_delete to delete the mail queue - type: string - type: object - summary: Delete Queue + description: Queue IDs (a single ID works too) + oneOf: + - type: array + items: + type: string + - type: string + example: + - B98C6260CA1 + - 4C1E8260D02 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/delete/oauth2-client: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - oauth2 - - delete - - client - - id: - - "1" - msg: - - items_deleted - - "1" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + summary: Delete OAuth2 clients + description: >- + Deletes OAuth2 clients. Tokens issued to them are not deleted (use + `revoke_tokens` of `edit/oauth2-client` first). + + + Success: one `items_deleted` message for the whole call. Error: + `access_denied` for an ID that is no number. + operationId: deleteOAuth2Client tags: - oAuth Clients - description: >- - Using this endpoint you can delete a oAuth client, for this you have to - know its ID. You can get the ID using the GET method. - operationId: Delete oAuth Client requestBody: + required: true content: application/json: schema: - example: - - "3" - properties: - items: - description: contains list of oAuth clients you want to delete - type: object - type: object - summary: Delete oAuth Client + type: array + description: IDs of the clients + items: + type: [integer, string] + example: + - 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/delete/qitem: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - quarantine - - delete - - id: - - "33" - msg: - - item_deleted - - "33" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + summary: Delete quarantined messages + description: >- + Deletes quarantine items without releasing or learning them. An API + key (admin) may delete every item, others only items of their + mailboxes or domains; the quarantine module must be allowed for the + caller. + + + Messages: `item_deleted` per item (for an admin also for IDs that do + not exist); `access_denied` (danger) for a non-numeric or foreign ID. + operationId: deleteQuarantineItems tags: - Quarantine - description: >- - Using this endpoint you can delete a email from quarantine, for this you - have to know its ID. You can get the ID using the GET method. - operationId: Delete mails in Quarantine requestBody: content: application/json: schema: - example: - - "33" - properties: - items: - description: contains list of emails you want to delete - type: object - type: object - summary: Delete mails in Quarantine + description: IDs of quarantine items (a single ID works too) + oneOf: + - type: array + items: + type: [integer, string] + - type: [integer, string] + example: + - "33" + - "34" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/edit/qitem: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - release: - value: - - log: - - quarantine - - edit - - id: - - "33" - action: release - msg: - - item_released - - "33" - type: success - learnham: - value: - - log: - - quarantine - - edit - - id: - - "34" - action: learnham - msg: - - item_learned - - "34" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + summary: Release or learn quarantined messages + description: >- + Acts on quarantine items. An API key (admin) may act on every item, + others only on items of their mailboxes or domains; the quarantine + module must be allowed for the caller. + + + - `release` and `learnham` do the same: the message is delivered to + its recipient (through Postfix, as the original message or as an + attachment, see the release format of `POST /api/v1/edit/quarantine`), + deleted from the quarantine and learned as ham by Rspamd. + - `learnspam` deletes the item and learns the message as spam. + + Messages: `item_released` and `learned_ham`, or `qlearn_spam`, per + item; `access_denied` (danger) for a non-numeric, unknown or foreign + ID; `release_send_failed` (warning); `ham_learn_error`, + `spam_learn_error` (danger); `fuzzy_learn_error` (warning). When + Postfix cannot be reached or answers with an unexpected SMTP code, the + remaining items are not processed. An unknown `action` does nothing + and is answered with `Task completed`. + operationId: editQuarantineItems tags: - Quarantine - description: >- - Using this endpoint you can perform actions on quarantine items. It is possible to release - emails from quarantine into to the inbox, or learn them as ham to improve Rspamd filtering. - You must provide the quarantine item IDs. You can get the IDs using the GET method. - operationId: Edit mails in Quarantine requestBody: content: application/json: schema: - example: - items: - - "33" - - "34" - attr: - action: release + type: object properties: items: - description: contains list of quarantine item IDs to release or learn as ham - type: object + description: ID or list of IDs of quarantine items + oneOf: + - type: array + items: + type: [integer, string] + - type: [integer, string] attr: - description: attributes for the action type: object properties: action: @@ -3121,4874 +6231,8874 @@ paths: enum: - release - learnham - description: "release - return email to inbox; learnham - learn as ham to improve filtering" - type: object - summary: Edit mails in Quarantine + - learnspam + required: + - action + required: + - items + - attr + example: + items: + - "33" + - "34" + attr: + action: release + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/delete/recipient_map: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - recipient_map - - delete - - id: - - "1" - - null - msg: - - recipient_map_entry_deleted - - "1" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} tags: - Address Rewriting + summary: Delete recipient maps + operationId: deleteRecipientMap description: >- - Using this endpoint you can delete a recipient map, for this you have to - know its ID. You can get the ID using the GET method. - operationId: Delete Recipient Map + Deletes recipient maps by ID (`recipient_map_entry_deleted` per ID, + also for IDs that do not exist). A non-numeric ID stops the call. + Administrators and API keys only. requestBody: content: application/json: schema: - example: - - "1" - properties: - items: - description: contains list of recipient maps you want to delete - type: object - type: object - summary: Delete Recipient Map - /api/v1/delete/relayhost: - post: + type: array + description: IDs of the recipient maps (a single value works too) + items: + type: [integer, string] + example: + - 1 responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - relayhost - - delete - - id: - - "1" - msg: - - relayhost_removed - - "1" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/relayhost: + post: tags: - Routing + summary: Delete sender-dependent transports + operationId: deleteRelayhost description: >- - Using this endpoint you can delete a Sender-Dependent Transport, for - this you have to know its ID. You can get the ID using the GET method. - operationId: Delete Sender-Dependent Transports + Deletes sender-dependent transports by ID (`relayhost_removed` per ID, + also for IDs that do not exist). Domains that used one fall back to the + default route (`relayhost` 0); the `relayhost` attribute of mailboxes + is not reset. Administrators and API keys only. requestBody: content: application/json: schema: - example: - - "1" + type: array + description: IDs of the sender-dependent transports (a single value works too) + items: + type: [integer, string] + example: + - 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/resource: + post: + tags: + - Resources + summary: Delete resources + operationId: deleteResource + description: >- + Deletes resources and their SOGo data. Admins and domain admins (for + their domains). Messages per resource: `resource_removed` or + `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + type: array + description: Resource addresses + items: + type: string + example: + - MeetingRoom1@example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/syncjob: + post: + summary: Delete sync jobs + description: >- + Deletes sync jobs. Needs the `syncjobs` ACL and access to the local + mailbox of each job (admins and API keys: every job). The OAuth2 token + of the job's remote account is deleted when no other job uses it. + Answers one message per id: `deleted_syncjob` with the id as sent, or + `access_denied` (unknown job, id that is no number, no access). + operationId: deleteSyncJobs + tags: + - Sync jobs + requestBody: + content: + application/json: + schema: + type: array + description: Job ids + items: + type: + - integer + - string + example: + - 12 + - 13 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/syncjob_source: + post: + summary: Delete sync sources + description: >- + Deletes sync sources that no sync job uses (ids that are no number are + skipped). Needs the `syncjobs` ACL and edit rights on the source. + Answers one message per id: `imapsync_source_deleted`, or + `imapsync_source_in_use`, `access_denied` (unknown id, no edit rights). + operationId: deleteSyncJobSources + tags: + - Sync jobs + requestBody: + content: + application/json: + schema: + type: array + description: Source ids + items: + type: + - integer + - string + example: + - 3 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/tls-policy-map: + post: + tags: + - Outgoing TLS Policy Map Overrides + summary: Delete TLS policy maps + operationId: deleteTlsPolicyMap + description: >- + Deletes TLS policy maps by ID (`tls_policy_map_entry_deleted` per ID, + also for IDs that do not exist). A non-numeric ID stops the call. + Administrators and API keys only. + requestBody: + content: + application/json: + schema: + type: array + description: IDs of the TLS policy maps (a single value works too) + items: + type: [integer, string] + example: + - 2 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/transport: + post: + tags: + - Routing + summary: Delete transport maps + operationId: deleteTransport + description: >- + Deletes transport maps by ID (`relayhost_removed` per ID, also for IDs + that do not exist). Administrators and API keys only. + requestBody: + content: + application/json: + schema: + type: array + description: IDs of the transport maps (a single value works too) + items: + type: [integer, string] + example: + - 4 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/mailbox/tag/{mailbox}: + post: + tags: + - Mailboxes + summary: Remove tags from a mailbox + operationId: deleteMailboxTags + description: >- + Removes the given tags from the mailbox. Admins and domain admins (for + their domains); `access_denied` otherwise. Answers `mailbox_modified`, + or "Cannot delete item" when no tag was sent. + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + requestBody: + required: true + content: + application/json: + schema: + type: array + description: Tag names + items: + type: string + example: + - sales + - trainee + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/domain/tag/{domain}: + post: + summary: Remove tags of a domain + description: >- + Removes tags from a domain. Answers `domain_modified` when at least one + tag name was sent (also when the domain did not have it), `Cannot + delete item` for an empty list; errors `domain_invalid`, + `access_denied`. + operationId: deleteDomainTags + tags: + - Domains + parameters: + - name: domain + in: path + required: true + description: Domain name + schema: + type: string + example: example.org + requestBody: + required: true + content: + application/json: + schema: + type: array + description: Tag names + items: + type: string + example: + - customer + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/alias: + post: + tags: + - Aliases + summary: Update aliases + operationId: editAlias + description: >- + Changes the aliases in `items`; attributes that are left out keep their + value, except the `goto_*` flags, which are 0 when left out. An alias + that discards or learns its mail (`null@localhost`, `spam@localhost`, + `ham@localhost`) therefore needs its `goto_*` flag in every edit: + without it, the current destination fails the address check + (`goto_invalid`), nothing is saved and the answer still contains + `alias_modified`. + + + Per alias: `alias_modified` on success; `alias_invalid` for an unknown + alias or one of a domain the caller does not manage; + `alias_group_managed` for the distribution address of a mailbox group + (change it in the group). A new `address` is checked like in + add/alias (`access_denied`, `alias_invalid`, `is_alias_or_mailbox`, + `domain_not_found`, `is_spam_alias`); send-as grants of mailbox groups + follow the new address. Destinations that are no e-mail address + (`goto_invalid`), external while external destinations are disabled + (`external_goto_denied`) or equal to the alias (`alias_goto_identical`) + are dropped; if none remains, nothing is saved. Comments are not + limited to 160 characters here. + + + `expand_alias` does not change the alias: it creates the missing copies + of it in the alias domains of its domain (same destinations, active, + visible in SOGo, `sender_allowed` 0) and answers `alias_modified`, or + warns `is_not_primary_alias` for an alias that is itself in an alias + domain. + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: IDs or addresses of the aliases, a single value or a list + oneOf: + - type: array + items: + type: [integer, string] + - type: [integer, string] + attr: + type: object + properties: + address: + type: string + description: New alias address, `@example.org` for a catch-all + goto: + type: string + description: >- + New destination addresses, separated by spaces, + commas, semicolons or line breaks; replaces the current + ones. Ignored if a `goto_*` flag is 1. + goto_null: + type: [boolean, integer, string] + description: 1 = discard the mail silently + default: 0 + goto_spam: + type: [boolean, integer, string] + description: 1 = learn the mail as spam + default: 0 + goto_ham: + type: [boolean, integer, string] + description: 1 = learn the mail as ham + default: 0 + active: + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + sogo_visible: + type: [boolean, integer, string] + description: 1 = selectable sender in SOGo + internal: + type: [boolean, integer, string] + description: 1 = only reachable from its own domain and its alias domains + sender_allowed: + type: [boolean, integer, string] + description: 1 = the destination mailboxes may send as this alias + public_comment: + type: string + description: Comment shown to the users + private_comment: + type: string + description: Comment for administrators + expand_alias: + type: [boolean, integer] + description: >- + true or 1 = create the missing copies of the alias in + the alias domains; all other attributes are ignored + required: + - items + - attr + example: + items: + - 12 + attr: + goto: anna@example.org,ben@example.org,carla@example.org + active: "1" + public_comment: Sales team + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/api-key: + post: + summary: Edit API keys + description: >- + Changes API keys; attributes that are not sent keep their value. + `regen_key` gives a key a new random value: clients using the old value + are locked out. Service keys from mailcow.conf (API_KEY, + API_KEY_READ_ONLY) cannot be changed here. + + + One message per key. Success: `api_key_modified`. Errors: + `access_denied` (no key with this ID), `api_key_service_locked`, + `api_key_access_invalid`, `api_key_description_invalid`, + `api_key_network_invalid`, `ip_list_empty`. + operationId: editApiKey + tags: + - API Keys + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + items: + description: ID of the API key, or a list of IDs + oneOf: + - type: [integer, string] + - type: array + items: + type: [integer, string] + attr: + allOf: + - $ref: "#/components/schemas/ApiKeyAttributes" + - type: object + properties: + regen_key: + type: [integer, string, boolean] + description: 1 = generate a new key value + default: 0 + required: + - items + - attr + example: + items: + - 3 + attr: + allow_from: + - 192.0.2.10 + - 198.51.100.0/24 + regen_key: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/domain: + post: + summary: Edit domains + description: >- + Changes domains. With an API key (admin) every attribute can be set; + quotas are in MiB, attributes not sent keep their value (`aliases`, + `maxquota` and `quota` keep it also for 0 or an empty value). + `relay_all_recipients` = 1 sets `backupmx`, `relay_unknown_only` = 1 + sets both. `tags` are added to the existing tags (remove tags with + delete/domain/tag); a tag the domain has already ends the request + without an answer after the other values were saved. A domain admin (UI session) can only change + `description` (with the ACL `domain_desc`), `gal` and `tags`. Rate + limits are set with edit/rl-domain. Errors per domain: + `domain_invalid`, `mailbox_defquota_exceeds_mailbox_maxquota`, + `defquota_empty`, `mailbox_quota_exceeds_domain_quota`, + `maxquota_empty`, `max_quota_in_use`, `domain_quota_m_in_use`, + `mailboxes_in_use`, `aliases_in_use`; success `domain_modified`. + operationId: editDomain + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: Domain name or list of domain names + oneOf: + - type: string + - type: array + items: + type: string + attr: + type: object + properties: + description: + type: string + description: Description + aliases: + type: [integer, string] + description: Max. number of aliases + mailboxes: + type: [integer, string] + description: Max. number of mailboxes + defquota: + type: [integer, string] + description: Default quota of new mailboxes in MiB + maxquota: + type: [integer, string] + description: Max. quota per mailbox in MiB + quota: + type: [integer, string] + description: Quota of the whole domain in MiB + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Domain active + gal: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Global address list in SOGo + backupmx: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Relay this domain (backup MX) + relay_all_recipients: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Relay all recipients + relay_unknown_only: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Relay non-existing mailboxes only + relayhost: + type: [integer, string] + description: >- + ID of the sender-dependent transport (get/relayhost/all) + the domain sends through, 0 = none + tags: + type: array + items: + type: string + description: Tags to add + example: + items: + - example.org + attr: + description: Example Org + aliases: 400 + mailboxes: 20 + defquota: 3072 + maxquota: 10240 + quota: 51200 + active: 1 + gal: 1 + backupmx: 0 + relay_all_recipients: 0 + relay_unknown_only: 0 + relayhost: 0 + tags: + - premium + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/domain/footer: + post: + summary: Set the domain-wide footer + description: >- + Sets the footer that is added to all outgoing mails of addresses of the + domains. The footer is replaced as a whole: values not sent are emptied. + The footer texts can use `{= auth_user =}`, `{= from_user =}`, + `{= from_name =}`, `{= from_addr =}`, `{= from_domain =}` and custom + mailbox attributes like `{= foo =}`. Entries of `exclude` that are no + existing mailbox, alias or alias domain are reported with + `username_invalid` and left out. Success `domain_footer_modified`, + errors `domain_invalid`, `access_denied` (both stop the call). + operationId: editDomainFooter + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: Domain name or list of domain names + oneOf: + - type: string + - type: array + items: + type: string + attr: + type: object + properties: + html: + type: string + default: "" + description: HTML footer + plain: + type: string + default: "" + description: Plain text footer + skip_replies: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + description: No footer in replies + exclude: + description: >- + Mailboxes, aliases (addresses) and alias domains that + get no footer + oneOf: + - type: string + - type: array + items: + type: string + example: + items: + - example.org + attr: + html: "

Example Org · {= from_name =}

" + plain: "Example Org - {= from_name =}" + skip_replies: 1 + exclude: + - noreply@example.org + - example.com + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/fail2ban: + post: + summary: Edit Fail2Ban settings, bans and filters + description: >- + Changes Fail2Ban (admins only, an API key may). What happens depends + on `attr.action`: + + + - `unban`, `whitelist`, `blacklist` act on the networks in `items`: + queue the unban; add to the allowlist (and unban); add to the + denylist (not `0.0.0.0/0` or the mailcow networks). Answer + `object_modified` per network; `network_host_invalid` (danger) + for an invalid network with `whitelist`/`blacklist` (`unban` + skips it silently). + - `unban-all` queues all active bans for unbanning (the denylist + stays). Answer `f2b_modified`. + - `edit-regex` replaces the regex filters with the list in + `attr.regex` (numbered from 1). Answer `f2b_modified`. + - `reset-regex` restores the default filters and waits up to 15 + seconds for netfilter (`reset_f2b_regex` (danger) when it takes + longer). Send a non-empty `items` (the UI sends `["self"]`); the + answer is `object_modified` per item. + - Without `action` the settings are saved. Values that are not sent + keep their value, except `ban_time_increment` and + `manage_external`, which become 0, and `whitelist`/`blacklist` + replace the lists. Values are clamped: `ban_time`, `max_ban_time` + at least 60, `max_attempts`, `retry_window` at least 1, + `netban_ipv4` 8-32, `netban_ipv6` 8-128. Answer `f2b_modified`, + `network_host_invalid` (danger) for every entry of the lists that + is left out. + + An `unban`, `whitelist`, `blacklist` or `reset-regex` without `items` + saves the settings like a call without `action` (and so turns off + `ban_time_increment` and `manage_external`); an unknown `action` with + `items` answers `object_modified` without changing anything. + `access_denied` for non-admins. + operationId: editFail2Ban + tags: + - Fail2Ban + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: Networks or IPs for `unban`, `whitelist`, `blacklist` (single value or list) + oneOf: + - type: array + items: + type: string + - type: string + attr: + type: object + properties: + action: + type: string + enum: + - unban + - whitelist + - blacklist + - unban-all + - edit-regex + - reset-regex + regex: + type: array + items: + type: string + description: "`edit-regex`: the regex filters; the first group must match the IP" + ban_time: + type: [integer, string] + description: Ban time in seconds (min. 60) + ban_time_increment: + type: [boolean, integer, string] + description: 1 = the ban time grows with every ban of a network; not sent = 0 + max_ban_time: + type: [integer, string] + description: Maximum ban time in seconds (min. 60) + max_attempts: + type: [integer, string] + description: Failed logins within `retry_window` that lead to a ban (min. 1) + retry_window: + type: [integer, string] + description: Window in seconds for `max_attempts` (min. 1) + netban_ipv4: + type: [integer, string] + description: IPv4 prefix length that is banned (8-32) + netban_ipv6: + type: [integer, string] + description: IPv6 prefix length that is banned (8-128) + whitelist: + type: string + description: Allowlist, networks or host names separated by space, comma, semicolon or newline; replaces the list + blacklist: + type: string + description: Denylist, networks separated by space, comma, semicolon or newline; replaces the list + manage_external: + type: [boolean, integer, string] + description: 1 = netfilter only keeps the banlist and sets no rules (block with the banlist URL externally); not sent = 0 + required: + - attr + examples: + settings: + summary: Save the settings + value: + items: [] + attr: + ban_time: "1800" + ban_time_increment: "1" + max_ban_time: "86400" + max_attempts: "10" + retry_window: "600" + netban_ipv4: "32" + netban_ipv6: "128" + whitelist: |- + 192.0.2.0/24 + mail.example.org + blacklist: 198.51.100.66/32 + manage_external: "0" + unban: + summary: Unban a network + value: + items: + - 203.0.113.7/32 + attr: + action: unban + regex: + summary: Replace the regex filters + value: + items: + - self + attr: + action: edit-regex + regex: + - "^mailcow UI: Invalid password for .+ by ([0-9a-f\\.:]+)$" + - "^Rspamd UI: Invalid password by ([0-9a-f\\.:]+)$" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/mailbox: + post: + tags: + - Mailboxes + summary: Update mailboxes + operationId: editMailbox + description: >- + Changes the given mailboxes. Fields that are not sent keep their value, + except `attribute_hash`, which is cleared. Admins and domain admins (for + their domains); a mailbox user gets `access_denied`. Some fields need an + ACL of domain admins and are silently ignored without it: `sogo_access` + (sogo_access), the protocol fields (protocol_access), `relayhost` + (mailbox_relayhost), `extended_sender_acl` (extend_sender_acl, error + `extended_sender_acl_denied`); API keys have all ACLs. + + + Messages per mailbox: `mailbox_modified` or `username_invalid`, + `access_denied`, `mailbox_quota_left_exceeded`, + `mailbox_quota_exceeded`, `password_complexity`, `password_mismatch`, + `sender_acl_invalid`, `extra_acl_invalid`, `extra_acl_invalid_domain`. + Quota 0 (unlimited) for a mailbox with a quota needs the + `unlimited_quota` ACL (`unlimited_quota_acl`, stops the call). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + $ref: "#/components/schemas/MailboxItems" + attr: + $ref: "#/components/schemas/MailboxEdit" + example: + items: + - john.doe@example.org + attr: + name: John Doe + quota: 5120 + active: 1 + protocol_access: + - imap + - smtp + - sieve + - dav + sender_acl: + - sales@example.org + - "@example.com" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/mailbox/custom-attribute: + post: + tags: + - Mailboxes + summary: Set custom attributes of mailboxes + operationId: editMailboxCustomAttributes + description: >- + Replaces the custom attributes (free key/value pairs, e.g. for + identity provider mappings) of the given mailboxes: `attribute[i]` + gets `value[i]`, attributes that are not sent are removed; spaces in + attribute names are removed. Both lists must have the same length. + Admins and domain admins (for their domains). Messages per mailbox: + `mailbox_modified`, `username_invalid`, `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + $ref: "#/components/schemas/MailboxItems" + attr: + type: object + properties: + attribute: + description: Attribute names (a single string is one name) + oneOf: + - type: string + - type: array + items: + type: string + value: + description: Values, in the order of `attribute` + oneOf: + - type: string + - type: array + items: + type: string + example: + items: + - john.doe@example.org + attr: + attribute: + - department + - employee_id + value: + - Sales + - "4711" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/mailbox-group: + post: + summary: Update mailbox groups + description: >- + Applies `attr` to every group in `items`, attributes that are not sent + keep their value. The domain of a group cannot be changed. `members` + replaces the manual members the caller may access; members added by the + identity provider sync (`source` idp) stay and cannot be removed here + (`group_member_idp`). Admins (every read/write API key) may edit every + group; domain admins need the `mailbox_groups` ACL, and groups with + send-as addresses or grants outside their domains are read-only for + them. + + + Answers one message per id: `group_modified` (plus warnings as for + `add`) or the error of that group (`access_denied` for unknown ids and + ids that are no number, the errors listed for `add`). + operationId: editMailboxGroups + tags: + - Groups + requestBody: + content: + application/json: + schema: + type: object + required: + - items + - attr properties: items: description: >- - contains list of Sender-Dependent Transport you want to - delete - type: object - type: object - summary: Delete Sender-Dependent Transports - /api/v1/delete/resource: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - delete - - resource - - name: - - test@mailcow.tld - - null - msg: - - resource_removed - - test@mailcow.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Resources - description: >- - Using this endpoint you can delete a Resources, for this you have to - know its ID. You can get the ID using the GET method. - operationId: Delete Resources - requestBody: - content: - application/json: - schema: - example: - - test@mailcow.tld - properties: - items: - description: contains list of Resources you want to delete - type: object - type: object - summary: Delete Resources - /api/v1/delete/syncjob: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - log: - - entity - - action - - object - msg: - - message - - entity name - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Sync jobs - description: You can delete one or more sync jobs. - operationId: Delete sync job - requestBody: - content: - application/json: - schema: - example: - - "6" - - "9" - properties: - items: - description: contains list of aliases you want to delete - type: object - type: object - summary: Delete sync job - /api/v1/delete/syncjob_source: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - syncjob - - delete - - source - - id: - - "3" - msg: - - imapsync_source_deleted - - Example import - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Sync jobs - description: >- - Delete one or more sync job sources by id. Sources still referenced by - a sync job cannot be deleted. Delete or - repoint the dependent jobs first. - operationId: Delete sync job source - summary: Delete sync job source - requestBody: - content: - application/json: - schema: - example: - - "3" + Group ids, a list or a single id. An `id` inside `attr` + replaces `items`. + oneOf: + - type: array + items: + type: + - integer + - string + - type: + - integer + - string + attr: + allOf: + - $ref: "#/components/schemas/MailboxGroupAttributes" + - type: object + properties: + reapply_template: + type: + - boolean + - integer + - string + description: >- + Admins only: apply the effective templates to all + members again, also when nothing changed + default: 0 + example: items: - type: string - type: array - /api/v1/delete/tls-policy-map: - post: + - 7 + attr: + add_members: + - carol@example.org + remove_members: + - bob@example.org + sogo_grants: + - owner: shared@example.org + type: Calendar + folder: personal + access: write + subscribe: 1 responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - tls_policy_maps - - delete - - id: - - "1" - - null - msg: - - tls_policy_map_entry_deleted - - "1" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Outgoing TLS Policy Map Overrides + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/identity-provider-sync: + post: + summary: Request an identity sync run description: >- - Using this endpoint you can delete a TLS Policy Map, for this you have - to know its ID. You can get the ID using the GET method. - operationId: Delete TLS Policy Map + Admins only (every read/write API key). Queues a sync run of the + Identity Provider (Keycloak and LDAP); the identity sync cron starts it + within a minute, see `GET /api/v1/get/identity-provider/sync-status`. + An identical pending request is reused. `items` is not used. + + + Answers `iam_sync_requested`, or `iam_sync_mode_invalid`, + `iam_sync_unavailable` (no Keycloak or LDAP provider configured), + `access_denied`. + operationId: requestIdentitySync + tags: + - Identity Provider requestBody: content: application/json: schema: - example: - - "3" + type: object + required: + - attr properties: items: - description: contains list of tls policy maps you want to delete + description: Not used + attr: type: object - type: object - summary: Delete TLS Policy Map - /api/v1/delete/transport: - post: + properties: + mode: + type: string + description: >- + `preview` lists the changes without applying them, + `run` applies them, `force` applies them without the + change limit (`offboarding_limit`) + enum: + - preview + - run + - force + default: run + example: + attr: + mode: preview responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - transport - - delete - - id: - - "1" - msg: - - relayhost_removed - - "1" - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Routing + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/mailq: + post: + summary: Act on the mail queue description: >- - Using this endpoint you can delete a Transport Maps, for this you have - to know its ID. You can get the ID using the GET method. - operationId: Delete Transport Maps + Runs a Postfix queue command (admins only, an API key may): + + + - `hold` (`postsuper -h`), `unhold` (`postsuper -H`) and `deliver` + (`postqueue -i`) act on the queue IDs in `items`. + - `flush` (`postqueue -f`) tries to deliver the whole queue, + `super_delete` (`postsuper -d ALL`) deletes every queued message. + Both ignore `items`. + + Messages: `queue_command_success`; `Error: command failed: ...` + (danger) with the output of the command; `unknown` (danger) when the + command gave no answer; `access_denied` for non-admins. An unknown + `action`, or `hold`/`unhold`/`deliver` without `items`, does nothing + and is answered with `Task completed`. Queue IDs that are not + hexadecimal are skipped. + operationId: editMailQueue + tags: + - Queue Manager requestBody: content: application/json: schema: - example: - - "1" + type: object properties: items: - description: contains list of transport maps you want to delete + description: Queue ID or list of queue IDs (for `hold`, `unhold`, `deliver`) + oneOf: + - type: array + items: + type: string + - type: string + attr: type: object - type: object - summary: Delete Transport Maps - "/api/v1/delete/mailbox/tag/{mailbox}": - post: - parameters: - - description: name of mailbox - in: path - name: mailbox - example: info@domain.tld - required: true - schema: - type: string + properties: + action: + type: string + enum: + - hold + - unhold + - deliver + - flush + - super_delete + required: + - action + required: + - attr + examples: + hold: + summary: Hold two messages + value: + items: + - B98C6260CA1 + - 4C1E8260D02 + attr: + action: hold + flush: + summary: Flush the whole queue + value: + items: [] + attr: + action: flush responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/pushover: + post: + tags: + - Mailbox user settings + summary: Set the Pushover notifications of mailboxes + operationId: editPushover + description: >- + Saves the Pushover settings (push notification on new mail) of the + mailboxes, or removes them with `delete: "true"`. Needs the user ACL + `pushover` (domain admins: their own ACL `pushover`, off by default). + API keys may change any mailbox, a mailbox user only its own (the UI + sends its address as `items`). `key` and `token` must be sent the first + time; later, empty fields keep their value. Messages per mailbox: + `pushover_settings_edited` or `access_denied`, `pushover_key`, + `pushover_token`, "Invalid regex". + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + $ref: "#/components/schemas/MailboxItems" + attr: + type: object + properties: + key: + type: string + description: Pushover user key, 30 letters and digits + token: + type: string + description: Pushover application token, 30 letters and digits + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + title: + type: string + description: Title of the notification (default "Mail") + text: + type: string + description: Text of the notification + senders: + type: string + description: Only notify for these senders (addresses separated by comma, space or semicolon; invalid ones are dropped) + senders_regex: + type: string + description: Only notify for senders matching this regular expression + evaluate_x_prio: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: 1 sends high-priority mails as high-priority notifications (0 when not sent) + only_x_prio: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: 1 notifies for high-priority mails only (0 when not sent) + sound: + type: string + description: Pushover sound name (empty when not sent) + delete: + type: string + enum: + - "true" + description: '"true" removes the Pushover settings, other fields are ignored' + example: + items: + - john.doe@example.org + attr: + key: uQiRzpo4DXghDmr9QzzfQu27cmVRsG + token: azGDORePK8gMaC0QOYAMyEEuzJnyUi + active: 1 + title: New mail + text: You've got mail + senders: boss@example.org + evaluate_x_prio: 1 + only_x_prio: 0 + sound: pushover + responses: "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - delete - - tags_mailbox - - tags: - - tag1 - - tag2 - mailbox: info@domain.tld - - null - msg: - - mailbox_modified - - info@domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/quarantine_notification: + post: + tags: + - Quarantine + summary: Set the quarantine notification interval of mailboxes + operationId: editQuarantineNotification + description: >- + How often the mailbox user gets a summary of quarantined mail. Needs + the user ACL `quarantine_notification`. API keys may change any + mailbox, a mailbox user only its own. `quarantine_notification` must be + sent: without it the call fails with an empty answer. Messages per + mailbox: `mailbox_modified` or `access_denied` (also for an invalid + value). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + $ref: "#/components/schemas/MailboxItems" + attr: + type: object + required: + - quarantine_notification + properties: + quarantine_notification: + type: string + enum: + - never + - hourly + - daily + - weekly + example: + items: + - john.doe@example.org + attr: + quarantine_notification: daily + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/syncjob: + post: + summary: Update sync jobs + description: >- + Applies `attr` to every job in `items`; attributes that are not sent + keep their value, an empty `password1` or `user1` keeps the stored one. + The local mailbox (`username`) cannot be changed. Needs the `syncjobs` + ACL and access to the local mailbox of each job (admins and API keys: + every job). The source is only checked again when `source_id` changes. + Sending `last_run` or `success` (any value except null) resets that + field. `prio` is set by admins only. + + + Answers one message per id: `mailbox_modified` with the local mailbox, + or the error of that job (`access_denied` for unknown jobs and ids that + are no number, otherwise the errors of `add`). + operationId: editSyncJobs + tags: + - Sync jobs + requestBody: + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: >- + Job ids, a list or a single id. An `id` inside `attr` + replaces `items`. + oneOf: + - type: array + items: + type: + - integer + - string + - type: + - integer + - string + attr: + allOf: + - $ref: "#/components/schemas/SyncJobAttributes" + - type: object + properties: + last_run: + description: Any value except null resets the last run time (the job is due again) + success: + description: Any value except null resets the result of the last run + prio: + type: + - integer + - string + description: >- + Admins only: queue priority, higher runs first (see + `edit/syncjob/run_next`) + example: + items: + - 12 + - 13 + attr: + active: 0 + mins_interval: 60 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/syncjob/run_next: + post: + summary: Run sync jobs next + description: >- + Admins only (every read/write API key): moves the jobs to the front of + the queue. Each job gets a `prio` above the current maximum, `last_run` + and `success` are cleared, so the runner starts them next. Values in + `items` that are no number are ignored, `attr` is not used. + + + Answers `imapsync_job_running` (danger) for each job that is running + right now and `imapsync_run_next_done` with the number of moved jobs. + When no job was moved and none is running (e.g. unknown ids) the answer + is `{"type": "error", "msg": "Cannot edit item"}`; without valid ids + `access_denied`. + operationId: runSyncJobsNext + tags: + - Sync jobs + requestBody: + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + description: Job ids + type: array + items: + type: + - integer + - string + attr: + description: Not used + type: object + example: + items: + - 12 + attr: {} + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/syncjob_source: + post: + summary: Update sync sources + description: >- + Applies `attr` to every source in `items` (ids that are no number are + skipped); attributes that are not sent keep their value, an empty + `oauth_client_secret` keeps the stored secret. Domains and users are + only replaced when `scope` is sent. Needs the `syncjobs` ACL and edit + rights on the source (admins and API keys: every source; others the + sources they created, domain admins also those of their mailboxes). + Changing the server or the OAuth2 client drops the stored tokens. + Changing the server or the OAuth2 client, or turning `verify_cert` off, + needs access to every mailbox whose jobs use the source + (`imapsync_source_used_by_others`). A client_credentials source fetches + a new token while it is saved. + + + Answers one message per id: `imapsync_source_modified` (plus + `imapsync_source_token_refresh_failed` when the new token could not be + fetched), or the error of that source (`access_denied` for unknown + ids, the errors of `add`). + operationId: editSyncJobSources + tags: + - Sync jobs + requestBody: + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: >- + Source ids, a list or a single id. An `id` inside `attr` + replaces `items`. + oneOf: + - type: array + items: + type: + - integer + - string + - type: + - integer + - string + attr: + $ref: "#/components/schemas/SyncJobSourceAttributes" + example: + items: + - 3 + attr: + description: Migration finished in December + scope: user + users: + - alice@example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/syncjob_source/refresh_token: + post: + summary: Refresh the token of a sync source + description: >- + Fetches a new OAuth2 token for a XOAUTH2 client_credentials source now + (the cron refreshes tokens before they expire). Only the first id of + `items` is used, `attr` is not used. Needs the `syncjobs` ACL and edit + rights on the source. Answers `imapsync_source_token_refreshed`, or + `imapsync_source_refresh_unsupported` (other sources), + `imapsync_source_token_refresh_failed` (with the provider error), + `access_denied` (unknown id, id that is no number, no edit rights). + operationId: refreshSyncJobSourceToken + tags: + - Sync jobs + requestBody: + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + description: Source id (the first entry is used) + type: array + items: + type: + - integer + - string + attr: + description: Not used + type: object + example: + items: + - 5 + attr: {} + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/imapsync_settings: + post: + summary: Update global sync job settings + description: >- + Admins only (every read/write API key). Sets how many sync jobs run at + the same time and the bandwidth limit of every sync job process (a + lower per-job `maxbytespersecond` still applies). `items` is not used. + Answers `max_parallel_saved`, or `imapsync_max_parallel_invalid`, + `imapsync_max_kb_per_second_invalid`, `access_denied`. + operationId: editSyncJobSettings + tags: + - Sync jobs + requestBody: + content: + application/json: + schema: + type: object + required: + - attr + properties: + items: + description: Not used + attr: + type: object + required: + - max_parallel + properties: + max_parallel: + type: + - integer + - string + description: Sync jobs running at the same time, 1-32 + minimum: 1 + maximum: 32 + max_kb_per_second: + type: + - integer + - string + description: >- + Bandwidth limit per sync job process in KB/s, 0-10485760 + (stored as bytes/s); 0, empty or not sent = unlimited + default: 0 + minimum: 0 + maximum: 10485760 + example: + attr: + max_parallel: 4 + max_kb_per_second: 2000 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/user-acl: + post: tags: - Mailboxes - description: You can delete one or more mailbox tags. - operationId: Delete mailbox tags + summary: Set the ACL of mailboxes + operationId: editUserAcl + description: >- + Sets the user ACL (what a mailbox user may change in the UI) of the + given mailboxes: listed ACLs are switched on, all others off. Admins + and domain admins (for their domains); a domain admin cannot change + ACLs that it does not have itself. Mailbox users get `access_denied`. + Success: `acl_saved` per mailbox. requestBody: + required: true content: application/json: schema: - example: - - tag1 - - tag2 + type: object + required: + - items + - attr properties: items: - description: contains list of mailboxes you want to delete + $ref: "#/components/schemas/MailboxItems" + attr: type: object - type: object - summary: Delete mailbox tags - "/api/v1/delete/domain/tag/{domain}": - post: - parameters: - - description: name of domain - in: path - name: domain - example: domain.tld - required: true - schema: - type: string + properties: + user_acl: + description: ACLs to switch on (a single string is one ACL); an empty list switches all off + oneOf: + - $ref: "#/components/schemas/UserAclName" + - type: array + items: + $ref: "#/components/schemas/UserAclName" + example: + items: + - john.doe@example.org + attr: + user_acl: + - spam_alias + - tls_policy + - spam_score + - spam_policy + - delimiter_action + - eas_reset + - pushover + - quarantine + - quarantine_notification + - app_passwds + - pw_reset responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - delete - - tags_domain - - tags: - - tag1 - - tag2 - domain: domain.tld - - null - msg: - - domain_modified - - domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Domains - description: You can delete one or more domain tags. - operationId: Delete domain tags - requestBody: - content: - application/json: - schema: - example: - - tag1 - - tag2 - properties: - items: - description: contains list of domains you want to delete - type: object - type: object - summary: Delete domain tags - /api/v1/edit/alias: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - edit - - alias - - active: "1" - address: alias@domain.tld - goto: destination@domain.tld - id: - - "6" - private_comment: private comment - public_comment: public comment - - null - msg: - - alias_modified - - alias@domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/alias/{id}: + get: tags: - Aliases + summary: Get aliases + operationId: getAliases description: >- - You can update one or more aliases per request. You can also send just - attributes you want to change - operationId: Update alias - requestBody: - content: - application/json: - schema: - example: - attr: - active: "1" - address: alias@domain.tld - goto: destination@domain.tld - private_comment: private comment - public_comment: public comment - items: ["6"] - properties: - attr: - properties: - active: - description: is alias active or not - type: boolean - address: - description: 'alias address, for catchall use "@domain.tld"' - type: string - goto: - description: "destination address, comma separated" - type: string - goto_ham: - description: learn as ham - type: boolean - goto_null: - description: silently ignore - type: boolean - goto_spam: - description: learn as spam - type: boolean - private_comment: - type: string - public_comment: - type: string - sogo_visible: - description: toggle visibility as selectable sender in SOGo - type: boolean - type: object - items: - description: contains list of aliases you want update - type: object - type: object - summary: Update alias - /api/v1/edit/api-key: - post: + `all` lists the aliases of every domain and alias domain the caller + manages (an API key: all), an ID or address returns that alias. + Mailbox addresses (alias rows pointing to themselves) are not listed. + `{}` if there is none. + parameters: + - name: id + in: path + required: true + description: "`all`, or the ID or address of an alias" + schema: + type: string + example: all responses: - "401": - $ref: "#/components/responses/Unauthorized" "200": + description: The list of aliases (`all`) or one alias content: application/json: - examples: - response: - value: - - log: - - api - - edit - - key - - id: - - "3" - regen_key: "1" - msg: - - api_key_modified - - provisioning - type: success schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + anyOf: + - type: array + items: + $ref: "#/components/schemas/Alias" + - $ref: "#/components/schemas/Alias" + examples: + all: + summary: get/alias/all + value: + - in_primary_domain: "" + id: 12 + domain: example.org + public_comment: Sales team + private_comment: null + goto: anna@example.org,ben@example.org + address: sales@example.org + is_catch_all: 0 + internal: 0 + active: 1 + active_int: 1 + sogo_visible: 1 + sogo_visible_int: 1 + sender_allowed: 1 + group_id: null + group_name: null + created: "2026-10-26 14:43:58" + modified: "2026-11-08 20:38:01" + - in_primary_domain: example.org + id: 20 + domain: example.net + public_comment: "" + private_comment: "" + goto: null@localhost + address: "@example.net" + is_catch_all: 1 + internal: 0 + active: 1 + active_int: 1 + sogo_visible: 0 + sogo_visible_int: 0 + sender_allowed: 0 + group_id: null + group_name: null + created: "2026-11-02 09:12:40" + modified: null + one: + summary: get/alias/12 + value: + in_primary_domain: "" + id: 12 + domain: example.org + public_comment: Sales team + private_comment: null + goto: anna@example.org,ben@example.org + address: sales@example.org + is_catch_all: 0 + internal: 0 + active: 1 + active_int: 1 + sogo_visible: 1 + sogo_visible_int: 1 + sender_allowed: 1 + group_id: null + group_name: null + created: "2026-10-26 14:43:58" + modified: "2026-11-08 20:38:01" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/alias/all/{domains}: + get: + tags: + - Aliases + summary: Get the aliases of domains + operationId: getAliasesByDomain + description: >- + Lists the aliases of the named domains or alias domains. Domains the + caller does not manage are skipped. `{}` if there is none. + parameters: + - name: domains + in: path + required: true + description: Comma-separated domain names + schema: + type: string + example: example.org,example.net + responses: + "200": + description: The list of aliases + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Alias" + example: + - in_primary_domain: "" + id: 12 + domain: example.org + public_comment: Sales team + private_comment: null + goto: anna@example.org,ben@example.org + address: sales@example.org + is_catch_all: 0 + internal: 0 + active: 1 + active_int: 1 + sogo_visible: 1 + sogo_visible_int: 1 + sender_allowed: 1 + group_id: null + group_name: null + created: "2026-10-26 14:43:58" + modified: "2026-11-08 20:38:01" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/time_limited_aliases/{mailbox}: + get: + tags: + - Mailbox user settings + summary: Get the time-limited aliases of a mailbox + operationId: getTimeLimitedAliases + description: >- + The time-limited aliases of the mailbox that are still valid or + permanent. `{}` when there are none or the caller has no access (a + mailbox user: other mailboxes). If `{mailbox}` is no address, the + session user's aliases are returned (nothing for API keys). + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + responses: + "200": + description: List of aliases + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TimeLimitedAlias" + example: + - address: kahd.osuwe@example.org + goto: john.doe@example.org + description: Online shop registration + validity: 1795699200 + created: "2026-11-10 09:00:00" + modified: null + permanent: 0 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/time_limited_alias: + post: + tags: + - Mailbox user settings + summary: Update time-limited aliases + operationId: editTimeLimitedAlias + description: >- + Sets a new expiry (`validity` hours from now) or makes the aliases + permanent. Needs the user ACL `spam_alias` and access to the target + mailbox (API keys: any, mailbox users: their own). Aliases are skipped + silently when neither `validity` nor a true `permanent` is sent. + Messages per alias: `mailbox_modified` (without the address) or + `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: Alias address or list of addresses + oneOf: + - type: string + - type: array + items: + type: string + attr: + type: object + properties: + validity: + type: [integer, string] + description: Hours from now until the alias expires (not range-checked); ends a permanent state + permanent: + type: [boolean, integer, string] + description: true makes the alias permanent (wins over `validity`) + example: + items: + - kahd.osuwe@example.org + attr: + validity: 24 + permanent: 0 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/time_limited_alias: + post: + tags: + - Mailbox user settings + summary: Delete time-limited aliases + operationId: deleteTimeLimitedAlias + description: >- + Deletes time-limited aliases. Needs the user ACL `spam_alias` and + access to the target mailbox (API keys: any, mailbox users: their own). + Messages per alias: `mailbox_modified` or `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + type: array + description: Alias addresses + items: + type: string + example: + - kahd.osuwe@example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/pushover-test: + post: + tags: + - Mailbox user settings + summary: Verify the Pushover credentials of mailboxes + operationId: testPushover + description: >- + Checks the stored Pushover key and token of each mailbox with the + Pushover API; `attr` is not used. Same access rules as `edit/pushover`. + Messages per mailbox: "Pushover API OK (200): ..." (success) or + "Pushover API ERR (code): ..." (danger), `access_denied`; a mailbox + without settings stops the call with `pushover_credentials_missing`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + $ref: "#/components/schemas/MailboxItems" + attr: + type: object + description: Not used + example: + items: + - john.doe@example.org + attr: {} + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/delimiter_action: + post: + tags: + - Mailbox user settings + summary: Set the handling of tagged mail + operationId: editDelimiterAction + description: >- + What happens to mail for `user+tag@example.org`: moved to the subfolder + "tag" (`subfolder`), "[tag]" added to the subject (`subject`), or + nothing (`none`, also for any other value). Needs the user ACL + `delimiter_action`. API keys may change any mailbox, a mailbox user + only its own. Messages per mailbox: `mailbox_modified` or + `access_denied`, `redis_error`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + $ref: "#/components/schemas/MailboxItems" + attr: + type: object + properties: + tagged_mail_handler: + type: string + enum: + - subfolder + - subject + - none + example: + items: + - john.doe@example.org + attr: + tagged_mail_handler: subfolder + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/tls_policy: + post: + tags: + - Mailbox user settings + summary: Set the TLS policy of mailboxes + operationId: editTlsPolicy + description: >- + Enforces TLS for incoming and/or outgoing mail of the mailboxes; a + field that is not sent keeps its value. Needs the user ACL + `tls_policy`. API keys may change any mailbox, a mailbox user only its + own. Messages per mailbox: `mailbox_modified` or `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + $ref: "#/components/schemas/MailboxItems" + attr: + type: object + properties: + tls_enforce_in: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: 1 accepts incoming mail only over TLS + tls_enforce_out: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: 1 sends outgoing mail only over TLS + example: + items: + - john.doe@example.org + attr: + tls_enforce_in: 1 + tls_enforce_out: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/spam-score: + get: + tags: + - Mailbox user settings + summary: Get the default spam score + operationId: getDefaultSpamScore + description: >- + The spam score thresholds of the session user; for API keys, which have + no mailbox, the server default from the Rspamd actions ("add header" + and "reject"; "5,15" if Rspamd cannot be asked). + responses: + "200": + description: Thresholds + content: + application/json: + schema: + $ref: "#/components/schemas/SpamScore" + example: + score: "8,15" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/api-key/{id}: + get: + summary: Get API key + description: One API key including its key value. A read-only API key gets `{}`. + operationId: getApiKey tags: - API Keys + parameters: + - name: id + in: path + required: true + description: ID of the API key + schema: + type: integer + example: 3 + responses: + "200": + description: The API key, `{}` if there is none with this ID or for a read-only API key + content: + application/json: + schema: + $ref: "#/components/schemas/ApiKey" + example: + id: 3 + description: provisioning + access: rw + api_key: 1B7E2C-94F0A3-6D5B18-C2E7F4-03A9D6 + allow_from: + - 192.0.2.10 + - 2001:db8::/64 + skip_ip_check: 0 + service: 0 + active: 1 + created: "2026-11-02 09:12:44" + modified: "2026-11-05 16:40:02" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/api-key/all: + get: + summary: Get API keys description: >- - Update API keys. Attributes you do not send keep their value, - `regen_key` replaces the key value with a new random one. Service keys - from mailcow.conf cannot be changed (`api_key_service_locked`). - operationId: Update API keys + Lists all API keys including their key values, service keys from + mailcow.conf first. A read-only API key gets `{}`: it may not see the + keys. + operationId: getApiKeys + tags: + - API Keys + responses: + "200": + description: The API keys, `{}` if there are none or for a read-only API key + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/ApiKey" + example: + - id: 1 + description: API_KEY + access: rw + api_key: 8F2D41-0C9A7B-5E3F12-AB46C0-77D1E9 + allow_from: + - 192.0.2.0/24 + skip_ip_check: 0 + service: 1 + active: 1 + created: "2026-09-21 10:20:00" + modified: null + - id: 3 + description: provisioning + access: rw + api_key: 1B7E2C-94F0A3-6D5B18-C2E7F4-03A9D6 + allow_from: + - 192.0.2.10 + - 2001:db8::/64 + skip_ip_check: 0 + service: 0 + active: 1 + created: "2026-11-02 09:12:44" + modified: "2026-11-05 16:40:02" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/app-passwd/all/{mailbox}: + get: + tags: + - App Passwords + summary: Get the app passwords of a mailbox + operationId: getAppPasswords + description: >- + The app passwords of the mailbox, including the password hashes. `{}` + if there are none or the caller has no access to the mailbox (a + mailbox user: other mailboxes). + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + responses: + "200": + description: List of app passwords + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AppPassword" + example: + - id: 23 + name: Thunderbird + mailbox: john.doe@example.org + domain: example.org + password: "{BLF-CRYPT}$2y$10$..." + created: "2026-11-08 20:38:01" + modified: null + imap_access: 1 + smtp_access: 1 + dav_access: 0 + eas_access: 0 + pop3_access: 0 + sieve_access: 1 + active: 1 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/app-passwd/all: + get: + tags: + - App Passwords + summary: Get the app passwords of the logged-in mailbox + operationId: getOwnAppPasswords + description: >- + The app passwords of the session user. Meant for a mailbox user + session; an API key has no mailbox and gets `{}` (use + `get/app-passwd/all/{mailbox}`). + responses: + "200": + description: List of app passwords + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AppPassword" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/app-passwd/{id}: + get: + tags: + - App Passwords + summary: Get an app password (broken) + operationId: getAppPassword + description: >- + Does not work: json_api.php reads `$object['id']` from the path segment, + which is a string, so the request ends with an error and an empty body. + Use `get/app-passwd/all/{mailbox}`. + parameters: + - name: id + in: path + required: true + description: App password ID + schema: + type: integer + example: 23 + responses: + "200": + description: Empty body + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/app-passwd: + post: + tags: + - App Passwords + summary: Update app passwords + operationId: editAppPassword + description: >- + Changes app passwords by ID; fields that are not sent keep their value. + Needs the user ACL `app_passwds`. The app passwords are assigned to + `username`, which defaults to the session user: API keys must send the + mailbox the app passwords belong to (without it the backend tries to + assign them to "API", the database refuses that and the answer is + empty); a mailbox user may leave it out. Messages per ID: + `object_modified`, `app_passwd_id_invalid`, `password_complexity`, + `password_mismatch`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: App password ID or list of IDs + oneOf: + - type: integer + - type: array + items: + type: integer + attr: + type: object + properties: + username: + type: string + description: Mailbox the app passwords belong to (required for API keys) + app_name: + type: string + app_passwd: + type: string + format: password + description: New password; changed only together with `app_passwd2` + app_passwd2: + type: string + format: password + protocols: + description: Replaces the allowed protocols + type: array + items: + type: string + enum: + - imap_access + - smtp_access + - pop3_access + - sieve_access + - dav_access + - eas_access + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + example: + items: + - 23 + attr: + username: john.doe@example.org + active: 0 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/bcc/{id}: + get: + tags: + - Address Rewriting + summary: Get BCC maps + operationId: getBccMaps + description: >- + `all` lists the BCC maps of the domains the caller manages (an API key: + all), an ID returns that map. `{}` if there is none. The answer for an + ID carries an additional member `"0": null` (json_api.php appends an + undefined variable). + parameters: + - name: id + in: path + required: true + description: "`all` or the ID of a BCC map" + schema: + type: string + example: all + responses: + "200": + description: The list of BCC maps (`all`) or one BCC map + content: + application/json: + schema: + anyOf: + - type: array + items: + $ref: "#/components/schemas/BccMap" + - $ref: "#/components/schemas/BccMap" + examples: + all: + summary: get/bcc/all + value: + - id: 3 + local_dest: "@example.org" + bcc_dest: archive@example.com + active: 1 + type: rcpt + created: "2026-11-03 10:15:22" + domain: example.org + modified: null + one: + summary: get/bcc/3 + value: + id: 3 + local_dest: "@example.org" + bcc_dest: archive@example.com + active: 1 + type: rcpt + created: "2026-11-03 10:15:22" + domain: example.org + modified: null + "0": null + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/dkim/{domain}: + get: + summary: Get the DKIM key of a domain + description: >- + The DKIM public key of a domain or alias domain with the TXT record to + publish at `._domainkey.`; `{}` if it has none. The + private key is only answered when `SHOW_DKIM_PRIV_KEYS` is enabled in + vars.local.inc.php, else `privkey` is empty. + operationId: getDkimKey + tags: + - DKIM + parameters: + - name: domain + in: path + required: true + description: Domain or alias domain name + schema: + type: string + example: example.org + responses: + "200": + description: The DKIM key + content: + application/json: + schema: + $ref: "#/components/schemas/DkimKey" + example: + pubkey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxV3...IDAQAB + length: "2048" + dkim_txt: v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxV3...IDAQAB + dkim_selector: dkim + privkey: "" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/add/dkim_import: + post: + summary: Import a DKIM key + description: >- + Imports an RSA private key as the DKIM key of a domain; the public key + is derived from it. Admins only. The domain name must be valid but is + not checked against the domains of mailcow. The old key of the domain + is removed first, so a successful import answers `dkim_removed` and + `dkim_added`. Errors `access_denied`, `private_key_error`, + `dkim_domain_or_sel_invalid`, `dkim_domain_or_sel_exists` (a key exists + and `overwrite_existing` is not set). + operationId: importDkimKey + tags: + - DKIM + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - domain + - private_key_file + properties: + domain: + type: string + description: Domain name + dkim_selector: + type: string + default: dkim + description: Selector (letters, digits, `-`, `_`, `.`) + private_key_file: + type: string + description: The private key in PEM format (the text, not a file upload) + overwrite_existing: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + description: Replace an existing key of the domain + example: + domain: example.org + dkim_selector: dkim2026 + private_key_file: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC...\n-----END PRIVATE KEY-----" + overwrite_existing: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/domain-admin/all: + get: + summary: Get domain administrators + description: Lists all domain administrators with their details. + operationId: getDomainAdmins + tags: + - Domain admin + responses: + "200": + description: The domain administrators, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DomainAdmin" + example: + - username: domainadmin1 + tfa_active: 0 + tfa_active_int: 0 + active: 1 + active_int: 1 + created: "2026-09-21 10:24:48" + attributes: + force_tfa: "0" + force_pw_update: "0" + selected_domains: + - example.org + unselected_domains: + - example.com + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/domain-admin/{username}: + get: + summary: Get domain administrator + description: Details of one domain administrator. + operationId: getDomainAdmin + tags: + - Domain admin + parameters: + - name: username + in: path + required: true + description: Username of the domain administrator + schema: + type: string + example: domainadmin1 + responses: + "200": + description: The domain administrator, `{}` if there is none of this name + content: + application/json: + schema: + $ref: "#/components/schemas/DomainAdmin" + example: + username: domainadmin1 + tfa_active: 0 + tfa_active_int: 0 + active: 1 + active_int: 1 + created: "2026-09-21 10:24:48" + attributes: + force_tfa: "0" + force_pw_update: "0" + selected_domains: + - example.org + - example.com + unselected_domains: "" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/domain/{id}: + get: + summary: Get domains + description: >- + `all` answers the list of all domains, a domain name answers that + domain. An alias domain name answers its target domain. An API key sees + every domain, a domain admin (UI session) only the domains assigned to + it. Quotas are in bytes. `{}` when nothing is found, also for `all` + without domains. + operationId: getDomains + tags: + - Domains + parameters: + - name: id + in: path + required: true + description: "`all` or a domain name" + schema: + type: string + example: all + - name: tags + in: query + required: false + description: >- + Only with `all`: comma-separated tag names, answers only the domains + with a tag that contains one of them + schema: + type: string + example: customer,premium + responses: + "200": + description: The list of domains (`all`) or one domain + content: + application/json: + schema: + oneOf: + - type: array + items: + $ref: "#/components/schemas/Domain" + - $ref: "#/components/schemas/Domain" + example: + - max_new_mailbox_quota: 10632560640 + def_new_mailbox_quota: 3221225472 + quota_used_in_domain: "104857600" + bytes_total: "79324302" + msgs_total: "132" + mboxes_in_domain: 3 + mboxes_left: 7 + domain_name: example.org + domain_h_name: example.org + description: Example Org + max_num_aliases_for_domain: 400 + max_num_mboxes_for_domain: 10 + def_quota_for_mbox: 3221225472 + max_quota_for_mbox: 10737418240 + max_quota_for_domain: 10737418240 + relayhost: "0" + backupmx: 0 + backupmx_int: 0 + gal: 1 + gal_int: 1 + rl: + value: "100" + frame: h + active: 1 + active_int: 1 + relay_all_recipients: 0 + relay_all_recipients_int: 0 + relay_unknown_only: 0 + relay_unknown_only_int: 0 + created: "2026-09-21 10:24:34" + modified: "2026-11-06 18:15:08" + aliases_in_domain: 1 + aliases_left: 399 + domain_admins: anna.admin + tags: + - customer + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/domain/template/{id}: + get: + summary: Get domain templates + description: >- + `all` answers the list of domain templates, an ID that template + (`{}` if there is none; a non-numeric ID is read as 0). Admins and + domain admins. The template attributes hold the quotas in bytes. + operationId: getDomainTemplates + tags: + - Domains + parameters: + - name: id + in: path + required: true + description: "`all` or a template ID" + schema: + type: string + example: all + responses: + "200": + description: The list of templates (`all`) or one template + content: + application/json: + schema: + oneOf: + - type: array + items: + $ref: "#/components/schemas/DomainTemplate" + - $ref: "#/components/schemas/DomainTemplate" + example: + - id: 1 + template: Default + type: domain + attributes: + tags: [] + max_num_aliases_for_domain: 400 + max_num_mboxes_for_domain: 10 + def_quota_for_mbox: 3221225472 + max_quota_for_mbox: 10737418240 + max_quota_for_domain: 10737418240 + rl_frame: s + rl_value: "" + active: 1 + gal: 1 + backupmx: 0 + relay_all_recipients: 0 + relay_unknown_only: 0 + dkim_selector: dkim + key_size: 2048 + created: "2026-09-21 10:23:08" + modified: null + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/add/domain/template: + post: + summary: Add a domain template + description: >- + Adds a domain template, the defaults for new domains (see `template` of + add/domain). Admins only. Quotas are sent in MiB and stored in bytes; + values not sent get the defaults shown (numbers and quotas also for + 0). Success `template_added`, + errors `access_denied`, `template_name_invalid`, `template_exists`. + operationId: addDomainTemplate + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/DomainTemplateInput" + - required: + - template + example: + template: Small business + max_num_aliases_for_domain: 400 + max_num_mboxes_for_domain: 25 + def_quota_for_mbox: 2048 + max_quota_for_mbox: 5120 + max_quota_for_domain: 51200 + rl_value: 500 + rl_frame: h + active: 1 + gal: 1 + backupmx: 0 + relay_all_recipients: 0 + relay_unknown_only: 0 + dkim_selector: dkim + key_size: 2048 + tags: + - small + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/domain/template: + post: + summary: Edit domain templates + description: >- + Replaces the attributes of domain templates. Admins only. Send all + attributes: one that is not sent is reset (numbers and quotas to 0, + `tags` to none, `rl_frame` to `s`, `active` and `gal` to 1, + `dkim_selector` to `dkim`, `key_size` to 2048). The `Default` template + keeps its name. Edit one template per call: with several IDs the + templates after the first get the name of the first one. Unknown IDs + answer `template_id_invalid`; the call always ends with + `template_modified`. + operationId: editDomainTemplate + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: Template ID or list of template IDs + oneOf: + - type: [integer, string] + - type: array + items: + type: [integer, string] + attr: + $ref: "#/components/schemas/DomainTemplateInput" + example: + items: + - 2 + attr: + template: Small business + max_num_aliases_for_domain: 400 + max_num_mboxes_for_domain: 50 + def_quota_for_mbox: 2048 + max_quota_for_mbox: 5120 + max_quota_for_domain: 102400 + rl_value: 500 + rl_frame: h + active: 1 + gal: 1 + backupmx: 0 + relay_all_recipients: 0 + relay_unknown_only: 0 + dkim_selector: dkim + key_size: 2048 + tags: + - small + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/domain/template: + post: + summary: Delete a domain template + description: >- + Deletes a domain template. Admins only. Only the first ID of the list + is deleted. The `Default` template is never deleted; the answer is + `template_removed` in every case. + operationId: deleteDomainTemplate + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + type: array + description: Template IDs (only the first is used) + items: + type: [integer, string] + example: + - 2 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/search/domain: + post: + summary: Search domains (table of the UI) + description: >- + Paging, sorting and searching of domains with the server-side + processing parameters of DataTables, used by the domain table of the + UI. Admins (also read-only API keys) search all domains, domain admins + their domains; other logins get `403` with `Insufficient permissions`. + + + The position of an entry in `columns` (and `order[].column`) selects + the database column: 2 domain name, 3 max. aliases (sorted by the + number of aliases), 4 max. mailboxes (sorted by the number of + mailboxes), 5 domain quota (sorted by the quota of the mailboxes), 6 + storage (sorting only), 7 default mailbox quota, 8 max. mailbox quota, + 10 relay domain, 14 tags (global search only), 15 active; other positions + mean the domain name. `search.value` is searched (`LIKE %value%`) in + the columns with `searchable: true` (positions 2 and 14 in the UI), + `columns[].search.value` filters one column: the one whose position is + given in `data`, else the domain name. `orderable` and `searchable` + must be set (`true` or `"true"`). `columns` is required when + `search.value` is set. + + + Send `Content-Type: application/json`; without it the body is ignored + and all domains are answered. A database error answers + `{"error": "An SQL error occurred: ..."}` (status 200), e.g. a search + in position 6. + operationId: searchDomains + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DomainSearchRequest" + example: + draw: 1 + start: 0 + length: 10 + search: + value: example + order: + - column: 2 + dir: asc + columns: + - data: "" + searchable: false + orderable: false + - data: chkbox + searchable: false + orderable: false + - data: domain_h_name + searchable: true + orderable: true + search: + value: "" + responses: + "200": + description: One page of domains, with the counts DataTables needs + content: + application/json: + schema: + $ref: "#/components/schemas/DomainSearchResult" + example: + draw: 1 + recordsTotal: 12 + recordsFiltered: 1 + data: + - max_new_mailbox_quota: 10632560640 + def_new_mailbox_quota: 3221225472 + quota_used_in_domain: "104857600" + bytes_total: "79324302" + msgs_total: "132" + mboxes_in_domain: 3 + mboxes_left: 7 + domain_name: example.org + domain_h_name: example.org + description: Example Org + max_num_aliases_for_domain: 400 + max_num_mboxes_for_domain: 10 + def_quota_for_mbox: 3221225472 + max_quota_for_mbox: 10737418240 + max_quota_for_domain: 10737418240 + relayhost: "0" + backupmx: 0 + backupmx_int: 0 + gal: 1 + gal_int: 1 + rl: false + active: 1 + active_int: 1 + relay_all_recipients: 0 + relay_all_recipients_int: 0 + relay_unknown_only: 0 + relay_unknown_only_int: 0 + created: "2026-09-21 10:24:34" + modified: "2026-11-06 18:15:08" + aliases_in_domain: 1 + aliases_left: 399 + domain_admins: "-" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/alias-domain/{id}: + get: + summary: Get alias domains + description: >- + `all` answers the list of all alias domains, an alias domain name + answers that alias domain; `{}` when nothing is found. An API key sees + every alias domain, a domain admin those of its domains. + operationId: getAliasDomains + tags: + - Domains + parameters: + - name: id + in: path + required: true + description: "`all` or an alias domain name" + schema: + type: string + example: all + responses: + "200": + description: The list of alias domains (`all`) or one alias domain + content: + application/json: + schema: + oneOf: + - type: array + items: + $ref: "#/components/schemas/AliasDomain" + - $ref: "#/components/schemas/AliasDomain" + example: + - alias_domain: example.net + parent_is_backupmx: 0 + target_domain: example.org + active: 1 + active_int: 1 + rl: false + created: "2026-10-02 08:12:45" + modified: null + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/add/alias-domain: + post: + summary: Add alias domains + description: >- + Adds alias domains: mail to an address of the alias domain goes to the + same local part in the target domain. Needs the ACL `alias_domains` + (API keys have it) and access to the target domain, which must be an + existing domain and no relay domain. Like add/domain this can set the + rate limit and create a DKIM key for each alias domain. Per alias + domain: success `aliasd_added`, errors `alias_domain_invalid` (also + when the name is taken by a domain or alias domain), + `aliasd_targetd_identical`, `targetd_not_found`, + `targetd_relay_domain`; for the call: `access_denied`, + `target_domain_invalid`. An invalid alias domain name currently ends + the request without an answer. + operationId: addAliasDomain + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - alias_domain + - target_domain + properties: + alias_domain: + type: string + description: >- + Alias domain name; several are separated by spaces, commas, + semicolons or line breaks + target_domain: + type: string + description: The domain the alias domain points to + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + description: Alias domain active (inactive when not sent) + rl_value: + type: [integer, string] + description: Rate limit, messages per `rl_frame`; empty or 0 = none + rl_frame: + type: string + enum: [s, m, h, d] + description: Rate limit time frame + dkim_selector: + type: string + description: Selector of the DKIM key that is created + key_size: + type: [integer, string] + description: >- + RSA key size in bits of the DKIM key that is created; a key + is created only when `key_size` and `dkim_selector` are sent + example: + alias_domain: example.net + target_domain: example.org + active: 1 + rl_value: 100 + rl_frame: h + dkim_selector: dkim + key_size: 2048 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/alias-domain: + post: + summary: Edit alias domains + description: >- + Changes alias domains; attributes not sent keep their value. The new + target domain must be an existing domain (no alias domain) the caller + has access to. Success `aliasd_modified`, errors per alias domain + `alias_domain_invalid` (unknown), `target_domain_invalid`, + `access_denied`. Rate limits are set with edit/rl-domain. + operationId: editAliasDomain + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: Alias domain name or list of alias domain names + oneOf: + - type: string + - type: array + items: + type: string + attr: + type: object + properties: + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Alias domain active + target_domain: + type: string + description: New target domain + example: + items: + - example.net + attr: + active: 1 + target_domain: example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/alias-domain: + post: + summary: Delete alias domains + description: >- + Deletes alias domains together with their aliases, spam aliases, BCC + maps and mailbox group data. Success `alias_domain_removed`, errors + `domain_invalid`, `access_denied`. The rate limit of the alias domain + is not removed. + operationId: deleteAliasDomain + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + type: array + description: Alias domain names + items: + type: string + example: + - example.net + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/mta-sts: + post: + summary: Add an MTA-STS policy + description: >- + Adds the MTA-STS policy of a domain, published (when active) at + `https://mta-sts./.well-known/mta-sts.txt`. Alias domains always + publish the policy of their target domain: a policy added for an alias + domain is accepted but never published. The policy ID for the + `_mta-sts` TXT record is the time of the change (`YYYYMMDDhhmmss`). There is no read route: the policy is shown in the + UI only. Answers `Task completed` on success; errors `access_denied` + (unknown domain or no access), `version_invalid`, `mode_invalid`, + `max_age_invalid`, `mx_invalid`, and the database message when the + domain has a policy already (use edit/mta-sts). + operationId: addMtaSts + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - domain + - version + - mode + - mx + - max_age + properties: + domain: + type: string + description: Domain or alias domain + version: + type: string + enum: [stsv1, STSv1] + description: Policy version (case-insensitive) + mode: + type: string + enum: [enforce, testing, none] + description: >- + `enforce` rejects delivery without valid TLS, `testing` + only reports, `none` publishes without effect + mx: + type: string + description: >- + Comma-separated MX host names the senders may deliver to, + wildcards like `*.example.org` allowed + max_age: + type: [integer, string] + description: Seconds senders may cache the policy, 1 to 31536000 + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + description: Policy published (inactive when not sent) + example: + domain: example.org + version: stsv1 + mode: enforce + mx: mail.example.org,mx2.example.org + max_age: 86400 + active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/mta-sts: + post: + summary: Edit MTA-STS policies + description: >- + Changes the MTA-STS policies of domains; attributes not sent keep their + value, but `version` must always be sent (else `version_invalid`). A + change of `version`, `mode`, `mx` or `max_age` gives the policy a new + ID. Success `object_modified`, errors per domain `access_denied` (also + for a domain without policy), `version_invalid`, `mode_invalid`, + `max_age_invalid`, `mx_invalid`. + operationId: editMtaSts + tags: + - Domains + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: Domain name or list of domain names + oneOf: + - type: string + - type: array + items: + type: string + attr: + type: object + required: + - version + properties: + version: + type: string + enum: [stsv1, STSv1] + description: Policy version + mode: + type: string + enum: [enforce, testing, none] + description: Policy mode + mx: + type: string + description: Comma-separated MX host names + max_age: + type: [integer, string] + description: Seconds senders may cache the policy, 1 to 31557600 + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + description: Policy published + example: + items: + - example.org + attr: + version: stsv1 + mode: testing + mx: mail.example.org + max_age: 604800 + active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/fail2ban: + get: + summary: Get Fail2Ban settings and bans + description: >- + Returns the Fail2Ban (netfilter) settings, the regex filters, the + allow- and denylists and the current bans. Admins only (an API key + may); other callers get `{}`. Any further path segment + (e.g. `/api/v1/get/fail2ban/all`) gives the same answer. + operationId: getFail2Ban + tags: + - Fail2Ban + responses: + "200": + description: Settings and bans + content: + application/json: + schema: + $ref: "#/components/schemas/Fail2Ban" + example: + ban_time: 1800 + ban_time_increment: true + max_ban_time: 86400 + netban_ipv4: 32 + netban_ipv6: 128 + max_attempts: 10 + retry_window: 600 + banlist_id: a2c11baf-5770-47de-9c5e-575bd7bc9218 + manage_external: 0 + regex: + "1": "^mailcow UI: Invalid password for .+ by ([0-9a-f\\.:]+)$" + "2": "^Rspamd UI: Invalid password by ([0-9a-f\\.:]+)$" + whitelist: |- + 192.0.2.0/24 + mail.example.org + blacklist: 198.51.100.66/32 + perm_bans: + - network: 198.51.100.66/32 + ip: 198.51.100.66 + active_bans: + - queued_for_unban: 0 + network: 203.0.113.7/32 + ip: 203.0.113.7 + banned_until: 00h 27m 08s + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/fail2ban/banlist: + post: + summary: Renew the Fail2Ban banlist URL + description: >- + Gives the banlist (`/f2b-banlist?id=`, the list of denied + and banned networks for an external firewall) a new random ID, so the + old URL stops working. `items` must hold the current `banlist_id` + (from `GET /api/v1/get/fail2ban`); `attr` can be left out. Admins + only (an API key may). + + + Answers `f2b_banlist_refreshed`; a wrong ID is answered with status + 404 and `Cannot edit item`, a Redis error with 500 and `redis_error`. + operationId: refreshFail2BanBanlist + tags: + - Fail2Ban requestBody: content: application/json: schema: - example: - items: - - "3" - attr: - description: provisioning - access: ro - allow_from: "192.0.2.10" - skip_ip_check: "0" - active: "1" - regen_key: "0" - properties: - attr: - properties: - description: - description: free text, max. 255 characters - type: string - access: - enum: - - ro - - rw - type: string - allow_from: - description: IPs/CIDR networks, separated by commas, spaces or newlines (or an array) - type: string - skip_ip_check: - type: boolean - active: - type: boolean - regen_key: - description: issue a new key value - type: boolean - type: object - items: - description: contains list of API key IDs you want to update - type: object type: object - summary: Update API keys - /api/v1/edit/domain: - post: + properties: + items: + description: The current banlist ID (single value or a list with it as first element) + oneOf: + - type: array + items: + type: string + - type: string + attr: + type: object + description: Not used + required: + - items + example: + items: + - a2c11baf-5770-47de-9c5e-575bd7bc9218 responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: "`items` does not hold the current banlist ID" + content: + application/json: + schema: + $ref: "#/components/schemas/TaskResult" + example: + type: error + msg: Cannot edit item + /api/v1/get/logs/acme/{count}: + get: + summary: Get ACME logs (count or range) + description: Entries of the ACME log (Let's Encrypt certificates), newest first. + operationId: getAcmeLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: the entries from position + `from` to `to` (the newest entry is 1). Characters other than + digits and `-` are removed; `0` or a range with `to` < `from` gives + `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: "200": + $ref: "#/components/responses/AcmeLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/api/{count}: + get: + summary: Get API logs (count or range) + description: >- + Entries of the API log: every request to `/api/v1` with its form data + (values of keys like passwords and secrets replaced by `*`). Newest + first. + operationId: getApiLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: the entries from position + `from` to `to` (the newest entry is 1). Characters other than + digits and `-` are removed; `0` or a range with `to` < `from` gives + `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/ApiLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/autodiscover/{count}: + get: + summary: Get Autodiscover logs (count or range) + description: >- + Requests to Autodiscover (Outlook, ActiveSync) and the service that was + offered, newest first. + operationId: getAutodiscoverLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: the entries from position + `from` to `to` (the newest entry is 1). Characters other than + digits and `-` are removed; `0` or a range with `to` < `from` gives + `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/AutodiscoverLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/dovecot/{count}: + get: + summary: Get Dovecot logs (count or range) + description: Entries of the Dovecot log (IMAP, POP3, Sieve, LMTP), newest first. + operationId: getDovecotLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: the entries from position + `from` to `to` (the newest entry is 1). Characters other than + digits and `-` are removed; `0` or a range with `to` < `from` gives + `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/DovecotLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/netfilter/{count}: + get: + summary: Get netfilter logs (count or range) + description: Entries of netfilter (failed logins, bans), newest first. + operationId: getNetfilterLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: the entries from position + `from` to `to` (the newest entry is 1). Characters other than + digits and `-` are removed; `0` or a range with `to` < `from` gives + `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/NetfilterLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/postfix/{count}: + get: + summary: Get Postfix logs (count or range) + description: Entries of the Postfix log (SMTP), newest first. + operationId: getPostfixLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: the entries from position + `from` to `to` (the newest entry is 1). Characters other than + digits and `-` are removed; `0` or a range with `to` < `from` gives + `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/PostfixLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/ratelimited/{count}: + get: + summary: Get rate limit logs (count or range) + description: >- + Messages that were rejected because a rate limit was exceeded, newest + first. + operationId: getRatelimitLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: the entries from position + `from` to `to` (the newest entry is 1). Characters other than + digits and `-` are removed; `0` or a range with `to` < `from` gives + `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/RatelimitLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/rspamd-history/{count}: + get: + summary: Get Rspamd history (count or range) + description: >- + Messages scanned by Rspamd with score, action and symbols, newest + first. + operationId: getRspamdHistoryByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N messages. `from-to` (from ≥ 1, to ≥ from) is + passed to Rspamd, which counts from 0: `2-3` gives the third and + fourth newest message. Characters other than digits and `-` are + removed; `0` gives `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/RspamdHistory" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/sogo/{count}: + get: + summary: Get SOGo logs (count or range) + description: Entries of the SOGo log, newest first. + operationId: getSogoLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: the entries from position + `from` to `to` (the newest entry is 1). Characters other than + digits and `-` are removed; `0` or a range with `to` < `from` gives + `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/SogoLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/watchdog/{count}: + get: + summary: Get watchdog logs (count or range) + description: Health checks of the watchdog container, newest first. + operationId: getWatchdogLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: the entries from position + `from` to `to` (the newest entry is 1). Characters other than + digits and `-` are removed; `0` or a range with `to` < `from` gives + `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/WatchdogLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/acme: + get: + summary: Get ACME logs + description: >- + The newest 1000 entries of the ACME log (Let's Encrypt certificates), + newest first. + operationId: getAcmeLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/AcmeLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/api: + get: + summary: Get API logs + description: >- + The newest 1000 entries of the API log: every request to `/api/v1` + with its form data (values of keys like passwords and secrets + replaced by `*`). Requests with a JSON body show the body as `attr` or + `items`. Newest first. + operationId: getApiLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/ApiLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/autodiscover: + get: + summary: Get Autodiscover logs + description: >- + The newest 1000 requests to Autodiscover (Outlook, ActiveSync) and the + service that was offered, newest first. + operationId: getAutodiscoverLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/AutodiscoverLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/cron: + get: + summary: Get cron logs + description: >- + The newest 1000 entries of the scheduled tasks of mailcow (e.g. + identity provider sync, group shares), newest first. + operationId: getCronLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/CronLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/cron/{count}: + get: + summary: Get cron logs (count or range) + description: >- + Entries of the scheduled tasks of mailcow (e.g. identity provider + sync, group shares), newest first. + operationId: getCronLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: the entries from position + `from` to `to` (the newest entry is 1). Characters other than + digits and `-` are removed; `0` or a range with `to` < `from` gives + `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/CronLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/dovecot: + get: + summary: Get Dovecot logs + description: The newest 1000 entries of the Dovecot log (IMAP, POP3, Sieve, LMTP), newest first. + operationId: getDovecotLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/DovecotLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/netfilter: + get: + summary: Get netfilter logs + description: >- + The newest 1000 entries of netfilter (failed logins, bans), newest + first. + operationId: getNetfilterLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/NetfilterLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/postfix: + get: + summary: Get Postfix logs + description: The newest 1000 entries of the Postfix log (SMTP), newest first. + operationId: getPostfixLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/PostfixLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/ratelimited: + get: + summary: Get rate limit logs + description: >- + The newest 1000 messages that were rejected because a rate limit was + exceeded, newest first. + operationId: getRatelimitLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/RatelimitLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/rspamd-history: + get: + summary: Get Rspamd history + description: >- + The newest 1000 messages scanned by Rspamd with score, action and + symbols, newest first. + operationId: getRspamdHistory + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/RspamdHistory" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/rspamd-stats: + get: + summary: Get Rspamd statistics + description: >- + The statistics of Rspamd (`/stat` of its controller): scanned and + learned messages, actions, Bayes statistics. Further path segments are + ignored. + operationId: getRspamdStats + tags: + - Logs + responses: + "200": + description: The statistics, `{}` if Rspamd cannot be reached + content: + application/json: + schema: + $ref: "#/components/schemas/RspamdStats" + example: + version: 3.12.1 + config_id: ch8ha1o68oxb3xf9izngisz4wxuxgs86n9gg6yko7jtt4msu4bxhuunnoi4pk7an4y5htzjb1y9nwsxmkjhdu671ocb5b5ea1g38csb + uptime: 25891 + read_only: false + scanned: 300 + learned: 30 + actions: + reject: 38 + soft reject: 0 + rewrite subject: 0 + add header: 6 + greylist: 1 + no action: 255 + scan_times: + - 0.340365 + - 0.48114 + spam_count: 44 + ham_count: 256 + connections: 24 + control_connections: 700 + total_learns: 29 + statfiles: + - revision: 6 + users: 1 + used: 0 + total: 0 + size: 0 + symbol: BAYES_SPAM + type: redis + languages: 0 + class: spam + fuzzy_hashes: + mailcow: 0 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/sasl: + get: + summary: Get SASL logs + description: >- + The newest 1000 successful logins (IMAP, POP3, SMTP, SOGo, ...), one + entry per service, address and user with the time of the last login, + newest first. + operationId: getSaslLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/SaslLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/sasl/{count}: + get: + summary: Get SASL logs (count or range) + description: >- + Successful logins (IMAP, POP3, SMTP, SOGo, ...), one entry per + service, address and user with the time of the last login, newest + first. + operationId: getSaslLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: `to` entries starting at + position `from` (the newest entry is 1), so `2-3` gives the entries + 2 to 4. Characters other than digits and `-` are removed; `0` or a + range with `to` < `from` gives `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/SaslLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/sogo: + get: + summary: Get SOGo logs + description: The newest 1000 entries of the SOGo log, newest first. + operationId: getSogoLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/SogoLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/ui: + get: + summary: Get mailcow UI logs + description: >- + The newest 1000 actions of the mailcow UI and API (the messages of + their calls), newest first. + operationId: getUiLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/UiLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/ui/{count}: + get: + summary: Get mailcow UI logs (count or range) + description: >- + Actions of the mailcow UI and API (the messages of their calls), + newest first. + operationId: getUiLogsByCount + tags: + - Logs + parameters: + - name: count + in: path + required: true + description: >- + `N`: the newest N entries. `from-to`: `to` entries starting at + position `from` (the newest entry is 1), so `2-3` gives the entries + 2 to 4. Characters other than digits and `-` are removed; `0` or a + range with `to` < `from` gives `{}`. + schema: + type: string + pattern: ^[0-9]+(-[0-9]+)?$ + example: "50" + responses: + "200": + $ref: "#/components/responses/UiLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/logs/watchdog: + get: + summary: Get watchdog logs + description: >- + The newest 1000 health checks of the watchdog container, newest first. + operationId: getWatchdogLogs + tags: + - Logs + responses: + "200": + $ref: "#/components/responses/WatchdogLogs" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailbox/{id}: + get: + tags: + - Mailboxes + summary: Get a mailbox + operationId: getMailbox + description: >- + Details of one mailbox, `{}` if it does not exist or the caller has no + access to it. A mailbox user may read its own mailbox. + + + With the query parameter `tags`, `{id}` is a search term instead: the + answer is the list of mailboxes whose address contains `{id}` and that + have a tag containing one of the tags, `[]` when none matches. + parameters: + - name: id + in: path + required: true + description: "Mailbox address (with `tags`: part of the address, e.g. a domain)" + schema: + type: string + example: john.doe@example.org + - $ref: "#/components/parameters/MailboxTagsQuery" + responses: + "200": + description: The mailbox; with `tags` a list of mailboxes + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/Mailbox" + - type: array + items: + $ref: "#/components/schemas/Mailbox" + examples: + mailbox: + $ref: "#/components/examples/MailboxDetails" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailbox-group/{id}: + get: + summary: Get mailbox group + description: >- + One group by id, with the same visibility rules as + `GET /api/v1/get/mailbox-group/all`. Answers `{}` when the id is not a + number, the group does not exist or the caller may not see it. + operationId: getMailboxGroup + tags: + - Groups + parameters: + - name: id + in: path + required: true + description: Group id + schema: + type: integer + example: 7 + responses: + "200": + description: The group + content: + application/json: + schema: + $ref: "#/components/schemas/MailboxGroup" + example: + id: 7 + domain: example.org + name: Sales + description: Sales team + address: sales@example.org + sender_allowed: 1 + sogo_visible: 0 + internal: 1 + template_id: 3 + template: Staff + priority: 10 + idp_ref: null + members: + - username: alice@example.org + source: manual + send_as: + - "@example.com" + imap_grants: [] + sogo_grants: [] + active: 1 + created: "2026-10-25 12:00:00" + modified: null + can_edit: true + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailbox-group/member/{username}: + get: + summary: Get group memberships of a mailbox + description: >- + The groups of a mailbox, its effective group template (the template of + the active group with the highest priority, then the lowest id) and the + addresses it may send as through its active groups. Admins (every API + key) get it for every existing mailbox, domain admins with the + `mailbox_groups` ACL for their mailboxes (only the groups of their + domains), a mailbox user for himself. Answers `{}` for unknown mailboxes + or without access. + operationId: getMailboxGroupMemberships + tags: + - Groups + parameters: + - name: username + in: path + required: true + description: Mailbox (case-insensitive) + schema: + type: string + example: alice@example.org + responses: + "200": + description: The memberships + content: + application/json: + schema: + $ref: "#/components/schemas/MailboxGroupMemberships" + example: + groups: + - id: 7 + domain: example.org + name: Sales + address: sales@example.org + sender_allowed: 1 + active: 1 + priority: 10 + template: Staff + source: manual + template: Staff + send_as: + - orders@example.org + - sales@example.org + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailbox-group/all: + get: + summary: Get mailbox groups + description: >- + All groups the caller may see, ordered by domain and name: admins (and + every API key) all groups, domain admins with the `mailbox_groups` ACL + the groups of their domains, others none. `can_edit` is false for + read-only API keys. Answers `{}` when there is no group. + operationId: getMailboxGroups + tags: + - Groups + responses: + "200": + description: The groups + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MailboxGroup" + example: + - id: 7 + domain: example.org + name: Sales + description: Sales team + address: sales@example.org + sender_allowed: 1 + sogo_visible: 0 + internal: 0 + template_id: 3 + template: Staff + priority: 10 + idp_ref: null + members: + - username: alice@example.org + source: manual + - username: bob@example.org + source: idp + send_as: + - orders@example.org + imap_grants: + - owner: shared@example.org + rights: write + subscribe: 1 + state: applied + last_error: null + sogo_grants: + - owner: shared@example.org + type: Calendar + folder: personal + access: read + subscribe: 0 + state: pending + last_error: null + active: 1 + created: "2026-10-25 12:00:00" + modified: "2026-10-28 09:14:03" + can_edit: true + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailbox-group/all/{domains}: + get: + summary: Get mailbox groups of domains + description: >- + Like `GET /api/v1/get/mailbox-group/all`, limited to the groups of the + given domains. Answers `{}` when there is no group. + operationId: getMailboxGroupsOfDomains + tags: + - Groups + parameters: + - name: domains + in: path + required: true + description: One domain or a comma-separated list of domains + schema: + type: string + example: example.org,example.com + responses: + "200": + description: The groups of the domains + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MailboxGroup" + example: + - id: 7 + domain: example.org + name: Sales + description: "" + address: sales@example.org + sender_allowed: 1 + sogo_visible: 1 + internal: 0 + template_id: null + template: null + priority: 0 + idp_ref: null + members: + - username: alice@example.org + source: manual + send_as: [] + imap_grants: [] + sogo_grants: [] + active: 1 + created: "2026-10-25 12:00:00" + modified: null + can_edit: true + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/identity-provider/sync-status: + get: + summary: Get identity sync status + description: >- + Status of the Identity Provider sync (admins and API keys, also + read-only; `{}` for others): the provider, whether it can be synced + (Keycloak and LDAP) and a scheduled sync is enabled, counts of the + mailboxes of the provider and their links, and the last 10 sync runs, + newest first. Run status: `pending`, `running`, `ok`, `blocked` (the + change limit `offboarding_limit` stopped the run, nothing was changed) + or `error` (`summary.error`, nothing was changed). + operationId: getIdentitySyncStatus + tags: + - Identity Provider + responses: + "200": + description: The sync status + content: + application/json: + schema: + $ref: "#/components/schemas/IdentitySyncStatus" + example: + provider: keycloak + can_sync: true + sync_enabled: true + counts: + accounts: 120 + linked: 118 + idp_disabled: 2 + missing: 1 + runs: + - id: 42 + provider: keycloak + mode: run + status: ok + requested_by: "" + created: "2026-11-09 12:00:00" + started: "2026-11-09 12:00:01" + finished: "2026-11-09 12:00:03" + summary: + counts: + links: 0 + creates: 1 + deactivate: 1 + restore: 0 + release: 0 + missing: 1 + found: 0 + updates: 3 + group_creates: 0 + group_renames: 0 + memberships: 2 + orphaned_groups: 0 + conflicts: 0 + membership_removals: 0 + skipped: 0 + errors: 0 + details: + creates: + - new.user@example.org + deactivate: + - left.user@example.org + restore: [] + missing: + - left.user@example.org + group_creates: [] + conflicts: [] + blocked: false + errors: [] + mode: run + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/identity-link/{username}: + get: + summary: Get identity link of a mailbox + description: >- + The Identity Provider user a mailbox is linked to (admins and API keys, + domain admins for their mailboxes). Answers `{}` without a link or + without access. `missing_since` is set while the sync does not find the + mailbox at the provider; a mailbox that was never linked then has a row + without `external_id`. + operationId: getIdentityLink + tags: + - Identity Provider + parameters: + - name: username + in: path + required: true + description: Mailbox (case-insensitive) + schema: + type: string + example: alice@example.org + responses: + "200": + description: The link + content: + application/json: + schema: + $ref: "#/components/schemas/IdentityLink" + example: + provider: keycloak + external_id: 8d5da28d-7541-428b-8356-a9704f73e7f4 + idp_disabled: 0 + missing_since: null + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailq/all: + get: + summary: Get the mail queue + description: >- + Returns the Postfix mail queue (`postqueue -j`), at most 10000 + entries. Admins only (an API key may); other callers and an empty + queue get `[]`. + operationId: getMailQueue + tags: + - Queue Manager + responses: + "200": + description: List of queued messages + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MailQueueItem" + example: + - queue_name: deferred + queue_id: B98C6260CA1 + arrival_time: 1762768034 + message_size: 1848 + forced_expire: false + sender: sender@example.org + recipients: + - "rcpt@example.com (connect to mx.example.com[192.0.2.25]:25: Connection timed out)" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/postcat/{qid}: + get: + summary: Get a queued message + description: >- + Returns the output of `postcat -q ` for a message in the Postfix + queue: envelope records, headers and body. Admins only (an API key + may); other callers get an empty answer. + + + The answer is sent with `Content-Type: application/json`, but the body + is the plain postcat text, not JSON-encoded. An unknown or + non-hexadecimal queue ID gives the error text of postcat. + operationId: getQueuedMessage + tags: + - Queue Manager + parameters: + - name: qid + in: path + required: true + description: Postfix queue ID + schema: + type: string + example: B98C6260CA1 + responses: + "200": + description: The postcat output as plain text (despite the JSON content type) + content: + application/json: + schema: + type: string + example: | + *** ENVELOPE RECORDS deferred/B/B98C6260CA1 *** + message_size: 1848 612 1 0 1848 0 + message_arrival_time: Mon Nov 10 09:47:14 2026 + create_time: Mon Nov 10 09:47:14 2026 + named_attribute: rewrite_context=local + sender: sender@example.org + *** MESSAGE CONTENTS deferred/B/B98C6260CA1 *** + From: Sender + To: rcpt@example.com + Subject: Test + + Hello + *** HEADER EXTRACTED deferred/B/B98C6260CA1 *** + original_recipient: rcpt@example.com + recipient: rcpt@example.com + *** MESSAGE FILE END deferred/B/B98C6260CA1 *** + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/oauth2-client/{id}: + get: + summary: Get OAuth2 client + description: One OAuth2 client including its secret. + operationId: getOAuth2Client + tags: + - oAuth Clients + parameters: + - name: id + in: path + required: true + description: ID of the client (`id`, not `client_id`) + schema: + type: integer + example: 1 + responses: + "200": + description: The client, `{}` if there is none with this ID + content: + application/json: + schema: + $ref: "#/components/schemas/OAuth2Client" + example: + id: 1 + client_id: 17c76aaa88c0 + client_secret: 73fc668a88147e32a31ff80c + redirect_uri: https://app.example.org/oauth/callback + grant_types: null + scope: profile + user_id: null + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/oauth2-client/all: + get: + summary: Get OAuth2 clients + description: >- + Lists the OAuth2 clients that may use mailcow as OAuth2 server, + including their secrets. + operationId: getOAuth2Clients + tags: + - oAuth Clients + responses: + "200": + description: The clients, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/OAuth2Client" + example: + - id: 1 + client_id: 17c76aaa88c0 + client_secret: 73fc668a88147e32a31ff80c + redirect_uri: https://app.example.org/oauth/callback + grant_types: null + scope: profile + user_id: null + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/oauth2-client: + post: + summary: Edit OAuth2 clients + description: >- + Does one of three things per client, in this order of precedence: + `revoke_tokens` deletes all access and refresh tokens of the client, + `renew_secret` gives it a new random secret, otherwise `redirect_uri` + is set. `revoke_tokens` and `renew_secret` act when they are present, + whatever their value. + + + Success: `object_modified` per client. Errors: `access_denied` (no + client with this ID; stops the call), `Redirect/Callback URL cannot be + empty`. + operationId: editOAuth2Client + tags: + - oAuth Clients + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + items: + description: ID of the client, or a list of IDs + oneOf: + - type: [integer, string] + - type: array + items: + type: [integer, string] + attr: + type: object + properties: + redirect_uri: + type: string + description: New redirect URL; empty or missing keeps the current one + revoke_tokens: + type: [integer, string, boolean] + description: Present = delete the access and refresh tokens of the client + renew_secret: + type: [integer, string, boolean] + description: Present = generate a new client secret + required: + - items + - attr + example: + items: + - 1 + attr: + redirect_uri: https://app.example.org/oauth/callback2 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/policy_bl_domain/{domain}: + get: + summary: Get the sender deny list of a domain + description: >- + The deny list (blacklist) entries of the domain and of all its + mailboxes (`object` tells which); `{}` if there are none or the domain + is unknown. + operationId: getDomainBlacklist + tags: + - Domain antispam policies + parameters: + - name: domain + in: path + required: true + description: Domain name + schema: + type: string + example: example.org + responses: + "200": + description: The deny list entries + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DomainPolicyEntry" + example: + - object: example.org + value: "*@spam.example.com" + prefid: 13 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/policy_wl_domain/{domain}: + get: + summary: Get the sender allow list of a domain + description: >- + The allow list (whitelist) entries of the domain and of all its + mailboxes (`object` tells which); `{}` if there are none or the domain + is unknown. + operationId: getDomainWhitelist + tags: + - Domain antispam policies + parameters: + - name: domain + in: path + required: true + description: Domain name + schema: + type: string + example: example.org + responses: + "200": + description: The allow list entries + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DomainPolicyEntry" + example: + - object: example.org + value: "*@partner.example.com" + prefid: 12 + - object: anna@example.org + value: newsletter@example.net + prefid: 15 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/quarantine/all: + get: + summary: Get quarantined messages + description: >- + Returns the quarantined messages without their content. An API key + (admin) gets every item, a domain admin the items of their domains, a + mailbox user their own items. `[]` when there are none. + operationId: getQuarantine + tags: + - Quarantine + responses: + "200": + description: List of quarantine items + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/QuarantineItem" + example: + - id: 33 + qid: 8224615004C1 + subject: You have won + virus_flag: 0 + score: 15.48 + rcpt: john.doe@example.org + sender: bounces@send.example.com + action: reject + created: 1762768034 + notified: 1 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/quarantine_category: + post: + tags: + - Quarantine + summary: Set the quarantine notification category of mailboxes + operationId: editQuarantineCategory + description: >- + Which quarantined mail the notifications list: rejected mail + (`reject`), mail sent to the junk folder (`add_header`) or both + (`all`). Needs the user ACL `quarantine_category`. API keys may change + any mailbox, a mailbox user only its own. `quarantine_category` must be + sent: without it the call fails with an empty answer. Messages per + mailbox: `mailbox_modified` or `access_denied` (also for an invalid + value). + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + $ref: "#/components/schemas/MailboxItems" + attr: + type: object + required: + - quarantine_category + properties: + quarantine_category: + type: string + enum: + - reject + - add_header + - all + example: + items: + - john.doe@example.org + attr: + quarantine_category: all + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/quarantine/{id}: + get: + summary: Get a quarantined message + description: >- + Returns one quarantine item with the raw message and the Rspamd + symbols. An API key (admin) may read every item; others only items of + their mailboxes or domains. `[]` for an unknown or non-numeric ID or + without access. + operationId: getQuarantineItem + tags: + - Quarantine + parameters: + - name: id + in: path + required: true + description: ID of the quarantine item + schema: + type: string + example: "33" + responses: + "200": + description: The quarantine item + content: + application/json: + schema: + $ref: "#/components/schemas/QuarantineItemDetails" + example: + id: 33 + qid: 8224615004C1 + subject: You have won + score: 15.48 + ip: 198.51.100.23 + action: reject + symbols: '[{"name":"BAYES_SPAM","score":5.1,"options":["99.99%"]},{"name":"RBL_SPAMHAUS_SBL","score":4.0}]' + fuzzy_hashes: "[]" + sender: bounces@send.example.com + rcpt: john.doe@example.org + msg: "Return-Path: \r\nFrom: Prize \r\nTo: john.doe@example.org\r\nSubject: You have won\r\n\r\nClick here\r\n" + domain: null + notified: 1 + created: "2026-11-10 09:47:14" + user: unknown + qhash: 3f9d8c0b2a4e6f1d7c5b9a8e0f2d4c6b8a1e3f5d7c9b0a2e4f6d8c1b3a5e7f9d + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/quarantine: + post: + summary: Edit quarantine settings + description: >- + Saves the global quarantine settings (admins only, an API key may). + `attr.action` must be `settings`; `items` is ignored. Every call + writes all settings: an attribute that is not sent is reset (a missing + `retention_size` or `max_size` becomes 0, which turns the quarantine + off). The current values cannot be read through the API. + + + Messages: `saved_settings`; `redis_error` (danger); `access_denied` + for non-admins. Without `action` nothing happens and the answer is + `Task completed`. + operationId: editQuarantineSettings + tags: + - Quarantine + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: Ignored + attr: + type: object + properties: + action: + type: string + const: settings + retention_size: + type: [integer, string] + description: Items kept per mailbox; 0 disables the quarantine + max_size: + type: [integer, string] + description: Maximum message size in MiB; larger messages are not quarantined (0 is not unlimited) + max_score: + type: [number, string] + description: No notification for messages with a higher spam score; empty = no limit (9999.0) + max_age: + type: [integer, string] + description: Days an item is kept; 0 or less becomes 365 + release_format: + type: string + enum: + - raw + - attachment + default: raw + description: Deliver released messages as they are or as an attachment of a new message + exclude_domains: + type: array + items: + type: string + description: Domains and alias domains whose mail is not quarantined + sender: + type: string + description: Sender of the notifications (invalid address = empty) + subject: + type: string + description: Subject of the notifications + bcc: + type: string + description: Address that gets a copy of every notification (invalid address = empty) + redirect: + type: string + description: Address that gets all notifications instead of the mailboxes (invalid address = empty) + html_tmpl: + type: string + description: HTML template of the notifications (empty = the default template) + required: + - action + required: + - attr + example: + items: + - self + attr: + action: settings + retention_size: "20" + max_size: "15" + max_score: "" + max_age: "365" + release_format: raw + exclude_domains: + - example.com + sender: quarantine@example.org + subject: Spam Quarantine Notification + bcc: "" + redirect: "" + html_tmpl: "" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/recipient_map/{id}: + get: + tags: + - Address Rewriting + summary: Get recipient maps + operationId: getRecipientMaps + description: >- + `all` lists the recipient maps, an ID returns that map. `{}` if there + is none or the caller is no administrator. The answer for an ID + carries an additional member `"0": null` (json_api.php appends an + undefined variable). + parameters: + - name: id + in: path + required: true + description: "`all` or the ID of a recipient map" + schema: + type: string + example: all + responses: + "200": + description: The list of recipient maps (`all`) or one recipient map + content: + application/json: + schema: + anyOf: + - type: array + items: + $ref: "#/components/schemas/RecipientMap" + - $ref: "#/components/schemas/RecipientMap" + examples: + all: + summary: get/recipient_map/all + value: + - id: 1 + recipient_map_old: jane.doe@example.org + recipient_map_new: jane.smith@example.org + active: 1 + created: "2026-11-04 08:30:00" + modified: null + - id: 2 + recipient_map_old: "@example.net" + recipient_map_new: "@example.org" + active: 0 + created: "2026-11-04 08:31:12" + modified: "2026-11-05 17:02:45" + one: + summary: get/recipient_map/1 + value: + id: 1 + recipient_map_old: jane.doe@example.org + recipient_map_new: jane.smith@example.org + active: 1 + created: "2026-11-04 08:30:00" + modified: null + "0": null + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/bcc: + post: + tags: + - Address Rewriting + summary: Update BCC maps + operationId: editBccMap + description: >- + Changes the BCC maps in `items`; attributes that are left out keep + their value, `local_dest` cannot be changed. Same callers as + add/bcc. Per map: `bcc_edited`, `access_denied` (unknown, no access or + no ACL), `bcc_must_be_email`, `bcc_exists`. `type` is not checked by the + backend: use `sender` or `rcpt`. + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: IDs of the BCC maps, a single value or a list + oneOf: + - type: array + items: + type: [integer, string] + - type: [integer, string] + attr: + type: object + properties: + bcc_dest: + type: string + description: E-mail address that gets the copies + type: + type: string + enum: + - sender + - rcpt + active: + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + required: + - items + - attr + example: + items: + - 3 + attr: + bcc_dest: journal@example.com + active: "1" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/recipient_map: + post: + tags: + - Address Rewriting + summary: Update recipient maps + operationId: editRecipientMap + description: >- + Changes the recipient maps in `items`; attributes that are left out + keep their value. Administrators and API keys only. Per map: + `recipient_map_entry_saved`, `access_denied` (unknown ID), + `invalid_recipient_map_old`, `invalid_recipient_map_new`; + `recipient_map_entry_exists` stops the call. + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: IDs of the recipient maps, a single value or a list + oneOf: + - type: array + items: + type: [integer, string] + - type: [integer, string] + attr: + type: object + properties: + recipient_map_old: + type: string + description: Original recipient, an e-mail address or a domain + recipient_map_new: + type: string + description: New recipient, an e-mail address or a domain + active: + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + required: + - items + - attr + example: + items: + - 1 + attr: + recipient_map_new: jane.smith@example.org + active: "1" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/relayhost/{id}: + get: + tags: + - Routing + summary: Get sender-dependent transports + operationId: getRelayhosts + description: >- + `all` lists the sender-dependent transports, an ID returns that entry, + with the password in clear text and the domains and mailboxes that use + it. `{}` if there is none or the caller is no administrator. + parameters: + - name: id + in: path + required: true + description: "`all` or the ID of a sender-dependent transport" + schema: + type: string + example: all + responses: + "200": + description: The list of sender-dependent transports (`all`) or one entry + content: + application/json: + schema: + anyOf: + - type: array + items: + $ref: "#/components/schemas/Relayhost" + - $ref: "#/components/schemas/Relayhost" + examples: + all: + summary: get/relayhost/all + value: + - id: 1 + hostname: "[smtp.example.com]:587" + username: relay-user@example.com + password: Secret-Passphrase-42 + active: 1 + password_short: Sec... + used_by_domains: example.org, example.net + used_by_mailboxes: anna@example.com + one: + summary: get/relayhost/1 + value: + id: 1 + hostname: "[smtp.example.com]:587" + username: relay-user@example.com + password: Secret-Passphrase-42 + active: 1 + password_short: Sec... + used_by_domains: example.org, example.net + used_by_mailboxes: anna@example.com + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/resource/all: + get: + tags: + - Resources + summary: Get all resources + operationId: getResources + description: >- + All resources of the domains the caller manages, `{}` when there are + none. + responses: + "200": + description: List of resources + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Resource" + example: + - name: MeetingRoom1@example.org + kind: location + multiple_bookings: 0 + description: Meeting Room 1 + active: 1 + active_int: 1 + domain: example.org + local_part: MeetingRoom1 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/resource/{id}: + get: + tags: + - Resources + summary: Get a resource + operationId: getResource + description: >- + One resource by its address, `{}` if it does not exist or the caller + does not manage its domain. + parameters: + - name: id + in: path + required: true + description: Address of the resource + schema: + type: string + example: MeetingRoom1@example.org + responses: + "200": + description: The resource + content: + application/json: + schema: + $ref: "#/components/schemas/Resource" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/resource: + post: + tags: + - Resources + summary: Update resources + operationId: editResource + description: >- + Changes resources; fields that are not sent keep their value (the + address does not change with the description). Admins and domain + admins (for their domains). Messages per resource: `resource_modified` + or `resource_invalid`, `description_invalid`, `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: Resource address or list of addresses + oneOf: + - type: string + - type: array + items: + type: string + attr: + type: object + properties: + description: + type: string + description: Name shown in SOGo + kind: + type: string + enum: + - location + - group + - thing + multiple_bookings: + type: [integer, string] + description: 0 always free, -1 unlimited but busy when booked, n ≥ 1 at most n overlapping bookings + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + example: + items: + - MeetingRoom1@example.org + attr: + description: Meeting Room 1 (2nd floor) + multiple_bookings: -1 + active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/rl-mbox/{mailbox}: + get: + tags: + - Ratelimits + summary: Get the rate limit of a mailbox + operationId: getMailboxRatelimit + description: >- + The outgoing rate limit set on the mailbox, `{}` when it has none (the + domain limit applies then) or the caller is no admin or domain admin of + it. + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + responses: + "200": + description: The rate limit + content: + application/json: + schema: + $ref: "#/components/schemas/MailboxRatelimit" + example: + value: "100" + frame: h + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/rl-domain/{domain}: + get: + summary: Get rate limits of domains + description: >- + The rate limit (outgoing messages per time frame) of a domain or alias + domain, `{}` if it has none. `all` answers the list of all domains and + alias domains that have a rate limit, each with its `domain`. + operationId: getDomainRatelimits + tags: + - Ratelimits + parameters: + - name: domain + in: path + required: true + description: "`all` or a domain or alias domain name" + schema: + type: string + example: example.org + responses: + "200": + description: The rate limit, or the list of rate limits (`all`) + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/DomainRatelimit" + - type: array + items: + allOf: + - $ref: "#/components/schemas/DomainRatelimit" + - type: object + properties: + domain: + type: string + description: Domain or alias domain + example: + value: "100" + frame: h + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/rl-domain: + post: + summary: Set rate limits of domains + description: >- + Sets the rate limit (outgoing messages per time frame) of domains or + alias domains. Needs the ACL `ratelimit` (API keys have it). + `rl_value` empty or 0 removes the limit; `rl_frame` must be valid then + too. Success `rl_saved`, errors `rl_timeframe`, `access_denied`. + operationId: editDomainRatelimits + tags: + - Ratelimits + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: Domain or alias domain name, or a list of them + oneOf: + - type: string + - type: array + items: + type: string + attr: + type: object + required: + - rl_frame + properties: + rl_value: + type: [integer, string] + description: Messages per `rl_frame`; empty or 0 removes the limit + rl_frame: + type: string + enum: [s, m, h, d] + description: Time frame (second, minute, hour, day) + example: + items: + - example.org + attr: + rl_value: 100 + rl_frame: h + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/rl-mbox/all: + get: + tags: + - Ratelimits + summary: Get the rate limits of all mailboxes + operationId: getMailboxRatelimits + description: >- + The outgoing rate limits set on mailboxes the caller manages; mailboxes + without their own limit are left out. Admins and domain admins, `{}` + when there is none. + responses: + "200": + description: List of rate limits + content: + application/json: + schema: + type: array + items: + allOf: + - $ref: "#/components/schemas/MailboxRatelimit" + - type: object + properties: + mailbox: + type: string + example: + - value: "100" + frame: h + mailbox: john.doe@example.org + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/rl-mbox: + post: + tags: + - Ratelimits + summary: Set the rate limit of mailboxes + operationId: editMailboxRatelimit + description: >- + Sets or removes the outgoing rate limit of mailboxes: `rl_value` + messages per `rl_frame`. A value of 0 or empty removes the limit; + `rl_frame` must be valid in that case too. Needs the ACL `ratelimit` + (admins, domain admins for their domains); mailbox users get + `access_denied`. Messages per mailbox: `rl_saved` or `rl_timeframe`, + `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + $ref: "#/components/schemas/MailboxItems" + attr: + type: object + required: + - rl_frame + properties: + rl_value: + type: [integer, string] + description: Number of messages, 0 or empty removes the limit + rl_frame: + type: string + enum: + - s + - m + - h + - d + description: Per second, minute, hour or day + example: + items: + - john.doe@example.org + attr: + rl_value: 100 + rl_frame: h + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/rlhash: + post: + tags: + - Ratelimits + summary: Reset a rate limit counter + operationId: deleteRatelimitHash + description: >- + Deletes the counter of a rate-limited sender (the `rl_hash` of an entry + of `get/logs/ratelimited`) and its log lines, so the sender can send + again. Admins only. The body is a list with one hash (several are + concatenated into one). Unlike other write routes the answer is not + JSON: `1` when the hash was deleted, an empty body when it was not + found, is invalid or the caller is no admin. + requestBody: + required: true + content: + application/json: + schema: + type: array + description: The hash + items: + type: string + pattern: "^RL[0-9A-Za-z=]+$" + example: + - RLkfx5cv4sq1i3ehpkz5bgdhcq9u9gkzj + responses: + "200": + description: "`1` if deleted, else an empty body" + content: + application/json: + schema: + type: integer + const: 1 + example: 1 + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/status/containers: + get: + summary: Get container status + description: >- + State, start time and image of every container of the mailcow + installation, stopped containers included, keyed by service name. + operationId: getContainerStatus + tags: + - Status + responses: + "200": + description: The containers, `null` if dockerapi cannot be reached + content: + application/json: + schema: + type: object + additionalProperties: + $ref: "#/components/schemas/ContainerStatus" + example: + dovecot-mailcow: + type: info + container: dovecot-mailcow + state: running + started_at: "2026-11-10T11:00:51.541059812Z" + image: ghcr.io/mailcow/dovecot:2.3.21.1-4 + postfix-mailcow: + type: info + container: postfix-mailcow + state: running + started_at: "2026-11-10T11:00:51.292779901Z" + image: ghcr.io/mailcow/postfix:1.80 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/status/vmail: + get: + summary: Get mail storage status + description: Disk usage of the file system that holds the mail storage (`/var/vmail`). + operationId: getVmailStatus + tags: + - Status + responses: + "200": + description: The disk usage (`df -H` values) + content: + application/json: + schema: + type: object + properties: + type: + type: string + const: info + disk: + type: string + description: Device + used: + type: string + description: Used space + total: + type: string + description: Size of the file system + used_percent: + type: string + description: Used space in percent + example: + type: info + disk: /dev/sda1 + used: 65G + total: 105G + used_percent: 66% + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/status/version: + get: + summary: Get mailcow version + description: >- + The installed mailcow version: the release tag on the master branch, + else the short commit ID. + operationId: getVersion + tags: + - Status + responses: + "200": + description: The version + content: + application/json: + schema: + type: object + properties: + version: + type: [string, "null"] + description: Release tag or commit ID + example: + version: "2026-11" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/status/container/{id}: + get: + summary: Get container statistics + description: >- + Resource statistics of one container: the last (up to three) Docker + stats snapshots, taken about five seconds apart while this route is + called. The container is named by its full Docker container ID (64 hex + digits, e.g. from `docker ps --no-trunc`), which the API does not + list. For an unknown ID the call waits until the dockerapi timeout (60 + seconds) and answers with the curl error text as a JSON string. + operationId: getContainerStats + tags: + - Status + parameters: + - name: id + in: path + required: true + description: Full Docker container ID + schema: + type: string + example: 5d0f3cbb2a6e4f6c8e0e1c3b9a7d2f41b6c5e8a9d0f1e2c3b4a5968778695a4b + responses: + "200": + description: >- + Docker stats objects (see the Docker Engine API, `ContainerStats`), + oldest first; `false` without ID content: application/json: schema: type: array items: type: object + description: One Docker stats snapshot properties: - log: - type: array - description: contains request object - items: {} - msg: - type: array - items: {} - type: - enum: - - success - - danger - - error + read: type: string - description: OK - headers: {} - tags: - - Domains - description: >- - You can update one or more domains per request. You can also send just - attributes you want to change. - - Example: You can add domain names to items list and in attr object just - include `"active": "0"` to deactivate domains. - operationId: Update domain - requestBody: - content: - application/json: - schema: - example: - attr: - active: "1" - aliases: "400" - backupmx: "1" - defquota: "3072" - description: domain description - gal: "1" - mailboxes: "10" - maxquota: "10240" - quota: "10240" - relay_all_recipients: "0" - relayhost: "2" - tags: ["tag3", "tag4"] - items: domain.tld - properties: - attr: - properties: - active: - description: is domain active or not - type: boolean - aliases: - description: limit count of aliases associated with this domain - type: number - backupmx: - description: relay domain or not - type: boolean - defquota: - description: predefined mailbox quota in `add mailbox` form - type: number - description: - description: Description of domain - type: string - gal: - description: >- - is domain global address list active or not, it enables - shared contacts accross domain in SOGo webmail - type: boolean - mailboxes: - description: limit count of mailboxes associated with this domain - type: number - maxquota: - description: maximum quota per mailbox - type: number - quota: - description: maximum quota for this domain (for all mailboxes in sum) - type: number - relay_all_recipients: - description: >- - if not, them you have to create "dummy" mailbox for each - address to relay - type: boolean - relay_unknown_only: - description: Relay non-existing mailboxes only. Existing mailboxes will be delivered locally. - type: boolean - relayhost: - description: id of relayhost - type: number - rl_frame: - enum: - - s - - m - - h - - d - type: string - rl_value: - description: rate limit value - type: number - tags: - description: tags for this Domain - type: array - items: - type: string - type: object - items: - description: contains list of domain names you want update - type: array - items: - type: string - type: object - summary: Update domain - /api/v1/edit/domain/footer: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - edit - - domain_wide_footer - - domains: - - mailcow.tld - html: "
foo {= foo =}" - plain: "- - You can update the footer of one or more domains per request. - operationId: Update domain wide footer - requestBody: - content: - application/json: - schema: - example: - attr: - html: "
foo {= foo =}" - plain: "foo {= foo =}" - mbox_exclude: - - moo@mailcow.tld - items: mailcow.tld - properties: - attr: - properties: - html: - description: Footer text in HTML format - type: string - plain: - description: Footer text in PLAIN text format - type: string - mbox_exclude: - description: Array of mailboxes to exclude from domain wide footer - type: object - type: object - items: - description: contains a list of domain names where you want to update the footer - type: array - items: - type: string - type: object - summary: Update domain wide footer - /api/v1/edit/fail2ban: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - "*/*": - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Fail2Ban - description: >- - Using this endpoint you can edit the Fail2Ban config and black or - whitelist new ips. - operationId: Edit Fail2Ban - requestBody: - content: - application/json: - schema: - example: - attr: - ban_time: "86400" - ban_time_increment: "1" - blacklist: "10.100.6.5/32,10.100.8.4/32" - max_attempts: "5" - max_ban_time: "86400" - netban_ipv4: "24" - netban_ipv6: "64" - retry_window: "600" - whitelist: mailcow.tld - items: none - properties: - attr: - description: array containing the fail2ban settings - properties: - backlist: - description: the backlisted ips or hostnames separated by comma - type: string - ban_time: - description: the time an ip should be banned - type: number - ban_time_increment: - description: if the time of the ban should increase each time - type: boolean - max_attempts: - description: the maximum numbe of wrong logins before a ip is banned - type: number - max_ban_time: - description: the maximum time an ip should be banned - type: number - netban_ipv4: - description: the networks mask to ban for ipv4 - type: number - netban_ipv6: - description: the networks mask to ban for ipv6 - type: number - retry_window: - description: >- - the maximum time in which a ip as to login with false - credentials to be banned - type: number - whitelist: - description: whitelisted ips or hostnames sepereated by comma - type: string - type: object - items: - description: has to be none - type: object - summary: Edit Fail2Ban - /api/v1/edit/mailbox: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - edit - - mailbox - - active: "1" - force_pw_update: "0" - force_tfa: "0" - name: Full name - password: "*" - password2: "*" - quota: "3072" - sender_acl: - - default - - info@domain2.tld - - domain3.tld - - "*" - sogo_access: "1" - username: - - info@domain.tld - tags: ["tag3", "tag4"] - - null - msg: - - mailbox_modified - - info@domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Mailboxes - description: >- - You can update one or more mailboxes per request. You can also send just - attributes you want to change - operationId: Update mailbox - requestBody: - content: - application/json: - schema: - example: - attr: - active: "1" - force_pw_update: "0" - force_tfa: "0" - name: Full name - authsource: mailcow - password: "" - password2: "" - quota: "3072" - sender_acl: - - default - - info@domain2.tld - - domain3.tld - - "*" - sogo_access: "1" - tags: ["tag3", "tag4"] - items: - - info@domain.tld - properties: - attr: - properties: - active: - description: is mailbox active or not - type: boolean - force_pw_update: - description: force user to change password on next login - type: boolean - force_tfa: - description: force 2FA enrollment at login - type: boolean + description: Time of the snapshot name: - description: Full name of the mailbox user type: string - authsource: - description: Specifies the authentication source for the mailbox. + id: type: string - enum: [mailcow, ldap, keycloak, generic-oidc] - password2: - description: new mailbox password for confirmation - type: string - password: - description: new mailbox password when using `mailcow` as the authentication source. - type: string - quota: - description: mailbox quota - type: number - sender_acl: - description: list of allowed send from addresses + cpu_stats: type: object - sogo_access: - description: is access to SOGo webmail active or not - type: boolean - shared: - description: shared mailbox, no login of its own (IMAP, SMTP, SOGo, app passwords, mailcow UI) - type: boolean - type: object - items: - description: contains list of mailboxes you want update - type: object - type: object - summary: Update mailbox - /api/v1/edit/mailbox/custom-attribute: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox - - edit - - mailbox_custom_attribute - - mailboxes: - - moo@mailcow.tld - attribute: - - role - - foo - value: - - cow - - bar - - null - msg: - - mailbox_modified - - moo@mailcow.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Mailboxes - description: >- - You can update custom attributes of one or more mailboxes per request. - operationId: Update mailbox custom attributes - requestBody: - content: - application/json: - schema: - example: - attr: - attribute: - - role - - foo - value: - - cow - - bar - items: - - moo@mailcow.tld - properties: - attr: - properties: - attribute: - description: Array of attribute keys + precpu_stats: type: object - value: - description: Array of attribute values + memory_stats: type: object - type: object - items: - description: contains list of mailboxes you want update - type: object - type: object - summary: Update mailbox custom attributes - /api/v1/edit/mailbox-group: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - mailbox_group - - edit - - group - - id: - - "7" - add_members: - - carol@example.org - msg: - - group_modified - - Sales - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - warning - - error - type: string - type: object - description: OK - headers: {} - tags: - - Groups - description: >- - Update groups. Attributes you do not send keep their value. Members - synced from the identity provider cannot be removed here - (group_member_idp). - operationId: Update groups - requestBody: - content: - application/json: - schema: - example: - items: - - "7" - attr: - add_members: - - carol@example.org - properties: - attr: - properties: - name: - description: unique per domain, max. 100 characters - type: string - description: - description: max. 255 characters - type: string - local_part: - description: local part of the group address (domain of the group), empty = no address - type: string - address: - description: full group address instead of local_part, empty = no address - type: string - members: - description: >- - mailboxes (array or list separated by commas, spaces or - newlines); replaces the manual members you may access - type: array - send_as: - description: addresses (mailbox or alias) or @domain the members may send as - type: array - sender_allowed: - description: members may send as the group address - type: boolean - sogo_visible: - description: show the group address as SOGo identity (needs sender_allowed) - type: boolean - internal: - description: only accept mail from senders of the domain - type: boolean - template_id: - description: mailbox template applied to the members (admin only), empty = none - type: number - priority: - description: highest priority wins if a mailbox is in several groups with a template (admin only) - type: number - active: - type: boolean - add_members: - description: mailboxes to add - type: array - remove_members: - description: manual members to remove - type: array - reapply_template: - description: apply the effective templates to all members again (admin only) - type: boolean - imap_grants: - description: >- - replaces the mailbox access of the members (Dovecot ACL on every folder of the - mailbox, shown as Shared/); rights read, write or full; you need access - to each mailbox; subscribe = the group-grants cron subscribes the folders for every - member once (within 15 minutes) - type: array - items: - type: object - properties: - owner: - type: string - rights: - type: string - enum: [read, write, full] - subscribe: - type: boolean - default: false - example: - - owner: shared@example.org - rights: write - subscribe: true - sogo_grants: - description: >- - replaces the SOGo folders shared with the members; folder = SOGo folder ID - (default personal); pending until the mailbox opened SOGo once; subscribe = the - group-grants cron subscribes every member once (within 15 minutes) - type: array - items: - type: object - properties: - owner: - type: string - type: - type: string - enum: [Calendar, Contacts] - folder: - type: string - default: personal - access: - type: string - enum: [read, write] - subscribe: - type: boolean - default: false - example: - - owner: shared@example.org - type: Calendar - access: write - subscribe: true - type: object - items: - description: contains list of group IDs you want to update - type: object - type: object - summary: Update groups - /api/v1/edit/identity-provider-sync: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - identity_provider - - edit - - sync - - mode: preview - msg: iam_sync_requested - type: success - description: OK - headers: {} - tags: - - Identity Provider - description: >- - Queues a sync run of the Identity Provider (Keycloak and LDAP, admins only). The identity-sync cron - starts it within a minute. `preview` lists the changes without applying them, `run` applies them, - `force` applies them without the change limit (offboarding_limit). - operationId: Request identity sync run - requestBody: - content: - application/json: - schema: - example: - items: - - sync - attr: - mode: preview - properties: - items: - type: array - default: ["sync"] - attr: - type: object - properties: - mode: - type: string - enum: [preview, run, force] - default: run - type: object - summary: Request identity sync run - /api/v1/edit/mailq: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - msg: Task completed - type: success - description: OK - headers: {} - tags: - - Queue Manager - description: >- - Using this API you can flush the current mail queue. This will try to - deliver all mails currently in it. - - This API uses the command: `postqueue -f` - operationId: Flush Queue - requestBody: - content: - application/json: - schema: - example: - action: flush - properties: - action: - description: use flush to flush the mail queue - type: string - type: object - summary: Flush Queue - /api/v1/edit/pushover: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - pushover - - edit - - active: "0" - evaluate_x_prio: "0" - key: 21e8918e1jksdjcpis712 - only_x_prio: "0" - sound: "pushover" - senders: "" - senders_regex: "" - text: "" - title: Mail - token: 9023e2ohcwed27d1idu2 - username: - - info@domain.tld - msg: pushover_settings_edited - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Mailboxes - description: >- - Using this endpoint it is possible to update the pushover settings for - mailboxes - operationId: Update Pushover settings - requestBody: - content: - application/json: - schema: - example: - attr: - active: "0" - evaluate_x_prio: "0" - key: 21e8918e1jksdjcpis712 - only_x_prio: "0" - sound: "pushover" - senders: "" - senders_regex: "" - text: "" - title: Mail - token: 9023e2ohcwed27d1idu2 - items: info@domain.tld - properties: - attr: - properties: - active: - description: Enables pushover 1 disable pushover 0 - type: number - evaluate_x_prio: - description: Send the Push with High priority - type: number - key: - description: Pushover key - type: string - only_x_prio: - description: Only send push for prio mails - type: number - sound: - description: Set notification sound - type: string - senders: - description: Only send push for emails from these senders - type: string - senders_regex: - description: Regex to match senders for which a push will be send - type: string - text: - description: Custom push noficiation text - type: string - title: - description: Push title - type: string - token: - description: Pushover token - type: string - type: object - items: - description: contains list of mailboxes you want to delete - type: object - type: object - summary: Update Pushover settings - /api/v1/edit/quarantine_notification: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - description: OK - headers: {} - tags: - - Mailboxes - description: You can update one or more mailboxes per request. - operationId: Quarantine Notifications - requestBody: - content: - application/json: - schema: - example: - attr: - quarantine_notification: hourly - items: - anyOf: - - mailbox1@domain.tld - - mailbox2@domain.tld - properties: - attr: - properties: - quarantine_notification: - description: recurrence - enum: - - hourly - - daily - - weekly - - never - type: string - type: object - items: - description: >- - contains list of mailboxes you want set qurantine - notifications - type: object - type: object - summary: Quarantine Notifications - /api/v1/edit/syncjob: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - syncjob - - edit - - job - - id: - - "1" - active: "0" - msg: - - mailbox_modified - - mailbox@domain.tld - type: success - schema: - properties: - log: - description: contains request object (secrets are masked as "*") - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Sync jobs - description: >- - Update one or more sync jobs per request. Only send the attributes you - want to change. Requires the `syncjobs` ACL. - operationId: Update sync job - requestBody: - content: - application/json: - schema: - example: - attr: - active: "1" - source_id: "3" - user1: username - mins_interval: "20" - custom_params: - - {o: folder, v: INBOX} - items: - - "1" - properties: - attr: - properties: - source_id: - description: >- - id of a sync source visible to the caller (`GET /api/v1/get/syncjob_source/all`). - Visibility is only checked when the source changes. - type: integer - user1: - description: >- - remote username at the source. For XOAUTH2 authorization_code sources - a token for (source_id, local mailbox, user1) must exist, i.e. the - mailbox owner must have signed in via `/syncjob-oauth` first - (`imapsync_source_oauth_connect_required` otherwise). For XOAUTH2 - client_credentials sources user1 may only be changed by callers who can - see the source (`access_denied` otherwise); the other fields stay editable. - type: string - password1: - description: >- - remote password; empty keeps the stored one. Required when the job - is moved to a non-XOAUTH2 source and no password is stored. - type: string - mins_interval: - description: sync interval in minutes (1-43800) - type: number - subfolder2: - description: sync into subfolder on destination (empty = do not use subfolder) - type: string - maxage: - description: only sync messages up to this age in days (0 = ignore age) - type: number - maxbytespersecond: - description: per-job bandwidth limit in bytes/s (0 = unlimited, capped by the global limit) - type: number - timeout1: - description: timeout for connection to remote host - type: number - timeout2: - description: timeout for connection to local host - type: number - exclude: - description: exclude objects (regex) - type: string - custom_params: - description: >- - Allowlisted imapsync options as a list of option/value pairs (must be - an array). - `o` is the option name without leading dashes (see `GET - /api/v1/get/syncjob_options`), `v` the value. The allowlist decides - per option: `true` = a non-empty value is required, `false` = flag - without value (`v` must be empty), string = regular expression the - whole value must match. Options taking file paths are never allowed. - Values are passed to imapsync as a single argument. - type: array - items: - type: object - properties: - o: - type: string - v: - type: string - prio: - description: >- - priority (admin only, ignored otherwise). Due jobs run ordered by - prio DESC, never-run first, last_run ASC, id ASC. - type: number - last_run: - description: any value resets last_run (job becomes due) - type: string - success: - description: any value resets the last result - type: string - delete2duplicates: - description: delete duplicates on destination (--delete2duplicates) - type: boolean - delete1: - description: delete from source when completed (--delete1) - type: boolean - delete2: - description: delete messages on destination that are not on source (--delete2) - type: boolean - automap: - description: try to automap folders ("Sent items", "Sent" => "Sent" etc.) (--automap) - type: boolean - skipcrossduplicates: - description: skip duplicate messages across folders (first come, first serve) (--skipcrossduplicates) - type: boolean - subscribeall: - description: subscribe all folders (--subscribeall) - type: boolean - dry: - description: dry run, nothing is changed (--dry) - type: boolean - active: - description: enables or disables the sync job - type: boolean - host1: - description: >- - Removed. Host, port, encryption and authentication now come from - the sync source (`source_id`, see `/api/v1/add/syncjob_source`). - The attribute is ignored. - deprecated: true - type: string - port1: - description: >- - Removed. Host, port, encryption and authentication now come from - the sync source (`source_id`, see `/api/v1/add/syncjob_source`). - The attribute is ignored. - deprecated: true - type: string - enc1: - description: >- - Removed. Host, port, encryption and authentication now come from - the sync source (`source_id`, see `/api/v1/add/syncjob_source`). - The attribute is ignored. - deprecated: true - type: string - authmech1: - description: >- - Removed. Host, port, encryption and authentication now come from - the sync source (`source_id`, see `/api/v1/add/syncjob_source`). - The attribute is ignored. - deprecated: true - type: string - order_id: - description: Removed, use `prio` (admin) or `/api/v1/edit/syncjob/run_next`. Ignored. - deprecated: true - type: number - type: object - items: - description: list of sync job ids to update - type: array - items: - type: string - type: object - summary: Update sync job - /api/v1/edit/syncjob/run_next: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - syncjob - - edit - - run_next - - id: - - "1" - msg: - - imapsync_run_next_done - - 1 - type: success - schema: - properties: - log: - description: contains request object (secrets are masked as "*") - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Sync jobs - description: >- - Admin only. Moves the given jobs to the front of the queue: `prio` is set - above the current maximum and `last_run` is cleared, so the runner picks - them next. Jobs that are currently running are rejected with - `imapsync_job_running` (one message per job), the others are moved. - The jobs are locked while they are moved, so a job the runner claims at - the same time is reported as running instead of being counted. - operationId: Run sync job next - summary: Run sync job next - requestBody: - content: - application/json: - schema: - example: - items: - - "1" - attr: {} - properties: - items: - description: list of sync job ids - type: array - items: - type: string - attr: - description: unused, send an empty object - type: object - type: object - /api/v1/edit/syncjob_source: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - syncjob - - edit - - source - - id: - - "3" - name: Example import - oauth_client_secret: "*" - msg: - - imapsync_source_modified - - Example import - type: success - schema: - properties: - log: - description: contains request object (secrets are masked as "*") - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Sync jobs - description: >- - Update one or more sync sources (creator, admins, and domain admins for - sources created by mailboxes of their domains). Fields not provided are - left unchanged. All checks run before anything is written; a rejected - edit changes nothing. Requires the `syncjobs` ACL. - operationId: Update sync job source - summary: Update sync job source - requestBody: - content: - application/json: - schema: - example: - items: - - "3" - attr: - name: Example import - host: imap.example.org - port: "993" - enc: SSL - auth_type: PLAIN - active: "1" - properties: - attr: - properties: - name: - description: >- - source name, max. 100 characters, unique per creator - type: string - description: - description: >- - free text shown in the UI, max. 255 characters - type: string - host: - description: >- - remote IMAP host (hostname or IP, max. 253 characters). - `host1` is accepted as an alias. Host, port and enc are only validated - when they change (migrated legacy values stay editable). - type: string - port: - description: >- - remote IMAP port (1-65535). `port1` is accepted as an alias. - type: integer - enc: - description: >- - TLS (STARTTLS) / SSL / PLAIN. `enc1` is accepted as an alias. - type: string - enum: - - TLS - - SSL - - PLAIN - verify_cert: - description: >- - 1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check - it (self-signed, expired or not matching the host). Turning the check off - needs access to all sync jobs of the source, like changing the server. - type: integer - enum: - - 0 - - 1 - auth_type: - description: >- - authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job - password, XOAUTH2 uses OAuth2 tokens. XOAUTH2 sources can only be created - and edited by admins and domain admins (`access_denied` for users); users - can use XOAUTH2 sources visible to them in their sync jobs. - type: string - enum: - - PLAIN - - LOGIN - - CRAM-MD5 - - XOAUTH2 - oauth_flow: - description: >- - XOAUTH2 grant type (default client_credentials). client_credentials - sources are always private to their creator (scope is forced to `user` - without targets). - type: string - enum: - - client_credentials - - authorization_code - oauth_authorize_endpoint: - description: >- - authorize URL, required for authorization_code. Must be an https:// URL - without credentials and with a valid hostname - type: string - oauth_token_endpoint: - description: >- - token URL, required for XOAUTH2. Must be an https:// URL without credentials and with a valid hostname - type: string - oauth_userinfo_endpoint: - description: >- - optional identity endpoint for authorization_code (used when the token - response has no usable id_token). Must be an https:// URL without credentials and with a valid hostname - type: string - oauth_client_id: - description: >- - OAuth2 client id, required for XOAUTH2 - type: string - oauth_client_secret: - description: >- - OAuth2 client secret; empty keeps the stored one. Changing auth_type, - oauth_flow, oauth_token_endpoint, oauth_client_id, host, port or enc drops - the cached source token and all per-user tokens of the source (users have - to sign in again). - type: string - oauth_scope: - description: >- - OAuth2 scope, required for XOAUTH2 - type: string - oauth_extra_params: - description: >- - optional extra token request parameters: a JSON object with scalar values, - sent either as object or as JSON string (empty = none). They never - override the grant fields. - oneOf: - - type: object - additionalProperties: true - - type: string - scope: - description: >- - visibility: `all` (admins only), `domain` (needs `domains`) or `user` - (listed `users`; no targets = private to the creator). Users always - create private sources. Targets are only rewritten when `scope` is sent. - type: string - enum: - - all - - domain - - user - domains: - description: target domains when scope is domain - type: array - items: - type: string - users: - description: target mailboxes when scope is user - type: array - items: - type: string - active: - description: >- - enables or disables the source - type: boolean - type: object - items: - description: list of source ids to update - type: array - items: - type: string - type: object - /api/v1/edit/syncjob_source/refresh_token: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - syncjob - - edit - - refresh_token - - id: - - "3" - msg: - - imapsync_source_token_refreshed - - Example import - type: success - schema: - properties: - log: - description: contains request object (secrets are masked as "*") - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Sync jobs - description: >- - Force an OAuth2 access-token refresh for a XOAUTH2 client_credentials - source (other sources: `imapsync_source_refresh_unsupported`). Requires - edit rights on the source and the `syncjobs` ACL. - operationId: Refresh sync job source token - summary: Refresh sync job source token - requestBody: - content: - application/json: - schema: - example: - items: - - "3" - properties: - items: - description: source id (only the first one is used) - type: array - items: - type: string - type: object - /api/v1/edit/imapsync_settings: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - syncjob - - edit - - settings - - max_parallel: "4" - max_kb_per_second: "2000" - msg: max_parallel_saved - type: success - schema: - properties: - log: - description: contains request object (secrets are masked as "*") - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Sync jobs - description: >- - Update global sync job settings (admin only). `max_parallel` is the - number of imapsync processes that may run concurrently (1-32); - `max_kb_per_second` is a per-process bandwidth cap in KB/s (0 = - unlimited, max. 10485760 = 10 GiB/s), stored internally in bytes/s. - operationId: Update sync job settings - summary: Update sync job settings - requestBody: - content: - application/json: - schema: - example: - attr: - max_parallel: "4" - max_kb_per_second: "2000" - properties: - attr: - properties: - max_parallel: - description: max. concurrent sync processes (1-32) - type: number - max_kb_per_second: - description: bandwidth limit per process in KB/s (0 = unlimited, max. 10485760) - type: number - type: object - type: object - /api/v1/edit/user-acl: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - log: - - acl - - edit - - user - - user_acl: - - spam_alias - - tls_policy - - spam_score - - spam_policy - - delimiter_action - - syncjobs - - eas_reset - - quarantine - - sogo_profile_reset - - quarantine_attachments - - quarantine_notification - - app_passwds - - pushover - username: - - info@domain.tld - msg: - - acl_saved - - info@domain.tld - type: success - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Mailboxes - description: Using this endpoints its possible to update the ACL's for mailboxes - operationId: Update mailbox ACL - requestBody: - content: - application/json: - schema: - example: - attr: - user_acl: - - spam_alias - - tls_policy - - spam_score - - spam_policy - - delimiter_action - - syncjobs - - eas_reset - - quarantine - - sogo_profile_reset - - quarantine_attachments - - quarantine_notification - - app_passwds - - pushover - items: info@domain.tld - properties: - attr: - properties: - user_acl: - description: contains a list of active user acls + blkio_stats: type: object - type: object - items: - description: contains list of mailboxes you want to delete - type: object - type: object - summary: Update mailbox ACL - "/api/v1/get/alias/{id}": - get: - parameters: - - description: id of entry you want to get - example: all - in: path - name: id - required: true - schema: - enum: - - all - - "1" - - "2" - - "5" - - "10" - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - active: "1" - address: alias@domain.tld - created: "2019-04-04 19:29:49" - domain: domain.tld - goto: destination@domain.tld - id: 6 - in_primary_domain: "" - is_catch_all: 0 - modified: null - private_comment: null - public_comment: null - - active: "1" - address: "@domain.tld" - created: "2019-04-27 13:42:39" - domain: domain.tld - goto: destination@domain.tld - id: 10 - in_primary_domain: "" - is_catch_all: 1 - modified: null - private_comment: null - public_comment: null - description: OK - headers: {} - tags: - - Aliases - description: You can list mailbox aliases existing in system. - operationId: Get aliases - summary: Get aliases - "/api/v1/get/time_limited_aliases/{mailbox}": - get: - parameters: - - description: mailbox you want to get aliasses from - example: domain.tld - in: path - schema: - type: string - name: mailbox - required: true - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - address: alias@domain.tld - goto: destination@domain.tld - validity: 1668251246 - created: "2021-11-12 12:07:26" - modified: null - description: OK - headers: {} - tags: - - Aliases - description: You can list time limited mailbox aliases existing in system. - operationId: Get time limited aliases - summary: Get time limited aliases - "/api/v1/get/api-key/{id}": - get: - parameters: - - description: id of entry you want to get - example: all - in: path - name: id - required: true - schema: - enum: - - all - - "1" - - "2" - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - id: 1 - description: API_KEY - access: rw - api_key: 1A2B3C-4D5E6F-7A8B9C-0D1E2F-3A4B5C - allow_from: - - 192.0.2.10 - skip_ip_check: 0 - service: 1 - active: 1 - created: "2026-10-22 12:00:00" - modified: null - description: OK - headers: {} - tags: - - API Keys - description: >- - Admin only, not with a read-only API key (`{}` otherwise). Returns all - API keys incl. their values, service keys from mailcow.conf - (`service: 1`) first. - operationId: Get API keys - summary: Get API keys - "/api/v1/get/app-passwd/all/{mailbox}": - get: - parameters: - - description: mailbox of entry you want to get - example: hello@mailcow.email - in: path - name: mailbox - required: true - schema: - enum: - - hello@mailcow.email - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - active: "1" - created: "2019-12-21 16:04:55" - domain: mailcow.email - id: 2 - mailbox: hello@mailcow.email - modified: null - name: emclient - description: OK - headers: {} - tags: - - App Passwords - description: >- - Using this endpoint you can get all app passwords from a specific - mailbox. - operationId: Get App Password - summary: Get App Password - "/api/v1/get/bcc/{id}": - get: - parameters: - - description: id of entry you want to get - example: all - in: path - name: id - required: true - schema: - enum: - - all - - "1" - - "2" - - "5" - - "10" - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - active: "1" - bcc_dest: bcc@awesomecow.tld - created: "2019-10-02 21:44:34" - domain: mailcow.tld - id: 3 - local_dest: "@mailcow.tld" - modified: null - type: sender - description: OK - headers: {} - tags: - - Address Rewriting - description: Using this endpoint you can get all BCC maps. - operationId: Get BCC Map - summary: Get BCC Map - "/api/v1/get/dkim/{domain}": - get: - parameters: - - description: name of domain - in: path - name: domain - required: true - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - dkim_selector: dkim - dkim_txt: >- - v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA21tUSjyasQy/hJmVjPnlRGfzx6TPhYj8mXY9DVOzSAE64Gddw/GnE/GcCR6WXNT23u9q4zPnz1IPoNt5kFOps8vg/iNqrcH++494noaZuYyFPPFnebkfryO4EvEyxC/c66qts+gnOUml+M8uv5WObBJld2gG12jLwFM0263J/N6J8LuUsaXOB2uCIfx8Nf4zjuJ6Ieez2uyHNK5dXjDLfKA4mTr+EEK6W6e34M4KN1liWM6r9Oy5S1FlLrD42VpURxxBZtBiEtaJPEKSQuk6GQz8ihu7W20Yr53tyCdaORu8dhxXVUWVf+GjuuMEdAmQCjYkarXdYCrt56Psw703kwIDAQAB - length: "2048" - privkey: "" - pubkey: >- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA21tUSjyasQy/hJmVjPnlRGfzx6TPhYj8mXY9DVOzSAE64Gddw/GnE/GcCR6WXNT23u9q4zPnz1IPoNt5kFOps8vg/iNqrcH++494noaZuYyFPPFnebkfryO4EvEyxC/c66qts+gnOUml+M8uv5WObBJld2gG12jLwFM0263J/N6J8LuUsaXOB2uCIfx8Nf4zjuJ6Ieez2uyHNK5dXjDLfKA4mTr+EEK6W6e34M4KN1liWM6r9Oy5S1FlLrD42VpURxxBZtBiEtaJPEKSQuk6GQz8ihu7W20Yr53tyCdaORu8dhxXVUWVf+GjuuMEdAmQCjYkarXdYCrt56Psw703kwIDAQAB - description: OK - headers: {} - tags: - - DKIM - description: >- - Using this endpoint you can get the DKIM public key for a specific - domain. - operationId: Get DKIM Key - summary: Get DKIM Key - /api/v1/get/domain-admin/all: - get: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - active: "1" - created: "2019-10-02 10:29:41" - selected_domains: - - mailcow.tld - tfa_active: "0" - unselected_domains: - - awesomemailcow.de - - mailcowisgreat.de - username: testadmin - description: OK - headers: {} - tags: - - Domain admin - description: "" - operationId: Get Domain Admins - summary: Get Domain Admins - "/api/v1/get/domain/{id}": - get: - parameters: - - description: id of entry you want to get - example: all - in: path - name: id - required: true - schema: - enum: - - all - - mailcow.tld - type: string - - description: comma seperated list of tags to filter by - example: "tag1,tag2" - in: query - name: tags - required: false - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - active: "1" - aliases_in_domain: 0 - aliases_left: 400 - backupmx: "0" - bytes_total: "5076666944" - def_new_mailbox_quota: 3221225472 - def_quota_for_mbox: 3221225472 - description: Some description - domain_name: domain.tld - gal: "0" - max_new_mailbox_quota: 10737418240 - max_num_aliases_for_domain: 400 - max_num_mboxes_for_domain: 10 - max_quota_for_domain: 10737418240 - max_quota_for_mbox: 10737418240 - mboxes_in_domain: 0 - mboxes_left: 10 - msgs_total: "172440" - quota_used_in_domain: "0" - relay_all_recipients: "0" - relayhost: "0" - rl: false - tags: ["tag1", "tag2"] - - active: "1" - aliases_in_domain: 0 - aliases_left: 400 - backupmx: "1" - bytes_total: "5076666944" - def_new_mailbox_quota: 3221225472 - def_quota_for_mbox: 3221225472 - description: domain description - domain_name: domain2.tld - gal: "0" - max_new_mailbox_quota: 10737418240 - max_num_aliases_for_domain: 400 - max_num_mboxes_for_domain: 10 - max_quota_for_domain: 10737418240 - max_quota_for_mbox: 10737418240 - mboxes_in_domain: 0 - mboxes_left: 10 - msgs_total: "172440" - quota_used_in_domain: "0" - relay_all_recipients: "0" - relayhost: "0" - rl: false - tags: ["tag3", "tag4"] - description: OK - headers: {} - tags: - - Domains - description: You can list all domains existing in system. - operationId: Get domains - summary: Get domains - /api/v1/get/fail2ban: - get: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - ban_time: 604800 - ban_time_increment: 1 - blacklist: |- - 45.82.153.37/32 - 92.118.38.52/32 - max_attempts: 1 - max_ban_time: 604800 - netban_ipv4: 32 - netban_ipv6: 128 - perm_bans: - - 45.82.153.37/32 - - 92.118.38.52/32 - retry_window: 7200 - whitelist: 1.1.1.1 - description: OK - headers: {} - tags: - - Fail2Ban - description: Gets the current Fail2Ban configuration. - operationId: Get Fail2Ban Config - summary: Get Fail2Ban Config - /api/v1/get/fwdhost/all: - get: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - host: 5.1.76.202 - keep_spam: "yes" - source: hosted.mailcow.de - - host: "2a00:f820:417::202" - keep_spam: "yes" - source: hosted.mailcow.de - description: OK - headers: {} - tags: - - Fordwarding Hosts - description: You can list all Forwarding Hosts in your mailcow. - operationId: Get Forwarding Hosts - summary: Get Forwarding Hosts - "/api/v1/get/logs/acme/{count}": - get: - parameters: - - description: Number of logs to return - in: path - name: count - required: true - schema: - type: number - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - message: >- - Certificate validation done, neither changed nor due for - renewal, sleeping for another day. - time: "1569927728" - description: OK - headers: {} - tags: - - Logs - description: >- - This Api endpoint lists all ACME logs from issued Lets Enctypts - certificates. - - Tip: You can limit how many logs you want to get by using `/` at - the end of the api url. - operationId: Get ACME logs - summary: Get ACME logs - "/api/v1/get/logs/api/{count}": - get: - parameters: - - description: Number of logs to return - in: path - name: count - required: true - schema: - type: number - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - data: "" - method: GET - remote: 1.1.1.1 - time: 1569939001 - uri: /api/v1/get/logs/api/2 - description: OK - headers: {} - tags: - - Logs - description: >- - This Api endpoint lists all Api logs. - - Tip: You can limit how many logs you want to get by using `/` at - the end of the api url. - operationId: Get Api logs - summary: Get Api logs - "/api/v1/get/logs/autodiscover/{count}": - get: - parameters: - - description: Number of logs to return - in: path - name: count - required: true - schema: - type: number - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - service: activesync - time: 1569684212 - ua: >- - Microsoft Office/16.0 (Windows NT 6.2; MAPICPL - 16.0.11328; Pro) - user: awesome@mailcow.de - description: OK - headers: {} - tags: - - Logs - description: >- - This Api endpoint lists all Autodiscover logs. - - Tip: You can limit how many logs you want to get by using `/` at - the end of the api url. - operationId: Get Autodiscover logs - summary: Get Autodiscover logs - "/api/v1/get/logs/dovecot/{count}": - get: - parameters: - - description: Number of logs to return - in: path - name: count - required: true - schema: - type: number - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - message: >- - managesieve-login: Disconnected (no auth attempts in 0 - secs): user=<>, rip=172.22.1.3, lip=172.22.1.250 - priority: info - program: dovecot - time: "1569938740" - description: OK - headers: {} - tags: - - Logs - description: >- - This Api endpoint lists all Dovecot logs. - - Tip: You can limit how many logs you want to get by using `/` at - the end of the api url. - operationId: Get Dovecot logs - summary: Get Dovecot logs - "/api/v1/get/logs/netfilter/{count}": - get: - parameters: - - description: Number of logs to return - in: path - name: count - required: true - schema: - type: number - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - message: "Whitelist was changed, it has 1 entries" - priority: info - time: 1569754911 - - message: Add host/network 1.1.1.1/32 to blacklist - priority: crit - time: 1569754911 - description: OK - headers: {} - tags: - - Logs - description: >- - This Api endpoint lists all Netfilter logs. - - Tip: You can limit how many logs you want to get by using `/` at - the end of the api url. - operationId: Get Netfilter logs - summary: Get Netfilter logs - "/api/v1/get/logs/postfix/{count}": - get: - parameters: - - description: Number of logs to return - in: path - name: count - required: true - schema: - type: number - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - message: "EF1711500458: removed" - priority: info - program: postfix/qmgr - time: "1569937433" - description: OK - headers: {} - tags: - - Logs - description: >- - This Api endpoint lists all Postfix logs. - - Tip: You can limit how many logs you want to get by using `/` at - the end of the api url. - operationId: Get Postfix logs - summary: Get Postfix logs - "/api/v1/get/logs/ratelimited/{count}": - get: - parameters: - - description: Number of logs to return - in: path - name: count - required: true - schema: - type: number - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - from: awesome@mailcow.email - header_from: '"Awesome" ' - header_subject: Mailcow is amazing - ip: 172.22.1.248 - message_id: 6a-5d892500-7-240abd80@90879116 - qid: E3CF91500458 - rcpt: hello@mailcow.email - rl_hash: RLsdz3tuabozgd4oacbdh8kc78 - rl_info: mailcow(RLsdz3tuabozgd4oacbdh8kc78) - rl_name: mailcow - time: 1569269003 - user: awesome@mailcow.email - description: OK - headers: {} - tags: - - Logs - description: >- - This Api endpoint lists all Ratelimit logs. - - Tip: You can limit how many logs you want to get by using `/` at - the end of the api url. - operationId: Get Ratelimit logs - summary: Get Ratelimit logs - "/api/v1/get/logs/rspamd-history/{count}": - get: - parameters: - - description: Number of logs to return - in: path - name: count - required: true - schema: - type: number - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - description: OK - headers: {} - tags: - - Logs - description: >- - This Api endpoint lists all Rspamd logs. - - Tip: You can limit how many logs you want to get by using `/` at - the end of the api url. - operationId: Get Rspamd logs - summary: Get Rspamd logs - "/api/v1/get/logs/sogo/{count}": - get: - parameters: - - description: Number of logs to return - in: path - name: count - required: true - schema: - type: number - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - message: >- - [109]: - mailcowdockerized_watchdog-mailcow_1.mailcowdockerized_mailcow-network - "GET /SOGo.index/ HTTP/1.1" 200 2531/0 0.005 - - 0 - priority: notice - program: sogod - time: "1569938874" - description: OK - headers: {} - tags: - - Logs - description: >- - This Api endpoint lists all SOGo logs. - - Tip: You can limit how many logs you want to get by using `/` at - the end of the api url. - operationId: Get SOGo logs - summary: Get SOGo logs - "/api/v1/get/logs/watchdog/{count}": - get: - parameters: - - description: Number of logs to return - in: path - name: count - required: true - schema: - type: number - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - hpdiff: "0" - hpnow: "1" - hptotal: "1" - lvl: "100" - service: Fail2ban - time: "1569938958" - - hpdiff: "0" - hpnow: "5" - hptotal: "5" - lvl: "100" - service: Rspamd - time: "1569938956" - description: OK - headers: {} - tags: - - Logs - description: >- - This Api endpoint lists all Watchdog logs. - - Tip: You can limit how many logs you want to get by using `/` at - the end of the api url. - operationId: Get Watchdog logs - summary: Get Watchdog logs - "/api/v1/get/mailbox/{id}": - get: - parameters: - - description: id of entry you want to get - example: all - in: path - name: id - required: true - schema: - enum: - - all - - user@domain.tld - type: string - - description: comma seperated list of tags to filter by - example: "tag1,tag2" - in: query - name: tags - required: false - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - active: "1" - attributes: - force_pw_update: "0" - force_tfa: "0" - mailbox_format: "maildir:" - quarantine_notification: never - sogo_access: "1" - tls_enforce_in: "0" - tls_enforce_out: "0" - domain: doman3.tld - is_relayed: 0 - local_part: info - max_new_quota: 10737418240 - messages: 0 - name: Full name - percent_class: success - percent_in_use: 0 - quota: 3221225472 - quota_used: 0 - rl: false - sender_acl: ["otherbox@doman3.tld", "@aliasdomain.tld"] - spam_aliases: 0 - username: info@doman3.tld - tags: ["tag1", "tag2"] - description: OK - headers: {} - tags: - - Mailboxes - description: You can list all mailboxes existing in system. - operationId: Get mailboxes - summary: Get mailboxes - "/api/v1/get/mailbox-group/{id}": - get: - parameters: - - description: id of the group, or "all" (optionally followed by /domain1,domain2) - example: all - in: path - name: id - required: true - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - id: 7 - domain: example.org - name: Sales - description: Sales team - address: sales@example.org - sender_allowed: 1 - sogo_visible: 0 - internal: 0 - template_id: 3 - template: Staff - priority: 10 - idp_ref: null - members: - - username: alice@example.org - source: manual - - username: bob@example.org - source: idp - send_as: - - shared@example.org - imap_grants: - - owner: shared@example.org - rights: write - subscribe: 1 - state: applied - last_error: null - sogo_grants: - - owner: shared@example.org - type: Calendar - folder: personal - access: write - subscribe: 1 - state: applied - last_error: null - active: 1 - created: "2026-10-25 12:00:00" - modified: null - can_edit: true - description: OK - headers: {} - tags: - - Groups - description: >- - Groups visible to the caller (admins all, domain admins their domains). - operationId: Get groups - summary: Get groups - "/api/v1/get/mailbox-group/member/{username}": - get: - parameters: - - description: mailbox - example: alice@example.org - in: path - name: username - required: true - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - groups: - - id: 7 - domain: example.org - name: Sales - address: sales@example.org - sender_allowed: 1 - active: 1 - priority: 10 - template: Staff - source: manual - template: Staff - send_as: - - sales@example.org - - shared@example.org - description: OK - headers: {} - tags: - - Groups - description: >- - Groups of a mailbox, its effective group template and the addresses it - may send as through groups. - operationId: Get group memberships - summary: Get group memberships - /api/v1/get/identity-provider/sync-status: - get: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - provider: keycloak - can_sync: true - sync_enabled: true - counts: - accounts: 120 - linked: 118 - idp_disabled: 2 - missing: 1 - runs: - - id: 42 - provider: keycloak - mode: run - status: ok - requested_by: "" - created: "2026-10-25 12:00:00" - started: "2026-10-25 12:00:01" - finished: "2026-10-25 12:00:03" - summary: - counts: - creates: 1 - deactivate: 1 - memberships: 2 - details: - creates: - - new@example.org - deactivate: - - left@example.org - conflicts: [] - errors: [] - blocked: false - mode: run - description: OK - headers: {} - tags: - - Identity Provider - description: >- - Sync status (admins only): mailboxes of the Identity Provider and their links, and the last sync runs - with their summary. Run status: pending, running, ok, blocked (change limit), error (listing failed, - nothing was changed). - operationId: Get identity sync status - summary: Get identity sync status - "/api/v1/get/identity-link/{username}": - get: - parameters: - - description: mailbox - example: alice@example.org - in: path - name: username - required: true - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - provider: keycloak - external_id: 8d5da28d-7541-428b-8356-a9704f73e7f4 - idp_disabled: 0 - missing_since: null - description: OK - headers: {} - tags: - - Identity Provider - description: >- - Identity Provider user a mailbox is linked to (admins, and domain admins for their mailboxes). - `idp_disabled` = login was disabled by the sync, `missing_since` = not listed by the Identity Provider. - operationId: Get identity link - summary: Get identity link - /api/v1/get/mailq/all: - get: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - arrival_time: 1570091234 - message_size: 1848 - queue_id: B98C6260CA1 - queue_name: incoming - recipients: - - recipient@awesomecow.tld - sender: sender@mailcow.tld - description: OK - headers: {} - tags: - - Queue Manager - description: Get the current mail queue and everything it contains. - operationId: Get Queue - summary: Get Queue - "/api/v1/get/oauth2-client/{id}": - get: - parameters: - - description: id of entry you want to get - example: all - in: path - name: id - required: true - schema: - enum: - - all - - "1" - - "2" - - "5" - - "10" - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - client_id: 17c76aaa88c0 - client_secret: 73fc668a88147e32a31ff80c - grant_types: null - id: 1 - redirect_uri: "https://mailcow.tld" - scope: profile - user_id: null - description: OK - headers: {} - tags: - - oAuth Clients - description: Using this endpoint you can get all oAuth clients. - operationId: Get oAuth Clients - summary: Get oAuth Clients - "/api/v1/get/policy_bl_domain/{domain}": - get: - parameters: - - description: name of domain - in: path - name: domain - required: true - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - object: domain.tld - prefid: 2 - value: "*@baddomain.tld" - description: OK - headers: {} - tags: - - Domain antispam policies - description: You can list all blacklist policies per domain. - operationId: List blacklist domain policy - summary: List blacklist domain policy - "/api/v1/get/policy_wl_domain/{domain}": - get: - parameters: - - description: name of domain - in: path - name: domain - required: true - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - object: domain.tld - prefid: 1 - value: "*@gooddomain.tld" - description: OK - headers: {} - tags: - - Domain antispam policies - description: You can list all whitelist policies per domain. - operationId: List whitelist domain policy - summary: List whitelist domain policy - /api/v1/get/quarantine/all: - get: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - created: 1572688831 - id: 33 - notified: 1 - qid: 8224615004C1 - rcpt: admin@domain.tld - score: 15.48 - sender: bounces@send.domain.tld - subject: mailcow is awesome - virus_flag: 0 - description: OK - headers: {} - tags: - - Quarantine - description: Get all mails that are currently in Quarantine. - operationId: Get mails in Quarantine - summary: Get mails in Quarantine - "/api/v1/get/recipient_map/{id}": - get: - parameters: - - description: id of entry you want to get - example: all - in: path - name: id - required: true - schema: - enum: - - all - - "1" - - "2" - - "5" - - "10" - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - active: "1" - created: "2019-10-02 22:06:29" - id: 3 - modified: null - recipient_map_new: target@mailcow.tld - recipient_map_old: recipient@mailcow.tld - description: OK - headers: {} - tags: - - Address Rewriting - description: Using this endpoint you can get all recipient maps. - operationId: Get Recipient Map - summary: Get Recipient Map - "/api/v1/get/relayhost/{id}": - get: - parameters: - - description: id of entry you want to get - example: all - in: path - name: id - required: true - schema: - enum: - - all - - "1" - - "2" - - "5" - - "10" - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - active: "1" - hostname: "mailcow.tld:25" - id: 1 - password: supersecurepassword - password_short: tes... - used_by_domains: "" - username: testuser - description: OK - headers: {} - tags: - - Routing - description: Using this endpoint you can get all Sender-Dependent Transports. - operationId: Get Sender-Dependent Transports - summary: Get Sender-Dependent Transports - /api/v1/get/resource/all: - get: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - active: "1" - description: test - domain: mailcow.tld - kind: location - local_part: test - multiple_bookings: 0 - name: test@mailcow.tld - description: OK - headers: {} - tags: - - Resources - description: Using this endpoint you can get all Resources. - operationId: Get Resources - summary: Get Resources - "/api/v1/get/rl-mbox/{mailbox}": - get: - parameters: - - description: name of mailbox or all - in: path - name: mailbox - required: true - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - frame: s - mailbox: leon@mailcow.tld - value: "5" - - frame: s - mailbox: lisa@mailcow.tld - value: "3" - description: OK - headers: {} - tags: - - Ratelimits - description: >- - Using this endpoint you can get the ratelimits for a certain mailbox. - You can use all for all mailboxes. - operationId: Get mailbox ratelimits - summary: Get mailbox ratelimits - "/api/v1/get/rl-domain/{domain}": - get: - parameters: - - description: name of domain or all - in: path - name: domain - required: true - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - frame: s - domain: domain.tld - value: "5" - - frame: s - mailbox: domain2.tld - value: "3" - description: OK - headers: {} - tags: - - Ratelimits - description: >- - Using this endpoint you can get the ratelimits for a certain domains. - You can use all for all domain. - operationId: Get domain ratelimits - summary: Get domain ratelimits - /api/v1/edit/rl-mbox/: - post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - type: success - log: - - ratelimit - - edit - - mailbox - - object: - - info@domain.tld - rl_value: "10" - rl_frame: h - msg: - - rl_saved - - info@domain.tld - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Ratelimits - description: >- - Using this endpoint you can edit the ratelimits for a certain mailbox. - operationId: Edit mailbox ratelimits - requestBody: - content: - application/json: - schema: + networks: + type: object + pids_stats: + type: object + additionalProperties: true example: - attr: - rl_value: "10" - rl_frame: "h" - items: - - info@domain.tld - properties: - attr: - properties: - rl_frame: - description: contains the frame for the ratelimit h,s,m - type: string - rl_value: - description: contains the rate for the ratelimit 10,20,50,1 - type: number - type: object - items: - description: contains list of mailboxes you want to edit the ratelimit of - type: object - type: object - summary: Edit mailbox ratelimits - /api/v1/edit/rl-domain/: - post: - responses: + - read: "2026-11-10T19:20:01.123456789Z" + name: /mailcowdockerized-dovecot-mailcow-1 + id: 5d0f3cbb2a6e4f6c8e0e1c3b9a7d2f41b6c5e8a9d0f1e2c3b4a5968778695a4b + pids_stats: + current: 38 + memory_stats: + usage: 104857600 + limit: 12541669376 + blkio_stats: + io_service_bytes_recursive: + - major: 8 + minor: 0 + op: read + value: 1052672 + - major: 8 + minor: 0 + op: write + value: 40960 + networks: + eth0: + rx_bytes: 2621440 + tx_bytes: 1048576 "401": $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - type: success - - log: - - ratelimit - - edit - - domain - - object: - - domain.tld - rl_value: "50" - rl_frame: "h" - msg: - - rl_saved - - domain.tld - schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} - tags: - - Ratelimits - description: >- - Using this endpoint you can edit the ratelimits for a certain domains. - operationId: Edit domain ratelimits - requestBody: - content: - application/json: - schema: - example: - attr: - rl_value: "10" - rl_frame: "h" - items: - - domain.tld - properties: - attr: - properties: - rl_frame: - description: contains the frame for the ratelimit h,s,m - type: string - rl_value: - description: contains the rate for the ratelimit 10,20,50,1 - type: number - type: object - items: - description: contains list of domains you want to edit the ratelimit of - type: object - type: object - summary: Edit domain ratelimits - /api/v1/get/status/containers: + /api/v1/get/status/host: get: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - acme-mailcow: - container: acme-mailcow - image: "mailcow/acme:1.63" - started_at: "2019-12-22T21:00:08.270660275Z" - state: running - type: info - clamd-mailcow: - container: clamd-mailcow - image: "mailcow/clamd:1.35" - started_at: "2019-12-22T21:00:01.622856172Z" - state: running - type: info - dockerapi-mailcow: - container: dockerapi-mailcow - image: "mailcow/dockerapi:1.36" - started_at: "2019-12-22T20:59:59.984797808Z" - state: running - type: info - dovecot-mailcow: - container: dovecot-mailcow - image: "mailcow/dovecot:1.104" - started_at: "2019-12-22T21:00:08.988680259Z" - state: running - type: info - ipv6nat-mailcow: - container: ipv6nat-mailcow - image: robbertkl/ipv6nat - started_at: "2019-12-22T21:06:37.273225445Z" - state: running - type: info - memcached-mailcow: - container: memcached-mailcow - image: "memcached:alpine" - started_at: "2019-12-22T20:59:58.0907785Z" - state: running - type: info - mysql-mailcow: - container: mysql-mailcow - image: "mariadb:10.3" - started_at: "2019-12-22T21:00:02.201937528Z" - state: running - type: info - netfilter-mailcow: - container: netfilter-mailcow - image: "mailcow/netfilter:1.31" - started_at: "2019-12-22T21:00:09.851559297Z" - state: running - type: info - nginx-mailcow: - container: nginx-mailcow - image: "nginx:mainline-alpine" - started_at: "2019-12-22T21:00:12.9843038Z" - state: running - type: info - olefy-mailcow: - container: olefy-mailcow - image: "mailcow/olefy:1.2" - started_at: "2019-12-22T20:59:59.676259274Z" - state: running - type: info - php-fpm-mailcow: - container: php-fpm-mailcow - image: "mailcow/phpfpm:1.55" - started_at: "2019-12-22T21:00:00.955808957Z" - state: running - type: info - postfix-mailcow: - container: postfix-mailcow - image: "mailcow/postfix:1.44" - started_at: "2019-12-22T21:00:07.186717617Z" - state: running - type: info - redis-mailcow: - container: redis-mailcow - image: "redis:5-alpine" - started_at: "2019-12-22T20:59:56.827166834Z" - state: running - type: info - rspamd-mailcow: - container: rspamd-mailcow - image: "mailcow/rspamd:1.56" - started_at: "2019-12-22T21:00:12.456075355Z" - state: running - type: info - sogo-mailcow: - container: sogo-mailcow - image: "mailcow/sogo:1.65" - started_at: "2019-12-22T20:59:58.382274592Z" - state: running - type: info - unbound-mailcow: - container: unbound-mailcow - image: "mailcow/unbound:1.10" - started_at: "2019-12-22T20:59:58.760595825Z" - state: running - type: info - watchdog-mailcow: - container: watchdog-mailcow - image: "mailcow/watchdog:1.65" - started_at: "2019-12-22T20:59:56.028660382Z" - state: running - type: info - description: OK - headers: {} + summary: Get host statistics + description: CPU, memory, uptime and architecture of the Docker host. + operationId: getHostStatus tags: - Status - description: >- - Using this endpoint you can get the status of all containers and when - hey where started and a few other details. - operationId: Get container status - summary: Get container status - /api/v1/get/status/vmail: - get: responses: - "401": - $ref: "#/components/responses/Unauthorized" "200": + description: The host statistics content: application/json: - examples: - response: - value: - disk: /dev/mapper/mail--vg-root - total: 41G - type: info - used: 11G - used_percent: 28% - description: OK - headers: {} - tags: - - Status - description: >- - Using this endpoint you can get the status of the vmail and the amount - of used storage. - operationId: Get vmail status - summary: Get vmail status - /api/v1/get/status/version: - get: - responses: + schema: + $ref: "#/components/schemas/HostStats" + example: + cpu: + cores: 4 + usage: 6 + memory: + total: 12541669376 + usage: 62.5 + swap: + - 1022357504 + - 512716800 + - 509640704 + - 50.2 + - 1392812032 + - 3864260608 + uptime: 703800.5492310524 + system_time: "10.11.2026 19:13:07" + architecture: x86_64 "401": $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - version: "2022-04" - description: OK - headers: {} + /api/v1/get/status/host/ip: + get: + summary: Get public IP addresses + description: >- + The public IPv4 and IPv6 address of the server, as seen by the third + party services ipv4.mailcow.email and ipv6.mailcow.email (requested on + every call, regardless of the IP check setting of `edit/ip_check`). + Other path segments after `host` give an empty answer. + operationId: getHostIps tags: - Status - description: >- - Using this endpoint you can get the current running release of this - instance. - operationId: Get version status - summary: Get version status + responses: + "200": + description: The addresses, `false` for an address that could not be determined + content: + application/json: + schema: + type: object + properties: + ipv4: + type: [string, boolean] + description: Public IPv4 address + ipv6: + type: [string, boolean] + description: Public IPv6 address + example: + ipv4: 198.51.100.25 + ipv6: 2001:db8::25 + "401": + $ref: "#/components/responses/Unauthorized" /api/v1/get/syncjobs/all/no_log: get: - parameters: - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - id: 1 - user2: mailbox@domain.tld - source_id: 3 - user1: username - active: 1 - automap: 1 - custom_params: '[{"o":"folder","v":"INBOX"}]' - custom_params_legacy: null - delete1: 0 - delete2: 0 - delete2duplicates: 1 - dry: 0 - exclude: (?i)spam|(?i)junk - exit_status: EX_OK - is_running: 0 - last_run: "2026-10-15 11:40:02" - success: 1 - log: "" - maxage: 0 - maxbytespersecond: "0" - mins_interval: 20 - prio: 0 - skipcrossduplicates: 0 - subfolder2: External - subscribeall: 1 - timeout1: 600 - timeout2: 600 - created: "2026-10-01 11:37:25" - modified: "2026-10-15 11:40:02" - source_name: Example import - source_description: "" - source_host: imap.example.org - source_port: 993 - source_enc: SSL - source_auth_type: PLAIN - source_oauth_flow: client_credentials - source_created_by: "" - source_scope: all - source_active: 1 - source_oauth_token_expires: null - source_oauth_last_refresh_error: null - user_token_exists: 0 - user_token_expires: null - user_token_error: null - description: OK - headers: {} + summary: Get all sync jobs without logs + description: >- + Like `GET /api/v1/get/syncjobs/all`, with an empty `log` (the job list + of the UI). The last segment is a comma-separated option list, only + `no_log` has a meaning; other values are ignored. + operationId: getSyncJobsWithoutLog tags: - Sync jobs - description: >- - Lists all sync jobs the caller may access (`/api/v1/get/syncjobs/all` - includes the log, `/api/v1/get/syncjobs/[/no_log]` filters by - mailbox). Jobs are ordered by prio DESC, never-run first, last_run ASC, - id ASC. - `custom_params_legacy` holds options of migrated jobs that could not be - converted (informational, not used). The removed fields `host1`, - `port1`, `enc1` and `authmech1` are no longer part of a job. - operationId: Get sync jobs - summary: Get sync jobs - "/api/v1/get/syncjob_source/{id}": + responses: + "200": + description: The sync jobs + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SyncJob" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/syncjob_source/{id}: get: - parameters: - - description: source id, or `all` to list every source visible to the session (sorted by name) - example: all - in: path - name: id - required: true - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - id: 3 - name: Example import - description: "" - created_by: "" - scope: domain - domains: - - example.org - users: [] - host: imap.example.org - port: 993 - enc: SSL - host1: imap.example.org - port1: 993 - enc1: SSL - verify_cert: 1 - auth_type: XOAUTH2 - oauth_flow: authorization_code - oauth_token_endpoint: https://login.example.org/token - oauth_authorize_endpoint: https://login.example.org/authorize - oauth_userinfo_endpoint: null - oauth_client_id: 0000-1111 - oauth_scope: https://outlook.office.com/IMAP.AccessAsUser.All offline_access - oauth_extra_params: null - oauth_redirect_uri: https://mail.example.org/syncjob-oauth - oauth_token_expires: null - oauth_last_refresh_error: null - oauth_last_refresh_attempt: null - has_client_secret: true - has_access_token: false - can_edit: true - active: 1 - created: "2026-10-01 09:00:00" - modified: "2026-10-01 09:00:00" - description: OK - headers: {} + summary: Get sync source + description: >- + One sync source by id, with the visibility rules of + `GET /api/v1/get/syncjob_source/all`. Answers `{}` when the id is not a + whole number, the source does not exist or the caller may not see it. + operationId: getSyncJobSource tags: - Sync jobs - description: >- - Returns a single sync source by id, or all sources visible to the caller - when `id` is `all`. `can_edit` tells whether the caller may edit/delete - the source. - operationId: Get sync job sources - summary: Get sync job sources + parameters: + - name: id + in: path + required: true + description: Source id + schema: + type: integer + example: 4 + responses: + "200": + description: The sync source + content: + application/json: + schema: + $ref: "#/components/schemas/SyncJobSource" + example: + id: 4 + name: Google Workspace + description: "" + created_by: admin + scope: all + host1: imap.gmail.com + port1: 993 + enc1: SSL + verify_cert: 1 + auth_type: XOAUTH2 + oauth_flow: authorization_code + oauth_token_endpoint: https://oauth2.googleapis.com/token + oauth_authorize_endpoint: https://accounts.google.com/o/oauth2/v2/auth + oauth_userinfo_endpoint: null + oauth_client_id: 1234567890-example.apps.googleusercontent.com + oauth_scope: https://mail.google.com/ openid email + oauth_extra_params: '{"access_type":"offline","prompt":"consent"}' + oauth_token_expires: null + oauth_last_refresh_error: null + oauth_last_refresh_attempt: null + created: "2026-11-01 10:00:00" + modified: null + active: 1 + domains: [] + users: [] + can_edit: true + host: imap.gmail.com + port: 993 + enc: SSL + has_client_secret: true + has_access_token: false + oauth_redirect_uri: https://mail.example.org/syncjob-oauth + "401": + $ref: "#/components/responses/Unauthorized" /api/v1/get/syncjob_options: get: - parameters: - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - dry: false - folder: true - maxsize: true - sslargs1: ^SSL_verify_mode=[01]$ - description: OK - headers: {} + summary: Get allowed custom imapsync options + description: >- + The imapsync options sync jobs may use in `custom_params` + (`$IMAPSYNC_OPTIONS` of vars.local.inc.php, defaults from vars.inc.php; + options that run code, use local files or print secrets are always + removed). Option name (lowercase, without dashes) mapped to its rule: + `true` needs a value, `false` is a flag without value, a string is a + regular expression the whole value must match. Available to every + logged-in caller. + operationId: getSyncJobOptions tags: - Sync jobs - description: >- - Returns the allowlist of imapsync options accepted in a sync job's - `custom_params` as option => rule: `true` = requires a non-empty value, - `false` = flag without value, string = regular expression the whole - value must match. - operationId: Get sync job options - summary: Get sync job options + responses: + "200": + description: Option name => rule + content: + application/json: + schema: + type: object + additionalProperties: + type: + - boolean + - string + example: + authmd51: false + debugssl: true + folder: true + maxsize: true + syncinternaldates: false + sslargs1: "^SSL_verify_mode=[01]$" + "401": + $ref: "#/components/responses/Unauthorized" /api/v1/get/imapsync_settings: get: - parameters: - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - max_parallel: 4 - max_bytes_per_second: 2048000 - max_kb_per_second: 2000 - description: OK - headers: {} + summary: Get global sync job settings + description: >- + Settings of the sync job runner (admins and API keys; `{}` for others). + operationId: getSyncJobSettings tags: - Sync jobs - description: >- - Admin only. Returns the global sync job settings (`{}` for other roles). - operationId: Get sync job settings - summary: Get sync job settings - "/api/v1/get/tls-policy-map/{id}": - get: - parameters: - - description: id of entry you want to get - example: all - in: path - name: id - required: true - schema: - enum: - - all - - "1" - - "2" - - "5" - - "10" - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string responses: - "401": - $ref: "#/components/responses/Unauthorized" "200": + description: The settings content: application/json: - examples: - response: - value: - - parameters: "" - active: "1" - created: "2019-10-03 08:42:12" - dest: mailcow.tld - id: 1 - modified: null - policy: encrypt - description: OK - headers: {} + schema: + $ref: "#/components/schemas/SyncJobSettings" + example: + max_parallel: 3 + max_bytes_per_second: 2048000 + max_kb_per_second: 2000 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/syncjobs/all: + get: + summary: Get all sync jobs + description: >- + All sync jobs in queue order (highest `prio` first, then jobs that + never ran, then the least recently run), with their logs. Admins (every + API key) get every job, also jobs whose local mailbox no longer exists; + domain admins the jobs of the mailboxes of their domains; mailbox users + none (see `GET /api/v1/get/syncjobs/{mailbox}`). Answers `{}` when + there is no job. + operationId: getSyncJobs + tags: + - Sync jobs + responses: + "200": + description: The sync jobs + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SyncJob" + example: + - id: 12 + user2: alice@example.org + regextrans2: "" + authmd51: 0 + domain2: "" + subfolder2: Imported + user1: alice@old-provider.example.com + exclude: "(?i)spam|(?i)junk" + maxage: 0 + mins_interval: 20 + maxbytespersecond: "0" + delete2duplicates: 1 + delete1: 0 + delete2: 0 + automap: 1 + skipcrossduplicates: 0 + custom_params: '[{"o":"folder","v":"INBOX"}]' + timeout1: 600 + timeout2: 600 + subscribeall: 1 + dry: 0 + is_running: 0 + last_run: "2026-11-09 08:20:01" + success: 1 + exit_status: EX_OK + created: "2026-11-03 09:40:09" + modified: "2026-11-03 09:42:21" + active: 1 + source_id: 3 + custom_params_legacy: null + prio: 0 + source_name: Old provider + source_description: Migration from the old provider + source_host: imap.example.com + source_port: 993 + source_enc: SSL + source_auth_type: PLAIN + source_created_by: API + source_scope: domain + source_oauth_flow: client_credentials + source_active: 1 + source_oauth_token_expires: null + source_oauth_last_refresh_error: null + user_token_expires: null + user_token_error: null + user_token_exists: 0 + log: "Transfer started at Mon Nov 9 08:20:02 2026\n..." + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/syncjobs/{mailbox}: + get: + summary: Get sync jobs of a mailbox + description: >- + The sync jobs of a local mailbox in queue order, with their logs. + Admins (every API key) get the jobs of every existing mailbox, domain + admins of their mailboxes, users of their own. A value that is no email + address returns the caller's own jobs (none for an API key). Answers + `{}` when there is no job or no access. + operationId: getMailboxSyncJobs + tags: + - Sync jobs + parameters: + - name: mailbox + in: path + required: true + description: Local mailbox (`user2` of the jobs) + schema: + type: string + example: alice@example.org + responses: + "200": + description: The sync jobs of the mailbox + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SyncJob" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/syncjobs/{mailbox}/no_log: + get: + summary: Get sync jobs of a mailbox without logs + description: >- + Like `GET /api/v1/get/syncjobs/{mailbox}`, with an empty `log`. The + last segment is a comma-separated option list, only `no_log` has a + meaning. + operationId: getMailboxSyncJobsWithoutLog + tags: + - Sync jobs + parameters: + - name: mailbox + in: path + required: true + description: Local mailbox (`user2` of the jobs) + schema: + type: string + example: alice@example.org + responses: + "200": + description: The sync jobs of the mailbox + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SyncJob" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/syncjob_source/all: + get: + summary: Get sync sources + description: >- + The sync sources the caller may see, ordered by name: admins (every API + key) all; domain admins their own, `all` sources, sources for their + domains or mailboxes and sources created by their mailboxes; users + their own, `all` sources and sources for them or their domain. Secrets + are never returned (`has_client_secret`, `has_access_token`), extra + OAuth2 parameters only to callers who may edit the source (`can_edit`, + false for read-only API keys). Answers `{}` when there is no source. + operationId: getSyncJobSources + tags: + - Sync jobs + responses: + "200": + description: The sync sources + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SyncJobSource" + example: + - id: 3 + name: Old provider + description: Migration from the old provider + created_by: API + scope: domain + host1: imap.example.com + port1: 993 + enc1: SSL + verify_cert: 1 + auth_type: PLAIN + oauth_flow: client_credentials + oauth_token_endpoint: null + oauth_authorize_endpoint: null + oauth_userinfo_endpoint: null + oauth_client_id: null + oauth_scope: null + oauth_extra_params: null + oauth_token_expires: null + oauth_last_refresh_error: null + oauth_last_refresh_attempt: null + created: "2026-11-03 09:42:21" + modified: null + active: 1 + domains: + - example.org + users: [] + can_edit: true + host: imap.example.com + port: 993 + enc: SSL + has_client_secret: false + has_access_token: false + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/tls-policy-map/{id}: + get: tags: - Outgoing TLS Policy Map Overrides - description: Using this endpoint you can get all TLS policy map override maps. - operationId: Get TLS Policy Map - summary: Get TLS Policy Map - "/api/v1/get/transport/{id}": - get: + summary: Get TLS policy maps + operationId: getTlsPolicyMaps + description: >- + `all` lists the TLS policy maps, an ID returns that map. `{}` if there + is none or the caller is no administrator. The answer for an ID + carries an additional member `"0": null` (json_api.php appends an + undefined variable). parameters: - - description: id of entry you want to get - example: all + - name: id in: path - name: id required: true - schema: - enum: - - all - - "1" - - "2" - - "5" - - "10" - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false + description: "`all` or the ID of a TLS policy map" schema: type: string + example: all responses: - "401": - $ref: "#/components/responses/Unauthorized" "200": + description: The list of TLS policy maps (`all`) or one TLS policy map content: application/json: + schema: + anyOf: + - type: array + items: + $ref: "#/components/schemas/TlsPolicyMap" + - $ref: "#/components/schemas/TlsPolicyMap" examples: - response: + all: + summary: get/tls-policy-map/all value: - - active: "1" - destination: example.org - id: 1 - lookup_mx: "0" - nexthop: "host:25" - password: supersecurepw - password_short: sup... - username: testuser - description: OK - headers: {} + - id: 2 + dest: example.com + policy: encrypt + parameters: protocols=>=TLSv1.2 + active: 1 + created: "2026-11-06 12:00:31" + modified: null + one: + summary: get/tls-policy-map/2 + value: + id: 2 + dest: example.com + policy: encrypt + parameters: protocols=>=TLSv1.2 + active: 1 + created: "2026-11-06 12:00:31" + modified: null + "0": null + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/tls-policy-map: + post: + tags: + - Outgoing TLS Policy Map Overrides + summary: Update TLS policy maps + operationId: editTlsPolicyMap + description: >- + Changes the TLS policy maps in `items`; attributes that are left out + keep their value (`parameters: ""` clears the parameters). + Administrators and API keys only. Per map: `tls_policy_map_entry_saved` + or `access_denied` (unknown ID); `tls_policy_map_dest_invalid`, + `tls_policy_map_parameter_invalid` and an existing destination (answered + with `recipient_map_entry_exists`) stop the call. + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: IDs of the TLS policy maps, a single value or a list + oneOf: + - type: array + items: + type: [integer, string] + - type: [integer, string] + attr: + type: object + properties: + dest: + type: string + description: Destination, e.g. `example.org` or `[mail.example.org]:25` + policy: + type: string + enum: + - none + - may + - encrypt + - dane + - dane-only + - fingerprint + - verify + - secure + parameters: + type: string + description: Empty or space-separated `name=value` attributes + active: + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + required: + - items + - attr + example: + items: + - 2 + attr: + policy: dane + parameters: "" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/transport/{id}: + get: tags: - Routing - description: Using this endpoint you can get all Transport Maps. - operationId: Get Transport Maps - summary: Get Transport Maps - /api/v1/edit/spam-score/: - post: + summary: Get transport maps + operationId: getTransports + description: >- + `all` lists the transport maps, an ID returns that entry, with the + password in clear text. `{}` if there is none or the caller is no + administrator. + parameters: + - name: id + in: path + required: true + description: "`all` or the ID of a transport map" + schema: + type: string + example: all responses: - "401": - $ref: "#/components/responses/Unauthorized" "200": + description: The list of transport maps (`all`) or one entry content: application/json: - examples: - response: - value: - - type: success - log: - - mailbox - - edit - - spam_score - - username: - - info@domain.tld - spam_score: "8,15" - msg: - - mailbox_modified - - info@domain.tld schema: - properties: - log: - description: contains request object - items: {} - type: array - msg: - items: {} - type: array - type: - enum: - - success - - danger - - error - type: string - type: object - description: OK - headers: {} + anyOf: + - type: array + items: + $ref: "#/components/schemas/Transport" + - $ref: "#/components/schemas/Transport" + examples: + all: + summary: get/transport/all + value: + - id: 4 + is_mx_based: 0 + destination: example.com + nexthop: "[mx.example.com]:25" + username: "" + password: "" + active: 1 + password_short: "..." + - id: 5 + is_mx_based: 1 + destination: .*\.google\.com + nexthop: "[smtp.example.net]:587" + username: relay-user@example.net + password: Secret-Passphrase-42 + active: 1 + password_short: Sec... + one: + summary: get/transport/4 + value: + id: 4 + is_mx_based: 0 + destination: example.com + nexthop: "[mx.example.com]:25" + username: "" + password: "" + active: 1 + password_short: "..." + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/relayhost: + post: tags: - - Mailboxes + - Routing + summary: Update sender-dependent transports + operationId: editRelayhost description: >- - Using this endpoint you can edit the spam filter score for a certain mailbox. - operationId: Edit mailbox spam filter score + Changes the sender-dependent transports in `items`; attributes that are + left out keep their value (an empty `hostname` too). Administrators + and API keys only. Per entry: `object_modified` (without the host + name), `relayhost_invalid` (unknown ID), `mysql_error`. requestBody: content: application/json: schema: - example: - - items: - - info@domain.tld - attr: - spam_score: "8,15" - summary: Edit mailbox spam filter score - "/api/v1/get/mailbox/all/{domain}": - get: - parameters: - - description: name of domain - in: path - name: domain - required: true - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string + type: object + properties: + items: + description: IDs of the sender-dependent transports, a single value or a list + oneOf: + - type: array + items: + type: [integer, string] + - type: [integer, string] + attr: + type: object + properties: + hostname: + type: string + description: Next hop, e.g. `[smtp.example.com]:587` + username: + type: string + description: SMTP user name, empty for no authentication + password: + type: string + description: SMTP password + active: + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + required: + - items + - attr + example: + items: + - 1 + attr: + password: New-Secret-Passphrase-43 + active: "1" responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - active: "1" - attributes: - force_pw_update: "0" - force_tfa: "0" - mailbox_format: "maildir:" - quarantine_notification: never - sogo_access: "1" - tls_enforce_in: "0" - tls_enforce_out: "0" - custom_attributes: {} - domain: domain3.tld - is_relayed: 0 - local_part: info - max_new_quota: 10737418240 - messages: 0 - name: Full name - percent_class: success - percent_in_use: 0 - quota: 3221225472 - quota_used: 0 - rl: false - sender_acl: ["otherbox@domain3.tld", "@aliasdomain.tld"] - spam_aliases: 0 - username: info@domain3.tld - tags: ["tag1", "tag2"] - description: OK - headers: {} + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/transport: + post: tags: - - Mailboxes - description: You can list all mailboxes existing in system for a specific domain. - operationId: Get mailboxes of a domain - summary: Get mailboxes of a domain + - Routing + summary: Update transport maps + operationId: editTransport + description: >- + Changes the transport maps in `items`; attributes that are left out + keep their value (an empty `destination`, `nexthop`, `is_mx_based` or + `active` too). An empty `username` also clears the password. The + credentials are written to every entry with the same next hop. + Administrators and API keys only. + + + Per entry: `object_modified` (without names), `relayhost_invalid` + (unknown ID), `mysql_error`; `transport_dest_exists`, + `invalid_destination`, `next_hop_interferes` and + `next_hop_interferes_any` stop the call. + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: IDs of the transport maps, a single value or a list + oneOf: + - type: array + items: + type: [integer, string] + - type: [integer, string] + attr: + type: object + properties: + destination: + type: string + description: One destination (see add/transport) + nexthop: + type: string + description: Next hop, e.g. `[mx.example.com]:25` + username: + type: string + description: SMTP user name; empty removes the credentials + password: + type: string + description: SMTP password + is_mx_based: + type: [boolean, integer, string] + description: 1 = `destination` is a regular expression for MX names + active: + type: [boolean, integer, string] + description: 1 = active, 0 = inactive + required: + - items + - attr + example: + items: + - 4 + attr: + nexthop: "[mx2.example.com]:25" + active: "1" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" /api/v1/edit/cors: post: - responses: - "401": - $ref: "#/components/responses/Unauthorized" - "200": - content: - application/json: - examples: - response: - value: - - type: "success" - log: ["cors", "edit", {"allowed_origins": ["*", "https://mail.mailcow.tld"], "allowed_methods": ["POST", "GET", "DELETE", "PUT"]}] - msg: "cors_headers_edited" - description: OK - headers: { } + summary: Edit CORS settings + description: >- + Sets which origins (web pages of other hosts) may call the API from a + browser and with which methods. A missing value is replaced by its + default. `items` is not used. + + + Success: `cors_headers_edited`. Errors: `cors_invalid_origin`, + `cors_invalid_method`. + operationId: editCors tags: - Cross-Origin Resource Sharing (CORS) - description: >- - This endpoint allows you to manage Cross-Origin Resource Sharing (CORS) settings for the API. - CORS is a security feature implemented by web browsers to prevent unauthorized cross-origin requests. - By editing the CORS settings, you can specify which domains and which methods are permitted to access the API resources from outside the mailcow domain. - operationId: Edit Cross-Origin Resource Sharing (CORS) settings requestBody: + required: true content: application/json: schema: - example: - attr: - allowed_origins: ["*", "https://mail.mailcow.tld"] - allowed_methods: ["POST", "GET", "DELETE", "PUT"] + type: object properties: attr: type: object properties: allowed_origins: - type: array - items: - type: string + description: >- + Origins (`scheme://host[:port]`, http or https, no path) + or `*` for any origin; a list or a string with one + origin per line. Default: the URL of the mailcow host. + oneOf: + - type: array + items: + type: string + - type: string allowed_methods: - type: array - items: - type: string - summary: Edit Cross-Origin Resource Sharing (CORS) settings - "/api/v1/get/spam-score/{mailbox}": - get: - parameters: - - description: name of mailbox or empty for current user - admin user will retrieve the global spam filter score - in: path - name: mailbox - required: true - schema: - type: string - - description: e.g. api-key-string - example: api-key-string - in: header - name: X-API-Key - required: false - schema: - type: string + description: >- + Allowed methods: `GET`, `POST`, `PUT`, `DELETE` + (uppercase); a list or a string separated by single + spaces, commas, semicolons or newlines (`GET,POST`, not + `GET, POST`). Default: all four. + oneOf: + - type: array + items: + type: string + enum: + - GET + - POST + - PUT + - DELETE + - type: string + required: + - attr + example: + attr: + allowed_origins: + - https://app.example.org + - https://admin.example.com:8443 + allowed_methods: + - GET + - POST responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/spam-score/{mailbox}: + get: + tags: + - Mailbox user settings + summary: Get the spam score of a mailbox + operationId: getSpamScore + description: >- + The spam score thresholds of the mailbox: its own values or the server + default. `{}` if the caller has no access to the mailbox (a mailbox + user: other mailboxes). + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + responses: "200": + description: Thresholds content: application/json: - examples: - response: - value: - spam_score: "8,15" - description: OK - headers: {} + schema: + $ref: "#/components/schemas/SpamScore" + example: + score: "6,12" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/spam-score: + post: + tags: + - Mailbox user settings + summary: Set the spam score of mailboxes + operationId: editSpamScore + description: >- + Sets the spam thresholds of the mailboxes: from the first value on mail + goes to the junk folder, from the second it is rejected (equal values: + the second is raised by 0.1). `default` removes the own values. Needs + the user ACL `spam_score`. API keys may change any mailbox, a mailbox + user only its own. Messages per mailbox: `mailbox_modified` or + `access_denied`, "Invalid spam score, format must be "1,2" where first + is low and second is high spam value.". + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + $ref: "#/components/schemas/MailboxItems" + attr: + type: object + required: + - spam_score + properties: + spam_score: + type: string + description: '"low,high" (numbers) or "default"' + examples: + - "6,12" + - default + example: + items: + - john.doe@example.org + attr: + spam_score: "6,12" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/mailbox/template: + post: tags: - Mailboxes + summary: Create a mailbox template + operationId: addMailboxTemplate description: >- - Using this endpoint you can get the global spam filter score or the spam filter score of a certain mailbox. - operationId: Get mailbox or global spam filter score - summary: Get mailbox or global spam filter score - /api/v1/edit/identity-provider: - post: + Saves a named set of mailbox settings that `add/mailbox` (field + `template`) and the UI use as defaults. Admins only (`access_denied` + otherwise). Errors: `template_name_invalid` (empty name), + `template_exists`, `quarantine_category_invalid`. Success: + `template_added`. Fields that are not sent get the defaults named in + the schema; without `acl` the template grants no user ACL. + requestBody: + required: true + content: + application/json: + schema: + allOf: + - type: object + required: + - template + properties: + template: + type: string + description: Name of the template + - $ref: "#/components/schemas/MailboxTemplateInput" + example: + template: Sales + quota: 2048 + tags: + - sales + quarantine_notification: daily + quarantine_category: reject + rl_value: 100 + rl_frame: h + active: 1 + protocol_access: + - imap + - smtp + - sieve + acl: + - spam_alias + - spam_score + - app_passwds responses: + "200": + $ref: "#/components/responses/WriteResult" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/mailbox/all: + get: + tags: + - Mailboxes + summary: Get all mailboxes + operationId: getMailboxes + description: >- + Details of every mailbox in the domains the caller manages (API keys + and admins: all domains). Resources are not included. `{}` when there + are none. + parameters: + - $ref: "#/components/parameters/MailboxTagsQuery" + responses: "200": + description: List of mailboxes content: application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Mailbox" examples: - response: - value: - - type: "success" - log: - - "identity_provider" - - "edit" - - authsource: "keycloak" - server_url: "https://auth.mailcow.tld" - realm: "mailcow" - client_id: "mailcow_client" - client_secret: "*" - redirect_url: "https://mail.mailcow.tld" - redirect_url_extra: ["https://extramail.mailcow.tld"] - version: "26.1.3" - default_template: "Default" - mappers: - - "small_mbox" - - "medium_mbox" - templates: - - "small" - - "medium" - ignore_ssl_error: true - mailpassword_flow: true - periodic_sync: true - import_users: true - sync_interval: 30 - msg: - - "object_modified" - - "" - description: OK - headers: { } + mailboxes: + $ref: "#/components/examples/MailboxList" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailbox/all/{domains}: + get: + tags: + - Mailboxes + summary: Get the mailboxes of domains + operationId: getMailboxesOfDomains + description: >- + Like `get/mailbox/all`, limited to the given domains. Caution: a domain + that does not exist or that the caller does not manage adds all + mailboxes the caller manages (API keys: every mailbox) to the answer. + `{}` when nothing is found. + parameters: + - name: domains + in: path + required: true + description: Domain name or comma-separated list of domain names + schema: + type: string + example: example.org + - $ref: "#/components/parameters/MailboxTagsQuery" + responses: + "200": + description: List of mailboxes + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Mailbox" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailbox/reduced: + get: + tags: + - Mailboxes + summary: Get all mailboxes (reduced details) + operationId: getMailboxesReduced + description: >- + Like `get/mailbox/all`, but each mailbox without the costly fields + `max_new_quota`, `spam_aliases`, `pushover_active`, `rl`, `rl_scope`, + `is_relayed` and `sender_acl`. Faster for large installations. + parameters: + - $ref: "#/components/parameters/MailboxTagsQuery" + responses: + "200": + description: List of mailboxes + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Mailbox" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailbox/reduced/{domains}: + get: + tags: + - Mailboxes + summary: Get the mailboxes of domains (reduced details) + operationId: getMailboxesReducedOfDomains + description: >- + Like `get/mailbox/reduced`, limited to the given domains, with the same + caution as `get/mailbox/all/{domains}`: an unknown domain adds all + mailboxes the caller manages. + parameters: + - name: domains + in: path + required: true + description: Domain name or comma-separated list of domain names + schema: + type: string + example: example.org,example.com + - $ref: "#/components/parameters/MailboxTagsQuery" + responses: + "200": + description: List of mailboxes + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Mailbox" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailbox/template/all: + get: + tags: + - Mailboxes + summary: Get all mailbox templates + operationId: getMailboxTemplates + description: >- + All mailbox templates. Admins and domain admins; `{}` for others or + when there is none. + responses: + "200": + description: List of templates + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/MailboxTemplate" + example: + - id: 2 + template: Default + type: mailbox + attributes: + tags: [] + quota: 0 + quarantine_notification: hourly + quarantine_category: reject + rl_frame: s + rl_value: "" + force_pw_update: 0 + force_tfa: 0 + sogo_access: 1 + active: 1 + tls_enforce_in: 0 + tls_enforce_out: 0 + imap_access: 1 + pop3_access: 1 + smtp_access: 1 + sieve_access: 1 + eas_access: 1 + dav_access: 1 + acl_spam_alias: 1 + acl_tls_policy: 1 + acl_spam_score: 1 + acl_spam_policy: 1 + acl_delimiter_action: 1 + acl_syncjobs: 0 + acl_eas_reset: 1 + acl_sogo_profile_reset: 0 + acl_pushover: 1 + acl_quarantine: 1 + acl_quarantine_attachments: 1 + acl_quarantine_notification: 1 + acl_quarantine_category: 1 + acl_app_passwds: 1 + created: "2026-09-21 10:23:08" + modified: null + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/mailbox/template/{id}: + get: + tags: + - Mailboxes + summary: Get a mailbox template + operationId: getMailboxTemplate + description: >- + One mailbox template by its ID, `{}` if there is none. Admins and + domain admins. + parameters: + - name: id + in: path + required: true + description: Template ID + schema: + type: integer + example: 2 + responses: + "200": + description: The template + content: + application/json: + schema: + $ref: "#/components/schemas/MailboxTemplate" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/mailbox/template: + post: + tags: + - Mailboxes + summary: Update mailbox templates + operationId: editMailboxTemplate + description: >- + Changes mailbox templates and passes the change on to the mailboxes + whose effective group template it is. Admins only (`access_denied`). + The template "Default" keeps its name. Messages: `template_id_invalid`, + `quarantine_category_invalid`, once at the end `template_modified`. + + + Send the complete template as the UI does: values that are not sent are + not taken over from the stored template. `quota` falls back to 0 + (unlimited), `quarantine_category` must be sent (else + `quarantine_category_invalid`), and without `protocol_access` or `acl` + the protocol or ACL settings are lost. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: Template ID or list of template IDs + oneOf: + - type: integer + - type: array + items: + type: integer + attr: + allOf: + - type: object + properties: + template: + type: string + description: New name of the template (not for "Default") + - $ref: "#/components/schemas/MailboxTemplateInput" + example: + items: + - 3 + attr: + template: Sales + quota: 4096 + tags: + - sales + tagged_mail_handler: subfolder + quarantine_notification: daily + quarantine_category: reject + rl_value: 100 + rl_frame: h + force_pw_update: 0 + force_tfa: 0 + sogo_access: 1 + active: 1 + tls_enforce_in: 0 + tls_enforce_out: 1 + protocol_access: + - imap + - smtp + - sieve + - dav + acl: + - spam_alias + - spam_score + - delimiter_action + - app_passwds + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/rename-mbox: + post: + tags: + - Mailboxes + summary: Rename a mailbox + operationId: renameMailbox + description: >- + Renames `old_local_part@domain` to `new_local_part@domain`: the address + in all tables (aliases, ACLs, filters, app passwords, BCC and recipient + maps, sync jobs, groups, SOGo), the maildir and the SOGo account; IMAP + ACLs are moved over. `items` is not used, the mailbox is named in + `attr`. The mailbox must be active (1 or 2). Admins and domain admins + (for their domains). Errors: `username_invalid`, `access_denied`. + Success: `mailbox_renamed`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - attr + properties: + items: + description: Not used + attr: + type: object + required: + - domain + - old_local_part + - new_local_part + properties: + domain: + type: string + description: Domain of the mailbox + old_local_part: + type: string + description: Current local part + new_local_part: + type: string + description: New local part + create_alias: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + description: 1 creates an alias from the old to the new address + example: + items: [] + attr: + domain: example.org + old_local_part: john.doe + new_local_part: john + create_alias: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/mailbox/template: + post: + tags: + - Mailboxes + summary: Delete mailbox templates + operationId: deleteMailboxTemplate + description: >- + Deletes mailbox templates by ID; the template "Default" is kept + silently. Admins only (`access_denied`). Answers `template_removed`. + requestBody: + required: true + content: + application/json: + schema: + type: array + description: Template IDs + items: + type: integer + example: + - 3 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/last-login/{mailbox}: + get: + tags: + - Mailboxes + summary: Get the last logins of a mailbox + operationId: getLastLogin + description: >- + Logins of the mailbox in the last 7 days (`sasl`: one entry per + address, service and app password with the latest time) and its + previous UI login (`ui`, the one before the latest). `sasl` is empty + without access to the mailbox; `ui` is only given to admins (API keys) + and the mailbox user itself (`false` when there is none), domain admins + get an empty list. + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + responses: + "200": + description: Logins + content: + application/json: + schema: + $ref: "#/components/schemas/LastLogin" + example: + ui: + remote: 198.51.100.23 + time: 1793011200 + sasl: + - real_rip: 198.51.100.23 + datetime: "2026-11-09 08:12:44" + service: imap + app_password: 23 + app_password_name: Thunderbird + location: DE + - real_rip: Web/EAS/Internal (172.22.1.248) + datetime: "2026-11-08 17:03:10" + service: SOGO + app_password: 0 + app_password_name: null + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/last-login/{mailbox}/{days}: + get: + tags: + - Mailboxes + summary: Get the last logins of a mailbox for a period + operationId: getLastLoginDays + description: >- + Like `get/last-login/{mailbox}` for the last `{days}` days; values + below 1 mean 7. + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + - name: days + in: path + required: true + description: Number of days + schema: + type: integer + minimum: 1 + example: 30 + responses: + "200": + description: Logins + content: + application/json: + schema: + $ref: "#/components/schemas/LastLogin" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/reset-last-login/{mailbox}: + get: + tags: + - Mailboxes + summary: Clear the login history of a mailbox + operationId: resetLastLogin + description: >- + Deletes the stored logins of the mailbox (the SASL log and, for admins + and the mailbox user itself, the successful UI logins). A `get` route + that changes data: read-only API keys can call it too. Always answers + `true`. + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + responses: + "200": + description: Done + content: + application/json: + schema: + type: boolean + example: true + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/delete/eas_cache: + post: + tags: + - Mailboxes + summary: Reset the ActiveSync devices of mailboxes + operationId: deleteEasCache + description: >- + Deletes the ActiveSync device cache of the mailboxes, so devices sync + from scratch. Needs the user ACL `eas_reset`. API keys may reset any + mailbox, a mailbox user only its own. Messages per mailbox: `eas_reset` + or `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MailboxItems" + example: + - john.doe@example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/sogo_profile: + post: + tags: + - Mailboxes + summary: Reset the SOGo profile of mailboxes + operationId: deleteSogoProfile + description: >- + Deletes the SOGo data of the mailboxes: profile, settings, calendars, + address books and their shares. Needs the user ACL + `sogo_profile_reset`. API keys may reset any mailbox, a mailbox user + only its own. Messages per mailbox: `sogo_profile_reset` or + `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MailboxItems" + example: + - john.doe@example.org + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/bcc-destination-options: + get: + tags: + - Mailboxes + summary: Get the possible BCC map destinations + operationId: getBccDestinationOptions + description: >- + What a BCC map can be bound to: the domains the caller manages, per + mailbox the mailbox and its aliases (aliases of other domains only if + the caller manages them), and the alias domains. Keys without entries + are left out. + responses: + "200": + description: Domains, mailboxes with their addresses, alias domains + content: + application/json: + schema: + type: object + properties: + domains: + type: array + items: + type: string + mailboxes: + type: object + description: Mailbox address → the mailbox and its aliases + additionalProperties: + type: array + items: + type: string + alias_domains: + type: array + items: + type: string + example: + domains: + - example.org + mailboxes: + john.doe@example.org: + - john.doe@example.org + - sales@example.org + jane.roe@example.org: + - jane.roe@example.org + alias_domains: + - example.net + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/search/mailbox: + post: + tags: + - Mailboxes + summary: Search mailboxes page by page + operationId: searchMailboxes + description: >- + Server-side search behind the mailbox table of the UI, with the request + and answer format of DataTables (server-side processing): paging, + sorting and a search over the mailboxes the caller manages (API keys: + all; resources are not included). Read-only API keys may use it. + Mailbox users get `403` with `Insufficient permissions`. + + + Columns are addressed by number, as in the UI table: 2 address + (`username`), 3 quota, 4 last mail login, 5 last password change, 6 + quota in use, 7 name, 20 messages, 23 tags, 24 active. Sorting works on + all of them except 23; searching on 2, 3, 7, 23 and 24. Searching in 4, + 5, 6 or 20 or sorting by 23 makes the database query fail, so send + these columns as not searchable/orderable. Other numbers stand for the + address. `columns` is needed for a search or a sort: + `columns[n]` describes column n. + + + Each entry of `data` holds the full details of a mailbox, like + `get/mailbox/{id}`. `recordsTotal` counts the mailboxes after the + search too (same as `recordsFiltered`). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MailboxSearchRequest" + example: + draw: 1 + start: 0 + length: 25 + search: + value: john + order: + - column: 2 + dir: asc + columns: + - searchable: false + orderable: false + - searchable: false + orderable: false + - data: username + searchable: true + orderable: true + - searchable: false + orderable: true + - searchable: false + orderable: true + - searchable: false + orderable: true + - searchable: false + orderable: true + - data: name + searchable: true + orderable: true + responses: + "200": + description: >- + One page of mailboxes. A failing database query answers + `{"error": "An SQL error occurred: ..."}` instead. + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/MailboxSearchResult" + - type: object + properties: + error: + type: string + examples: + page: + $ref: "#/components/examples/MailboxSearchPage" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: The caller is a mailbox user or not logged in + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + type: error + msg: Insufficient permissions + /api/v1/edit/identity-provider: + post: + summary: Configure the Identity Provider + description: >- + Admins only (every read/write API key). Saves the configuration of the + external Identity Provider (Keycloak, generic OpenID Connect or LDAP). + Send the complete configuration of the provider: every setting of the + provider is written, settings that are not sent get their default (flags + off, `login_provisioning` off, `group_autocreate` off), and the secrets + (`client_secret`, `bindpass`) must be sent again. `redirect_url_extra`, + `default_template` and the `mappers`/`templates` pair are only changed + when sent. Settings of other providers stay stored. The cached Keycloak + access token is cleared. Changing the LDAP `id_attribute` drops the + stored LDAP ids (mailboxes are linked by email again). `items` is not + used. + + + Answers `object_modified`. Errors: `required_data_missing` (`authsource` + or a required setting missing: Keycloak `server_url`, `realm`, + `client_id`, `client_secret`, `redirect_url`, `version`; generic-oidc + `authorize_url`, `token_url`, `userinfo_url`, `client_id`, + `client_secret`, `redirect_url`; LDAP `basedn`, `binddn`, `bindpass`), + `invalid_authsource`, `authsource_in_use` (mailboxes use another + external provider), `access_denied`. Secrets are logged as `*`. + operationId: editIdentityProvider tags: - Identity Provider - description: >- - Configure an external Identity Provider to use as user authentication - operationId: Edit external Identity Provider settings requestBody: content: application/json: schema: + type: object + required: + - attr properties: items: - type: array - default: ["identity-provider"] + description: Not used attr: - type: object - properties: - authsource: - description: Specifies the type of the Identity Provider - type: string - enum: [ldap, keycloak, generic-oidc] - server_url: - description: The base URL of your Keycloak server. Required if `authsource` is keycloak. - type: string - realm: - description: The Keycloak realm where the mailcow client is configured. Required if `authsource` is keycloak. - type: string - client_id: - description: The Client ID assigned to mailcow Client in OIDC Provider. Required if `authsource` is keycloak or generic-oidc. - type: string - client_secret: - description: The Client Secret assigned to mailcow Client in OIDC Provider. Required if `authsource` is keycloak or generic-oidc. - type: string - redirect_url: - description: The redirect URL that OIDC Provider will use after authentication. Required if `authsource` is keycloak or generic-oidc. - type: string - redirect_url_extra: - description: Additional redirect URLs that OIDC Provider can use after authentication if valid. - type: array - version: - description: Specifies the Keycloak version. Required if `authsource` is keycloak. - type: string - default_template: - description: (Optional) If no matching Attribute Mapping exists for a User, the default template will be used for creating the mailbox, but not for updating the mailbox. - type: string - mappers: - description: (Optional) Attribute values used to match a mailbox template. Each element corresponds to the respective index in the templates array (i.e., the first element matches the first element of templates, the second matches the second, and so on). - type: array - templates: - description: (Optional) Defines the mailbox templates to be assigned. Each element corresponds to the respective index in the `mappers` array. - type: array - ignore_ssl_error: - description: If enabled, SSL certificate validation is bypassed - type: boolean - default: false - mailpassword_flow: - description: If enabled, mailcow will attempt to validate user credentials using the Keycloak Admin REST API instead of relying solely on the Authorization Code Flow. - type: boolean - default: false - periodic_sync: - description: If enabled, mailcow periodically performs a full sync of all users from Keycloak or LDAP. - type: boolean - default: false - import_users: - description: If enabled, new users are automatically imported from Keycloak or LDAP into mailcow. - type: boolean - default: false - sync_interval: - description: Defines the time interval (in minutes) for periodic synchronization and user imports. - type: number - default: 15 - host: - description: The address of your LDAP server. You can provide a single hostname or a comma-separated list of hosts for fallback in case the primary server is unreachable. Required if `authsource` is ldap. - type: string - port: - description: The port used to connect to the LDAP server. Required if `authsource` is ldap. - type: string - use_ssl: - description: enable LDAPS connection. If Port is set to 389 it will be overriden to 636. - type: boolean - default: false - use_tls: - description: enable TLS connection. TLS is recommended over SSL. SSL Ports cannot be used. - type: boolean - default: false - basedn: - description: The Distinguished Name (DN) from which searches will be performed. Required if `authsource` is ldap. - type: string - username_field: - description: The LDAP attribute used to identify users during authentication. Required if `authsource` is ldap. - type: string - default: mail - filter: - description: An optional LDAP search filter to refine which users can authenticate. - type: string - attribute_field: - description: (Optional) Specifies an LDAP attribute that holds a specific value which can be mapped to a mailbox template using the Attribute Mapping section. - type: string - binddn: - description: The Distinguished Name (DN) of the LDAP user that will be used to authenticate and perform LDAP searches. This account should have sufficient permissions to read the required attributes. Required if `authsource` is ldap. - type: string - bindpass: - description: The password for the Bind DN user. It is required for authentication when connecting to the LDAP server. Required if `authsource` is ldap. - type: string - authorize_url: - description: The OIDC provider's authorization server URL. Required if `authsource` is generic-oidc. - type: string - token_url: - description: The OIDC provider's token server URL. Required if `authsource` is generic-oidc. - type: string - userinfo_url: - description: The OIDC provider's user info server URL. Required if `authsource` is generic-oidc. - type: string - client_scopes: - description: Specifies the OIDC scopes requested during authentication. - type: string - default: "openid profile email mailcow_template" - offboarding: - description: (Keycloak, LDAP) Users disabled in the Identity Provider, or missing in two complete sync runs, get login disabled (mailbox active = 2, mail is still received). Only mailboxes disabled by the sync are enabled again. - type: boolean - default: false - offboarding_limit: - description: (Keycloak, LDAP) Sync runs that would disable more mailboxes or remove more group memberships than this share in percent (at least 5) are blocked. - type: number - default: 10 - group_sync: - description: (Keycloak, LDAP) Syncs the members of Identity Provider groups to mailbox groups. Members added in mailcow stay. - type: boolean - default: false - group_autocreate: - description: (Keycloak, LDAP) Creates a mailbox group for every Identity Provider group without one. The group mail becomes its address if it is free. - type: boolean - default: false - group_default_domain: - description: (Keycloak, LDAP) Domain of new groups whose mail is not in a mailcow domain. - type: string - group_paths: - description: (Keycloak) Comma-separated group paths, e.g. `/Sales` or `/mailcow/*` (all groups below /mailcow). Needs the `query-groups` permission. - type: string - id_attribute: - description: (LDAP) Attribute with a stable user id. Empty = objectGUID, entryUUID, nsUniqueId or ipaUniqueID. Changing it links all mailboxes again by email. - type: string - disabled_filter: - description: (LDAP) Filter for disabled users. Active Directory accounts with ACCOUNTDISABLE in userAccountControl are always disabled. - type: string - group_basedn: - description: (LDAP) Base DN of the group search, default `basedn`. - type: string - group_filter: - description: (LDAP) Filter for the synced groups, required for group sync. - type: string - group_name_attribute: - description: (LDAP) Attribute with the group name. - type: string - default: cn - group_member_mode: - description: "(LDAP) Source of the memberships: memberof (memberOf of the users), member (member attribute of the groups) or ad_chain (Active Directory incl. nested groups)." - type: string - enum: [memberof, member, ad_chain] - default: memberof - group_member_attribute: - description: (LDAP) Member attribute for group_member_mode member, e.g. member or uniqueMember. - type: string - default: member + $ref: "#/components/schemas/IdentityProviderAttributes" examples: keycloak: value: - items: - - "identity-provider" attr: - authsource: "keycloak" - server_url: "https://auth.mailcow.tld" - realm: "mailcow" - client_id: "mailcow_client" - client_secret: "Xy7GdPqvJ9m3R8sT2LkVZ5W1oNbCaYQf" - redirect_url: "https://mail.mailcow.tld" - redirect_url_extra: ["https://extramail.mailcow.tld"] - version: "26.1.3" - default_template: "Default" - mappers: ["small_mbox", "medium_mbox"] - templates: ["small", "medium"] - ignore_ssl_error: true - mailpassword_flow: true - periodic_sync: true - import_users: true - sync_interval: 30 + authsource: keycloak + server_url: https://auth.example.org + realm: mailcow + client_id: mailcow + client_secret: client-secret-value + redirect_url: https://mail.example.org + redirect_url_extra: + - https://webmail.example.org + version: 26.1.3 + login_provisioning: 1 + mailpassword_flow: 0 + periodic_sync: 1 + import_users: 1 + sync_interval: 15 + offboarding: 1 + offboarding_limit: 10 + group_sync: 1 + group_autocreate: 1 + group_paths: /mailcow/* + group_default_domain: example.org + default_template: Default + mappers: + - staff + templates: + - Staff ldap: value: - items: - - "identity-provider" attr: - authsource: "ldap" - host: "127.0.0.1" - port: "389" - use_ssl: false - use_tls: false - ignore_ssl_error: false - basedn: "DC=mailcow,DC=local" - username_field: "mail" - filter: "(memberOf:1.2.840.113556.1.4.1941:=DC=mailcow,DC=local)" - attribute_field: "othermailbox" - binddn: "CN=LDAP Read Only,CN=Users,DC=mailcow,DC=local" - bindpass: "moohoo" - default_template: "Default" - mappers: ["small_mbox", "medium_mbox"] - templates: ["small", "medium"] - periodic_sync: true - import_users: true + authsource: ldap + host: ldap1.example.org,ldap2.example.org + port: 636 + use_ssl: 1 + use_tls: 0 + ignore_ssl_error: 0 + basedn: dc=example,dc=org + binddn: cn=mailcow,ou=services,dc=example,dc=org + bindpass: bind-password + username_field: mail + filter: (objectClass=inetOrgPerson) + attribute_field: departmentNumber + login_provisioning: 1 + periodic_sync: 1 + import_users: 1 sync_interval: 30 + group_sync: 1 + group_filter: (objectClass=groupOfNames) + group_member_mode: member + group_member_attribute: member + default_template: Default generic-oidc: value: - items: - - "identity-provider" attr: - authsource: "generic-oidc" - authorize_url: "https://auth.mailcow.tld/application/o/authorize/" - token_url: "https://auth.mailcow.tld/application/o/token/" - userinfo_url: "https://auth.mailcow.tld/application/o/userinfo/" - client_id: "mailcow_client" - client_secret: "Xy7GdPqvJ9m3R8sT2LkVZ5W1oNbCaYQf" - redirect_url: "https://mail.mailcow.tld" - redirect_url_extra: ["https://extramail.mailcow.tld"] - client_scopes: "openid profile email mailcow_template" - default_template: "Default" - mappers: ["small_mbox", "medium_mbox"] - templates: ["small", "medium"] - ignore_ssl_error: true - summary: Edit external Identity Provider + authsource: generic-oidc + authorize_url: https://sso.example.org/application/o/authorize/ + token_url: https://sso.example.org/application/o/token/ + userinfo_url: https://sso.example.org/application/o/userinfo/ + client_id: mailcow + client_secret: client-secret-value + redirect_url: https://mail.example.org + client_scopes: openid profile email mailcow_template + login_provisioning: 1 + default_template: Default + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/identity-provider: + get: + summary: Get Identity Provider settings + description: >- + The stored settings of the external Identity Provider (admins and API + keys; `{}` for others). Values are returned as stored (mostly strings), + also settings of a provider that was configured before. Read-only API + keys get `*` instead of `client_secret`, `bindpass` and `access_token`; + read/write API keys get them in plain text. Without a configuration the + answer is `{"login_provisioning": 1}`. Any further path segment other + than `sync-status` returns the same. + operationId: getIdentityProvider + tags: + - Identity Provider + responses: + "200": + description: The settings + content: + application/json: + schema: + $ref: "#/components/schemas/IdentityProviderSettings" + example: + authsource: keycloak + server_url: https://auth.example.org + realm: mailcow + client_id: mailcow + client_secret: "*" + redirect_url: https://mail.example.org + redirect_url_extra: + - https://webmail.example.org + version: 26.1.3 + mailpassword_flow: "0" + periodic_sync: "1" + import_users: "1" + sync_interval: "15" + ignore_ssl_error: false + login_provisioning: true + group_paths: /mailcow/* + offboarding: "1" + offboarding_limit: "10" + group_sync: "1" + group_autocreate: "1" + group_default_domain: example.org + default_template: Default + mappers: + - staff + templates: + - Staff + access_token: "*" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/identity-provider-test: + post: + summary: Test an Identity Provider connection + description: >- + Admins only (every read/write API key). Tests the connection data in + `attr` (the stored settings are not used, send the secrets too): + Keycloak requests a client_credentials token from + `/realms//protocol/openid-connect/token`, generic-oidc + sends an OPTIONS request to `token_url` (both succeed on HTTP 200, 7 s + timeout), LDAP connects and binds with `host`, `port`, `basedn`, + `binddn`, `bindpass` (all required), `use_ssl`, `use_tls`. Nothing is + stored. `items` is not used. + + The answer carries no details: `{"type": "success", "msg": "Task + completed"}` or `{"type": "error", "msg": "Cannot edit item"}` + (`access_denied` for non-admins). A missing or unknown `authsource` + answers success without testing anything. + operationId: testIdentityProvider + tags: + - Identity Provider + requestBody: + content: + application/json: + schema: + type: object + required: + - attr + properties: + items: + description: Not used + attr: + type: object + required: + - authsource + properties: + authsource: + type: string + enum: + - keycloak + - generic-oidc + - ldap + server_url: + type: string + description: Keycloak base URL + realm: + type: string + description: Keycloak realm + client_id: + type: string + description: Keycloak client id + client_secret: + type: string + description: Keycloak client secret + token_url: + type: string + description: generic-oidc token endpoint + ignore_ssl_error: + type: + - boolean + - integer + - string + description: Do not verify the TLS certificate + host: + type: string + description: LDAP host, or several separated by commas + port: + type: + - integer + - string + description: LDAP port + basedn: + type: string + binddn: + type: string + bindpass: + type: string + use_ssl: + type: + - boolean + - integer + - string + description: LDAPS + use_tls: + type: + - boolean + - integer + - string + description: StartTLS, ignored with `use_ssl` + example: + attr: + authsource: keycloak + server_url: https://auth.example.org + realm: mailcow + client_id: mailcow + client_secret: client-secret-value + ignore_ssl_error: 0 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/identity-provider: + post: + summary: Delete the Identity Provider configuration + description: >- + Admins only (every read/write API key). Deletes all Identity Provider + settings, all mailbox links (`identity-link`) and the sync run history. + Refused with `authsource_in_use` while any mailbox uses an external + provider. The body must be valid JSON, its content is not used (e.g. + `[]`). Answers `item_deleted`, or `authsource_in_use`, `access_denied`. + operationId: deleteIdentityProvider + tags: + - Identity Provider + requestBody: + content: + application/json: + schema: + description: Not used, any JSON value + example: [] + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/filter: + post: + tags: + - Mailbox filters + summary: Create a Sieve filter + operationId: addFilter + description: >- + Adds a Sieve script to a mailbox. Needs the ACL `filters`, which admins + (API keys) and domain admins have, mailbox users not. `username` is + required for API keys and domain admins (`no_user_defined`). The script + is checked with a Sieve parser (`sieve_error`). Activating a filter + deactivates the other filters of the same type and mailbox. Errors: + `access_denied`, `script_empty`, `value_missing`, `filter_type`. + Success: `mailbox_modified`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - username + - script_data + - script_desc + - filter_type + properties: + username: + type: string + description: Mailbox of the filter + script_desc: + type: string + description: Description + script_data: + type: string + description: The Sieve script + filter_type: + type: string + enum: + - prefilter + - postfilter + description: Runs before (prefilter) or after (postfilter) the user's own filters + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + default: 0 + example: + username: john.doe@example.org + script_desc: Move newsletters + script_data: |- + require "fileinto"; + if header :contains "List-Id" "newsletter" { + fileinto "Newsletters"; + } + filter_type: prefilter + active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/filters/all: + get: + tags: + - Mailbox filters + summary: Get all Sieve filters + operationId: getFilters + description: >- + The Sieve filters of all mailboxes the caller manages, `{}` when there + are none. + responses: + "200": + description: List of filters + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SieveFilter" + example: + - active: 1 + id: 7 + username: john.doe@example.org + filter_type: prefilter + script_data: |- + require "fileinto"; + if header :contains "List-Id" "newsletter" { + fileinto "Newsletters"; + } + script_desc: Move newsletters + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/filters/{mailbox}: + get: + tags: + - Mailbox filters + summary: Get the Sieve filters of a mailbox + operationId: getMailboxFilters + description: >- + The Sieve filters of the mailbox, `{}` when there are none or the + caller has no access. If `{mailbox}` is no address, the session user's + filters are returned (nothing for API keys). + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + responses: + "200": + description: List of filters + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SieveFilter" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/filter: + post: + tags: + - Mailbox filters + summary: Update Sieve filters + operationId: editFilter + description: >- + Changes filters by ID; fields that are not sent keep their value. + Needs the ACL `filters` (admins, domain admins). Activating a filter + deactivates the other filters of the same type and mailbox. Messages + per ID: `mailbox_modified` or `access_denied` (unknown ID or no + access), `sieve_error`, `filter_type`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - items + - attr + properties: + items: + description: Filter ID or list of IDs + oneOf: + - type: integer + - type: array + items: + type: integer + attr: + type: object + properties: + script_desc: + type: string + script_data: + type: string + filter_type: + type: string + enum: + - prefilter + - postfilter + active: + type: [integer, string] + enum: [0, 1, "0", "1"] + example: + items: + - 7 + attr: + active: 0 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/filter: + post: + tags: + - Mailbox filters + summary: Delete Sieve filters + operationId: deleteFilter + description: >- + Deletes filters by ID; IDs that are not numeric are skipped. Needs the + ACL `filters` (admins, domain admins). Messages per ID: `delete_filter` + or `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + type: array + description: Filter IDs + items: + type: integer + example: + - 7 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/active-user-sieve/{mailbox}: + get: + tags: + - Mailbox filters + summary: Get the active Sieve script of a mailbox + operationId: getActiveUserSieve + description: >- + The Sieve script the user activated in its mail client or SOGo (read + from Dovecot), as a list with one string. `{}` when there is none or + the caller has no access. If `{mailbox}` is no address, the session + user's script is read (nothing for API keys). + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + responses: + "200": + description: The script as the only element of a list + content: + application/json: + schema: + type: array + maxItems: 1 + items: + type: string + example: + - |- + require ["fileinto"]; + if header :contains "subject" "invoice" { + fileinto "Invoices"; + } + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/add/mailbox-policy: + post: + tags: + - Mailbox antispam policies + summary: Add a sender to the allow or deny list of a mailbox + operationId: addMailboxPolicy + description: >- + Adds a sender address or pattern to the spam allow list (`wl`, mail is + never treated as spam) or deny list (`bl`, mail is rejected) of a + mailbox. Needs the user ACL `spam_policy`. `username` is required for + all callers; a mailbox user may only name its own mailbox. Errors: + `access_denied`, `policy_list_from_invalid` (characters other than + letters, digits, `@ _ . - *`), `policy_list_from_exists`. Success: + `mailbox_modified`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - username + - object_list + - object_from + properties: + username: + type: string + description: Mailbox + object_list: + type: string + enum: + - wl + - bl + description: wl = allow list, bl = deny list + object_from: + type: string + description: Sender address, domain or pattern with `*` + example: + username: john.doe@example.org + object_list: bl + object_from: "*@spam.example.com" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/policy_wl_mailbox/{mailbox}: + get: + tags: + - Mailbox antispam policies + summary: Get the allow list of a mailbox + operationId: getMailboxPolicyAllowList + description: >- + The allow list entries that apply to the mailbox: its own and those of + its domain (`object` tells which). `{}` when there are none, the + mailbox does not exist or the caller has no access. If `{mailbox}` is + no address, the session user's list is returned (nothing for API + keys). + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + responses: + "200": + description: List of entries + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SpamPolicyEntry" + example: + - object: john.doe@example.org + value: "*@partner.example.com" + prefid: 12 + - object: example.org + value: newsletter@example.net + prefid: 4 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/policy_bl_mailbox/{mailbox}: + get: + tags: + - Mailbox antispam policies + summary: Get the deny list of a mailbox + operationId: getMailboxPolicyDenyList + description: >- + Like `get/policy_wl_mailbox/{mailbox}` for the deny list. + parameters: + - name: mailbox + in: path + required: true + description: Mailbox address + schema: + type: string + example: john.doe@example.org + responses: + "200": + description: List of entries + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SpamPolicyEntry" + example: + - object: john.doe@example.org + value: "*@spam.example.com" + prefid: 13 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/delete/mailbox-policy: + post: + tags: + - Mailbox antispam policies + summary: Delete allow or deny list entries of mailboxes + operationId: deleteMailboxPolicy + description: >- + Deletes allow or deny list entries of mailboxes by their `prefid`. + Needs the user ACL `spam_policy` and access to the mailbox of the entry + (entries of a domain are refused, use `delete/domain-policy`). Messages + per ID: `items_deleted` or `access_denied`. + requestBody: + required: true + content: + application/json: + schema: + type: array + description: prefid values + items: + type: integer + example: + - 13 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/add/rsetting: + post: + summary: Add Rspamd settings rule + description: >- + Adds a rule to the Rspamd settings map (admins only, an API key may). + `content` is the body of an Rspamd settings entry (`priority`, `rcpt`, + `from`, `authenticated`, `apply` ...); active rules are handed to + Rspamd as `additional_settings_`. The presets of + `GET /api/v1/get/presets/rspamd` are examples. + + + Messages: `settings_map_added`; `map_content_empty` (danger) when + `content` is empty; `access_denied` for non-admins. `desc` is required + by the database: without it the insert fails and the answer is empty. + operationId: addRspamdSetting + tags: + - Rspamd + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/RspamdSettingAttributes" + example: + desc: Disable all but DKIM and rate limit for authenticated users + content: |- + priority = 10; + authenticated = yes; + apply "default" { + symbols_enabled = ["DKIM_SIGNED", "RATELIMITED", "RATELIMIT_UPDATE", "RATELIMIT_CHECK"]; + } + active: "1" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/rsetting/{id}: + get: + summary: Get Rspamd settings rules + description: >- + Returns one rule of the Rspamd settings map, or all rules with `all` + (a list of the same objects). Admins only (an API key may); other + callers, unknown IDs and an empty map get `{}`. + operationId: getRspamdSettings + tags: + - Rspamd + parameters: + - name: id + in: path + required: true + description: "`all` or the ID of a rule" + schema: + type: string + example: all + responses: + "200": + description: The rule, or the list of rules for `all` + content: + application/json: + schema: + oneOf: + - type: array + items: + $ref: "#/components/schemas/RspamdSetting" + - $ref: "#/components/schemas/RspamdSetting" + example: + - id: 1 + desc: Disable Rspamd for example.org + content: |- + priority = 10; + rcpt = "/.*@example\.org$/"; + from = "/.*/"; + apply "default" { + MAILCOW_WHITE = -9999.0; + } + symbols [ + "MAILCOW_WHITE" + ] + active: 1 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/rsetting: + post: + summary: Edit Rspamd settings rules + description: >- + Changes rules of the Rspamd settings map (admins only, an API key may). + Only the attributes that are sent change; an empty `desc` or `content` + keeps the old value. `content` is trimmed. + + + Messages: `object_modified` (with all requested IDs) for every changed + rule, `settings_map_invalid` (danger) for an unknown ID, + `access_denied` for non-admins. + operationId: editRspamdSetting + tags: + - Rspamd + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: ID or list of IDs of the rules + oneOf: + - type: array + items: + type: [integer, string] + - type: [integer, string] + attr: + type: object + properties: + desc: + type: string + description: Short description (empty = keep) + content: + type: string + description: Rule content (empty = keep) + active: + type: [boolean, integer, string] + description: 1 = the rule is handed to Rspamd, 0 = inactive (not sent = keep) + required: + - items + - attr + example: + items: + - 1 + attr: + desc: Disable Rspamd for example.org + active: "0" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/rsetting: + post: + summary: Delete Rspamd settings rules + description: >- + Deletes rules of the Rspamd settings map (admins only, an API key may). + Answers `settings_map_removed` for every ID, also for IDs that do not + exist; `access_denied` for non-admins. + operationId: deleteRspamdSettings + tags: + - Rspamd + requestBody: + content: + application/json: + schema: + description: IDs of the rules (a single ID works too) + oneOf: + - type: array + items: + type: [integer, string] + - type: [integer, string] + example: + - 1 + - 2 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/rspamd-map: + post: + summary: Edit Rspamd global regex maps + description: >- + Replaces the content of global Rspamd regex maps (Configuration > + Global filters in the UI; admins only, an API key may). Each line is a + regular expression like `/^spam@example\.org$/i`, lines starting with + `#` are comments. The UI checks the expressions before it saves, the + API does not. Every map takes about a second (the file is touched + again for Rspamd to reload it). + + + Messages: `object_modified` per map, `global_map_invalid` (danger) for + an unknown map name, `global_map_write_error` (danger) when the file + cannot be written, `access_denied` for non-admins. A known map whose + file does not exist is not written but still answered with + `object_modified`. + operationId: editRspamdMap + tags: + - Rspamd + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: File name or list of file names of the maps; all of them get the same content + oneOf: + - type: array + items: + $ref: "#/components/schemas/RspamdRegexMap" + - $ref: "#/components/schemas/RspamdRegexMap" + attr: + type: object + properties: + rspamd_map_data: + type: string + description: New content of the map (trimmed, replaces the whole file) + required: + - rspamd_map_data + required: + - items + - attr + example: + items: + - global_smtp_from_blacklist.map + attr: + rspamd_map_data: |- + # Envelope senders to reject + /^spammer@example\.com$/i + /@spam\.example\.net$/i + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/rspamd/actions: + get: + summary: Get Rspamd action statistics + description: >- + Returns how many messages Rspamd handled with each action (from + Rspamd's `/stat`), as `[action, count]` pairs. Greylisted + messages are counted as `soft reject`. Admins only (an API key may); + other callers, or an Rspamd that does not answer, get `{}`. + operationId: getRspamdActions + tags: + - Rspamd + responses: + "200": + description: List of `[action, count]` pairs + content: + application/json: + schema: + type: array + items: + type: array + prefixItems: + - type: string + description: Action, e.g. `reject`, `soft reject`, `rewrite subject`, `add header`, `no action` + - type: integer + description: Number of messages + minItems: 2 + maxItems: 2 + example: + - - reject + - 38 + - - soft reject + - 1 + - - rewrite subject + - 0 + - - add header + - 6 + - - no action + - 255 + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/presets/{kind}: + get: + summary: Get Rspamd or Sieve presets + description: >- + Returns the example presets the UI offers: `rspamd` for Rspamd settings + rules (see `POST /api/v1/add/rsetting`), `sieve` for Sieve filters. + `headline` is translated into the language of the session. Admins and + domain admins (an API key may); other callers get `{}`. + operationId: getPresets + tags: + - Rspamd + - Global filters + parameters: + - name: kind + in: path + required: true + description: Kind of presets + schema: + type: string + enum: + - rspamd + - sieve + example: rspamd + responses: + "200": + description: List of presets + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Preset" + example: + - name: preset_1 + headline: Disable all but DKIM and rate limit for authenticated users + content: |- + priority = 10; + authenticated = yes; + apply "default" { + symbols_enabled = ["BCC", "DKIM_SIGNED", "RATELIMITED", "RATELIMIT_UPDATE", "RATELIMIT_CHECK", "DYN_RL_CHECK", "HISTORY_SAVE", "MILTER_HEADERS", "ARC_SIGNED"]; + } + - name: preset_4 + headline: Disable Rspamd for a domain + content: |- + priority = 10; + rcpt = "/.*@example.org$/"; + from = "/.*/"; + apply "default" { + MAILCOW_WHITE = -9999.0; + } + symbols [ + "MAILCOW_WHITE" + ] + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/add/global-filter: + post: + summary: Save a global Sieve filter + description: >- + Replaces the global Sieve prefilter (runs before the filters of the + mailboxes) or postfilter (runs after them) and restarts Dovecot. + Admins only (an API key may). The script is parsed first and not + saved when it has errors. + + + Messages: `dovecot_restart_success`, or `dovecot_restart_failed` + (warning), then `global_filter_written`; `sieve_error` (danger) with + the parser error; `invalid_filter_type` (danger); + `global_filter_write_error` (danger); `access_denied` for non-admins. + operationId: addGlobalFilter + tags: + - Global filters + requestBody: + content: + application/json: + schema: + type: object + properties: + filter_type: + type: string + enum: + - prefilter + - postfilter + description: Which global filter to replace + script_data: + type: string + description: The Sieve script (CRLF and CR line ends become LF) + required: + - filter_type + - script_data + example: + filter_type: postfilter + script_data: |- + require "fileinto"; + if header :contains "X-Spam-Flag" "YES" { + fileinto "Junk"; + } + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/global_filters/all: + get: + summary: Get the global Sieve filters + description: >- + Returns the global Sieve prefilter and postfilter. Admins only (an API + key may); other callers get `{}`. + operationId: getGlobalFilters + tags: + - Global filters + responses: + "200": + description: Both scripts + content: + application/json: + schema: + $ref: "#/components/schemas/GlobalFilters" + example: + prefilter: | + # global_sieve_before script + # global_sieve_before -> user sieve_before (mailcow UI) -> user sieve_after (mailcow UI) -> global_sieve_after + postfilter: |- + # global_sieve_after script + require "fileinto"; + + if header :contains "X-Spam-Flag" "YES" { + fileinto "Junk"; + } + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/global_filters/{filter_type}: + get: + summary: Get one global Sieve filter + description: >- + Returns the global Sieve prefilter or postfilter as a JSON string. + Admins only (an API key may); other callers and an empty script get + `{}`. + operationId: getGlobalFilter + tags: + - Global filters + parameters: + - name: filter_type + in: path + required: true + description: Which global filter + schema: + type: string + enum: + - prefilter + - postfilter + example: prefilter + responses: + "200": + description: The Sieve script + content: + application/json: + schema: + type: string + example: | + # global_sieve_before script + # global_sieve_before -> user sieve_before (mailcow UI) -> user sieve_after (mailcow UI) -> global_sieve_after + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/quota_notification: + post: + summary: Edit quota notification settings + description: >- + Saves sender, subject and template of the mails users get when their + mailbox crosses 80 % and 95 % of its quota (admins only, an API key + may). `items` is ignored. Every call writes all three values: one that + is not sent becomes empty. The current values cannot be read through + the API. + + + Messages: `saved_settings`; `redis_error` (danger); `access_denied` + for non-admins. + operationId: editQuotaNotification + tags: + - Quota notifications + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: Ignored + attr: + type: object + properties: + sender: + type: string + description: Sender address (control characters and non-ASCII bytes are removed; an invalid address becomes empty) + subject: + type: string + description: Subject + html_tmpl: + type: string + description: HTML template (empty = the default template) + required: + - attr + example: + items: + - quota_notification + attr: + sender: quota-warning@example.org + subject: Quota warning + html_tmpl: "" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/quota_notification_bcc: + post: + summary: Edit quota notification BCC of a domain + description: >- + Sets the addresses that get a copy of every quota warning of a domain + (the subject gets the username appended). Admins and domain admins of + the domain (an API key may; the domain or alias domain must exist). + `items` is ignored. The list replaces the old one; the current list + cannot be read through the API. + + + Messages: `saved_settings`; `goto_invalid` (danger) for an invalid + address, which is left out while the others are saved; `redis_error` + (danger); `access_denied` for an unknown or foreign domain. + operationId: editQuotaNotificationBcc + tags: + - Quota notifications + requestBody: + content: + application/json: + schema: + type: object + properties: + items: + description: Ignored + attr: + type: object + properties: + domain: + type: string + description: Domain or alias domain + bcc_rcpt: + type: string + description: Addresses, separated by space, comma, semicolon or newline + active: + type: [boolean, integer, string] + description: 1 = send the copies; becomes 0 when no valid address is left + required: + - domain + required: + - attr + example: + items: + - quota_bcc + attr: + domain: example.org + bcc_rcpt: |- + postmaster@example.org + helpdesk@example.org + active: "1" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/admin/all: + get: + summary: Get administrators + description: >- + Lists all administrators with their details. Domain administrators are + not part of this list (see `get/domain-admin/all`). The API key itself + (account `API`) is no administrator account and is not listed. + operationId: getAdmins + tags: + - Administrators + responses: + "200": + description: The administrators, `{}` if there are none + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Admin" + example: + - username: admin + tfa_active: 1 + tfa_active_int: 1 + active: 1 + active_int: 1 + created: "2026-09-21 10:23:08" + attributes: + force_tfa: "0" + force_pw_update: "0" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/admin/{username}: + get: + summary: Get administrator + description: Details of one administrator. + operationId: getAdmin + tags: + - Administrators + parameters: + - name: username + in: path + required: true + description: Username of the administrator + schema: + type: string + example: admin + responses: + "200": + description: The administrator, `{}` if there is no administrator of this name + content: + application/json: + schema: + $ref: "#/components/schemas/Admin" + example: + username: admin + tfa_active: 0 + tfa_active_int: 0 + active: 1 + active_int: 1 + created: "2026-09-21 10:23:08" + attributes: + force_tfa: "0" + force_pw_update: "0" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/add/admin: + post: + summary: Create administrator + description: >- + Creates an administrator account with full access to mailcow. + The username is converted to lowercase; it may only contain letters, + digits, `_`, `.` and `-` and must not be used by another administrator + or domain administrator. The password must match `password2` and the + password policy. + + + Success: `admin_added`. Errors: `username_invalid`, `object_exists`, + `password_complexity`, `password_mismatch`. + operationId: addAdmin + tags: + - Administrators + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + description: Username (letters, digits, `_`, `.`, `-`; stored in lowercase) + password: + type: string + description: Password, must match the password policy + password2: + type: string + description: The password again + active: + type: [integer, string, boolean] + description: >- + 1 = the account can log in, 0 = disabled. Missing counts as + 0: send `1` for a usable account. + default: 0 + required: + - username + - password + - password2 + example: + username: admin2 + password: "Rand0m-Passw0rd!" + password2: "Rand0m-Passw0rd!" + active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/admin: + post: + summary: Edit administrators + description: >- + Changes administrator accounts. Attributes that are not sent keep their + value, except `force_tfa` and `force_pw_update`, which are set to 0 when + they are missing. The password is only changed when `password` is not + empty. Renaming an account moves its two-factor authentication and the + sync job sources it created to the new name (FIDO2 keys stay with the + old name). + + + One message per account. Success: `admin_modified`. Errors: + `access_denied` (no administrator of this name), `username_invalid` + (invalid `username_new` or the name of another administrator), warning + `no_active_admin` (the last active administrator cannot be + deactivated). A password that does not match `password2` or the policy + (`password_mismatch`, `password_complexity`) stops the call. + operationId: editAdmin + tags: + - Administrators + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + items: + description: Username of the administrator, or a list of usernames + oneOf: + - type: string + - type: array + items: + type: string + attr: + type: object + properties: + active: + type: [integer, string, boolean] + description: 1 = active, 0 = disabled + username_new: + type: string + description: New username (letters, digits, `_`, `.`, `-`) + password: + type: string + description: New password; empty or missing keeps the password + password2: + type: string + description: The new password again + force_tfa: + type: [integer, string, boolean] + description: 1 = the administrator must set up two-factor authentication at the next login + default: 0 + force_pw_update: + type: [integer, string, boolean] + description: 1 = the administrator must change the password at the next login + default: 0 + disable_tfa: + type: [integer, string, boolean] + description: >- + Present (with any value) = deactivate the two-factor + authentication of the account + required: + - items + - attr + example: + items: + - admin2 + attr: + active: 1 + password: "N3w-Passw0rd!" + password2: "N3w-Passw0rd!" + force_pw_update: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/delete/admin: + post: + summary: Delete administrators + description: >- + Deletes administrator accounts with their two-factor authentication + and FIDO2 keys; sync job sources they created are released. + + + One message per account. Success: `admin_removed`. Errors: + `username_invalid` (no administrator of this name), warning + `cannot_delete_self` (the account of the current login, never the case + for an API key). + operationId: deleteAdmin + tags: + - Administrators + requestBody: + required: true + content: + application/json: + schema: + type: array + description: Usernames of the administrators + items: + type: string + example: + - admin2 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/get/passwordpolicy: + get: + summary: Get password policy + description: >- + The password policy for mailboxes, administrators and domain + administrators. Any further path segment except `html` gives the same + answer. + operationId: getPasswordPolicy + tags: + - Password policy + responses: + "200": + description: The policy + content: + application/json: + schema: + $ref: "#/components/schemas/PasswordPolicy" + example: + length: "8" + chars: "1" + special_chars: "0" + lowerupper: "1" + numbers: "1" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/get/passwordpolicy/html: + get: + summary: Get password policy as HTML + description: >- + The password policy as an HTML snippet in the default language, as the + mailcow UI shows it below password fields. + operationId: getPasswordPolicyHtml + tags: + - Password policy + responses: + "200": + description: The HTML snippet as a JSON string + content: + application/json: + schema: + type: string + example:

- Minimum password length is 8
- Must contain at least one alphabetic character
- Must contain lowercase and uppercase characters
- Must contain at least one number

+ "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/edit/passwordpolicy: + post: + summary: Edit password policy + description: >- + Changes the password policy; attributes that are not sent keep their + value. The policy is checked when a password is set, existing passwords + are not affected. `items` is not used. + + + Success: `password_policy_saved`. + operationId: editPasswordPolicy + tags: + - Password policy + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + attr: + type: object + properties: + length: + type: [integer, string] + description: Minimum length; values below 3 keep the current length + chars: + type: [integer, string, boolean] + description: 1 = at least one letter + lowerupper: + type: [integer, string, boolean] + description: 1 = lowercase and uppercase letters + special_chars: + type: [integer, string, boolean] + description: 1 = at least one character that is no letter or digit + numbers: + type: [integer, string, boolean] + description: 1 = at least one digit + required: + - attr + example: + attr: + length: 12 + chars: 1 + lowerupper: 1 + special_chars: 0 + numbers: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/reset-password-notification: + post: + summary: Edit password reset e-mail + description: >- + Sets sender, subject and templates of the e-mail a mailbox user gets + to reset a forgotten password (sent to the recovery address of the + mailbox). All four values are replaced: a missing value is saved as + empty; without both templates the default templates are used. + Password reset is only offered while sender and subject are set. + Subject and templates may use the placeholders `{{username}}`, + `{{username2}}` (recovery address), `{{date}}`, `{{token_lifetime}}` + (minutes), `{{hostname}}` and `{{link}}`. `items` is not used. + + + Success: `saved_settings`. + operationId: editPasswordResetNotification + tags: + - Password policy + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + attr: + type: object + properties: + from: + type: string + description: Sender address; an invalid address is saved as empty + subject: + type: string + description: Subject + text_tmpl: + type: string + description: Plain text template + html_tmpl: + type: string + description: HTML template (the text template becomes its alternative part) + required: + - attr + example: + attr: + from: noreply@example.org + subject: Reset your password + text_tmpl: "Hello {{username}}, open {{link}} within {{token_lifetime}} minutes to set a new password." + html_tmpl: "

Hello {{username}},

Set a new password within {{token_lifetime}} minutes.

" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/app_links: + post: + summary: Edit app links + description: >- + Replaces the custom links of the "Apps" menu. The links are sent as + four lists of equal length, one entry per link; if the lengths differ, + nothing is saved but `app_links` is reported anyway. An empty list + removes all custom links. The links of mailcow itself (SOGo, ...) are + set in vars.local.inc.php and are not affected. `items` is not used. + + + Success: `app_links`. Error: `demo_mode_enabled`. + operationId: editAppLinks + tags: + - UI customization + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + attr: + type: object + properties: + app: + type: array + description: Names of the links + items: + type: string + href: + type: array + description: URLs shown on the login page and to users without `user_href` + items: + type: string + user_href: + type: array + description: >- + URLs for logged-in accounts, `%u` is replaced with the + username; empty = `href` + items: + type: string + hide: + type: array + description: >- + Per link: `"0"` or `0` shows it on the login page, any + other value (also `false`) hides it there + items: + type: [string, integer, boolean] + required: + - attr + example: + attr: + app: + - Wiki + - Webmail (Roundcube) + href: + - https://wiki.example.org + - https://mail.example.org/rc/ + user_href: + - https://wiki.example.org + - https://mail.example.org/rc/?_user=%u + hide: + - "0" + - "1" + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/custom_login: + post: + summary: Edit login page options + description: >- + Sets the options of the login pages. All four options are replaced: a + missing option counts as 0. `items` is not used. + + + Success: `custom_login_modified`. Error: `demo_mode_enabled`. + operationId: editCustomLogin + tags: + - UI customization + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + attr: + type: object + properties: + hide_user_quicklink: + type: [integer, string, boolean] + description: 1 = hide the link to the user login page + default: 0 + hide_domainadmin_quicklink: + type: [integer, string, boolean] + description: 1 = hide the link to the domain administrator login page + default: 0 + hide_admin_quicklink: + type: [integer, string, boolean] + description: 1 = hide the link to the administrator login page + default: 0 + force_sso: + type: [integer, string, boolean] + description: >- + 1 = with an OIDC identity provider configured, show only + the Single Sign-On button instead of the mailcow login + form + default: 0 + required: + - attr + example: + attr: + hide_user_quicklink: 0 + hide_domainadmin_quicklink: 1 + hide_admin_quicklink: 1 + force_sso: 0 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/ip_check: + post: + summary: Edit IP check + description: >- + Opt-in for the IP check of the dashboard, which asks the third party + services ipv4.mailcow.email and ipv6.mailcow.email for the public + addresses of the server. `items` is not used. + + + Success: `ip_check_opt_in_modified`. Error: `demo_mode_enabled`. + operationId: editIpCheck + tags: + - UI customization + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + attr: + type: object + properties: + ip_check_opt_in: + type: [integer, string, boolean] + description: 1/true = enabled; anything else or missing = disabled + default: 0 + required: + - attr + example: + attr: + ip_check_opt_in: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/ui_texts: + post: + summary: Edit UI texts + description: >- + Sets the texts of the mailcow UI. All values are replaced: a missing + value is saved as empty, which means the default for the three names. + `items` is not used. + + + Success: `ui_texts`. Error: `demo_mode_enabled`. + operationId: editUiTexts + tags: + - UI customization + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + attr: + type: object + properties: + title_name: + type: string + description: Title of the browser window (plain text); empty = " - mail UI" + main_name: + type: string + description: Name shown on the start page (plain text); empty = " - mail UI" + apps_name: + type: string + description: Name of the "Apps" menu (plain text); empty = "Apps" + help_text: + type: string + description: Help text below the login form (HTML) + ui_footer: + type: string + description: Footer of every page (HTML) + ui_announcement_text: + type: string + description: Announcement for all logged-in accounts and on the login page (HTML) + ui_announcement_type: + type: string + enum: + - info + - warning + - danger + description: Style of the announcement; other values are saved as empty + ui_announcement_active: + type: [integer, string, boolean] + description: Not empty and not 0 = show the announcement + default: 0 + required: + - attr + example: + attr: + title_name: Example Mail + main_name: Example Mail + apps_name: Apps + help_text: "Questions? Write to support@example.org." + ui_footer: "Imprint" + ui_announcement_text: Maintenance on Saturday from 8 to 10 am. + ui_announcement_type: warning + ui_announcement_active: 1 + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/edit/ui_theme: + post: + summary: Edit UI theme + description: >- + Selects the theme of the mailcow UI. Themes are folders + `data/web/css/themes//` with a `theme.css`. `items` is not used. + + + Success: `ui_theme_modified`. Errors: `ui_theme_invalid` (no theme of + this name), `demo_mode_enabled`. + operationId: editUiTheme + tags: + - UI customization + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + attr: + type: object + properties: + ui_theme: + description: >- + Folder name of the theme; empty or missing = the default + theme. A list counts with its first entry. + oneOf: + - type: string + - type: array + items: + type: string + required: + - attr + example: + attr: + ui_theme: servercow + responses: + "200": + $ref: "#/components/responses/WriteResult" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" tags: - name: Domains - description: You can create antispam whitelist and blacklist policies + description: >- + Domains, alias domains, domain templates, MTA-STS policies and + domain-wide footers - name: Domain antispam policies - description: You can edit the Domain Antispam policies + description: Sender allow lists (whitelists) and deny lists (blacklists) of domains - name: Mailboxes - description: You can manage mailboxes + description: Mailboxes, their templates, tags, ACLs and logins - name: Aliases description: You can manage aliases - name: Sync jobs description: Using Syncjobs you can sync your mails with other email servers - - name: Fordwarding Hosts - description: Forwarding Hosts enable you to send mail using a relay - name: Logs - description: Get all mailcow system logs + description: Logs of the mailcow containers and the UI - name: Queue Manager description: Manage the postfix mail queue - name: Quarantine @@ -7996,32 +15106,60 @@ tags: - name: Fail2Ban description: Manage the Netfilter fail2ban options - name: DKIM - description: Manage DKIM keys + description: Generate, import, copy and delete DKIM keys - name: Domain admin - description: Create or udpdate domain admin users + description: Domain administrators, their domains and ACLs - name: Single Sign-On - description: Issue tokens for users + description: Login tokens for domain administrators - name: Address Rewriting - description: Create BCC maps or recipient maps + description: BCC maps and recipient maps - name: Outgoing TLS Policy Map Overrides - description: Force global TLS policys + description: TLS policies for outgoing connections to single destinations - name: oAuth Clients - description: Use mailcow as a oAuth server + description: Clients that use mailcow as OAuth2 server - name: API Keys - description: Manage API keys + description: API keys; service keys from mailcow.conf are read-only - name: Groups description: Mailbox groups (distribution lists, send-as rights, templates) - name: Routing - description: Define your own email routes + description: Sender-dependent transports (relay hosts) and transport maps for outgoing mail - name: Resources - description: Manage ressources + description: Bookable resources (rooms, equipment, groups) for SOGo - name: App Passwords - description: Create mailbox app passwords + description: Separate passwords of a mailbox for single clients and protocols - name: Status - description: Get the status of your cow + description: Containers, mail storage, host and version - name: Ratelimits description: Edit domain ratelimits - name: Cross-Origin Resource Sharing (CORS) - description: Manage Cross-Origin Resource Sharing (CORS) settings + description: Origins and methods that may call the API from a browser - name: Identity Provider - description: Manage external Identity Provider settings + description: >- + External Identity Provider (Keycloak, OpenID Connect, LDAP): settings, + identity sync and links of mailboxes + - name: Mailbox user settings + description: >- + Settings a mailbox user changes on its own page (with the matching user + ACL): time-limited aliases, spam score, TLS policy, tagged mail, + Pushover. API keys can change them for any mailbox and must name the + mailbox; a mailbox user only for its own. + - name: Mailbox filters + description: Sieve filters of mailboxes, managed by admins and domain admins + - name: Mailbox antispam policies + description: Sender allow and deny lists of mailboxes + - name: Forwarding Hosts + description: >- + Hosts whose incoming mail is accepted unconditionally, e.g. servers that + forward mail to mailcow + - name: Rspamd + description: Rspamd settings rules, global regex maps, presets and statistics + - name: Global filters + description: Global Sieve filters that run before and after the filters of the mailboxes, Sieve presets + - name: Quota notifications + description: Mails to users whose mailbox is nearly full + - name: Administrators + description: Administrator accounts + - name: Password policy + description: Password rules and the password reset e-mail + - name: UI customization + description: Texts, links, login page and theme of the mailcow UI diff --git a/tests/php/Api/Admin/AdminRoutesTest.php b/tests/php/Api/Admin/AdminRoutesTest.php new file mode 100644 index 000000000..34001b374 --- /dev/null +++ b/tests/php/Api/Admin/AdminRoutesTest.php @@ -0,0 +1,82 @@ + 'admin', 'tfa_active' => 0, 'tfa_active_int' => 0, 'active' => 1, 'active_int' => 1, + 'created' => '2026-09-21 10:23:08', 'attributes' => ['force_tfa' => '0', 'force_pw_update' => '0']]; + + public function testGetAllCollectsTheDetailsOfEveryAdmin(): void + { + $result = $this->get('get/admin/all', ['script' => [ + self::answer('admin', ['get'], ['admin', 'gone']), + self::answer('admin', ['details', 'admin'], self::DETAILS), + self::answer('admin', ['details', 'gone'], false), + ]]); + + $this->assertSame([self::DETAILS], $result->json()); + $this->assertSame([['get'], ['details', 'admin'], ['details', 'gone']], $result->callsTo('admin')); + } + + public function testGetAllWithoutAdmins(): void + { + $result = $this->get('get/admin/all', ['script' => [self::answer('admin', ['get'], [])]]); + + $this->assertSame('{}', $result->body); + $this->assertSame([['get']], $result->callsTo('admin')); + } + + public function testGetOne(): void + { + $found = $this->get('get/admin/admin', ['script' => [self::answer('admin', ['details', 'admin'], self::DETAILS)]]); + $this->assertSame(self::DETAILS, $found->json()); + + $missing = $this->get('get/admin/nobody', ['script' => [self::answer('admin', ['details'], false)]]); + $this->assertSame('{}', $missing->body); + $this->assertCalled($missing, 'admin', ['details', 'nobody']); + } + + public function testAdd(): void + { + $attr = ['username' => 'admin2', 'password' => 'Rand0m-Passw0rd!', 'password2' => 'Rand0m-Passw0rd!', 'active' => 1]; + $messages = [self::message('success', ['admin_added', 'admin2'], ['admin', 'add', ['username' => 'admin2', 'password' => '*']])]; + + $result = $this->post('add/admin', $attr, ['script' => [self::answer('admin', ['add'], null, $messages)]]); + + $this->assertSame([['add', $attr]], $result->callsTo('admin')); + $this->assertSame($messages, $result->json()); + } + + public function testEdit(): void + { + $result = $this->post('edit/admin', ['items' => ['admin2', 'admin3'], 'attr' => ['active' => '0', 'force_tfa' => 1]]); + + $this->assertSame([['edit', ['username' => ['admin2', 'admin3'], 'active' => '0', 'force_tfa' => 1]]], $result->callsTo('admin')); + } + + public function testEditTakesASingleUsername(): void + { + $result = $this->post('edit/admin', ['items' => 'admin2', 'attr' => ['password' => 'x', 'password2' => 'x']]); + + $this->assertCalled($result, 'admin', ['edit', ['username' => ['admin2'], 'password' => 'x', 'password2' => 'x']]); + } + + public function testDelete(): void + { + $messages = [ + self::message('success', ['admin_removed', 'admin2']), + self::message('danger', ['username_invalid', 'nobody']), + ]; + + $result = $this->post('delete/admin', ['admin2', 'nobody'], ['script' => [self::answer('admin', ['delete'], null, $messages)]]); + + $this->assertSame([['delete', ['username' => ['admin2', 'nobody']]]], $result->callsTo('admin')); + $this->assertSame($messages, $result->json()); + } +} diff --git a/tests/php/Api/Admin/ApiKeyRoutesTest.php b/tests/php/Api/Admin/ApiKeyRoutesTest.php new file mode 100644 index 000000000..ec2807ce1 --- /dev/null +++ b/tests/php/Api/Admin/ApiKeyRoutesTest.php @@ -0,0 +1,76 @@ +api->'; + private const KEY = ['id' => 3, 'description' => 'provisioning', 'access' => 'rw', 'api_key' => '1B7E2C-94F0A3-6D5B18-C2E7F4-03A9D6', + 'allow_from' => ['192.0.2.10'], 'skip_ip_check' => 0, 'service' => 0, 'active' => 1, 'created' => '2026-11-02 09:12:44', 'modified' => null]; + + public function testGetAll(): void + { + $result = $this->get('get/api-key/all', ['script' => [self::answer(self::API . 'getKeys', [], [self::KEY])]]); + + $this->assertSame([self::KEY], $result->json()); + $this->assertSame([[]], $result->callsTo(self::API . 'getKeys')); + } + + public function testGetAllAnswersAnEmptyObjectWithoutKeys(): void + { + // also the answer for read-only API keys: the module returns no keys for them + $result = $this->get('get/api-key/all', ['session' => self::API_RO, 'script' => [self::answer(self::API . 'getKeys', [], [])]]); + + $this->assertSame('{}', $result->body); + } + + public function testGetOne(): void + { + $found = $this->get('get/api-key/3', ['script' => [self::answer(self::API . 'getKey', ['3'], self::KEY)]]); + $this->assertSame(self::KEY, $found->json()); + + $missing = $this->get('get/api-key/99', ['script' => [self::answer(self::API . 'getKey', [], [])]]); + $this->assertSame('{}', $missing->body); + $this->assertSame([['99']], $missing->callsTo(self::API . 'getKey')); + } + + public function testAdd(): void + { + $attr = ['description' => 'provisioning', 'access' => 'rw', 'allow_from' => '192.0.2.10, 2001:db8::/64', 'skip_ip_check' => 0, 'active' => 1]; + $messages = [self::message('success', ['api_key_added', 'provisioning'], ['api', 'add', 'key', $attr])]; + + $result = $this->post('add/api-key', $attr, ['script' => [self::answer(self::API . 'addKey', [], true, $messages)]]); + + $this->assertSame([[$attr]], $result->callsTo(self::API . 'addKey')); + $this->assertSame($messages, $result->json()); + } + + public function testEdit(): void + { + $result = $this->post('edit/api-key', ['items' => [3, 4], 'attr' => ['regen_key' => 1]]); + + $this->assertSame([[[3, 4], ['regen_key' => 1]]], $result->callsTo(self::API . 'editKeys')); + } + + public function testEditTakesASingleId(): void + { + $result = $this->post('edit/api-key', ['items' => 3, 'attr' => ['active' => 0]]); + + $this->assertSame([[[3], ['active' => 0]]], $result->callsTo(self::API . 'editKeys')); + } + + public function testDelete(): void + { + $messages = [self::message('danger', ['api_key_service_locked', 'API_KEY'])]; + + $result = $this->post('delete/api-key', [1], ['script' => [self::answer(self::API . 'deleteKeys', [], true, $messages)]]); + + $this->assertSame([[[1]]], $result->callsTo(self::API . 'deleteKeys')); + $this->assertSame($messages, $result->json()); + } +} diff --git a/tests/php/Api/Admin/DomainAdminRoutesTest.php b/tests/php/Api/Admin/DomainAdminRoutesTest.php new file mode 100644 index 000000000..4eab430bc --- /dev/null +++ b/tests/php/Api/Admin/DomainAdminRoutesTest.php @@ -0,0 +1,111 @@ + 'domainadmin1', 'tfa_active' => 0, 'tfa_active_int' => 0, 'active' => 1, 'active_int' => 1, + 'created' => '2026-09-21 10:24:48', 'attributes' => ['force_tfa' => '0', 'force_pw_update' => '0'], + 'selected_domains' => ['example.org'], 'unselected_domains' => ['example.com']]; + + public function testGetAllCollectsTheDetailsOfEveryDomainAdmin(): void + { + $result = $this->get('get/domain-admin/all', ['script' => [ + self::answer('domain_admin', ['get'], ['domainadmin1', 'gone']), + self::answer('domain_admin', ['details', 'domainadmin1'], self::DETAILS), + self::answer('domain_admin', ['details', 'gone'], false), + ]]); + + $this->assertSame([self::DETAILS], $result->json()); + $this->assertSame([['get'], ['details', 'domainadmin1'], ['details', 'gone']], $result->callsTo('domain_admin')); + } + + public function testGetAllWithoutDomainAdmins(): void + { + $result = $this->get('get/domain-admin/all', ['script' => [self::answer('domain_admin', ['get'], [])]]); + + $this->assertSame('{}', $result->body); + } + + public function testGetOne(): void + { + $found = $this->get('get/domain-admin/domainadmin1', ['script' => [self::answer('domain_admin', ['details', 'domainadmin1'], self::DETAILS)]]); + $this->assertSame(self::DETAILS, $found->json()); + + $missing = $this->get('get/domain-admin/nobody', ['script' => [self::answer('domain_admin', ['details'], false)]]); + $this->assertSame('{}', $missing->body); + $this->assertSame([['details', 'nobody']], $missing->callsTo('domain_admin')); + } + + public function testAdd(): void + { + $attr = ['username' => 'domainadmin1', 'password' => 'Rand0m-Passw0rd!', 'password2' => 'Rand0m-Passw0rd!', + 'domains' => ['example.org', 'example.com'], 'active' => 1]; + + $result = $this->post('add/domain-admin', $attr); + + $this->assertSame([['add', $attr]], $result->callsTo('domain_admin')); + } + + public function testEdit(): void + { + $result = $this->post('edit/domain-admin', ['items' => ['domainadmin1'], 'attr' => ['domains' => ['example.org'], 'disable_tfa' => 1]]); + + $this->assertSame([['edit', ['username' => ['domainadmin1'], 'domains' => ['example.org'], 'disable_tfa' => 1]]], $result->callsTo('domain_admin')); + } + + public function testDelete(): void + { + $result = $this->post('delete/domain-admin', ['domainadmin1', 'domainadmin2']); + + $this->assertSame([['delete', ['username' => ['domainadmin1', 'domainadmin2']]]], $result->callsTo('domain_admin')); + } + + public function testEditAcl(): void + { + $messages = [self::message('success', ['acl_saved', 'domainadmin1'])]; + + $result = $this->post('edit/da-acl', ['items' => 'domainadmin1', 'attr' => ['da_acl' => ['syncjobs', 'quarantine']]], + ['script' => [self::answer('acl', ['edit', 'domainadmin'], null, $messages)]]); + + $this->assertSame([['edit', 'domainadmin', ['username' => ['domainadmin1'], 'da_acl' => ['syncjobs', 'quarantine']]]], $result->callsTo('acl')); + $this->assertSame($messages, $result->json()); + } + + public function testSsoTokenIsTheAnswer(): void + { + $token = ['token' => '591F6D-5C3DD2-7455CD-DAF1C1-AA4FCC']; + + $result = $this->post('add/sso/domain-admin', ['username' => 'domainadmin1'], + ['script' => [self::answer('domain_admin_sso', ['issue'], $token)]]); + + $this->assertSame([['issue', ['username' => 'domainadmin1']]], $result->callsTo('domain_admin_sso')); + $this->assertSame($token, $result->json()); + } + + public function testSsoTokenFailureAnswersTheMessages(): void + { + $messages = [self::message('danger', ['object_doesnt_exist', 'nobody'])]; + + $failed = $this->post('add/sso/domain-admin', ['username' => 'nobody'], + ['script' => [self::answer('domain_admin_sso', ['issue'], false, $messages)]]); + $this->assertSame($messages, $failed->json()); + + $silent = $this->post('add/sso/domain-admin', ['username' => 'nobody'], + ['script' => [self::answer('domain_admin_sso', ['issue'], false)]]); + $this->assertSame(['type' => 'error', 'msg' => 'Cannot add item'], $silent->json()); + } + + public function testSsoForOtherObjectsAnswersNothing(): void + { + $result = $this->post('add/sso/mailbox', ['username' => 'user@example.org']); + + $this->assertSame([200, ''], [$result->status, $result->body]); + $this->assertNotCalled($result, 'domain_admin_sso'); + } +} diff --git a/tests/php/Api/Admin/LogRoutesTest.php b/tests/php/Api/Admin/LogRoutesTest.php new file mode 100644 index 000000000..2c37b70ce --- /dev/null +++ b/tests/php/Api/Admin/LogRoutesTest.php @@ -0,0 +1,82 @@ +[/] */ +class LogRoutesTest extends ApiTestCase +{ + /** @return array route type => log name of get_logs() */ + public static function logs(): array + { + return [ + 'dovecot' => ['dovecot', 'dovecot-mailcow'], + 'ratelimited' => ['ratelimited', 'ratelimited'], + 'netfilter' => ['netfilter', 'netfilter-mailcow'], + 'cron' => ['cron', 'cron-mailcow'], + 'postfix' => ['postfix', 'postfix-mailcow'], + 'autodiscover' => ['autodiscover', 'autodiscover-mailcow'], + 'sogo' => ['sogo', 'sogo-mailcow'], + 'ui' => ['ui', 'mailcow-ui'], + 'sasl' => ['sasl', 'sasl'], + 'watchdog' => ['watchdog', 'watchdog-mailcow'], + 'acme' => ['acme', 'acme-mailcow'], + 'api' => ['api', 'api-mailcow'], + 'rspamd-history' => ['rspamd-history', 'rspamd-history'], + ]; + } + + #[DataProvider('logs')] + public function testWithoutCount(string $type, string $log): void + { + $entries = [['time' => '1794308452', 'message' => 'first'], ['time' => '1794308451', 'message' => 'second']]; + + $result = $this->get("get/logs/$type", ['script' => [self::answer('get_logs', [$log], $entries)]]); + + $this->assertSame([[$log]], $result->callsTo('get_logs')); + $this->assertSame($entries, $result->json()); + } + + #[DataProvider('logs')] + public function testCountOrRange(string $type, string $log): void + { + $this->assertSame([[$log, '50']], $this->get("get/logs/$type/50")->callsTo('get_logs')); + $this->assertSame([[$log, '2-3']], $this->get("get/logs/$type/2-3")->callsTo('get_logs')); + } + + #[DataProvider('logs')] + public function testEmptyLogs(string $type, string $log): void + { + $this->assertSame('{}', $this->get("get/logs/$type/5", ['script' => [self::answer('get_logs', [$log], false)]])->body); + $this->assertSame('{}', $this->get("get/logs/$type", ['script' => [self::answer('get_logs', [$log], [])]])->body); + } + + public function testCountKeepsOnlyDigitsAndDashes(): void + { + $this->assertSame([['postfix-mailcow', '12-30']], $this->get('get/logs/postfix/a12-3x0')->callsTo('get_logs')); + } + + public function testTrailingSlashPassesAnEmptyCount(): void + { + $this->assertSame([['postfix-mailcow', '']], $this->get('get/logs/postfix/')->callsTo('get_logs')); + } + + public function testRspamdStatsIgnoresTheCount(): void + { + $stats = ['version' => '3.12.1', 'scanned' => 300, 'actions' => ['reject' => 38]]; + + $result = $this->get('get/logs/rspamd-stats/10', ['script' => [self::answer('get_logs', ['rspamd-stats'], $stats)]]); + + $this->assertSame([['rspamd-stats']], $result->callsTo('get_logs')); + $this->assertSame($stats, $result->json()); + } + + public function testUnknownLog(): void + { + $this->assertApiError($this->get('get/logs/clamd'), 404, 'route not found'); + } +} diff --git a/tests/php/Api/Admin/OAuth2ClientRoutesTest.php b/tests/php/Api/Admin/OAuth2ClientRoutesTest.php new file mode 100644 index 000000000..6dab458a5 --- /dev/null +++ b/tests/php/Api/Admin/OAuth2ClientRoutesTest.php @@ -0,0 +1,62 @@ + 1, 'client_id' => '17c76aaa88c0', 'client_secret' => '73fc668a88147e32a31ff80c', + 'redirect_uri' => 'https://app.example.org/oauth/callback', 'grant_types' => null, 'scope' => 'profile', 'user_id' => null]; + + public function testGetAllCollectsTheDetailsOfEveryClient(): void + { + $result = $this->get('get/oauth2-client/all', ['script' => [ + self::answer('oauth2', ['get', 'clients'], [1, 2]), + self::answer('oauth2', ['details', 'client', 1], self::CLIENT), + self::answer('oauth2', ['details', 'client', 2], false), + ]]); + + $this->assertSame([self::CLIENT], $result->json()); + $this->assertSame([['get', 'clients'], ['details', 'client', 1], ['details', 'client', 2]], $result->callsTo('oauth2')); + } + + public function testGetAllWithoutClients(): void + { + $this->assertSame('{}', $this->get('get/oauth2-client/all', ['script' => [self::answer('oauth2', ['get', 'clients'], null)]])->body); + } + + public function testGetOne(): void + { + $found = $this->get('get/oauth2-client/1', ['script' => [self::answer('oauth2', ['details', 'client', '1'], self::CLIENT)]]); + $this->assertSame(self::CLIENT, $found->json()); + + $missing = $this->get('get/oauth2-client/9', ['script' => [self::answer('oauth2', ['details'], false)]]); + $this->assertSame('{}', $missing->body); + $this->assertSame([['details', 'client', '9']], $missing->callsTo('oauth2')); + } + + public function testAdd(): void + { + $result = $this->post('add/oauth2-client', ['redirect_uri' => 'https://app.example.org/oauth/callback']); + + $this->assertSame([['add', 'client', ['redirect_uri' => 'https://app.example.org/oauth/callback']]], $result->callsTo('oauth2')); + } + + public function testEdit(): void + { + $result = $this->post('edit/oauth2-client', ['items' => [1, 2], 'attr' => ['renew_secret' => 1]]); + + $this->assertSame([['edit', 'client', ['id' => [1, 2], 'renew_secret' => 1]]], $result->callsTo('oauth2')); + } + + public function testDelete(): void + { + $result = $this->post('delete/oauth2-client', [1, 2]); + + $this->assertSame([['delete', 'client', ['id' => [1, 2]]]], $result->callsTo('oauth2')); + } +} diff --git a/tests/php/Api/Admin/OwnAccountRoutesTest.php b/tests/php/Api/Admin/OwnAccountRoutesTest.php new file mode 100644 index 000000000..2333a7a37 --- /dev/null +++ b/tests/php/Api/Admin/OwnAccountRoutesTest.php @@ -0,0 +1,58 @@ + 'old', 'user_new_pass' => 'N3w-Passw0rd!', 'user_new_pass2' => 'N3w-Passw0rd!']; + + public function testSelfDoesNothingForAnApiKey(): void + { + $result = $this->post('edit/self', ['attr' => self::PASSWORDS]); + + $this->assertSame([200, ''], [$result->status, $result->body]); + $this->assertNotCalled($result, 'domain_admin'); + $this->assertNotCalled($result, 'edit_user_account'); + } + + public function testSelfOfADomainAdmin(): void + { + $result = $this->post('edit/self', ['attr' => self::PASSWORDS], ['session' => self::DOMAIN_ADMIN]); + + $this->assertSame([['edit', self::PASSWORDS]], $result->callsTo('domain_admin')); + } + + public function testSelfOfAMailboxUser(): void + { + $result = $this->post('edit/self', ['attr' => self::PASSWORDS], ['session' => self::USER]); + + $this->assertSame([[self::PASSWORDS]], $result->callsTo('edit_user_account')); + } + + public function testFido2FriendlyName(): void + { + $attr = ['fido2_cid' => hash('sha256', 'credential'), 'fido2_fn' => 'YubiKey']; + + $result = $this->post('edit/fido2-fn', ['attr' => $attr]); + + $this->assertSame([[['action' => 'edit_fn', 'fido2_attrs' => $attr]]], $result->callsTo('fido2')); + } + + public function testPendingPasswordUpdateAllowsOnlyThePasswordRoutes(): void + { + $session = self::ADMIN + ['pending_pw_update' => true]; + + $this->assertCalled($this->post('edit/admin', ['items' => 'admin', 'attr' => ['password' => 'x', 'password2' => 'x']], ['session' => $session]), + 'admin', ['edit', ['username' => ['admin'], 'password' => 'x', 'password2' => 'x']]); + $this->assertCalled($this->get('get/passwordpolicy', ['session' => $session]), 'password_complexity', ['get']); + + $denied = $this->post('edit/domain-admin', ['items' => 'domainadmin1', 'attr' => ['active' => 0]], ['session' => $session]); + $this->assertApiError($denied, 403, 'API access denied until the pending account setup is finished'); + $this->assertNotCalled($denied, 'domain_admin'); + } +} diff --git a/tests/php/Api/Admin/SettingsRoutesTest.php b/tests/php/Api/Admin/SettingsRoutesTest.php new file mode 100644 index 000000000..17f4bb90b --- /dev/null +++ b/tests/php/Api/Admin/SettingsRoutesTest.php @@ -0,0 +1,94 @@ + '8', 'chars' => '1', 'special_chars' => '0', 'lowerupper' => '1', 'numbers' => '1']; + + public function testGetPasswordPolicy(): void + { + foreach (['get/passwordpolicy', 'get/passwordpolicy/all'] as $route) { + $result = $this->get($route, ['script' => [self::answer('password_complexity', ['get'], self::POLICY)]]); + $this->assertSame([['get']], $result->callsTo('password_complexity'), $route); + $this->assertSame(self::POLICY, $result->json(), $route); + } + } + + public function testGetPasswordPolicyAsHtml(): void + { + $html = '

- Minimum password length is 8

'; + + $result = $this->get('get/passwordpolicy/html', ['script' => [self::answer('password_complexity', ['html'], $html)]]); + + $this->assertSame([['html']], $result->callsTo('password_complexity')); + $this->assertSame($html, $result->json()); + } + + public function testGetPasswordPolicyFailure(): void + { + $this->assertSame('{}', $this->get('get/passwordpolicy', ['script' => [self::answer('password_complexity', ['get'], false)]])->body); + $this->assertSame('{}', $this->get('get/passwordpolicy/html', ['script' => [self::answer('password_complexity', ['html'], false)]])->body); + } + + public function testEditPasswordPolicyIgnoresItems(): void + { + $result = $this->post('edit/passwordpolicy', ['items' => ['x'], 'attr' => ['length' => 12, 'numbers' => 1]]); + + $this->assertSame([['edit', ['length' => 12, 'numbers' => 1]]], $result->callsTo('password_complexity')); + } + + public function testEditPasswordResetNotification(): void + { + $attr = ['from' => 'noreply@example.org', 'subject' => 'Reset your password', 'text_tmpl' => '{{link}}', 'html_tmpl' => '']; + + $result = $this->post('edit/reset-password-notification', ['attr' => $attr]); + + $this->assertSame([['edit_notification', $attr]], $result->callsTo('reset_password')); + } + + /** @return array */ + public static function customizations(): array + { + return [ + 'ui_texts' => ['ui_texts', ['title_name' => 'Example Mail', 'ui_announcement_type' => 'warning', 'ui_announcement_active' => 1]], + 'ip_check' => ['ip_check', ['ip_check_opt_in' => '1']], + 'custom_login' => ['custom_login', ['hide_admin_quicklink' => 1, 'force_sso' => 0]], + 'ui_theme' => ['ui_theme', ['ui_theme' => 'servercow']], + 'app_links' => ['app_links', ['app' => ['Wiki'], 'href' => ['https://wiki.example.org'], 'user_href' => [''], 'hide' => ['0']]], + ]; + } + + #[DataProvider('customizations')] + public function testCustomizationsPassAttrOnly(string $item, array $attr): void + { + $messages = [self::message('success', $item === 'ip_check' ? 'ip_check_opt_in_modified' : $item)]; + + $result = $this->post("edit/$item", ['items' => ['ignored'], 'attr' => $attr], ['script' => [self::answer('customize', ['edit', $item], null, $messages)]]); + + $this->assertSame([['edit', $item, $attr]], $result->callsTo('customize')); + $this->assertSame($messages, $result->json()); + } + + public function testCustomizationWithoutAttrPassesNothing(): void + { + $result = $this->post('edit/app_links', ['items' => []]); + + $this->assertSame([['edit', 'app_links', []]], $result->callsTo('customize')); + } + + public function testEditCors(): void + { + $attr = ['allowed_origins' => ['https://app.example.org'], 'allowed_methods' => ['GET', 'POST']]; + + $result = $this->post('edit/cors', ['attr' => $attr]); + + $this->assertSame([['set_headers'], ['edit', $attr]], $result->callsTo('cors')); + } +} diff --git a/tests/php/Api/Admin/StatusRoutesTest.php b/tests/php/Api/Admin/StatusRoutesTest.php new file mode 100644 index 000000000..7dec30864 --- /dev/null +++ b/tests/php/Api/Admin/StatusRoutesTest.php @@ -0,0 +1,120 @@ + */ +class StatusRoutesTest extends ApiTestCase +{ + public function testContainersAreReducedToStateStartAndImage(): void + { + $info = [ + 'dovecot-mailcow' => ['State' => ['Status' => 'running', 'StartedAt' => '2026-11-10T11:00:51.541059812Z', 'Pid' => 42], + 'Config' => ['Image' => 'ghcr.io/mailcow/dovecot:2.3.21.1-4', 'Hostname' => 'dovecot'], 'Id' => '5d0f3cbb2a6e'], + 'ofelia-mailcow' => ['State' => ['Status' => 'exited', 'StartedAt' => '2026-11-09T08:00:00Z'], + 'Config' => ['Image' => 'mcuadros/ofelia:latest'], 'Id' => '77aa01'], + ]; + + $result = $this->get('get/status/containers', ['script' => [self::answer('docker', ['info'], $info)]]); + + $this->assertSame([['info']], $result->callsTo('docker')); + $this->assertSame([ + 'dovecot-mailcow' => ['type' => 'info', 'container' => 'dovecot-mailcow', 'state' => 'running', + 'started_at' => '2026-11-10T11:00:51.541059812Z', 'image' => 'ghcr.io/mailcow/dovecot:2.3.21.1-4'], + 'ofelia-mailcow' => ['type' => 'info', 'container' => 'ofelia-mailcow', 'state' => 'exited', + 'started_at' => '2026-11-09T08:00:00Z', 'image' => 'mcuadros/ofelia:latest'], + ], $result->json()); + $this->assertStringContainsString('"ghcr.io/mailcow/dovecot', $result->body); + } + + public function testContainersWithoutDockerapi(): void + { + $result = $this->get('get/status/containers', ['script' => [self::answer('docker', ['info'], 'Could not resolve host: dockerapi')]]); + + $this->assertSame('null', $result->body); + } + + public function testContainerStatsByContainerId(): void + { + $stats = [['read' => '2026-11-10T19:20:01Z', 'pids_stats' => ['current' => 38]], ['read' => '2026-11-10T19:20:06Z', 'pids_stats' => ['current' => 39]]]; + + $result = $this->get('get/status/container/5d0f3cbb2a6e', ['script' => [self::answer('docker', ['container_stats'], $stats)]]); + + $this->assertSame([['container_stats', '5d0f3cbb2a6e']], $result->callsTo('docker')); + $this->assertSame($stats, $result->json()); + } + + public function testContainerStatsWithoutId(): void + { + $result = $this->get('get/status/container', ['script' => [self::answer('docker', ['container_stats'], false)]]); + + $this->assertSame([['container_stats', null]], $result->callsTo('docker')); + $this->assertSame('false', $result->body); + } + + public function testVmailSplitsTheDfLine(): void + { + $df = json_encode('/dev/sda1,105G,65G,40G,66%,/var/vmail'); + + $result = $this->get('get/status/vmail', ['script' => [self::answer('docker', ['post', 'dovecot-mailcow', 'exec'], $df)]]); + + $this->assertSame([['post', 'dovecot-mailcow', 'exec', ['cmd' => 'system', 'task' => 'df', 'dir' => '/var/vmail']]], $result->callsTo('docker')); + $this->assertSame(['type' => 'info', 'disk' => '/dev/sda1', 'used' => '65G', 'total' => '105G', 'used_percent' => '66%'], $result->json()); + } + + public function testHost(): void + { + $stats = ['cpu' => ['cores' => 4, 'usage' => 6], 'memory' => ['total' => 12541669376, 'usage' => 62.5], 'uptime' => 703800.5, + 'system_time' => '10.11.2026 19:13:07', 'architecture' => 'x86_64']; + + $result = $this->get('get/status/host', ['script' => [self::answer('docker', ['host_stats'], $stats)]]); + + $this->assertSame([['host_stats']], $result->callsTo('docker')); + $this->assertSame($stats, $result->json()); + } + + public function testPublicAddressesAskMailcowEmail(): void + { + $result = $this->get('get/status/host/ip', ['script' => [ + self::answer('curl_exec', [], '198.51.100.7', once: true), + self::answer('curl_exec', [], '2001:db8::7', once: true), + ]]); + + $this->assertSame([[null, CURLOPT_URL, 'http://ipv4.mailcow.email'], [null, CURLOPT_URL, 'http://ipv6.mailcow.email']], + array_values(array_filter($result->callsTo('curl_setopt'), fn(array $args): bool => $args[1] === CURLOPT_URL))); + $this->assertSame(['ipv4' => '198.51.100.7', 'ipv6' => '2001:db8::7'], $result->json()); + } + + public function testHostWithAnotherSegmentAnswersNothing(): void + { + $result = $this->get('get/status/host/cpu'); + + $this->assertSame([200, ''], [$result->status, $result->body]); + $this->assertNotCalled($result, 'docker'); + } + + public function testVersion(): void + { + // $MAILCOW_GIT_VERSION comes from app_info.inc.php, which the harness does not load + $this->assertSame(['version' => null], $this->get('get/status/version')->json()); + } + + public function testUnknownStatusAnswersNothing(): void + { + $result = $this->get('get/status/disks'); + + $this->assertSame([200, ''], [$result->status, $result->body]); + } + + public function testOnlyForAdmins(): void + { + foreach (['containers', 'vmail', 'host', 'version'] as $object) { + $result = $this->get("get/status/$object", ['session' => self::DOMAIN_ADMIN]); + $this->assertSame([200, ''], [$result->status, $result->body], $object); + $this->assertNotCalled($result, 'docker'); + } + } +} diff --git a/tests/php/Api/ApiResult.php b/tests/php/Api/ApiResult.php new file mode 100644 index 000000000..45a3aa3cd --- /dev/null +++ b/tests/php/Api/ApiResult.php @@ -0,0 +1,43 @@ + $calls function or method name and arguments, in call order + */ + public function __construct( + public readonly int $status, + public readonly string $body, + public readonly array $calls, + public readonly ?array $sessionReturn, + public readonly ?string $error + ) { + } + + /** The decoded output, null if it is no JSON */ + public function json(): mixed + { + return json_decode($this->body, true); + } + + /** + * The arguments of every call of $name, e.g. 'mailbox' or 'Mailcow\ApiKey\ApiKeyModule->api->addKey' + * + * @return list + */ + public function callsTo(string $name): array + { + return array_values(array_map(fn(array $call): array => $call[1], array_filter($this->calls, fn(array $call): bool => $call[0] === $name))); + } + + /** Names of the calls, without the API log of every request (redis->lPush) */ + public function callNames(): array + { + return array_values(array_filter(array_column($this->calls, 0), fn(string $name): bool => $name !== 'redis->lPush')); + } +} diff --git a/tests/php/Api/ApiTestCase.php b/tests/php/Api/ApiTestCase.php new file mode 100644 index 000000000..ab3f3a23a --- /dev/null +++ b/tests/php/Api/ApiTestCase.php @@ -0,0 +1,150 @@ +post('add/alias', ['address' => 'a@example.org', 'goto' => 'b@example.org']); + * $this->assertCalled($result, 'mailbox', ['add', 'alias', ['address' => 'a@example.org', 'goto' => 'b@example.org']]); + * + * Backend answers are scripted with self::answer(); a call without one returns null (module chains like + * Module::fromGlobals()->api->x() resolve to recorders). + */ +abstract class ApiTestCase extends TestCase +{ + /** Session of a read/write API key (sessions.inc.php logs it in as the admin "API") */ + protected const API_RW = ['mailcow_cc_username' => 'API', 'mailcow_cc_role' => 'admin', 'mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'rw']; + /** Session of a read-only API key */ + protected const API_RO = ['mailcow_cc_username' => 'API', 'mailcow_cc_role' => 'admin', 'mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'ro']; + /** UI sessions */ + protected const ADMIN = ['mailcow_cc_username' => 'admin', 'mailcow_cc_role' => 'admin']; + protected const DOMAIN_ADMIN = ['mailcow_cc_username' => 'domainadmin', 'mailcow_cc_role' => 'domainadmin']; + protected const USER = ['mailcow_cc_username' => 'user@example.org', 'mailcow_cc_role' => 'user']; + protected const NO_SESSION = []; + + private const JSON_API = __DIR__ . '/../../../data/web/json_api.php'; + /** PHP functions with outside effects that json_api.php calls directly: disabled, so the harness records them */ + private const DISABLED = ['curl_init', 'curl_setopt', 'curl_setopt_array', 'curl_exec', 'curl_close', 'curl_error', 'curl_errno', 'curl_getinfo']; + + /** + * One scripted backend answer: the first call of $call whose arguments start with $args returns $return and + * appends $messages to $_SESSION['return'] (what the legacy functions do), $once makes it answer one call only. + */ + protected static function answer(string $call, array $args = [], mixed $return = null, array $messages = [], bool $once = false): array + { + return ['call' => $call, 'args' => $args, 'return' => $return, 'messages' => $messages, 'once' => $once]; + } + + /** A mailcow message as the legacy functions put it into $_SESSION['return'] */ + protected static function message(string $type, string|array $msg, array $log = []): array + { + return ['type' => $type, 'log' => $log, 'msg' => $msg]; + } + + /** + * @param array{session?: array, script?: list, server?: array, query?: array} $options + */ + protected function get(string $route, array $options = []): ApiResult + { + return $this->request('GET', $route, $options); + } + + /** + * POST with a JSON body (any value; a string is sent as it is), as API clients send it + * + * @param array{session?: array, script?: list, server?: array, query?: array, content_type?: string} $options + */ + protected function post(string $route, mixed $json = null, array $options = []): ApiResult + { + if ($json !== null) { + $options['body'] = is_string($json) ? $json : json_encode($json); + $options['content_type'] ??= 'application/json'; + } + return $this->request('POST', $route, $options); + } + + /** + * POST with form fields (attr, items as JSON strings), as the mailcow UI sends it + * + * @param array $fields + */ + protected function postForm(string $route, array $fields, array $options = []): ApiResult + { + $options['post'] = $fields; + $options['content_type'] ??= 'application/x-www-form-urlencoded'; + return $this->request('POST', $route, $options); + } + + /** + * @param string $route the part after /api/v1/, e.g. "get/domain/all" + * @param array{session?: array, script?: list, server?: array, query?: array, post?: array, body?: string, + * content_type?: string, globals?: array, allow_error?: bool} $options globals: variables prerequisites.inc.php + * would set (e.g. iam_settings) + */ + protected function request(string $method, string $route, array $options = []): ApiResult + { + $dir = sys_get_temp_dir(); + $requestFile = tempnam($dir, 'api-req-'); + $resultFile = tempnam($dir, 'api-res-'); + try { + file_put_contents($requestFile, json_encode([ + 'json_api' => realpath(self::JSON_API), + 'method' => $method, + 'query' => ltrim(preg_replace('#^/?api/v1/#', '', $route), '/'), + 'get' => $options['query'] ?? [], + 'post' => $options['post'] ?? [], + 'body' => $options['body'] ?? null, + 'content_type' => $options['content_type'] ?? null, + 'server' => $options['server'] ?? [], + 'session' => $options['session'] ?? self::API_RW, + 'script' => $options['script'] ?? [], + 'globals' => $options['globals'] ?? [], + 'result' => $resultFile, + ], JSON_THROW_ON_ERROR)); + $process = proc_open([PHP_BINARY, '-d', 'display_errors=stderr', '-d', 'disable_functions=' . implode(',', self::DISABLED), + __DIR__ . '/Harness/runner.php', $requestFile], + [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + $data = json_decode((string)file_get_contents($resultFile), true); + $this->assertIsArray($data, "the harness wrote no result for $method $route:\n$stdout$stderr"); + } finally { + @unlink($requestFile); + @unlink($resultFile); + } + $result = new ApiResult((int)$data['status'], (string)$data['body'], $data['calls'], $data['session_return'], $data['error']); + if (empty($options['allow_error'])) { + $this->assertNull($result->error, "json_api.php failed for $method $route"); + } + return $result; + } + + /** At least one call of $name had exactly $args */ + protected function assertCalled(ApiResult $result, string $name, array $args): void + { + $this->assertContains($args, $result->callsTo($name), + "no call $name(" . json_encode($args) . "), calls were:\n" . json_encode($result->calls, JSON_PRETTY_PRINT)); + } + + /** $name was not called at all */ + protected function assertNotCalled(ApiResult $result, string $name): void + { + $this->assertSame([], $result->callsTo($name), "unexpected call of $name"); + } + + /** The JSON answer json_api.php gives without calling a backend, e.g. 404 route not found */ + protected function assertApiError(ApiResult $result, int $status, string $msg): void + { + $this->assertSame([$status, ['type' => 'error', 'msg' => $msg]], [$result->status, $result->json()], $result->body); + } +} diff --git a/tests/php/Api/Domains/AliasDomainTest.php b/tests/php/Api/Domains/AliasDomainTest.php new file mode 100644 index 000000000..9efa0e910 --- /dev/null +++ b/tests/php/Api/Domains/AliasDomainTest.php @@ -0,0 +1,71 @@ +get('get/alias-domain/all', ['script' => [ + self::answer('mailbox', ['get', 'alias_domains'], ['example.net', 'example.com']), + self::answer('mailbox', ['get', 'alias_domain_details', 'example.net'], ['alias_domain' => 'example.net', 'target_domain' => 'example.org']), + self::answer('mailbox', ['get', 'alias_domain_details', 'example.com'], false), + ]]); + + $this->assertCalled($result, 'mailbox', ['get', 'alias_domains']); + $this->assertSame([['alias_domain' => 'example.net', 'target_domain' => 'example.org']], $result->json()); + } + + public function testAllAliasDomainsWithoutAliasDomains(): void + { + $result = $this->get('get/alias-domain/all', ['script' => [self::answer('mailbox', ['get', 'alias_domains'], [])]]); + + $this->assertSame('{}', $result->body); + $this->assertSame([['get', 'alias_domains']], $result->callsTo('mailbox')); + } + + public function testOneAliasDomain(): void + { + $result = $this->get('get/alias-domain/example.net', ['script' => [ + self::answer('mailbox', ['get', 'alias_domain_details', 'example.net'], ['alias_domain' => 'example.net', 'rl' => false]), + ]]); + + $this->assertSame(['alias_domain' => 'example.net', 'rl' => false], $result->json()); + } + + public function testUnknownAliasDomain(): void + { + $result = $this->get('get/alias-domain/example.com', ['script' => [self::answer('mailbox', ['get', 'alias_domain_details'], false)]]); + + $this->assertCalled($result, 'mailbox', ['get', 'alias_domain_details', 'example.com']); + $this->assertSame('{}', $result->body); + } + + public function testAddAliasDomains(): void + { + $attr = ['alias_domain' => "example.net\nexample.com", 'target_domain' => 'example.org', 'active' => '1', 'key_size' => '2048', 'dkim_selector' => 'dkim']; + + $result = $this->post('add/alias-domain', $attr); + + $this->assertCalled($result, 'mailbox', ['add', 'alias_domain', $attr]); + } + + public function testEditAliasDomains(): void + { + $result = $this->post('edit/alias-domain', ['items' => ['example.net', 'example.com'], 'attr' => ['active' => '0']]); + + $this->assertCalled($result, 'mailbox', ['edit', 'alias_domain', ['alias_domain' => ['example.net', 'example.com'], 'active' => '0']]); + } + + public function testDeleteAliasDomains(): void + { + $result = $this->post('delete/alias-domain', ['example.net']); + + $this->assertCalled($result, 'mailbox', ['delete', 'alias_domain', ['alias_domain' => ['example.net']]]); + } +} diff --git a/tests/php/Api/Domains/DkimTest.php b/tests/php/Api/Domains/DkimTest.php new file mode 100644 index 000000000..a2ce819ec --- /dev/null +++ b/tests/php/Api/Domains/DkimTest.php @@ -0,0 +1,62 @@ + 'MIIBIjAN', 'length' => '2048', 'dkim_txt' => 'v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjAN', 'dkim_selector' => 'dkim', 'privkey' => '']; + + $result = $this->get('get/dkim/example.org', ['script' => [self::answer('dkim', ['details', 'example.org'], $key)]]); + + $this->assertSame([['details', 'example.org']], $result->callsTo('dkim')); + $this->assertSame($key, $result->json()); + } + + public function testDomainWithoutKey(): void + { + $result = $this->get('get/dkim/example.com', ['script' => [self::answer('dkim', ['details'], [])]]); + + $this->assertSame('{}', $result->body); + } + + public function testGenerateKeys(): void + { + $attr = ['domains' => 'example.org, example.net', 'dkim_selector' => 'dkim', 'key_size' => 2048]; + + $result = $this->post('add/dkim', $attr); + + $this->assertSame([['add', $attr]], $result->callsTo('dkim')); + } + + public function testDuplicateKey(): void + { + $attr = ['from_domain' => 'example.org', 'to_domain' => ['example.net', 'example.com']]; + + $result = $this->post('add/dkim_duplicate', $attr); + + $this->assertSame([['duplicate', $attr]], $result->callsTo('dkim')); + } + + public function testImportKey(): void + { + $attr = ['domain' => 'example.org', 'dkim_selector' => 'dkim2026', 'private_key_file' => "-----BEGIN PRIVATE KEY-----\nMIIE\n-----END PRIVATE KEY-----", 'overwrite_existing' => '1']; + + $result = $this->post('add/dkim_import', $attr); + + $this->assertSame([['import', $attr]], $result->callsTo('dkim')); + } + + public function testDeleteKeys(): void + { + $result = $this->post('delete/dkim', ['example.org', 'example.net']); + + $this->assertSame([['delete', ['domains' => ['example.org', 'example.net']]]], $result->callsTo('dkim')); + } +} diff --git a/tests/php/Api/Domains/DomainPolicyTest.php b/tests/php/Api/Domains/DomainPolicyTest.php new file mode 100644 index 000000000..eaf72a3f3 --- /dev/null +++ b/tests/php/Api/Domains/DomainPolicyTest.php @@ -0,0 +1,57 @@ + [['object' => 'example.org', 'value' => '*@partner.example.com', 'prefid' => 12]], + 'blacklist' => [['object' => 'anna@example.org', 'value' => '*@spam.example.com', 'prefid' => 13]], + ]; + + public function testAllowListOfADomain(): void + { + $result = $this->get('get/policy_wl_domain/example.org', ['script' => [self::answer('policy', ['get', 'domain', 'example.org'], self::LISTS)]]); + + $this->assertSame([['get', 'domain', 'example.org']], $result->callsTo('policy')); + $this->assertSame(self::LISTS['whitelist'], $result->json()); + } + + public function testDenyListOfADomain(): void + { + $result = $this->get('get/policy_bl_domain/example.org', ['script' => [self::answer('policy', ['get', 'domain', 'example.org'], self::LISTS)]]); + + $this->assertSame([['get', 'domain', 'example.org']], $result->callsTo('policy')); + $this->assertSame(self::LISTS['blacklist'], $result->json()); + } + + public function testEmptyListsAndUnknownDomains(): void + { + $empty = $this->get('get/policy_wl_domain/example.org', ['script' => [self::answer('policy', ['get', 'domain'], ['whitelist' => [], 'blacklist' => []])]]); + $this->assertSame('{}', $empty->body); + + $unknown = $this->get('get/policy_bl_domain/example.com', ['script' => [self::answer('policy', ['get', 'domain'], false)]]); + $this->assertSame('{}', $unknown->body); + } + + public function testAddEntry(): void + { + $attr = ['domain' => 'example.org', 'object_list' => 'wl', 'object_from' => '*@partner.example.com']; + + $result = $this->post('add/domain-policy', $attr); + + $this->assertSame([['add', 'domain', $attr]], $result->callsTo('policy')); + } + + public function testDeleteEntries(): void + { + $result = $this->post('delete/domain-policy', [12, 13]); + + $this->assertSame([['delete', 'domain', ['prefid' => [12, 13]]]], $result->callsTo('policy')); + } +} diff --git a/tests/php/Api/Domains/DomainRatelimitTest.php b/tests/php/Api/Domains/DomainRatelimitTest.php new file mode 100644 index 000000000..abac77d99 --- /dev/null +++ b/tests/php/Api/Domains/DomainRatelimitTest.php @@ -0,0 +1,68 @@ +get('get/rl-domain/example.org', ['script' => [self::answer('ratelimit', ['get', 'domain', 'example.org'], ['value' => '100', 'frame' => 'h'])]]); + + $this->assertSame([['get', 'domain', 'example.org']], $result->callsTo('ratelimit')); + $this->assertSame(['value' => '100', 'frame' => 'h'], $result->json()); + } + + public function testDomainWithoutRatelimit(): void + { + $result = $this->get('get/rl-domain/example.org', ['script' => [self::answer('ratelimit', ['get', 'domain'], false)]]); + + $this->assertSame('{}', $result->body); + } + + public function testAllRatelimitsOfDomainsAndAliasDomains(): void + { + $result = $this->get('get/rl-domain/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com']), + self::answer('mailbox', ['get', 'alias_domains'], ['example.net']), + self::answer('ratelimit', ['get', 'domain', 'example.org'], ['value' => '100', 'frame' => 'h']), + self::answer('ratelimit', ['get', 'domain', 'example.com'], false), + self::answer('ratelimit', ['get', 'domain', 'example.net'], ['value' => '10', 'frame' => 's']), + ]]); + + $this->assertSame([['get', 'domains'], ['get', 'alias_domains']], $result->callsTo('mailbox')); + $this->assertSame([['get', 'domain', 'example.org'], ['get', 'domain', 'example.com'], ['get', 'domain', 'example.net']], $result->callsTo('ratelimit')); + $this->assertSame([ + ['value' => '100', 'frame' => 'h', 'domain' => 'example.org'], + ['value' => '10', 'frame' => 's', 'domain' => 'example.net'], + ], $result->json()); + } + + public function testAllWithoutRatelimits(): void + { + $none = $this->get('get/rl-domain/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org']), + self::answer('mailbox', ['get', 'alias_domains'], []), + self::answer('ratelimit', ['get', 'domain'], false), + ]]); + $this->assertSame('{}', $none->body); + + $noDomains = $this->get('get/rl-domain/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], []), + self::answer('mailbox', ['get', 'alias_domains'], []), + ]]); + $this->assertSame('{}', $noDomains->body); + $this->assertNotCalled($noDomains, 'ratelimit'); + } + + public function testSetRatelimits(): void + { + $result = $this->post('edit/rl-domain', ['items' => ['example.org', 'example.net'], 'attr' => ['rl_value' => 100, 'rl_frame' => 'h']]); + + $this->assertSame([['edit', 'domain', ['object' => ['example.org', 'example.net'], 'rl_value' => 100, 'rl_frame' => 'h']]], $result->callsTo('ratelimit')); + } +} diff --git a/tests/php/Api/Domains/DomainSearchTest.php b/tests/php/Api/Domains/DomainSearchTest.php new file mode 100644 index 000000000..498768f9e --- /dev/null +++ b/tests/php/Api/Domains/DomainSearchTest.php @@ -0,0 +1,115 @@ + 3, + 'start' => 0, + 'length' => 10, + 'search' => ['value' => 'example'], + 'order' => [['column' => 2, 'dir' => 'asc']], + 'columns' => [ + ['data' => '', 'searchable' => false, 'orderable' => false], + ['data' => 'chkbox', 'searchable' => false, 'orderable' => false], + ['data' => 'domain_h_name', 'searchable' => true, 'orderable' => true, 'search' => ['value' => '']], + ], + ]; + + private const PAGE = ['draw' => 3, 'recordsTotal' => 12, 'recordsFiltered' => 2, 'data' => [[2 => 'example.org'], [2 => 'example.com']]]; + + public function testAdminsSearchAllDomains(): void + { + $result = $this->post('search/domain', self::REQUEST, ['script' => [self::answer('SSP::simple', [], self::PAGE)]]); + + $call = $this->sspCall($result, 'SSP::simple'); + $this->assertSame([self::REQUEST, '@pdo', ['domain', 'd'], 'domain'], array_slice($call, 0, 4)); + $this->assertSame([ + 2 => 'domain', 3 => 'aliases', 4 => 'mailboxes', 5 => 'quota', 6 => 'stats', 7 => 'defquota', 8 => 'maxquota', + 10 => 'backupmx', 14 => 'tags', 15 => 'active', + ], array_column($call[4], 'db', 'dt')); + $this->assertNotCalled($result, 'SSP::complex'); + } + + public function testRowsAreReplacedByTheDomainDetails(): void + { + $result = $this->post('search/domain', self::REQUEST, ['script' => [ + self::answer('SSP::simple', [], self::PAGE), + self::answer('mailbox', ['get', 'domain_details', 'example.org'], ['domain_name' => 'example.org']), + self::answer('mailbox', ['get', 'domain_details', 'example.com'], false), + ]]); + + $this->assertSame([['get', 'domain_details', 'example.org'], ['get', 'domain_details', 'example.com']], $result->callsTo('mailbox')); + $this->assertSame(['draw' => 3, 'recordsTotal' => 12, 'recordsFiltered' => 2, 'data' => [['domain_name' => 'example.org']]], $result->json()); + } + + public function testAnEmptyPageIsAnsweredAsItIs(): void + { + $page = ['draw' => 1, 'recordsTotal' => 12, 'recordsFiltered' => 0, 'data' => []]; + + $result = $this->post('search/domain', ['draw' => 1], ['script' => [self::answer('SSP::simple', [], $page)]]); + + $this->assertSame($page, $result->json()); + $this->assertNotCalled($result, 'mailbox'); + } + + public function testErrorsOfTheSearchArePassedOn(): void + { + $result = $this->post('search/domain', self::REQUEST, ['script' => [self::answer('SSP::simple', [], ['error' => 'An SQL error occurred: unknown column'])]]); + + $this->assertSame(['error' => 'An SQL error occurred: unknown column'], $result->json()); + } + + public function testReadOnlyKeysCanSearch(): void + { + $result = $this->post('search/domain', self::REQUEST, ['session' => self::API_RO, 'script' => [self::answer('SSP::simple', [], self::PAGE)]]); + + $this->assertSame(200, $result->status); + $this->assertSame(self::REQUEST, $this->sspCall($result, 'SSP::simple')[0]); + } + + public function testDomainAdminsSearchTheirDomains(): void + { + $result = $this->post('search/domain', self::REQUEST, ['session' => self::DOMAIN_ADMIN, 'script' => [self::answer('SSP::complex', [], self::PAGE)]]); + + $call = $this->sspCall($result, 'SSP::complex'); + $this->assertSame([self::REQUEST, '@pdo', ['domain', 'd'], 'domain'], array_slice($call, 0, 4)); + $this->assertSame(['INNER JOIN domain_admins as da ON da.domain = d.domain', [ + 'condition' => 'da.active = 1 and da.username = :username', + 'bindings' => ['username' => 'domainadmin'], + ]], array_slice($call, 5)); + $this->assertNotCalled($result, 'SSP::simple'); + } + + public function testOtherLoginsAreRefused(): void + { + foreach ([self::USER, self::NO_SESSION] as $session) { + $result = $this->post('search/domain', self::REQUEST, ['session' => $session]); + + $this->assertApiError($result, 403, 'Insufficient permissions'); + $this->assertNotCalled($result, 'SSP::simple'); + $this->assertNotCalled($result, 'SSP::complex'); + } + } + + public function testWithoutJsonBodyTheSearchGetsNoParameters(): void + { + $result = $this->request('POST', 'search/domain', ['script' => [self::answer('SSP::simple', [], self::PAGE)]]); + + $this->assertNull($this->sspCall($result, 'SSP::simple')[0]); + } + + private function sspCall(ApiResult $result, string $name): array + { + $calls = $result->callsTo($name); + $this->assertCount(1, $calls, "one call of $name"); + return $calls[0]; + } +} diff --git a/tests/php/Api/Domains/DomainTemplateTest.php b/tests/php/Api/Domains/DomainTemplateTest.php new file mode 100644 index 000000000..24560c3a7 --- /dev/null +++ b/tests/php/Api/Domains/DomainTemplateTest.php @@ -0,0 +1,66 @@ + 1, 'template' => 'Default', 'type' => 'domain', 'attributes' => ['max_num_aliases_for_domain' => 400]]; + + public function testAllTemplates(): void + { + $result = $this->get('get/domain/template/all', ['script' => [self::answer('mailbox', ['get', 'domain_templates'], [self::TEMPLATE])]]); + + $this->assertSame([['get', 'domain_templates']], $result->callsTo('mailbox')); + $this->assertSame([self::TEMPLATE], $result->json()); + } + + public function testOneTemplate(): void + { + $result = $this->get('get/domain/template/1', ['script' => [self::answer('mailbox', ['get', 'domain_templates', '1'], self::TEMPLATE)]]); + + $this->assertSame(self::TEMPLATE, $result->json()); + } + + public function testUnknownTemplate(): void + { + $result = $this->get('get/domain/template/9', ['script' => [self::answer('mailbox', ['get', 'domain_templates'], false)]]); + + $this->assertCalled($result, 'mailbox', ['get', 'domain_templates', '9']); + $this->assertSame('{}', $result->body); + } + + public function testTemplateWithoutIdAsksForAll(): void + { + $result = $this->get('get/domain/template'); + + $this->assertSame([['get', 'domain_templates', null]], $result->callsTo('mailbox')); + } + + public function testAddTemplate(): void + { + $attr = ['template' => 'Small business', 'max_num_mboxes_for_domain' => 25, 'def_quota_for_mbox' => 2048, 'tags' => ['small']]; + + $result = $this->post('add/domain/template', $attr); + + $this->assertSame([['add', 'domain_templates', $attr]], $result->callsTo('mailbox')); + } + + public function testEditTemplates(): void + { + $result = $this->post('edit/domain/template', ['items' => [2, 3], 'attr' => ['template' => 'Small business', 'active' => 1]]); + + $this->assertCalled($result, 'mailbox', ['edit', 'domain_templates', ['ids' => [2, 3], 'template' => 'Small business', 'active' => 1]]); + } + + public function testDeleteTemplates(): void + { + $result = $this->post('delete/domain/template', [2, 3]); + + $this->assertCalled($result, 'mailbox', ['delete', 'domain_templates', ['ids' => [2, 3]]]); + } +} diff --git a/tests/php/Api/Domains/DomainTest.php b/tests/php/Api/Domains/DomainTest.php new file mode 100644 index 000000000..fc48495b8 --- /dev/null +++ b/tests/php/Api/Domains/DomainTest.php @@ -0,0 +1,152 @@ +get('get/domain/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com', 'example.net']), + self::answer('mailbox', ['get', 'domain_details', 'example.org'], ['domain_name' => 'example.org']), + self::answer('mailbox', ['get', 'domain_details', 'example.com'], false), + self::answer('mailbox', ['get', 'domain_details', 'example.net'], ['domain_name' => 'example.net']), + ]]); + + $this->assertCalled($result, 'mailbox', ['get', 'domains', null, null]); + $this->assertSame([['domain_name' => 'example.org'], ['domain_name' => 'example.net']], $result->json()); + } + + public function testAllDomainsFilteredByTags(): void + { + $result = $this->get('get/domain/all', ['query' => ['tags' => 'customer,premium'], 'script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org']), + self::answer('mailbox', ['get', 'domain_details', 'example.org'], ['domain_name' => 'example.org', 'tags' => ['customer']]), + ]]); + + $this->assertCalled($result, 'mailbox', ['get', 'domains', null, ['customer', 'premium']]); + $this->assertSame([['domain_name' => 'example.org', 'tags' => ['customer']]], $result->json()); + } + + public function testAnEmptyTagsParameterDoesNotFilter(): void + { + $result = $this->get('get/domain/all', ['query' => ['tags' => ''], 'script' => [self::answer('mailbox', ['get', 'domains'], [])]]); + + $this->assertCalled($result, 'mailbox', ['get', 'domains', null, null]); + } + + public function testAllDomainsWithoutDomains(): void + { + $result = $this->get('get/domain/all', ['script' => [self::answer('mailbox', ['get', 'domains'], [])]]); + + $this->assertSame('{}', $result->body); + $this->assertSame([['get', 'domains', null, null]], $result->callsTo('mailbox')); + } + + public function testAllDomainsWithoutReadableDetails(): void + { + $result = $this->get('get/domain/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org']), + self::answer('mailbox', ['get', 'domain_details'], false), + ]]); + + $this->assertSame('{}', $result->body); + } + + public function testOneDomain(): void + { + $result = $this->get('get/domain/example.org', ['script' => [ + self::answer('mailbox', ['get', 'domain_details', 'example.org'], ['domain_name' => 'example.org', 'rl' => false]), + ]]); + + $this->assertSame(['domain_name' => 'example.org', 'rl' => false], $result->json()); + $this->assertSame([['get', 'domain_details', 'example.org']], $result->callsTo('mailbox')); + } + + public function testUnknownDomain(): void + { + $result = $this->get('get/domain/example.com', ['script' => [self::answer('mailbox', ['get', 'domain_details'], false)]]); + + $this->assertSame('{}', $result->body); + } + + public function testAddDomain(): void + { + $attr = ['domain' => 'example.org', 'description' => 'Example Org', 'defquota' => 3072, 'tags' => ['customer'], 'restart_sogo' => '1']; + + $result = $this->post('add/domain', $attr); + + $this->assertCalled($result, 'mailbox', ['add', 'domain', $attr]); + } + + public function testAddDomainIgnoresFurtherSegments(): void + { + $result = $this->post('add/domain/example.org', ['domain' => 'example.org']); + + $this->assertCalled($result, 'mailbox', ['add', 'domain', ['domain' => 'example.org']]); + } + + public function testEditDomains(): void + { + $result = $this->post('edit/domain', ['items' => ['example.org', 'example.com'], 'attr' => ['quota' => 51200, 'tags' => ['premium']]]); + + $this->assertCalled($result, 'mailbox', ['edit', 'domain', ['domain' => ['example.org', 'example.com'], 'quota' => 51200, 'tags' => ['premium']]]); + } + + public function testEditDomainWithASingleItem(): void + { + $result = $this->post('edit/domain', ['items' => 'example.org', 'attr' => ['active' => '0']]); + + $this->assertCalled($result, 'mailbox', ['edit', 'domain', ['domain' => ['example.org'], 'active' => '0']]); + } + + public function testAnAttributeNamedLikeTheItemsWins(): void + { + $result = $this->post('edit/domain', ['items' => ['example.org'], 'attr' => ['domain' => 'example.com']]); + + $this->assertCalled($result, 'mailbox', ['edit', 'domain', ['domain' => 'example.com']]); + } + + public function testDeleteDomains(): void + { + $result = $this->post('delete/domain', ['example.org', 'example.com']); + + $this->assertCalled($result, 'mailbox', ['delete', 'domain', ['domain' => ['example.org', 'example.com']]]); + } + + public function testDeleteDomainTags(): void + { + $result = $this->post('delete/domain/tag/example.org', ['customer', 'premium']); + + $this->assertCalled($result, 'mailbox', ['delete', 'tags_domain', ['tags' => ['customer', 'premium'], 'domain' => 'example.org']]); + } + + public function testDeleteDomainTagsWithoutTags(): void + { + $result = $this->post('delete/domain/tag/example.org', [], ['script' => [self::answer('mailbox', ['delete', 'tags_domain'], false)]]); + + $this->assertCalled($result, 'mailbox', ['delete', 'tags_domain', ['tags' => [], 'domain' => 'example.org']]); + $this->assertSame(['type' => 'error', 'msg' => 'Cannot delete item'], $result->json()); + } + + public function testDeleteDomainTagsWithoutDomain(): void + { + $result = $this->post('delete/domain/tag', ['customer']); + + $this->assertCalled($result, 'mailbox', ['delete', 'tags_domain', ['tags' => ['customer'], 'domain' => null]]); + } + + public function testEditDomainFooter(): void + { + $attr = ['html' => '

{= from_name =}

', 'plain' => '{= from_name =}', 'skip_replies' => 1, 'exclude' => ['noreply@example.org', 'example.net']]; + + $result = $this->post('edit/domain/footer', ['items' => ['example.org'], 'attr' => $attr]); + + $this->assertCalled($result, 'mailbox', ['edit', 'domain_wide_footer', ['domains' => ['example.org']] + $attr]); + } +} diff --git a/tests/php/Api/Domains/MtaStsTest.php b/tests/php/Api/Domains/MtaStsTest.php new file mode 100644 index 000000000..92409f0d3 --- /dev/null +++ b/tests/php/Api/Domains/MtaStsTest.php @@ -0,0 +1,34 @@ + 'example.org', 'version' => 'stsv1', 'mode' => 'enforce', 'mx' => 'mail.example.org,mx2.example.org', 'max_age' => 86400, 'active' => 1]; + + $result = $this->post('add/mta-sts', $attr); + + $this->assertSame([['add', 'mta_sts', $attr]], $result->callsTo('mailbox')); + // the backend reports no success message of its own + $this->assertSame(['type' => 'success', 'msg' => 'Task completed'], $result->json()); + } + + public function testEditPolicies(): void + { + $result = $this->post('edit/mta-sts', ['items' => ['example.org'], 'attr' => ['version' => 'stsv1', 'mode' => 'testing']]); + + $this->assertSame([['edit', 'mta_sts', ['domains' => ['example.org'], 'version' => 'stsv1', 'mode' => 'testing']]], $result->callsTo('mailbox')); + } + + public function testThereIsNoReadRoute(): void + { + $this->assertApiError($this->get('get/mta-sts/example.org'), 404, 'route not found'); + } +} diff --git a/tests/php/Api/Filtering/Fail2BanTest.php b/tests/php/Api/Filtering/Fail2BanTest.php new file mode 100644 index 000000000..5f3f638e4 --- /dev/null +++ b/tests/php/Api/Filtering/Fail2BanTest.php @@ -0,0 +1,70 @@ + 1800, 'ban_time_increment' => true, 'banlist_id' => self::BANLIST_ID, 'whitelist' => '192.0.2.0/24']; + + foreach (['get/fail2ban', 'get/fail2ban/all'] as $route) { + $result = $this->get($route, ['script' => [self::answer('fail2ban', ['get'], $settings)]]); + $this->assertSame([['get']], $result->callsTo('fail2ban'), $route); + $this->assertSame($settings, $result->json(), $route); + } + + $this->assertSame('{}', $this->get('get/fail2ban', ['script' => [self::answer('fail2ban', ['get'], false)]])->body); + } + + public function testEditFail2BanSettings(): void + { + $attr = ['ban_time' => '1800', 'max_attempts' => '10', 'whitelist' => "192.0.2.0/24\nmail.example.org"]; + + $result = $this->post('edit/fail2ban', ['items' => [], 'attr' => $attr]); + + $this->assertSame([['edit', ['network' => []] + $attr]], $result->callsTo('fail2ban')); + } + + public function testItemsAreTheNetworks(): void + { + $list = $this->post('edit/fail2ban', ['items' => ['203.0.113.7/32', '2001:db8::/64'], 'attr' => ['action' => 'unban']]); + $this->assertCalled($list, 'fail2ban', ['edit', ['network' => ['203.0.113.7/32', '2001:db8::/64'], 'action' => 'unban']]); + + $single = $this->post('edit/fail2ban', ['items' => '198.51.100.66/32', 'attr' => ['action' => 'blacklist']]); + $this->assertCalled($single, 'fail2ban', ['edit', ['network' => ['198.51.100.66/32'], 'action' => 'blacklist']]); + } + + public function testFurtherSegmentsAreIgnored(): void + { + $result = $this->post('edit/fail2ban/self', ['items' => ['self'], 'attr' => ['action' => 'unban-all']]); + + $this->assertSame([['edit', ['network' => ['self'], 'action' => 'unban-all']]], $result->callsTo('fail2ban')); + } + + public function testRefreshBanlist(): void + { + $messages = [self::message('success', 'f2b_banlist_refreshed')]; + + $result = $this->post('edit/fail2ban/banlist', ['items' => [self::BANLIST_ID]], ['script' => [self::answer('fail2ban', ['banlist', 'refresh'], true, $messages)]]); + $this->assertSame([['banlist', 'refresh', [self::BANLIST_ID]]], $result->callsTo('fail2ban')); + $this->assertSame($messages, $result->json()); + + $single = $this->post('edit/fail2ban/banlist', ['items' => self::BANLIST_ID, 'attr' => []]); + $this->assertSame([['banlist', 'refresh', [self::BANLIST_ID]]], $single->callsTo('fail2ban')); + } + + public function testRefreshBanlistWithAWrongId(): void + { + $result = $this->post('edit/fail2ban/banlist', ['items' => ['wrong']], ['script' => [self::answer('fail2ban', ['banlist'], false)]]); + + $this->assertSame(['type' => 'error', 'msg' => 'Cannot edit item'], $result->json()); + } +} diff --git a/tests/php/Api/Filtering/GlobalFilterTest.php b/tests/php/Api/Filtering/GlobalFilterTest.php new file mode 100644 index 000000000..d100b78f3 --- /dev/null +++ b/tests/php/Api/Filtering/GlobalFilterTest.php @@ -0,0 +1,56 @@ + "# before\n", 'postfilter' => "require \"fileinto\";\n"]; + + public function testAddGlobalFilter(): void + { + $attr = ['filter_type' => 'postfilter', 'script_data' => "require \"fileinto\";\nfileinto \"Junk\";"]; + $messages = [ + self::message('success', 'dovecot_restart_success'), + self::message('success', 'global_filter_written'), + ]; + + $result = $this->post('add/global-filter', $attr, ['script' => [self::answer('mailbox', ['add', 'global_filter'], true, $messages)]]); + + $this->assertCalled($result, 'mailbox', ['add', 'global_filter', $attr]); + $this->assertSame($messages, $result->json()); + } + + public function testGetAllGlobalFilters(): void + { + $result = $this->get('get/global_filters/all', ['script' => [self::answer('mailbox', ['get', 'global_filter_details'], self::FILTERS)]]); + + $this->assertSame([['get', 'global_filter_details']], $result->callsTo('mailbox')); + $this->assertSame(self::FILTERS, $result->json()); + + $this->assertSame('{}', $this->get('get/global_filters/all', ['script' => [self::answer('mailbox', ['get', 'global_filter_details'], false)]])->body); + } + + public function testOneGlobalFilterIsAJsonString(): void + { + $script = [self::answer('mailbox', ['get', 'global_filter_details'], self::FILTERS)]; + + $this->assertSame("# before\n", $this->get('get/global_filters/prefilter', ['script' => $script])->json()); + $this->assertSame("require \"fileinto\";\n", $this->get('get/global_filters/postfilter', ['script' => $script])->json()); + + $empty = [self::answer('mailbox', ['get', 'global_filter_details'], ['prefilter' => '', 'postfilter' => ''])]; + $this->assertSame('{}', $this->get('get/global_filters/prefilter', ['script' => $empty])->body); + $this->assertSame('{}', $this->get('get/global_filters/postfilter', ['script' => $empty])->body); + } + + public function testOtherObjectsAnswerNothing(): void + { + $result = $this->get('get/global_filters/before', ['script' => [self::answer('mailbox', ['get', 'global_filter_details'], self::FILTERS)]]); + + $this->assertSame([200, ''], [$result->status, $result->body]); + } +} diff --git a/tests/php/Api/Filtering/QuarantineTest.php b/tests/php/Api/Filtering/QuarantineTest.php new file mode 100644 index 000000000..f7aec0de6 --- /dev/null +++ b/tests/php/Api/Filtering/QuarantineTest.php @@ -0,0 +1,59 @@ + 33, 'qid' => '8224615004C1', 'subject' => 'You have won', 'virus_flag' => 0, 'score' => 15.48, 'rcpt' => 'john.doe@example.org']]; + + $result = $this->get('get/quarantine/all', ['script' => [self::answer('quarantine', ['get'], $items)]]); + $this->assertSame([['get']], $result->callsTo('quarantine')); + $this->assertSame($items, $result->json()); + + $this->assertSame('[]', $this->get('get/quarantine/all')->body); + } + + public function testGetQuarantineItem(): void + { + $item = ['id' => 33, 'qid' => '8224615004C1', 'msg' => "Subject: You have won\r\n\r\nClick here\r\n", 'qhash' => str_repeat('a', 64)]; + + $result = $this->get('get/quarantine/33', ['script' => [self::answer('quarantine', ['details', '33'], $item)]]); + $this->assertSame([['details', '33']], $result->callsTo('quarantine')); + $this->assertSame($item, $result->json()); + + $this->assertSame('[]', $this->get('get/quarantine/34', ['script' => [self::answer('quarantine', ['details'], false)]])->body); + } + + public function testEditQuarantineItems(): void + { + $release = $this->post('edit/qitem', ['items' => ['33', '34'], 'attr' => ['action' => 'release']]); + $this->assertCalled($release, 'quarantine', ['edit', ['id' => ['33', '34'], 'action' => 'release']]); + + $spam = $this->post('edit/qitem', ['items' => 35, 'attr' => ['action' => 'learnspam']]); + $this->assertCalled($spam, 'quarantine', ['edit', ['id' => [35], 'action' => 'learnspam']]); + } + + public function testDeleteQuarantineItems(): void + { + $this->assertCalled($this->post('delete/qitem', ['33', '34']), 'quarantine', ['delete', ['id' => ['33', '34']]]); + $this->assertCalled($this->post('delete/qitem', '"33"'), 'quarantine', ['delete', ['id' => ['33']]]); + } + + public function testEditQuarantineSettingsIgnoresItems(): void + { + $attr = ['action' => 'settings', 'retention_size' => '20', 'max_size' => '15', 'exclude_domains' => ['example.com']]; + $messages = [self::message('success', 'saved_settings')]; + + $result = $this->post('edit/quarantine', ['items' => ['self'], 'attr' => $attr], ['script' => [self::answer('quarantine', ['edit'], true, $messages)]]); + + $this->assertSame([['edit', $attr]], $result->callsTo('quarantine')); + $this->assertSame($messages, $result->json()); + } +} diff --git a/tests/php/Api/Filtering/QueueTest.php b/tests/php/Api/Filtering/QueueTest.php new file mode 100644 index 000000000..955880486 --- /dev/null +++ b/tests/php/Api/Filtering/QueueTest.php @@ -0,0 +1,78 @@ +get('get/mailq/all', ['script' => [self::answer('mailq', ['get'], $queue)]]); + + $this->assertSame([['get']], $result->callsTo('mailq')); + $this->assertSame($queue, $result->body); + } + + public function testEmptyMailQueue(): void + { + $this->assertSame('[]', $this->get('get/mailq/all', ['script' => [self::answer('mailq', ['get'], '[]')]])->body); + $this->assertSame('[]', $this->get('get/mailq/all', ['script' => [self::answer('mailq', ['get'], false)]])->body); + } + + public function testPostcatAnswersThePlainText(): void + { + $text = "*** ENVELOPE RECORDS deferred/B/B98C6260CA1 ***\nsender: sender@example.org\n*** MESSAGE CONTENTS deferred/B/B98C6260CA1 ***\nSubject: Test\n"; + + $result = $this->get('get/postcat/B98C6260CA1', ['script' => [self::answer('mailq', ['cat'], $text)]]); + + $this->assertSame([['cat', ['qid' => 'B98C6260CA1']]], $result->callsTo('mailq')); + $this->assertSame($text, $result->body); + $this->assertNull($result->json()); + } + + public function testPostcatWithoutAccessAnswersNothing(): void + { + $result = $this->get('get/postcat/B98C6260CA1', ['script' => [self::answer('mailq', ['cat'], false)]]); + + $this->assertSame([200, ''], [$result->status, $result->body]); + } + + public function testEditMailQueue(): void + { + $hold = $this->post('edit/mailq', ['items' => ['B98C6260CA1', '4C1E8260D02'], 'attr' => ['action' => 'hold']]); + $this->assertCalled($hold, 'mailq', ['edit', ['qid' => ['B98C6260CA1', '4C1E8260D02'], 'action' => 'hold']]); + + $deliver = $this->post('edit/mailq', ['items' => 'B98C6260CA1', 'attr' => ['action' => 'deliver']]); + $this->assertCalled($deliver, 'mailq', ['edit', ['qid' => ['B98C6260CA1'], 'action' => 'deliver']]); + + $flush = $this->post('edit/mailq', ['items' => [], 'attr' => ['action' => 'flush']]); + $this->assertCalled($flush, 'mailq', ['edit', ['qid' => [], 'action' => 'flush']]); + } + + public function testAnActionOutsideOfAttrIsNotSeen(): void + { + $result = $this->post('edit/mailq', ['action' => 'flush']); + + $this->assertCalled($result, 'mailq', ['edit', ['qid' => []]]); + $this->assertSame(['type' => 'success', 'msg' => 'Task completed'], $result->json()); + } + + public function testDeleteQueuedMessages(): void + { + $this->assertCalled($this->post('delete/mailq', ['B98C6260CA1', '4C1E8260D02']), 'mailq', ['delete', ['qid' => ['B98C6260CA1', '4C1E8260D02']]]); + $this->assertCalled($this->post('delete/mailq', '"B98C6260CA1"'), 'mailq', ['delete', ['qid' => ['B98C6260CA1']]]); + } + + public function testDeleteTakesNoAction(): void + { + $result = $this->post('delete/mailq', ['action' => 'super_delete']); + + $this->assertSame([['delete', ['qid' => ['action' => 'super_delete']]]], $result->callsTo('mailq')); + } +} diff --git a/tests/php/Api/Filtering/QuotaNotificationTest.php b/tests/php/Api/Filtering/QuotaNotificationTest.php new file mode 100644 index 000000000..f12ccb1a4 --- /dev/null +++ b/tests/php/Api/Filtering/QuotaNotificationTest.php @@ -0,0 +1,31 @@ + 'quota-warning@example.org', 'subject' => 'Quota warning', 'html_tmpl' => '']; + + $result = $this->post('edit/quota_notification', ['items' => ['quota_notification'], 'attr' => $attr]); + + $this->assertSame([['edit', $attr]], $result->callsTo('quota_notification')); + } + + public function testEditQuotaNotificationBccIgnoresItems(): void + { + $attr = ['domain' => 'example.org', 'bcc_rcpt' => "postmaster@example.org\nhelpdesk@example.org", 'active' => '1']; + $messages = [self::message('success', 'saved_settings')]; + + $result = $this->post('edit/quota_notification_bcc', ['items' => ['quota_bcc'], 'attr' => $attr], ['script' => [self::answer('quota_notification_bcc', ['edit'], null, $messages)]]); + + $this->assertSame([['edit', $attr]], $result->callsTo('quota_notification_bcc')); + $this->assertSame($messages, $result->json()); + } +} diff --git a/tests/php/Api/Filtering/RspamdTest.php b/tests/php/Api/Filtering/RspamdTest.php new file mode 100644 index 000000000..e417c8f8c --- /dev/null +++ b/tests/php/Api/Filtering/RspamdTest.php @@ -0,0 +1,118 @@ + 'Disable Rspamd for example.org', 'content' => 'priority = 10;', 'active' => '1']; + + public function testAddRsetting(): void + { + $messages = [self::message('success', 'settings_map_added', ['rsettings', 'add', self::RULE])]; + + $result = $this->post('add/rsetting', self::RULE, ['script' => [self::answer('rsettings', ['add'], null, $messages)]]); + + $this->assertCalled($result, 'rsettings', ['add', self::RULE]); + $this->assertSame($messages, $result->json()); + } + + public function testGetAllRsettingsReadsTheDetailsOfEveryRule(): void + { + $first = ['id' => 1, 'desc' => 'first', 'content' => 'priority = 10;', 'active' => 1]; + $third = ['id' => 3, 'desc' => 'third', 'content' => 'priority = 5;', 'active' => 0]; + + $result = $this->get('get/rsetting/all', ['script' => [ + self::answer('rsettings', ['get'], [['id' => 1, 'desc' => 'first', 'active' => 1], ['id' => 2, 'desc' => 'gone', 'active' => 1], ['id' => 3, 'desc' => 'third', 'active' => 0]]), + self::answer('rsettings', ['details', 1], $first), + self::answer('rsettings', ['details', 2], false), + self::answer('rsettings', ['details', 3], $third), + ]]); + + $this->assertSame([['get'], ['details', 1], ['details', 2], ['details', 3]], $result->callsTo('rsettings')); + $this->assertSame([$first, $third], $result->json()); + } + + public function testGetAllRsettingsWithoutRules(): void + { + $result = $this->get('get/rsetting/all', ['script' => [self::answer('rsettings', ['get'], false)]]); + + $this->assertSame('{}', $result->body); + $this->assertSame([['get']], $result->callsTo('rsettings')); + } + + public function testGetOneRsetting(): void + { + $rule = ['id' => 7, 'desc' => 'seven', 'content' => 'priority = 10;', 'active' => 1]; + + $found = $this->get('get/rsetting/7', ['script' => [self::answer('rsettings', ['details', '7'], $rule)]]); + $this->assertSame([['details', '7']], $found->callsTo('rsettings')); + $this->assertSame($rule, $found->json()); + + $this->assertSame('{}', $this->get('get/rsetting/8', ['script' => [self::answer('rsettings', ['details'], false)]])->body); + } + + public function testEditRsetting(): void + { + $result = $this->post('edit/rsetting', ['items' => [1, 2], 'attr' => ['desc' => 'renamed', 'active' => '0']]); + $this->assertCalled($result, 'rsettings', ['edit', ['id' => [1, 2], 'desc' => 'renamed', 'active' => '0']]); + + $single = $this->post('edit/rsetting', ['items' => '3', 'attr' => ['content' => 'priority = 1;']]); + $this->assertCalled($single, 'rsettings', ['edit', ['id' => ['3'], 'content' => 'priority = 1;']]); + } + + public function testDeleteRsetting(): void + { + $this->assertCalled($this->post('delete/rsetting', [1, 2]), 'rsettings', ['delete', ['id' => [1, 2]]]); + $this->assertCalled($this->post('delete/rsetting', 3), 'rsettings', ['delete', ['id' => [3]]]); + } + + public function testEditRspamdMap(): void + { + $data = "/^spammer@example\\.com$/i\n/@spam\\.example\\.net$/i"; + + $result = $this->post('edit/rspamd-map', ['items' => ['global_smtp_from_blacklist.map', 'global_rcpt_blacklist.map'], 'attr' => ['rspamd_map_data' => $data]]); + + $this->assertCalled($result, 'rspamd_maps', ['edit', ['map' => ['global_smtp_from_blacklist.map', 'global_rcpt_blacklist.map'], 'rspamd_map_data' => $data]]); + } + + public function testRspamdActions(): void + { + $actions = [['reject', 38], ['soft reject', 1], ['add header', 6], ['no action', 255]]; + + $result = $this->get('get/rspamd/actions', ['script' => [self::answer('rspamd_actions', [], $actions)]]); + $this->assertSame([[]], $result->callsTo('rspamd_actions')); + $this->assertSame($actions, $result->json()); + + $this->assertSame('{}', $this->get('get/rspamd/actions', ['script' => [self::answer('rspamd_actions', [], false)]])->body); + } + + public function testOtherRspamdObjectsAnswerNothing(): void + { + $result = $this->get('get/rspamd/stat'); + + $this->assertSame([200, ''], [$result->status, $result->body]); + $this->assertNotCalled($result, 'rspamd_actions'); + } + + public function testPresets(): void + { + $preset = [['name' => 'preset_1', 'headline' => 'Disable all but DKIM', 'content' => 'priority = 10;']]; + + $rspamd = $this->get('get/presets/rspamd', ['script' => [self::answer('presets', ['get', 'rspamd'], $preset)]]); + $this->assertSame([['get', 'rspamd']], $rspamd->callsTo('presets')); + $this->assertSame($preset, $rspamd->json()); + + $sieve = $this->get('get/presets/sieve', ['session' => self::DOMAIN_ADMIN]); + $this->assertSame([['get', 'sieve']], $sieve->callsTo('presets')); + $this->assertSame('{}', $sieve->body); + + $other = $this->get('get/presets/other'); + $this->assertSame([200, ''], [$other->status, $other->body]); + $this->assertNotCalled($other, 'presets'); + } +} diff --git a/tests/php/Api/Harness/runner.php b/tests/php/Api/Harness/runner.php new file mode 100644 index 000000000..dcbdd30d3 --- /dev/null +++ b/tests/php/Api/Harness/runner.php @@ -0,0 +1,266 @@ + */ + public static array $calls = []; + /** @var list, once?: bool}> */ + public static array $script = []; + /** Methods that return a recorder unless scripted, so that chains like Module::fromGlobals()->api->x() work */ + private const CHAINED = ['fromGlobals', 'create', 'prepare', 'query']; + + public static function record(string $name, array $args, ?string $method = null): mixed + { + $args = self::plain($args); + self::$calls[] = [$name, $args]; + foreach (self::$script as $i => $entry) { + if ($entry['call'] !== $name || !self::startsWith($args, $entry['args'] ?? [])) { + continue; + } + foreach ($entry['messages'] ?? [] as $message) { + $_SESSION['return'][] = $message; + } + if (!empty($entry['once'])) { + unset(self::$script[$i]); + } + return $entry['return'] ?? null; + } + return ($method !== null && in_array($method, self::CHAINED, true)) + ? new Recorder(preg_replace('/::(fromGlobals|create)$/', '', $name)) : null; + } + + /** Arguments as JSON data: recorders and other objects become "@" */ + private static function plain(mixed $value): mixed + { + if ($value instanceof Recorder) { + return '@' . $value->label(); + } + if (is_object($value)) { + return '@' . get_class($value); + } + return is_array($value) ? array_map([self::class, 'plain'], $value) : $value; + } + + private static function startsWith(array $args, array $prefix): bool + { + foreach (array_values($prefix) as $i => $expected) { + if (!array_key_exists($i, $args) || $args[$i] !== $expected) { + return false; + } + } + return true; + } + } + + /** Stand-in for objects and classes: records method calls, reads give further recorders */ + class Recorder implements \Countable, \IteratorAggregate, \ArrayAccess, \JsonSerializable + { + private string $recorderLabel; + + public function __construct(mixed ...$args) + { + $this->recorderLabel = (isset($args[0]) && is_string($args[0]) && static::class === self::class) ? $args[0] : static::class; + if (static::class !== self::class) { + Calls::record('new ' . static::class, $args); + } + } + + public function label(): string + { + return $this->recorderLabel; + } + + public function __get(string $name): self + { + return new self($this->recorderLabel . '->' . $name); + } + + public function __call(string $name, array $args): mixed + { + return Calls::record($this->recorderLabel . '->' . $name, $args, $name); + } + + public static function __callStatic(string $name, array $args): mixed + { + return Calls::record(static::class . '::' . $name, $args, $name); + } + + public function count(): int + { + return 0; + } + + public function getIterator(): \Iterator + { + return new \EmptyIterator(); + } + + public function offsetExists(mixed $offset): bool + { + return false; + } + + public function offsetGet(mixed $offset): mixed + { + return null; + } + + public function offsetSet(mixed $offset, mixed $value): void + { + } + + public function offsetUnset(mixed $offset): void + { + } + + public function jsonSerialize(): mixed + { + return ['@' => $this->recorderLabel]; + } + } + + /** php://input with the request body (the CLI has none); every other php:// stream is the real one */ + final class InputStream + { + public static string $body = ''; + /** @var resource|null */ + public $context; + private int $position = 0; + /** @var resource|null */ + private $inner = null; + + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + if (strtolower($path) === 'php://input') { + return true; + } + stream_wrapper_restore('php'); + $this->inner = fopen($path, $mode) ?: null; + stream_wrapper_unregister('php'); + stream_wrapper_register('php', self::class); + return $this->inner !== null; + } + + public function stream_read(int $count): string|false + { + if ($this->inner !== null) { + return fread($this->inner, $count); + } + $chunk = substr(self::$body, $this->position, $count); + $this->position += strlen($chunk); + return $chunk; + } + + public function stream_write(string $data): int + { + return ($this->inner !== null) ? (int)fwrite($this->inner, $data) : 0; + } + + public function stream_eof(): bool + { + return ($this->inner !== null) ? feof($this->inner) : $this->position >= strlen(self::$body); + } + + public function stream_stat(): array|false + { + return ($this->inner !== null) ? fstat($this->inner) : ['size' => strlen(self::$body)]; + } + + public function stream_close(): void + { + if ($this->inner !== null) { + fclose($this->inner); + } + } + } + + /** Names of the functions $file calls that are neither PHP functions nor declared in $file */ + function calledFunctions(string $file): array + { + $tokens = array_values(array_filter(token_get_all(file_get_contents($file)), + fn($t) => !is_array($t) || !in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true))); + $declared = []; + $called = []; + foreach ($tokens as $i => $token) { + if (!is_array($token) || $token[0] !== T_STRING) { + continue; + } + $previous = $tokens[$i - 1] ?? null; + $next = $tokens[$i + 1] ?? null; + if (is_array($previous) && $previous[0] === T_FUNCTION) { + $declared[strtolower($token[1])] = true; + continue; + } + if ($next !== '(' || (is_array($previous) && in_array($previous[0], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR, T_DOUBLE_COLON, T_NEW], true))) { + continue; + } + $called[strtolower($token[1])] = $token[1]; + } + return array_values(array_filter($called, fn(string $name): bool => !isset($declared[strtolower($name)]) && !function_exists($name))); + } +} + +namespace { + + use ApiHarness\Calls; + use ApiHarness\InputStream; + + $request = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); + $jsonApi = $request['json_api']; + + foreach (ApiHarness\calledFunctions($jsonApi) as $name) { + eval('function ' . $name . '(...$args) { return \ApiHarness\Calls::record(' . var_export($name, true) . ', $args); }'); + } + spl_autoload_register(function (string $class): void { + $parts = explode('\\', ltrim($class, '\\')); + $short = array_pop($parts); + $namespace = implode('\\', $parts); + eval(($namespace !== '' ? "namespace $namespace; " : '') . "class $short extends \\ApiHarness\\Recorder {}"); + }); + + $_SERVER['DOCUMENT_ROOT'] = __DIR__ . '/webroot'; + $_SERVER['REQUEST_METHOD'] = $request['method']; + $_SERVER['REQUEST_URI'] = '/api/v1/' . $request['query']; + $_SERVER['REMOTE_ADDR'] = '192.0.2.10'; + if (isset($request['content_type'])) { + $_SERVER['HTTP_CONTENT_TYPE'] = $request['content_type']; + } + foreach ($request['server'] ?? [] as $key => $value) { + $_SERVER[$key] = $value; + } + $_GET = ['query' => $request['query']] + ($request['get'] ?? []); + $_POST = $request['post'] ?? []; + $GLOBALS['__harness_session'] = $request['session'] ?? []; + $GLOBALS['__harness_globals'] = $request['globals'] ?? []; + Calls::$script = $request['script'] ?? []; + InputStream::$body = (string)($request['body'] ?? ''); + stream_wrapper_unregister('php'); + stream_wrapper_register('php', InputStream::class); + + register_shutdown_function(function () use ($request): void { + $output = ''; + while (ob_get_level() > 0) { + $output = ob_get_clean() . $output; + } + $error = error_get_last(); + $fatal = ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)); + stream_wrapper_restore('php'); + file_put_contents($request['result'], json_encode([ + 'status' => http_response_code() ?: 200, + 'body' => $output, + 'calls' => Calls::$calls, + 'session_return' => $_SESSION['return'] ?? null, + 'error' => $fatal ? $error['message'] . ' in ' . $error['file'] . ':' . $error['line'] : null, + ], JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE)); + }); + + ob_start(); + require $jsonApi; +} diff --git a/tests/php/Api/Harness/webroot/inc/lib/ssp.class.php b/tests/php/Api/Harness/webroot/inc/lib/ssp.class.php new file mode 100644 index 000000000..93ffb832c --- /dev/null +++ b/tests/php/Api/Harness/webroot/inc/lib/ssp.class.php @@ -0,0 +1,3 @@ + $value) { + $GLOBALS[$name] = $value; +} diff --git a/tests/php/Api/Mailboxes/AppPasswordRoutesTest.php b/tests/php/Api/Mailboxes/AppPasswordRoutesTest.php new file mode 100644 index 000000000..e15ea1954 --- /dev/null +++ b/tests/php/Api/Mailboxes/AppPasswordRoutesTest.php @@ -0,0 +1,52 @@ + 'john@example.org', 'app_name' => 'Thunderbird', 'app_passwd' => 'eiQu4ahz8iePh', 'app_passwd2' => 'eiQu4ahz8iePh', 'protocols' => ['imap_access']]; + + $this->assertCalled($this->post('add/app-passwd', $body), 'app_passwd', ['add', $body]); + } + + public function testListsOfAMailboxAndOfTheSessionUser(): void + { + $script = [ + self::answer('app_passwd', ['get'], [['id' => 23, 'name' => 'Thunderbird'], ['id' => 24, 'name' => 'Gone']]), + self::answer('app_passwd', ['details', 23], ['id' => 23, 'mailbox' => 'john@example.org']), + self::answer('app_passwd', ['details', 24], false), + ]; + + $mailbox = $this->get('get/app-passwd/all/john@example.org', ['script' => $script]); + $this->assertSame([['get', ['username' => 'john@example.org']], ['details', 23], ['details', 24]], $mailbox->callsTo('app_passwd')); + $this->assertSame([['id' => 23, 'mailbox' => 'john@example.org']], $mailbox->json()); + + $own = $this->get('get/app-passwd/all', ['session' => self::USER, 'script' => $script]); + $this->assertSame(['get'], $own->callsTo('app_passwd')[0]); + + $this->assertSame('{}', $this->get('get/app-passwd/all')->body); + } + + public function testSingleAppPasswordFails(): void + { + $result = $this->get('get/app-passwd/23', ['allow_error' => true]); + + $this->assertStringContainsString('Cannot access offset of type string on string', (string)$result->error); + $this->assertNotCalled($result, 'app_passwd'); + } + + public function testEditAndDeleteAppPasswords(): void + { + $edit = $this->post('edit/app-passwd', ['items' => [23], 'attr' => ['username' => 'john@example.org', 'active' => '0']]); + $this->assertCalled($edit, 'app_passwd', ['edit', ['id' => [23], 'username' => 'john@example.org', 'active' => '0']]); + + $this->assertCalled($this->post('delete/app-passwd', [23, 24]), 'app_passwd', ['delete', ['id' => [23, 24]]]); + } +} diff --git a/tests/php/Api/Mailboxes/FilterAndPolicyRoutesTest.php b/tests/php/Api/Mailboxes/FilterAndPolicyRoutesTest.php new file mode 100644 index 000000000..f527ac1d9 --- /dev/null +++ b/tests/php/Api/Mailboxes/FilterAndPolicyRoutesTest.php @@ -0,0 +1,84 @@ + 'john@example.org', 'script_desc' => 'Move newsletters', 'script_data' => 'keep;', 'filter_type' => 'prefilter', 'active' => 1]; + $this->assertCalled($this->post('add/filter', $body), 'mailbox', ['add', 'filter', $body]); + + $this->assertCalled($this->post('edit/filter', ['items' => [7], 'attr' => ['active' => 0]]), 'mailbox', ['edit', 'filter', ['id' => [7], 'active' => 0]]); + $this->assertCalled($this->post('delete/filter', [7]), 'mailbox', ['delete', 'filter', ['id' => [7]]]); + } + + public function testAllFiltersOfAllMailboxes(): void + { + $result = $this->get('get/filters/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org']), + self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['john@example.org', 'jane@example.org']), + self::answer('mailbox', ['get', 'filters', 'john@example.org'], [7, 8]), + self::answer('mailbox', ['get', 'filter_details', 7], ['id' => 7]), + self::answer('mailbox', ['get', 'filter_details', 8], false), + ]]); + + $this->assertCalled($result, 'mailbox', ['get', 'filters', 'jane@example.org']); + $this->assertSame([['id' => 7]], $result->json()); + $this->assertSame('{}', $this->get('get/filters/all')->body); + } + + public function testFiltersOfAMailbox(): void + { + $result = $this->get('get/filters/john@example.org', ['script' => [ + self::answer('mailbox', ['get', 'filters'], [7]), + self::answer('mailbox', ['get', 'filter_details', 7], ['id' => 7, 'username' => 'john@example.org']), + ]]); + + $this->assertCalled($result, 'mailbox', ['get', 'filters', 'john@example.org']); + $this->assertSame([['id' => 7, 'username' => 'john@example.org']], $result->json()); + $this->assertSame('{}', $this->get('get/filters/jane@example.org')->body); + } + + public function testActiveUserSieveIsAListWithOneScript(): void + { + $result = $this->get('get/active-user-sieve/john@example.org', ['script' => [self::answer('mailbox', ['get', 'active_user_sieve'], "require \"fileinto\";\nkeep;")]]); + + $this->assertSame([['get', 'active_user_sieve', 'john@example.org']], $result->callsTo('mailbox')); + $this->assertSame(["require \"fileinto\";\nkeep;"], $result->json()); + $this->assertSame('{}', $this->get('get/active-user-sieve/john@example.org', ['script' => [self::answer('mailbox', [], false)]])->body); + + $none = $this->get('get/active-user-sieve'); + $this->assertNotCalled($none, 'mailbox'); + $this->assertSame('{}', $none->body); + } + + public function testAddAndDeleteMailboxPolicies(): void + { + $body = ['username' => 'john@example.org', 'object_list' => 'bl', 'object_from' => '*@spam.example.com']; + $this->assertCalled($this->post('add/mailbox-policy', $body), 'policy', ['add', 'mailbox', $body]); + + $this->assertCalled($this->post('delete/mailbox-policy', [13]), 'policy', ['delete', 'mailbox', ['prefid' => [13]]]); + } + + public function testAllowAndDenyListsArePartsOfThePolicy(): void + { + $policy = [ + 'whitelist' => [['object' => 'john@example.org', 'value' => '*@partner.example.com', 'prefid' => 12]], + 'blacklist' => [['object' => 'example.org', 'value' => '*@spam.example.com', 'prefid' => 13]], + ]; + $script = [self::answer('policy', ['get', 'mailbox'], $policy)]; + + $allow = $this->get('get/policy_wl_mailbox/john@example.org', ['script' => $script]); + $this->assertSame([['get', 'mailbox', 'john@example.org']], $allow->callsTo('policy')); + $this->assertSame($policy['whitelist'], $allow->json()); + + $this->assertSame($policy['blacklist'], $this->get('get/policy_bl_mailbox/john@example.org', ['script' => $script])->json()); + $this->assertSame('{}', $this->get('get/policy_bl_mailbox/nobody@example.org')->body); + } +} diff --git a/tests/php/Api/Mailboxes/MailboxRoutesTest.php b/tests/php/Api/Mailboxes/MailboxRoutesTest.php new file mode 100644 index 000000000..db63db0d5 --- /dev/null +++ b/tests/php/Api/Mailboxes/MailboxRoutesTest.php @@ -0,0 +1,193 @@ + 'john.doe', 'domain' => 'example.org', 'password' => 'Moo2Shoh5aiXu', 'password2' => 'Moo2Shoh5aiXu', 'tags' => ['sales']]; + + $this->assertCalled($this->post('add/mailbox', $body), 'mailbox', ['add', 'mailbox', $body]); + // any other object is a mailbox as well + $this->assertCalled($this->post('add/mailbox/something', $body), 'mailbox', ['add', 'mailbox', $body]); + } + + public function testAddMailboxTemplate(): void + { + $body = ['template' => 'Sales', 'quota' => 2048, 'acl' => ['spam_alias']]; + + $this->assertCalled($this->post('add/mailbox/template', $body), 'mailbox', ['add', 'mailbox_templates', $body]); + } + + public function testGetAllMailboxesWalksAllDomains(): void + { + $result = $this->get('get/mailbox/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com']), + self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['john@example.org', 'gone@example.org']), + self::answer('mailbox', ['get', 'mailboxes', 'example.com'], ['jane@example.com']), + self::answer('mailbox', ['get', 'mailbox_details', 'john@example.org'], ['username' => 'john@example.org']), + self::answer('mailbox', ['get', 'mailbox_details', 'gone@example.org'], false), + self::answer('mailbox', ['get', 'mailbox_details', 'jane@example.com'], ['username' => 'jane@example.com']), + ]]); + + $this->assertCalled($result, 'mailbox', ['get', 'mailboxes', 'example.org', null]); + $this->assertCalled($result, 'mailbox', ['get', 'mailbox_details', 'john@example.org', 'all']); + $this->assertSame([['username' => 'john@example.org'], ['username' => 'jane@example.com']], $result->json()); + } + + public function testReducedDetailsOfGivenDomainsWithTags(): void + { + $result = $this->get('get/mailbox/reduced/example.org,example.com', [ + 'query' => ['tags' => 'sales,support'], + 'script' => [ + self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['john@example.org']), + self::answer('mailbox', ['get', 'mailbox_details'], ['username' => 'john@example.org']), + ], + ]); + + $this->assertNotContains(['get', 'domains'], $result->callsTo('mailbox')); + $this->assertCalled($result, 'mailbox', ['get', 'mailboxes', 'example.org', ['sales', 'support']]); + $this->assertCalled($result, 'mailbox', ['get', 'mailboxes', 'example.com', ['sales', 'support']]); + $this->assertCalled($result, 'mailbox', ['get', 'mailbox_details', 'john@example.org', 'reduced']); + $this->assertSame([['username' => 'john@example.org']], $result->json()); + } + + public function testMailboxListsWithoutResults(): void + { + $this->assertSame('{}', $this->get('get/mailbox/all')->body); + $this->assertSame('{}', $this->get('get/mailbox/all/example.org')->body); + } + + public function testGetOneMailbox(): void + { + $found = $this->get('get/mailbox/john@example.org', ['script' => [ + self::answer('mailbox', ['get', 'mailbox_details', 'john@example.org'], ['username' => 'john@example.org', 'quota' => 0]), + ]]); + + $this->assertCalled($found, 'mailbox', ['get', 'mailbox_details', 'john@example.org']); + $this->assertSame(['username' => 'john@example.org', 'quota' => 0], $found->json()); + $this->assertSame('{}', $this->get('get/mailbox/nobody@example.org')->body); + } + + public function testGetMailboxWithTagsSearchesByAddressPart(): void + { + $result = $this->get('get/mailbox/example.org', [ + 'query' => ['tags' => 'sales'], + 'script' => [ + self::answer('mailbox', ['get', 'mailboxes', 'example.org', ['sales']], ['john@example.org']), + self::answer('mailbox', ['get', 'mailbox_details', 'john@example.org'], ['username' => 'john@example.org']), + ], + ]); + + $this->assertCalled($result, 'mailbox', ['get', 'mailbox_details', 'john@example.org']); + $this->assertSame([['username' => 'john@example.org']], $result->json()); + $this->assertSame('[]', $this->get('get/mailbox/example.org', ['query' => ['tags' => 'sales']])->body); + } + + public function testGetMailboxTemplates(): void + { + $templates = [['id' => 2, 'template' => 'Default', 'type' => 'mailbox', 'attributes' => ['quota' => 0]]]; + $all = $this->get('get/mailbox/template/all', ['script' => [self::answer('mailbox', ['get', 'mailbox_templates'], $templates)]]); + + $this->assertSame([['get', 'mailbox_templates']], $all->callsTo('mailbox')); + $this->assertSame($templates, $all->json()); + $this->assertCalled($this->get('get/mailbox/template/2'), 'mailbox', ['get', 'mailbox_templates', '2']); + } + + public function testEditMailbox(): void + { + $result = $this->post('edit/mailbox', ['items' => 'john@example.org', 'attr' => ['quota' => 5120, 'sender_acl' => ['@example.com']]]); + + $this->assertCalled($result, 'mailbox', ['edit', 'mailbox', ['username' => ['john@example.org'], 'quota' => 5120, 'sender_acl' => ['@example.com']]]); + } + + public function testEditMailboxTemplatesAndCustomAttributes(): void + { + $template = $this->post('edit/mailbox/template', ['items' => [3], 'attr' => ['template' => 'Sales', 'quarantine_category' => 'reject']]); + $this->assertCalled($template, 'mailbox', ['edit', 'mailbox_templates', ['ids' => [3], 'template' => 'Sales', 'quarantine_category' => 'reject']]); + + $custom = $this->post('edit/mailbox/custom-attribute', ['items' => ['john@example.org'], 'attr' => ['attribute' => ['department'], 'value' => ['Sales']]]); + $this->assertCalled($custom, 'mailbox', ['edit', 'mailbox_custom_attribute', ['mailboxes' => ['john@example.org'], 'attribute' => ['department'], 'value' => ['Sales']]]); + } + + public function testRenameMailboxTakesTheNamesFromAttr(): void + { + $attr = ['domain' => 'example.org', 'old_local_part' => 'john.doe', 'new_local_part' => 'john', 'create_alias' => 1]; + + $result = $this->post('edit/rename-mbox', ['items' => [], 'attr' => $attr]); + + $this->assertCalled($result, 'mailbox', ['edit', 'mailbox_rename', ['mailbox' => []] + $attr]); + } + + public function testEditUserAcl(): void + { + $result = $this->post('edit/user-acl', ['items' => ['john@example.org'], 'attr' => ['user_acl' => ['spam_alias', 'app_passwds']]]); + + $this->assertCalled($result, 'acl', ['edit', 'user', ['username' => ['john@example.org'], 'user_acl' => ['spam_alias', 'app_passwds']]]); + } + + public function testDeleteMailboxesTagsAndTemplates(): void + { + $this->assertCalled($this->post('delete/mailbox', ['john@example.org']), 'mailbox', ['delete', 'mailbox', ['username' => ['john@example.org']]]); + + $tags = $this->post('delete/mailbox/tag/john@example.org', ['sales', 'trainee']); + $this->assertCalled($tags, 'mailbox', ['delete', 'tags_mailbox', ['tags' => ['sales', 'trainee'], 'username' => 'john@example.org']]); + + $this->assertCalled($this->post('delete/mailbox/template', [3]), 'mailbox', ['delete', 'mailbox_templates', ['ids' => [3]]]); + } + + public function testResetEasCacheAndSogoProfile(): void + { + $this->assertCalled($this->post('delete/eas_cache', ['john@example.org']), 'mailbox', ['delete', 'eas_cache', ['username' => ['john@example.org']]]); + $this->assertCalled($this->post('delete/sogo_profile', ['john@example.org']), 'mailbox', ['delete', 'sogo_profile', ['username' => ['john@example.org']]]); + } + + public function testLastLogin(): void + { + $logins = ['ui' => false, 'sasl' => [['real_rip' => '198.51.100.23', 'service' => 'imap']]]; + $default = $this->get('get/last-login/john@example.org', ['script' => [self::answer('last_login', [], $logins)]]); + $this->assertSame([['get', 'john@example.org']], $default->callsTo('last_login')); + $this->assertSame($logins, $default->json()); + + $this->assertSame([['get', 'john@example.org', 30]], $this->get('get/last-login/john@example.org/30')->callsTo('last_login')); + $this->assertSame([['get', 'john@example.org']], $this->get('get/last-login/john@example.org/0')->callsTo('last_login')); + + $none = $this->get('get/last-login'); + $this->assertSame('', $none->body); + $this->assertNotCalled($none, 'last_login'); + } + + public function testResetLastLoginIsAReadRoute(): void + { + $result = $this->get('get/reset-last-login/john@example.org', ['session' => self::API_RO, 'script' => [self::answer('last_login', ['reset'], true)]]); + + $this->assertSame([['reset', 'john@example.org']], $result->callsTo('last_login')); + $this->assertSame('true', $result->body); + } + + public function testBccDestinationOptions(): void + { + $result = $this->get('get/bcc-destination-options', ['script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org']), + self::answer('mailbox', ['get', 'alias_domains'], ['example.net']), + self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['john@example.org']), + self::answer('user_get_alias_details', ['john@example.org'], [ + 'direct_aliases' => ['sales@example.org' => ['public_comment' => ''], 'john@other.example' => ['public_comment' => '']], + 'shared_aliases' => ['team@example.org' => ['public_comment' => '']], + ]), + self::answer('hasDomainAccess', ['API', 'admin', 'example.org'], true), + ]]); + + $this->assertSame([ + 'domains' => ['example.org'], + 'mailboxes' => ['john@example.org' => ['john@example.org', 'sales@example.org', 'team@example.org']], + 'alias_domains' => ['example.net'], + ], $result->json()); + } +} diff --git a/tests/php/Api/Mailboxes/MailboxSearchTest.php b/tests/php/Api/Mailboxes/MailboxSearchTest.php new file mode 100644 index 000000000..5b7bb9ce0 --- /dev/null +++ b/tests/php/Api/Mailboxes/MailboxSearchTest.php @@ -0,0 +1,91 @@ + 1, + 'start' => 0, + 'length' => 25, + 'search' => ['value' => 'john'], + 'order' => [['column' => 2, 'dir' => 'asc']], + 'columns' => [['data' => 'username', 'searchable' => true, 'orderable' => true]], + ]; + + private const KIND_FILTER = "(`m`.`kind` = '' OR `m`.`kind` = NULL)"; + + public function testAdminsSearchAllMailboxes(): void + { + $call = $this->searchCall($this->post('search/mailbox', self::REQUEST)); + + $this->assertSame([self::REQUEST, '@pdo', ['mailbox', 'm'], 'username'], array_slice($call, 0, 4)); + $this->assertSame([null, self::KIND_FILTER], array_slice($call, 5)); + $this->assertSame([2 => 'username', 3 => 'quota', 4 => 'last_mail_login', 5 => 'last_pw_change', 6 => 'in_use', 7 => 'name', + 20 => 'messages', 23 => 'tags', 24 => 'active'], array_column($call[4], 'db', 'dt')); + } + + public function testReadOnlyKeysMaySearch(): void + { + $this->searchCall($this->post('search/mailbox', self::REQUEST, ['session' => self::API_RO])); + } + + public function testDomainAdminsSearchTheirDomains(): void + { + $call = $this->searchCall($this->post('search/mailbox', self::REQUEST, ['session' => self::DOMAIN_ADMIN])); + + $this->assertSame('INNER JOIN domain_admins as da ON da.domain = m.domain', $call[5]); + $this->assertSame([ + 'condition' => self::KIND_FILTER . ' AND `da`.`active` = 1 AND `da`.`username` = :username', + 'bindings' => ['username' => 'domainadmin'], + ], $call[6]); + } + + public function testOthersAreRefused(): void + { + foreach ([self::USER, self::NO_SESSION] as $session) { + $result = $this->post('search/mailbox', self::REQUEST, ['session' => $session]); + $this->assertApiError($result, 403, 'Insufficient permissions'); + $this->assertNotCalled($result, 'SSP::complex'); + } + } + + public function testRowsBecomeMailboxDetails(): void + { + $page = ['draw' => 1, 'recordsTotal' => 2, 'recordsFiltered' => 2, 'data' => [ + [2 => 'john@example.org', 3 => 0, 4 => '', 7 => 'John'], + [2 => 'gone@example.org', 3 => 0, 4 => '', 7 => 'Gone'], + ]]; + $result = $this->post('search/mailbox', self::REQUEST, ['script' => [ + self::answer('SSP::complex', [], $page), + self::answer('mailbox', ['get', 'mailbox_details', 'john@example.org'], ['username' => 'john@example.org']), + ]]); + + $this->assertSame([['get', 'mailbox_details', 'john@example.org'], ['get', 'mailbox_details', 'gone@example.org']], $result->callsTo('mailbox')); + $this->assertSame(['draw' => 1, 'recordsTotal' => 2, 'recordsFiltered' => 2, 'data' => [['username' => 'john@example.org']]], $result->json()); + } + + public function testEmptyPageIsPassedOn(): void + { + $page = ['draw' => 4, 'recordsTotal' => 0, 'recordsFiltered' => 0, 'data' => []]; + + $result = $this->post('search/mailbox', self::REQUEST, ['script' => [self::answer('SSP::complex', [], $page)]]); + + $this->assertNotCalled($result, 'mailbox'); + $this->assertSame($page, $result->json()); + } + + private function searchCall(ApiResult $result): array + { + $calls = $result->callsTo('SSP::complex'); + $this->assertCount(1, $calls, $result->body); + $this->assertNotCalled($result, 'SSP::simple'); + return $calls[0]; + } +} diff --git a/tests/php/Api/Mailboxes/MailboxSettingsRoutesTest.php b/tests/php/Api/Mailboxes/MailboxSettingsRoutesTest.php new file mode 100644 index 000000000..82a6c8672 --- /dev/null +++ b/tests/php/Api/Mailboxes/MailboxSettingsRoutesTest.php @@ -0,0 +1,78 @@ + 'john@example.org', 'domain' => 'example.org', 'validity' => 720]; + $this->assertCalled($this->post('add/time_limited_alias', $body), 'mailbox', ['add', 'time_limited_alias', $body]); + + $edit = $this->post('edit/time_limited_alias', ['items' => ['kahd.osuwe@example.org'], 'attr' => ['validity' => 24]]); + $this->assertCalled($edit, 'mailbox', ['edit', 'time_limited_alias', ['address' => ['kahd.osuwe@example.org'], 'validity' => 24]]); + + $delete = $this->post('delete/time_limited_alias', ['kahd.osuwe@example.org']); + $this->assertCalled($delete, 'mailbox', ['delete', 'time_limited_alias', ['address' => ['kahd.osuwe@example.org']]]); + } + + public function testListTimeLimitedAliases(): void + { + $aliases = [['address' => 'kahd.osuwe@example.org', 'goto' => 'john@example.org', 'permanent' => 0]]; + + $result = $this->get('get/time_limited_aliases/john@example.org', ['script' => [self::answer('mailbox', ['get', 'time_limited_aliases'], $aliases)]]); + + $this->assertSame([['get', 'time_limited_aliases', 'john@example.org']], $result->callsTo('mailbox')); + $this->assertSame($aliases, $result->json()); + $this->assertSame('{}', $this->get('get/time_limited_aliases/jane@example.org')->body); + } + + public function testPushover(): void + { + $attr = ['key' => str_repeat('a', 30), 'token' => str_repeat('b', 30), 'active' => 1]; + $this->assertCalled($this->post('edit/pushover', ['items' => ['john@example.org'], 'attr' => $attr]), 'pushover', ['edit', ['username' => ['john@example.org']] + $attr]); + + $this->assertCalled($this->post('edit/pushover-test', ['items' => ['john@example.org'], 'attr' => []]), 'pushover', ['test', ['username' => ['john@example.org']]]); + } + + public function testMailboxUserSettingsGetTheMailboxesAsUsername(): void + { + $routes = [ + 'delimiter_action' => ['tagged_mail_handler' => 'subfolder'], + 'tls_policy' => ['tls_enforce_in' => 1], + 'quarantine_notification' => ['quarantine_notification' => 'daily'], + 'quarantine_category' => ['quarantine_category' => 'all'], + 'spam_score' => ['spam_score' => '6,12'], + ]; + foreach ($routes as $type => $attr) { + $route = 'edit/' . ($type === 'spam_score' ? 'spam-score' : $type); + $result = $this->post($route, ['items' => 'john@example.org', 'attr' => $attr]); + $this->assertCalled($result, 'mailbox', ['edit', $type, ['username' => ['john@example.org']] + $attr]); + } + } + + public function testTheUiSendsItsOwnAddressAsItem(): void + { + $result = $this->postForm('edit/delimiter_action', ['items' => '["user@example.org"]', 'attr' => '{"tagged_mail_handler":"subject","csrf_token":"t"}'], ['session' => self::USER]); + + $this->assertCalled($result, 'mailbox', ['edit', 'delimiter_action', ['username' => ['user@example.org'], 'tagged_mail_handler' => 'subject']]); + } + + public function testSpamScoreWithoutSpaces(): void + { + $mailbox = $this->get('get/spam-score/john@example.org', ['script' => [self::answer('mailbox', ['get', 'spam_score'], '6, 12')]]); + $this->assertSame([['get', 'spam_score', 'john@example.org']], $mailbox->callsTo('mailbox')); + $this->assertSame(['score' => '6,12'], $mailbox->json()); + + $default = $this->get('get/spam-score', ['script' => [self::answer('mailbox', ['get', 'spam_score'], '8, 15')]]); + $this->assertSame([['get', 'spam_score', null]], $default->callsTo('mailbox')); + $this->assertSame(['score' => '8,15'], $default->json()); + + $this->assertSame('{}', $this->get('get/spam-score/jane@example.org', ['script' => [self::answer('mailbox', [], false)]])->body); + } +} diff --git a/tests/php/Api/Mailboxes/RatelimitRoutesTest.php b/tests/php/Api/Mailboxes/RatelimitRoutesTest.php new file mode 100644 index 000000000..8ca980440 --- /dev/null +++ b/tests/php/Api/Mailboxes/RatelimitRoutesTest.php @@ -0,0 +1,52 @@ +get('get/rl-mbox/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org']), + self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['john@example.org', 'jane@example.org']), + self::answer('ratelimit', ['get', 'mailbox', 'john@example.org'], ['value' => '100', 'frame' => 'h']), + self::answer('ratelimit', ['get', 'mailbox', 'jane@example.org'], false), + ]]); + + $this->assertSame([['value' => '100', 'frame' => 'h', 'mailbox' => 'john@example.org']], $result->json()); + $this->assertSame('{}', $this->get('get/rl-mbox/all')->body); + } + + public function testRatelimitOfAMailbox(): void + { + $result = $this->get('get/rl-mbox/john@example.org', ['script' => [self::answer('ratelimit', [], ['value' => '100', 'frame' => 'h'])]]); + + $this->assertSame([['get', 'mailbox', 'john@example.org']], $result->callsTo('ratelimit')); + $this->assertSame(['value' => '100', 'frame' => 'h'], $result->json()); + $this->assertSame('{}', $this->get('get/rl-mbox/jane@example.org')->body); + } + + public function testEditRatelimit(): void + { + $result = $this->post('edit/rl-mbox', ['items' => ['john@example.org'], 'attr' => ['rl_value' => 100, 'rl_frame' => 'h']]); + + $this->assertCalled($result, 'ratelimit', ['edit', 'mailbox', ['object' => ['john@example.org'], 'rl_value' => 100, 'rl_frame' => 'h']]); + } + + public function testDeleteRatelimitHashAnswersTheRawResult(): void + { + $deleted = $this->post('delete/rlhash', ['RLabc123'], ['script' => [self::answer('ratelimit', [], true, [self::message('success', 'hash_deleted')])]]); + $this->assertSame([['delete', null, 'RLabc123']], $deleted->callsTo('ratelimit')); + $this->assertSame('1', $deleted->body); + + $missing = $this->post('delete/rlhash', ['RLabc123'], ['script' => [self::answer('ratelimit', [], false, [self::message('warning', 'hash_not_found')])]]); + $this->assertSame('', $missing->body); + + $this->assertSame([['delete', null, 'RLabcRLdef']], $this->post('delete/rlhash', ['RLabc', 'RLdef'])->callsTo('ratelimit')); + } +} diff --git a/tests/php/Api/Mailboxes/ResourceRoutesTest.php b/tests/php/Api/Mailboxes/ResourceRoutesTest.php new file mode 100644 index 000000000..9e15ab348 --- /dev/null +++ b/tests/php/Api/Mailboxes/ResourceRoutesTest.php @@ -0,0 +1,49 @@ + 'example.org', 'description' => 'Meeting Room 1', 'kind' => 'location', 'multiple_bookings' => 0, 'active' => 1]; + + $this->assertCalled($this->post('add/resource', $body), 'mailbox', ['add', 'resource', $body]); + } + + public function testGetAllResourcesWalksAllDomains(): void + { + $result = $this->get('get/resource/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com']), + self::answer('mailbox', ['get', 'resources', 'example.org'], ['room1@example.org', 'gone@example.org']), + self::answer('mailbox', ['get', 'resource_details', 'room1@example.org'], ['name' => 'room1@example.org']), + self::answer('mailbox', ['get', 'resource_details', 'gone@example.org'], false), + ]]); + + $this->assertCalled($result, 'mailbox', ['get', 'resources', 'example.com']); + $this->assertSame([['name' => 'room1@example.org']], $result->json()); + $this->assertSame('{}', $this->get('get/resource/all')->body); + } + + public function testGetOneResource(): void + { + $result = $this->get('get/resource/room1@example.org', ['script' => [self::answer('mailbox', ['get', 'resource_details'], ['name' => 'room1@example.org'])]]); + + $this->assertSame([['get', 'resource_details', 'room1@example.org']], $result->callsTo('mailbox')); + $this->assertSame(['name' => 'room1@example.org'], $result->json()); + } + + public function testEditAndDeleteResources(): void + { + $edit = $this->post('edit/resource', ['items' => ['room1@example.org'], 'attr' => ['multiple_bookings' => -1]]); + $this->assertCalled($edit, 'mailbox', ['edit', 'resource', ['name' => ['room1@example.org'], 'multiple_bookings' => -1]]); + + $delete = $this->post('delete/resource', ['room1@example.org']); + $this->assertCalled($delete, 'mailbox', ['delete', 'resource', ['name' => ['room1@example.org']]]); + } +} diff --git a/tests/php/Api/Modules/IdentityRoutesTest.php b/tests/php/Api/Modules/IdentityRoutesTest.php new file mode 100644 index 000000000..ba0506214 --- /dev/null +++ b/tests/php/Api/Modules/IdentityRoutesTest.php @@ -0,0 +1,136 @@ +api->'; + /** The Identity Provider settings that prerequisites.inc.php loads into $iam_settings */ + private const IAM_SETTINGS = ['iam_settings' => [ + 'authsource' => 'keycloak', + 'server_url' => 'https://auth.example.org', + 'realm' => 'mailcow', + 'client_id' => 'mailcow', + 'client_secret' => 'client-secret-value', + 'bindpass' => 'bind-password', + 'access_token' => 'cached-token', + 'redirect_url_extra' => ['https://webmail.example.org'], + 'login_provisioning' => true, + ]]; + private const SETTINGS = [ + 'authsource' => 'keycloak', + 'server_url' => 'https://auth.example.org', + 'realm' => 'mailcow', + 'client_id' => 'mailcow', + 'client_secret' => 'client-secret-value', + 'bindpass' => 'bind-password', + 'access_token' => 'cached-token', + 'redirect_url_extra' => ['https://webmail.example.org'], + 'login_provisioning' => true, + ]; + + public function testSettingsForReadWriteKeys(): void + { + $result = $this->get('get/identity-provider', ['globals' => self::IAM_SETTINGS]); + + $this->assertSame(self::SETTINGS, $result->json()); + $this->assertNotCalled($result, self::API . 'getStatus'); + } + + public function testReadOnlyKeysGetNoSecrets(): void + { + $result = $this->get('get/identity-provider', ['globals' => self::IAM_SETTINGS, 'session' => self::API_RO]); + + $this->assertSame(array_replace(self::SETTINGS, ['client_secret' => '*', 'bindpass' => '*', 'access_token' => '*']), $result->json()); + } + + public function testSettingsOnlyForAdmins(): void + { + foreach ([self::DOMAIN_ADMIN, self::USER] as $session) { + $this->assertSame('{}', $this->get('get/identity-provider', ['globals' => self::IAM_SETTINGS, 'session' => $session])->body); + } + } + + public function testOtherObjectsAnswerTheSettingsToo(): void + { + $result = $this->get('get/identity-provider/anything', ['globals' => self::IAM_SETTINGS]); + + $this->assertSame(self::SETTINGS, $result->json()); + } + + public function testSyncStatus(): void + { + $status = ['provider' => 'keycloak', 'can_sync' => true, 'sync_enabled' => false, 'counts' => ['accounts' => 2], 'runs' => []]; + + $result = $this->get('get/identity-provider/sync-status', ['session' => self::API_RO, 'script' => [self::answer(self::API . 'getStatus', [], $status)]]); + + $this->assertCalled($result, self::API . 'getStatus', []); + $this->assertSame($status, $result->json()); + } + + public function testLink(): void + { + $link = ['provider' => 'keycloak', 'external_id' => '8d5da28d-7541-428b-8356-a9704f73e7f4', 'idp_disabled' => 0, 'missing_since' => null]; + + $result = $this->get('get/identity-link/alice@example.org', ['script' => [self::answer(self::API . 'getLink', ['alice@example.org'], $link)]]); + $this->assertCalled($result, self::API . 'getLink', ['alice@example.org']); + $this->assertSame($link, $result->json()); + + $none = $this->get('get/identity-link'); + $this->assertCalled($none, self::API . 'getLink', ['']); + $this->assertSame('{}', $none->body); + } + + public function testEditSettings(): void + { + $attr = ['authsource' => 'ldap', 'host' => 'ldap.example.org', 'basedn' => 'dc=example,dc=org', 'binddn' => 'cn=mailcow', 'bindpass' => 'secret']; + $messages = [self::message('success', ['object_modified', ''], ['identity_provider', 'edit', ['bindpass' => '*']])]; + + $result = $this->post('edit/identity-provider', ['items' => ['identity-provider'], 'attr' => $attr], + ['script' => [self::answer('identity_provider', ['edit'], true, $messages)]]); + + $this->assertCalled($result, 'identity_provider', ['edit', $attr]); + $this->assertSame($messages, $result->json()); + } + + public function testConnectionTestAnswersWithoutDetails(): void + { + $attr = ['authsource' => 'keycloak', 'server_url' => 'https://auth.example.org', 'realm' => 'mailcow', 'client_id' => 'mailcow', 'client_secret' => 'secret']; + + $ok = $this->post('edit/identity-provider-test', ['attr' => $attr], ['script' => [self::answer('identity_provider', ['test'], true)]]); + $this->assertCalled($ok, 'identity_provider', ['test', $attr]); + $this->assertSame(['type' => 'success', 'msg' => 'Task completed'], $ok->json()); + + $failed = $this->post('edit/identity-provider-test', ['attr' => $attr], ['script' => [self::answer('identity_provider', ['test'], false)]]); + $this->assertSame(['type' => 'error', 'msg' => 'Cannot edit item'], $failed->json()); + } + + public function testRequestSync(): void + { + $result = $this->post('edit/identity-provider-sync', ['items' => ['sync'], 'attr' => ['mode' => 'preview']]); + + $this->assertCalled($result, self::API . 'requestRun', [['mode' => 'preview']]); + } + + public function testDeleteSettingsIgnoresTheBody(): void + { + $result = $this->post('delete/identity-provider', ['anything']); + + $this->assertSame([['delete']], $result->callsTo('identity_provider')); + } + + public function testDeleteLinks(): void + { + $messages = [self::message('success', ['iam_link_removed', 'alice@example.org'], ['identity_provider', 'delete', 'link', ['alice@example.org']])]; + + $result = $this->post('delete/identity-link', ['alice@example.org'], ['script' => [self::answer(self::API . 'unlink', [], true, $messages)]]); + + $this->assertCalled($result, self::API . 'unlink', [['alice@example.org']]); + $this->assertSame($messages, $result->json()); + } +} diff --git a/tests/php/Api/Modules/MailboxGroupRoutesTest.php b/tests/php/Api/Modules/MailboxGroupRoutesTest.php new file mode 100644 index 000000000..44fe416d7 --- /dev/null +++ b/tests/php/Api/Modules/MailboxGroupRoutesTest.php @@ -0,0 +1,111 @@ +api->'; + + public function testAdd(): void + { + $attr = ['domain' => 'example.org', 'name' => 'Sales', 'members' => ['alice@example.org'], 'sender_allowed' => 1]; + $messages = [ + self::message('success', ['group_added', 'Sales'], ['mailbox_group', 'add', 'group', $attr]), + self::message('warning', ['group_imap_grant_failed', 'shared@example.org'], ['mailbox_group', 'add', 'group', $attr]), + ]; + + $result = $this->post('add/mailbox-group', $attr, ['script' => [self::answer(self::API . 'addGroup', [], true, $messages)]]); + + $this->assertCalled($result, self::API . 'addGroup', [$attr]); + $this->assertSame($messages, $result->json()); + } + + public function testGetAll(): void + { + $groups = [['id' => 7, 'domain' => 'example.org', 'name' => 'Sales', 'can_edit' => true]]; + + $result = $this->get('get/mailbox-group/all', ['script' => [self::answer(self::API . 'getGroups', [], $groups)]]); + + $this->assertCalled($result, self::API . 'getGroups', [null]); + $this->assertSame($groups, $result->json()); + } + + public function testGetAllOfDomains(): void + { + $result = $this->get('get/mailbox-group/all/example.org,example.com'); + + $this->assertCalled($result, self::API . 'getGroups', ['example.org,example.com']); + } + + public function testNoGroupsAnswerAnEmptyObject(): void + { + $result = $this->get('get/mailbox-group/all', ['script' => [self::answer(self::API . 'getGroups', [], [])]]); + + $this->assertSame('{}', $result->body); + } + + public function testGetMemberships(): void + { + $memberships = ['groups' => [['id' => 7, 'name' => 'Sales', 'source' => 'manual']], 'template' => 'Staff', 'send_as' => ['sales@example.org']]; + + $result = $this->get('get/mailbox-group/member/alice@example.org', ['script' => [self::answer(self::API . 'getMemberships', [], $memberships)]]); + + $this->assertCalled($result, self::API . 'getMemberships', ['alice@example.org']); + $this->assertSame($memberships, $result->json()); + $this->assertNotCalled($result, self::API . 'getGroup'); + } + + public function testGetMembershipsWithoutMailbox(): void + { + $result = $this->get('get/mailbox-group/member'); + + $this->assertCalled($result, self::API . 'getMemberships', ['']); + $this->assertSame('{}', $result->body); + } + + public function testGetOne(): void + { + $group = ['id' => 7, 'domain' => 'example.org', 'name' => 'Sales']; + + $result = $this->get('get/mailbox-group/7', ['script' => [self::answer(self::API . 'getGroup', ['7'], $group)]]); + + $this->assertCalled($result, self::API . 'getGroup', ['7']); + $this->assertSame($group, $result->json()); + } + + public function testEdit(): void + { + $attr = ['add_members' => ['carol@example.org'], 'remove_members' => 'bob@example.org']; + + $result = $this->post('edit/mailbox-group', ['items' => [7, '8'], 'attr' => $attr]); + + $this->assertCalled($result, self::API . 'editGroups', [[7, '8'], $attr]); + } + + public function testEditWithASingleIdOrWithoutItems(): void + { + $single = $this->post('edit/mailbox-group', ['items' => 7, 'attr' => ['active' => 0]]); + $this->assertCalled($single, self::API . 'editGroups', [[7], ['active' => 0]]); + + $none = $this->post('edit/mailbox-group', ['attr' => ['id' => [9], 'active' => 0]]); + $this->assertCalled($none, self::API . 'editGroups', [[], ['id' => [9], 'active' => 0]]); + } + + public function testDelete(): void + { + $messages = [ + self::message('success', ['group_removed', 'Sales'], ['mailbox_group', 'delete', 'group', ['id' => [7, 'x']]]), + self::message('danger', 'access_denied', ['mailbox_group', 'delete', 'group', ['id' => [7, 'x']]]), + ]; + + $result = $this->post('delete/mailbox-group', [7, 'x'], ['script' => [self::answer(self::API . 'deleteGroups', [], true, $messages)]]); + + $this->assertCalled($result, self::API . 'deleteGroups', [[7, 'x']]); + $this->assertSame($messages, $result->json()); + } +} diff --git a/tests/php/Api/Modules/SyncJobRoutesTest.php b/tests/php/Api/Modules/SyncJobRoutesTest.php new file mode 100644 index 000000000..d28d21bc6 --- /dev/null +++ b/tests/php/Api/Modules/SyncJobRoutesTest.php @@ -0,0 +1,194 @@ +api->'; + + public function testAddJob(): void + { + $attr = ['username' => 'alice@example.org', 'source_id' => 3, 'user1' => 'alice@example.com', 'password1' => 'secret', 'mins_interval' => 20]; + $messages = [self::message('success', ['mailbox_modified', 'alice@example.org'], ['syncjob', 'add', 'job', ['password1' => '*']])]; + + $result = $this->post('add/syncjob', $attr, ['script' => [self::answer(self::API . 'addJob', [], true, $messages)]]); + + $this->assertCalled($result, self::API . 'addJob', [$attr]); + $this->assertSame($messages, $result->json()); + } + + public function testAddSource(): void + { + $attr = ['name' => 'Old provider', 'host' => 'imap.example.com', 'port' => 993, 'enc' => 'SSL', 'scope' => 'domain', 'domains' => ['example.org']]; + + $result = $this->post('add/syncjob_source', $attr); + + $this->assertCalled($result, self::API . 'addSource', [$attr]); + } + + public function testAllJobsOfAnApiKey(): void + { + $jobs = [['id' => 12, 'user2' => 'alice@example.org', 'log' => 'done']]; + + $result = $this->get('get/syncjobs/all', ['script' => [self::answer(self::API . 'getAllJobs', [], $jobs)]]); + + $this->assertCalled($result, self::API . 'getAllJobs', [null, []]); + $this->assertNotCalled($result, 'mailbox'); + $this->assertSame($jobs, $result->json()); + } + + public function testAllJobsWithoutLog(): void + { + $result = $this->get('get/syncjobs/all/no_log'); + + $this->assertCalled($result, self::API . 'getAllJobs', ['no_log', []]); + $this->assertSame('{}', $result->body); + } + + public function testAllJobsOfADomainAdminAreTheJobsOfHisMailboxes(): void + { + $result = $this->get('get/syncjobs/all/no_log', ['session' => self::DOMAIN_ADMIN, 'script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com']), + self::answer('mailbox', ['get', 'mailboxes', 'example.org'], ['alice@example.org', 'bob@example.org']), + self::answer('mailbox', ['get', 'mailboxes', 'example.com'], false), + ]]); + + $this->assertCalled($result, self::API . 'getAllJobs', ['no_log', ['alice@example.org', 'bob@example.org']]); + } + + public function testAllJobsOfAUser(): void + { + $result = $this->get('get/syncjobs/all', ['session' => self::USER, 'script' => [self::answer('mailbox', ['get', 'domains'], false)]]); + + $this->assertCalled($result, self::API . 'getAllJobs', [null, []]); + } + + public function testJobsOfAMailbox(): void + { + $withLog = $this->get('get/syncjobs/alice@example.org'); + $this->assertCalled($withLog, self::API . 'getJobs', ['alice@example.org', null]); + $this->assertNotCalled($withLog, self::API . 'getAllJobs'); + + $withoutLog = $this->get('get/syncjobs/alice@example.org/no_log'); + $this->assertCalled($withoutLog, self::API . 'getJobs', ['alice@example.org', 'no_log']); + + $own = $this->get('get/syncjobs'); + $this->assertCalled($own, self::API . 'getJobs', [null, null]); + } + + public function testSources(): void + { + $sources = [['id' => 3, 'name' => 'Old provider', 'has_client_secret' => false]]; + + $all = $this->get('get/syncjob_source/all', ['script' => [self::answer(self::API . 'getSources', [], $sources)]]); + $this->assertCalled($all, self::API . 'getSources', []); + $this->assertSame($sources, $all->json()); + + $one = $this->get('get/syncjob_source/3', ['script' => [self::answer(self::API . 'getSource', ['3'], $sources[0])]]); + $this->assertCalled($one, self::API . 'getSource', ['3']); + $this->assertSame($sources[0], $one->json()); + + $this->assertSame('{}', $this->get('get/syncjob_source/99')->body); + } + + public function testOptions(): void + { + $options = ['folder' => true, 'syncinternaldates' => false, 'sslargs1' => '^SSL_verify_mode=[01]$']; + + $result = $this->get('get/syncjob_options', ['session' => self::USER, 'script' => [self::answer(self::API . 'getOptions', [], $options)]]); + + $this->assertCalled($result, self::API . 'getOptions', []); + $this->assertSame($options, $result->json()); + } + + public function testSettings(): void + { + $settings = ['max_parallel' => 3, 'max_bytes_per_second' => 2048000, 'max_kb_per_second' => 2000]; + + $result = $this->get('get/imapsync_settings', ['script' => [self::answer(self::API . 'getSettings', [], $settings)]]); + $this->assertCalled($result, self::API . 'getSettings', []); + $this->assertSame($settings, $result->json()); + + $this->assertSame('{}', $this->get('get/imapsync_settings', ['session' => self::DOMAIN_ADMIN, 'script' => [self::answer(self::API . 'getSettings', [], [])]])->body); + } + + public function testEditJobs(): void + { + $result = $this->post('edit/syncjob', ['items' => [12, '13'], 'attr' => ['active' => 0, 'mins_interval' => 60]]); + + $this->assertCalled($result, self::API . 'editJobs', [[12, '13'], ['active' => 0, 'mins_interval' => 60]]); + $this->assertNotCalled($result, self::API . 'runNext'); + + $other = $this->post('edit/syncjob/anything', ['items' => [12], 'attr' => ['active' => 1]]); + $this->assertCalled($other, self::API . 'editJobs', [[12], ['active' => 1]]); + } + + public function testRunNext(): void + { + $messages = [ + self::message('danger', ['imapsync_job_running', 13], ['syncjob', 'edit', 'run_next', ['id' => [12, 13]]]), + self::message('success', ['imapsync_run_next_done', 1], ['syncjob', 'edit', 'run_next', ['id' => [12, 13]]]), + ]; + + $result = $this->post('edit/syncjob/run_next', ['items' => [12, 13], 'attr' => ['ignored' => 1]], + ['script' => [self::answer(self::API . 'runNext', [], true, $messages)]]); + + $this->assertCalled($result, self::API . 'runNext', [[12, 13]]); + $this->assertNotCalled($result, self::API . 'editJobs'); + $this->assertSame($messages, $result->json()); + } + + public function testRunNextWithoutMovedJobs(): void + { + $result = $this->post('edit/syncjob/run_next', ['items' => [99]], ['script' => [self::answer(self::API . 'runNext', [], false)]]); + + $this->assertSame(['type' => 'error', 'msg' => 'Cannot edit item'], $result->json()); + } + + public function testEditSettings(): void + { + $result = $this->post('edit/imapsync_settings', ['items' => ['ignored'], 'attr' => ['max_parallel' => 4, 'max_kb_per_second' => 2000]]); + + $this->assertCalled($result, self::API . 'editSettings', [['max_parallel' => 4, 'max_kb_per_second' => 2000]]); + } + + public function testEditSources(): void + { + $attr = ['description' => 'Migration finished', 'scope' => 'user', 'users' => ['alice@example.org']]; + + $result = $this->post('edit/syncjob_source', ['items' => [3], 'attr' => $attr]); + + $this->assertCalled($result, self::API . 'editSources', [[3], $attr]); + $this->assertNotCalled($result, self::API . 'refreshSourceToken'); + } + + public function testRefreshToken(): void + { + $result = $this->post('edit/syncjob_source/refresh_token', ['items' => [5, 6], 'attr' => []]); + + $this->assertCalled($result, self::API . 'refreshSourceToken', [[5, 6]]); + $this->assertNotCalled($result, self::API . 'editSources'); + } + + public function testDeleteJobs(): void + { + $result = $this->post('delete/syncjob', [12, '13']); + + $this->assertCalled($result, self::API . 'deleteJobs', [[12, '13']]); + } + + public function testDeleteSources(): void + { + $messages = [self::message('danger', ['imapsync_source_in_use', 'Old provider'], ['syncjob', 'delete', 'source', ['id' => [3]]])]; + + $result = $this->post('delete/syncjob_source', [3], ['script' => [self::answer(self::API . 'deleteSources', [], true, $messages)]]); + + $this->assertCalled($result, self::API . 'deleteSources', [[3]]); + $this->assertSame($messages, $result->json()); + } +} diff --git a/tests/php/Api/OpenApiSpecTest.php b/tests/php/Api/OpenApiSpecTest.php new file mode 100644 index 000000000..fa60cc109 --- /dev/null +++ b/tests/php/Api/OpenApiSpecTest.php @@ -0,0 +1,155 @@ + 'WebAuthn', + 'add/webauthn-tfa-registration' => 'WebAuthn', + 'get/fido2-registration' => 'WebAuthn', + 'get/fido2-get-args' => 'WebAuthn', + 'get/webauthn-tfa-registration' => 'WebAuthn', + 'get/webauthn-tfa-get-args' => 'WebAuthn', + // the own account of a UI session (domain admin, mailbox user): an API key gets an empty answer + 'edit/self' => 'own account', + // renames a FIDO2 key of the logged-in account, the API account has none + 'edit/fido2-fn' => 'own account', + ]; + + private static ?array $spec = null; + + private static function spec(): array + { + return self::$spec ??= Yaml::parseFile(self::SPEC); + } + + /** @return list path, method, operation */ + private static function operations(): array + { + $operations = []; + foreach (self::spec()['paths'] as $path => $item) { + foreach ($item as $method => $operation) { + if (in_array($method, ['get', 'post', 'put', 'patch', 'delete'], true)) { + $operations[] = [$path, $method, $operation]; + } + } + } + return $operations; + } + + public function testEveryRouteIsDocumented(): void + { + $paths = array_keys(self::spec()['paths']); + $missing = []; + foreach (RouteMap::fromFile(self::JSON_API) as $route) { + $covered = isset(self::UNDOCUMENTED[$route]) || !empty(array_filter($paths, fn(string $path): bool => RouteMap::covers($path, $route))); + if (!$covered) { + $missing[] = $route; + } + } + $this->assertSame([], $missing, 'routes of json_api.php missing in openapi.yaml'); + } + + public function testTheUndocumentedRoutesExist(): void + { + $this->assertSame([], array_values(array_diff(array_keys(self::UNDOCUMENTED), RouteMap::fromFile(self::JSON_API)))); + } + + public function testEveryDocumentedRouteExists(): void + { + $unknown = []; + foreach (self::operations() as [$path, $method, $operation]) { + $route = preg_replace_callback('/\{([^}]+)\}/', fn(array $m): string => self::exampleOf($operation, $m[1]), $path); + $action = explode('/', trim(preg_replace('#^/api/v1/#', '', $path), '/'))[0]; + $body = match ($action) { + 'edit' => ['items' => [], 'attr' => (object)[]], + 'delete' => [], + default => (object)[], + }; + $result = ($method === 'get') ? $this->get($route, ['allow_error' => true]) : $this->post($route, $body, ['allow_error' => true]); + $answer = $result->json(); + if ($result->status === 404 && is_array($answer) && ($answer['type'] ?? '') === 'error') { + $unknown[] = strtoupper($method) . ' ' . $path . ': ' . ($answer['msg'] ?? ''); + } + } + $this->assertSame([], $unknown, 'documented routes that json_api.php does not know'); + } + + public function testMethodsFollowTheAction(): void + { + foreach (self::operations() as [$path, $method]) { + $action = explode('/', trim(preg_replace('#^/api/v1/#', '', $path), '/'))[0]; + $this->assertSame(($action === 'get') ? 'get' : 'post', $method, $path); + } + } + + public function testOperationsAreComplete(): void + { + $tags = array_column(self::spec()['tags'], 'name'); + $ids = []; + foreach (self::operations() as [$path, $method, $operation]) { + $where = strtoupper($method) . ' ' . $path; + $this->assertNotEmpty($operation['summary'] ?? null, "$where: summary"); + $this->assertNotEmpty($operation['operationId'] ?? null, "$where: operationId"); + $ids[] = $operation['operationId']; + $this->assertNotEmpty($operation['tags'] ?? [], "$where: tags"); + $this->assertSame([], array_values(array_diff($operation['tags'] ?? [], $tags)), "$where: tags not in the tag list"); + $this->assertArrayHasKey('200', $operation['responses'] ?? [], "$where: 200 response"); + $this->assertArrayHasKey('401', $operation['responses'] ?? [], "$where: 401 response"); + preg_match_all('/\{([^}]+)\}/', $path, $placeholders); + $declared = array_column(array_filter($operation['parameters'] ?? [], fn(array $p): bool => ($p['in'] ?? '') === 'path'), 'name'); + sort($declared); + $expected = $placeholders[1]; + sort($expected); + $this->assertSame($expected, $declared, "$where: path parameters"); + } + $this->assertSame(array_values(array_unique($ids)), $ids, 'operationIds must be unique'); + } + + public function testEveryTagIsUsed(): void + { + $used = array_merge(...array_map(fn(array $op): array => $op[2]['tags'] ?? [], self::operations())); + $this->assertSame([], array_values(array_diff(array_column(self::spec()['tags'], 'name'), $used))); + } + + public function testReferencesResolve(): void + { + $refs = []; + $spec = self::spec(); + array_walk_recursive($spec, function (mixed $value, mixed $key) use (&$refs): void { + if ($key === '$ref') { + $refs[] = $value; + } + }); + foreach (array_unique($refs) as $ref) { + $node = self::spec(); + foreach (explode('/', substr($ref, 2)) as $part) { + $this->assertIsArray($node, $ref); + $this->assertArrayHasKey(str_replace(['~1', '~0'], ['/', '~'], $part), $node, "unresolved $ref"); + $node = $node[str_replace(['~1', '~0'], ['/', '~'], $part)]; + } + } + } + + private static function exampleOf(array $operation, string $name): string + { + foreach ($operation['parameters'] ?? [] as $parameter) { + if (($parameter['name'] ?? null) === $name) { + $example = $parameter['example'] ?? $parameter['schema']['example'] ?? $parameter['schema']['enum'][0] ?? 'test'; + return rawurlencode((string)$example); + } + } + return 'test'; + } +} diff --git a/tests/php/Api/RequestHandlingTest.php b/tests/php/Api/RequestHandlingTest.php new file mode 100644 index 000000000..7c99ccae1 --- /dev/null +++ b/tests/php/Api/RequestHandlingTest.php @@ -0,0 +1,131 @@ +request('GET', $route, ['post' => ['attr' => '{}', 'items' => '[]']]); + $this->assertApiError($result, 405, 'only POST method is allowed'); + $this->assertNotCalled($result, 'mailbox'); + } + } + + public function testReadsNeedGet(): void + { + $this->assertApiError($this->post('get/domain/all'), 405, 'only GET method is allowed'); + } + + public function testSearchNeedsPost(): void + { + $this->assertApiError($this->get('search/domain'), 405, 'only POST method is allowed'); + } + + public function testReadOnlyKeysCannotWrite(): void + { + foreach (['add/alias' => ['address' => 'a@example.org'], 'edit/alias' => ['items' => [1], 'attr' => []], 'delete/alias' => [1]] as $route => $body) { + $result = $this->post($route, $body, ['session' => self::API_RO]); + $this->assertApiError($result, 403, 'API read/write access denied'); + $this->assertNotCalled($result, 'mailbox'); + } + } + + public function testReadOnlyKeysCanRead(): void + { + $result = $this->get('get/alias/1', ['session' => self::API_RO]); + + $this->assertSame(200, $result->status); + $this->assertCalled($result, 'mailbox', ['get', 'alias_details', '1']); + } + + public function testWritesNeedALogin(): void + { + $this->assertApiError($this->post('add/alias', ['address' => 'a@example.org'], ['session' => self::NO_SESSION]), 403, 'API read/write access denied'); + } + + public function testReadsWithoutLoginAnswerNothing(): void + { + // a wrong API key ends in sessions.inc.php with 401; without key and session the read routes are skipped + $result = $this->get('get/domain/all', ['session' => self::NO_SESSION]); + + $this->assertSame([200, ''], [$result->status, $result->body]); + $this->assertNotCalled($result, 'mailbox'); + } + + public function testUnknownRoutes(): void + { + $this->assertApiError($this->get('get/no-such-thing'), 404, 'route not found'); + $this->assertApiError($this->post('add/no-such-thing', ['x' => 1]), 404, 'route not found'); + $this->assertApiError($this->post('edit/no-such-thing', ['items' => [], 'attr' => []]), 404, 'route not found'); + $this->assertApiError($this->post('delete/no-such-thing', [1]), 404, 'route not found'); + $this->assertApiError($this->get('nothing/at/all'), 404, 'route not found'); + } + + public function testInvalidJsonBody(): void + { + $this->assertApiError($this->post('add/alias', '{"address": '), 400, 'Request body doesn\'t contain valid json!'); + } + + public function testJsonBodies(): void + { + // add: the body is attr + $add = $this->post('add/alias', ['address' => 'a@example.org', 'goto' => 'b@example.org']); + $this->assertCalled($add, 'mailbox', ['add', 'alias', ['address' => 'a@example.org', 'goto' => 'b@example.org']]); + + // edit: {"items": ..., "attr": ...} + $edit = $this->post('edit/alias', ['items' => [3, 4], 'attr' => ['active' => '0']]); + $this->assertCalled($edit, 'mailbox', ['edit', 'alias', ['id' => [3, 4], 'active' => '0']]); + + // delete: the body is the list of items + $delete = $this->post('delete/alias', [3, 4]); + $this->assertCalled($delete, 'mailbox', ['delete', 'alias', ['id' => [3, 4]]]); + } + + public function testFormBodiesOfTheUi(): void + { + $result = $this->postForm('edit/alias', ['items' => '[3]', 'attr' => '{"active":"1","csrf_token":"t"}'], ['session' => self::ADMIN]); + + $this->assertCalled($result, 'mailbox', ['edit', 'alias', ['id' => [3], 'active' => '1']]); + } + + public function testMissingAttributes(): void + { + $this->assertSame(['type' => 'error', 'msg' => 'Cannot find attributes in post data'], $this->request('POST', 'add/alias')->json()); + $this->assertSame(['type' => 'error', 'msg' => 'Cannot find attributes in post data'], $this->request('POST', 'delete/alias')->json()); + } + + public function testWriteAnswers(): void + { + $messages = [self::message('success', ['alias_added', 'a@example.org'], ['mailbox', 'add', 'alias'])]; + + $done = $this->post('add/alias', ['address' => 'a@example.org'], ['script' => [self::answer('mailbox', ['add', 'alias'], true, $messages)]]); + $this->assertSame($messages, $done->json()); + + $silent = $this->post('add/alias', ['address' => 'a@example.org'], ['script' => [self::answer('mailbox', ['add', 'alias'], true)]]); + $this->assertSame(['type' => 'success', 'msg' => 'Task completed'], $silent->json()); + + $failed = $this->post('add/alias', ['address' => 'a@example.org'], ['script' => [self::answer('mailbox', ['add', 'alias'], false)]]); + $this->assertSame(['type' => 'error', 'msg' => 'Cannot add item'], $failed->json()); + } + + public function testReadAnswers(): void + { + $found = $this->get('get/alias/1', ['script' => [self::answer('mailbox', ['get', 'alias_details'], ['id' => 1, 'address' => 'a@example.org'])]]); + $this->assertSame(['id' => 1, 'address' => 'a@example.org'], $found->json()); + + $this->assertSame('{}', $this->get('get/alias/1')->body); + } + + public function testRequestsFromABrowserPageAreRefused(): void + { + $result = $this->get('get/domain/all', ['server' => ['HTTP_SEC_FETCH_DEST' => 'document']]); + + $this->assertSame('', $result->body); + $this->assertSame(['cors'], $result->callNames()); + } +} diff --git a/tests/php/Api/RouteMap.php b/tests/php/Api/RouteMap.php new file mode 100644 index 000000000..d870ae067 --- /dev/null +++ b/tests/php/Api/RouteMap.php @@ -0,0 +1,115 @@ +). + * Default cases that answer 404 are no routes. Only the most specific routes are listed (get/mailbox/all, not get/mailbox). + */ +final class RouteMap +{ + private const ROUTE_VARS = ['$action', '$category', '$object', '$extra']; + + /** @return list */ + public static function fromFile(string $file): array + { + $tokens = array_values(array_filter(token_get_all((string)file_get_contents($file)), + fn($t): bool => !is_array($t) || !in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true))); + $stack = []; + $depth = 0; + $pending = null; + $routes = []; + foreach ($tokens as $i => $token) { + $id = is_array($token) ? $token[0] : $token; + if ($id === T_SWITCH) { + $isVar = ($tokens[$i + 1] ?? null) === '(' && is_array($tokens[$i + 2] ?? null) && $tokens[$i + 2][0] === T_VARIABLE && ($tokens[$i + 3] ?? null) === ')'; + $pending = ($isVar && in_array($tokens[$i + 2][1], self::ROUTE_VARS, true)) ? $tokens[$i + 2][1] : ''; + } elseif ($id === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) { + $depth++; + if ($pending !== null) { + $stack[] = ['var' => $pending, 'depth' => $depth, 'label' => null]; + $pending = null; + } + } elseif ($id === '}') { + if (!empty($stack) && end($stack)['depth'] === $depth) { + array_pop($stack); + } + $depth--; + } elseif (($id === T_CASE || $id === T_DEFAULT) && !empty($stack) && end($stack)['depth'] === $depth) { + $label = ($id === T_DEFAULT) ? '*' : self::literal($tokens[$i + 1] ?? null); + $stack[count($stack) - 1]['label'] = $label; + if (end($stack)['var'] === '' || $label === null || ($id === T_DEFAULT && self::answers404($tokens, $i))) { + continue; + } + $route = []; + foreach ($stack as $switch) { + if ($switch['var'] !== '') { + $route[] = $switch['label']; + } + } + $routes[implode('/', $route)] = true; + } + } + $routes = array_keys($routes); + return array_values(array_filter($routes, function (string $route) use ($routes): bool { + foreach ($routes as $other) { + if (str_starts_with($other, $route . '/')) { + return false; + } + } + return true; + })); + } + + /** + * Whether $documented (an OpenAPI path like /api/v1/get/mailbox/{id}) covers $route: same leading segments, a + * {parameter} matches anything, trailing "*" of the route are optional (add/mailbox/* = add/mailbox) + */ + public static function covers(string $documented, string $route): bool + { + $doc = explode('/', trim(preg_replace('#^/?api/v1/#', '', $documented), '/')); + $segments = explode('/', $route); + while (count($segments) > count($doc) && end($segments) === '*') { + array_pop($segments); + } + if (count($segments) > count($doc)) { + return false; + } + foreach ($segments as $i => $segment) { + $isParam = (bool)preg_match('/^\{.+\}$/', $doc[$i]); + if ($segment === '*' ? !$isParam : ($segment !== $doc[$i] && !$isParam)) { + return false; + } + } + return true; + } + + private static function literal(mixed $token): ?string + { + return (is_array($token) && $token[0] === T_CONSTANT_ENCAPSED_STRING) ? substr($token[1], 1, -1) : null; + } + + /** A default case whose code (up to the next case of its switch) sets the status 404 */ + private static function answers404(array $tokens, int $start): bool + { + $level = 0; + for ($i = $start + 1; $i < count($tokens); $i++) { + $id = is_array($tokens[$i]) ? $tokens[$i][0] : $tokens[$i]; + if ($id === '{' || $id === T_CURLY_OPEN || $id === T_DOLLAR_OPEN_CURLY_BRACES) { + $level++; + } elseif ($id === '}') { + if ($level-- === 0) { + return false; + } + } elseif ($level === 0 && ($id === T_CASE || $id === T_DEFAULT)) { + return false; + } elseif ($id === T_STRING && $tokens[$i][1] === 'http_response_code' && is_array($tokens[$i + 2] ?? null) && $tokens[$i + 2][1] === '404') { + return true; + } + } + return false; + } +} diff --git a/tests/php/Api/Routing/AliasRoutesTest.php b/tests/php/Api/Routing/AliasRoutesTest.php new file mode 100644 index 000000000..aa8b1d2ec --- /dev/null +++ b/tests/php/Api/Routing/AliasRoutesTest.php @@ -0,0 +1,116 @@ + 12, 'address' => 'sales@example.org', 'goto' => 'anna@example.org,ben@example.org', 'active' => 1]; + private const INFO = ['id' => 13, 'address' => 'info@example.org', 'goto' => 'anna@example.org', 'active' => 1]; + private const SHOP = ['id' => 20, 'address' => 'shop@example.net', 'goto' => 'ben@example.org', 'active' => 0]; + + public function testAddPassesTheBody(): void + { + $attr = [ + 'address' => "sales@example.org, info@example.org", + 'goto' => 'anna@example.org,ben@example.org', + 'active' => '1', + 'sogo_visible' => 1, + 'internal' => '0', + 'sender_allowed' => true, + 'public_comment' => 'Sales team', + ]; + + $result = $this->post('add/alias', $attr + ['csrf_token' => 'x']); + + $this->assertSame([['add', 'alias', $attr]], $result->callsTo('mailbox')); + } + + public function testEditMergesItemsAndAttributes(): void + { + $list = $this->post('edit/alias', ['items' => [12, 'info@example.org'], 'attr' => ['goto_null' => '1', 'active' => '0']]); + $this->assertCalled($list, 'mailbox', ['edit', 'alias', ['id' => [12, 'info@example.org'], 'goto_null' => '1', 'active' => '0']]); + + $single = $this->post('edit/alias', ['items' => 12, 'attr' => ['expand_alias' => true]]); + $this->assertCalled($single, 'mailbox', ['edit', 'alias', ['id' => [12], 'expand_alias' => true]]); + } + + public function testDeleteTakesIdsOrAddresses(): void + { + $result = $this->post('delete/alias', [12, 'info@example.org']); + + $this->assertSame([['delete', 'alias', ['id' => [12, 'info@example.org']]]], $result->callsTo('mailbox')); + } + + public function testGetOneAliasByIdOrAddress(): void + { + $byId = $this->get('get/alias/12', ['script' => [self::answer('mailbox', ['get', 'alias_details', '12'], self::SALES)]]); + $this->assertSame(self::SALES, $byId->json()); + + $byAddress = $this->get('get/alias/sales@example.org'); + $this->assertSame([['get', 'alias_details', 'sales@example.org']], $byAddress->callsTo('mailbox')); + $this->assertSame('{}', $byAddress->body); + } + + public function testGetAllWalksTheDomainsAndAliasDomains(): void + { + $result = $this->get('get/alias/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], ['example.org', 'example.com']), + self::answer('mailbox', ['get', 'alias_domains'], ['example.net']), + self::answer('mailbox', ['get', 'aliases', 'example.org'], [12, 13, 14]), + self::answer('mailbox', ['get', 'aliases', 'example.com'], []), + self::answer('mailbox', ['get', 'aliases', 'example.net'], [20]), + self::answer('mailbox', ['get', 'alias_details', 12], self::SALES), + self::answer('mailbox', ['get', 'alias_details', 13], self::INFO), + self::answer('mailbox', ['get', 'alias_details', 14], false), + self::answer('mailbox', ['get', 'alias_details', 20], self::SHOP), + ]]); + + $this->assertSame([self::SALES, self::INFO, self::SHOP], $result->json()); + $this->assertSame([ + ['get', 'domains'], + ['get', 'alias_domains'], + ['get', 'aliases', 'example.org'], + ['get', 'alias_details', 12], + ['get', 'alias_details', 13], + ['get', 'alias_details', 14], + ['get', 'aliases', 'example.com'], + ['get', 'aliases', 'example.net'], + ['get', 'alias_details', 20], + ], $result->callsTo('mailbox')); + } + + public function testGetAllOfTheNamedDomains(): void + { + $result = $this->get('get/alias/all/example.org,example.net', ['script' => [ + self::answer('mailbox', ['get', 'aliases', 'example.org'], [12]), + self::answer('mailbox', ['get', 'aliases', 'example.net'], [20]), + self::answer('mailbox', ['get', 'alias_details', 12], self::SALES), + self::answer('mailbox', ['get', 'alias_details', 20], self::SHOP), + ]]); + + $this->assertSame([self::SALES, self::SHOP], $result->json()); + $this->assertSame([ + ['get', 'aliases', 'example.org'], + ['get', 'alias_details', 12], + ['get', 'aliases', 'example.net'], + ['get', 'alias_details', 20], + ], $result->callsTo('mailbox')); + } + + public function testGetAllWithoutAliases(): void + { + $noDomains = $this->get('get/alias/all', ['script' => [ + self::answer('mailbox', ['get', 'domains'], []), + self::answer('mailbox', ['get', 'alias_domains'], []), + ]]); + $this->assertSame('{}', $noDomains->body); + + $noAliases = $this->get('get/alias/all/example.org', ['script' => [self::answer('mailbox', ['get', 'aliases'], false)]]); + $this->assertSame('{}', $noAliases->body); + } +} diff --git a/tests/php/Api/Routing/ForwardingHostRoutesTest.php b/tests/php/Api/Routing/ForwardingHostRoutesTest.php new file mode 100644 index 000000000..b538bd0b1 --- /dev/null +++ b/tests/php/Api/Routing/ForwardingHostRoutesTest.php @@ -0,0 +1,62 @@ + '192.0.2.25', 'source' => 'relay.example.com', 'keep_spam' => 'yes'], + ['host' => '2001:db8::25', 'source' => 'relay.example.com', 'keep_spam' => 'yes'], + ]; + + public function testAddPassesTheBody(): void + { + $result = $this->post('add/fwdhost', ['hostname' => 'relay.example.com', 'filter_spam' => '1']); + + $this->assertSame([['add', ['hostname' => 'relay.example.com', 'filter_spam' => '1']]], $result->callsTo('fwdhost')); + } + + public function testEditNamesTheHostsFwdhost(): void + { + $result = $this->post('edit/fwdhost', ['items' => ['192.0.2.25', '198.51.100.0/24'], 'attr' => ['keep_spam' => '0']]); + + $this->assertSame([['edit', ['fwdhost' => ['192.0.2.25', '198.51.100.0/24'], 'keep_spam' => '0']]], $result->callsTo('fwdhost')); + } + + public function testDeleteNamesTheHostsForwardinghost(): void + { + $result = $this->post('delete/fwdhost', ['192.0.2.25', '2001:db8::25']); + + $this->assertSame([['delete', ['forwardinghost' => ['192.0.2.25', '2001:db8::25']]]], $result->callsTo('fwdhost')); + } + + public function testGetAllPassesTheList(): void + { + $result = $this->get('get/fwdhost/all', ['script' => [self::answer('fwdhost', ['get'], self::HOSTS)]]); + + $this->assertSame(self::HOSTS, $result->json()); + $this->assertSame([['get']], $result->callsTo('fwdhost')); + + $this->assertSame('{}', $this->get('get/fwdhost/all', ['script' => [self::answer('fwdhost', ['get'], [])]])->body); + } + + public function testGetOneHost(): void + { + $found = $this->get('get/fwdhost/192.0.2.25', ['script' => [self::answer('fwdhost', ['details', '192.0.2.25'], self::HOSTS[0])]]); + $this->assertSame(self::HOSTS[0], $found->json()); + + $this->assertSame('{}', $this->get('get/fwdhost/192.0.2.99', ['script' => [self::answer('fwdhost', ['details'], [])]])->body); + } + + public function testGetOneNetworkIsCutAtTheSlash(): void + { + $result = $this->get('get/fwdhost/198.51.100.0/24'); + + $this->assertSame([['details', '198.51.100.0']], $result->callsTo('fwdhost')); + } +} diff --git a/tests/php/Api/Routing/MapRoutesTest.php b/tests/php/Api/Routing/MapRoutesTest.php new file mode 100644 index 000000000..e80f63ca1 --- /dev/null +++ b/tests/php/Api/Routing/MapRoutesTest.php @@ -0,0 +1,86 @@ + ['bcc', 'bcc', + ['local_dest' => 'example.org', 'bcc_dest' => 'archive@example.com', 'type' => 'rcpt', 'active' => '1'], + ['id' => 3, 'local_dest' => '@example.org', 'bcc_dest' => 'archive@example.com', 'active' => 1, 'type' => 'rcpt', 'domain' => 'example.org']], + 'recipient_map' => ['recipient_map', 'recipient_map', + ['recipient_map_old' => 'old@example.org', 'recipient_map_new' => 'new@example.org', 'active' => 1], + ['id' => 3, 'recipient_map_old' => 'old@example.org', 'recipient_map_new' => 'new@example.org', 'active' => 1]], + 'tls-policy-map' => ['tls-policy-map', 'tls_policy_maps', + ['dest' => 'example.com', 'policy' => 'encrypt', 'parameters' => 'protocols=>=TLSv1.2', 'active' => '1'], + ['id' => 3, 'dest' => 'example.com', 'policy' => 'encrypt', 'parameters' => '', 'active' => 1]], + ]; + } + + #[DataProvider('maps')] + public function testAddPassesTheBody(string $category, string $function, array $attr): void + { + $result = $this->post("add/$category", $attr); + + $this->assertSame([['add', $attr]], $result->callsTo($function)); + } + + #[DataProvider('maps')] + public function testEditMergesItemsAndAttributes(string $category, string $function): void + { + $list = $this->post("edit/$category", ['items' => [3, '4'], 'attr' => ['active' => '0']]); + $this->assertSame([['edit', ['id' => [3, '4'], 'active' => '0']]], $list->callsTo($function)); + + $single = $this->post("edit/$category", ['items' => 3, 'attr' => ['active' => '1']]); + $this->assertSame([['edit', ['id' => [3], 'active' => '1']]], $single->callsTo($function)); + } + + #[DataProvider('maps')] + public function testDeleteTakesIds(string $category, string $function): void + { + $result = $this->post("delete/$category", [3, 4]); + + $this->assertSame([['delete', ['id' => [3, 4]]]], $result->callsTo($function)); + } + + #[DataProvider('maps')] + public function testGetAllCollectsTheDetailsOfEveryId(string $category, string $function, array $attr, array $details): void + { + $result = $this->get("get/$category/all", ['script' => [ + self::answer($function, ['get'], [3, 4, 5]), + self::answer($function, ['details', 3], $details), + self::answer($function, ['details', 4], false), + self::answer($function, ['details', 5], ['id' => 5] + $details), + ]]); + + $this->assertSame([$details, ['id' => 5] + $details], $result->json()); + $this->assertSame([['get'], ['details', 3], ['details', 4], ['details', 5]], $result->callsTo($function)); + } + + #[DataProvider('maps')] + public function testGetAllWithoutEntries(string $category, string $function): void + { + $result = $this->get("get/$category/all", ['script' => [self::answer($function, ['get'], [])]]); + + $this->assertSame('{}', $result->body); + } + + #[DataProvider('maps')] + public function testGetOneAddsANullMember(string $category, string $function, array $attr, array $details): void + { + // json_api.php appends the undefined $details to the answer + $found = $this->get("get/$category/3", ['script' => [self::answer($function, ['details', '3'], $details)]]); + $this->assertSame($details + [0 => null], $found->json()); + + $missing = $this->get("get/$category/9", ['script' => [self::answer($function, ['details', '9'], false)]]); + $this->assertSame('{}', $missing->body); + } +} diff --git a/tests/php/Api/Routing/TransportRoutesTest.php b/tests/php/Api/Routing/TransportRoutesTest.php new file mode 100644 index 000000000..38d24da41 --- /dev/null +++ b/tests/php/Api/Routing/TransportRoutesTest.php @@ -0,0 +1,85 @@ + ['relayhost', + ['hostname' => '[smtp.example.com]:587', 'username' => 'relay-user', 'password' => 'secret'], + ['id' => 1, 'hostname' => '[smtp.example.com]:587', 'username' => 'relay-user', 'active' => 1, 'used_by_domains' => 'example.org']], + 'transport' => ['transport', + ['destination' => 'example.com, .example.com', 'nexthop' => '[mx.example.com]:25', 'is_mx_based' => '0', 'active' => '1'], + ['id' => 1, 'is_mx_based' => 0, 'destination' => 'example.com', 'nexthop' => '[mx.example.com]:25', 'active' => 1]], + ]; + } + + #[DataProvider('transports')] + public function testAddPassesTheBody(string $category, array $attr): void + { + $result = $this->post("add/$category", $attr); + + $this->assertSame([['add', $attr]], $result->callsTo($category)); + } + + #[DataProvider('transports')] + public function testEditMergesItemsAndAttributes(string $category): void + { + $result = $this->post("edit/$category", ['items' => [1, 2], 'attr' => ['active' => '0', 'password' => '']]); + + $this->assertSame([['edit', ['id' => [1, 2], 'active' => '0', 'password' => '']]], $result->callsTo($category)); + } + + #[DataProvider('transports')] + public function testDeleteTakesIds(string $category): void + { + $result = $this->post("delete/$category", ['1', 2]); + + $this->assertSame([['delete', ['id' => ['1', 2]]]], $result->callsTo($category)); + } + + #[DataProvider('transports')] + public function testGetAllCollectsTheDetailsOfEveryRow(string $category, array $attr, array $details): void + { + $result = $this->get("get/$category/all", ['script' => [ + self::answer($category, ['get'], [['id' => 1, 'active' => 1], ['id' => 2, 'active' => 1], ['id' => 3, 'active' => 0]]), + self::answer($category, ['details', 1], $details), + self::answer($category, ['details', 2], false), + self::answer($category, ['details', 3], ['id' => 3] + $details), + ]]); + + $this->assertSame([$details, ['id' => 3] + $details], $result->json()); + $this->assertSame([['get'], ['details', 1], ['details', 2], ['details', 3]], $result->callsTo($category)); + } + + #[DataProvider('transports')] + public function testGetAllWithoutEntries(string $category): void + { + $none = $this->get("get/$category/all", ['script' => [self::answer($category, ['get'], [])]]); + $this->assertSame('{}', $none->body); + + $noDetails = $this->get("get/$category/all", ['script' => [ + self::answer($category, ['get'], [['id' => 1]]), + self::answer($category, ['details'], false), + ]]); + $this->assertSame('{}', $noDetails->body); + } + + #[DataProvider('transports')] + public function testGetOne(string $category, array $attr, array $details): void + { + $found = $this->get("get/$category/1", ['script' => [self::answer($category, ['details', '1'], $details)]]); + $this->assertSame($details, $found->json()); + + $missing = $this->get("get/$category/9", ['script' => [self::answer($category, ['details', '9'], false)]]); + $this->assertSame('{}', $missing->body); + } +} diff --git a/tests/php/composer.json b/tests/php/composer.json index b01af6131..5b8607385 100644 --- a/tests/php/composer.json +++ b/tests/php/composer.json @@ -4,7 +4,8 @@ "type": "project", "license": "GPL-3.0-only", "require-dev": { - "phpunit/phpunit": "^11.5" + "phpunit/phpunit": "^11.5", + "symfony/yaml": "^7.3" }, "autoload-dev": { "psr-4": { diff --git a/tests/php/composer.lock b/tests/php/composer.lock index 7a97b155a..91b993d2c 100644 --- a/tests/php/composer.lock +++ b/tests/php/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9a4f6f2c4beb80cf38c831d11167da8d", + "content-hash": "4211ddb7f0a4ed07b766e52d30177798", "packages": [], "packages-dev": [ { @@ -1721,6 +1721,239 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.8.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/feb1edf4e546af3b85c5caebe8359a20db230ca3", + "reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.8-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-16T14:15:10+00:00", + "published-time": "2026-11-04T11:25:34+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00", + "published-time": "2026-04-26T14:44:50+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/1e601207ffe627a2e621faa9d661f1d32dcf1cec", + "reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-11-04T08:27:22+00:00", + "published-time": "2026-11-04T11:28:41+00:00" + }, { "name": "theseer/tokenizer", "version": "1.3.1", @@ -1782,5 +2015,5 @@ "platform-overrides": { "php": "8.2.29" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.11.0" }