Skip to content

Mailspace mailboxes

Mailspace is the plan: you buy it for a domain and resize or cancel it. This page covers everything inside one mailspace's mailbox list — the mailboxes themselves, each mailbox's app passwords and filter rules, and its out-of-office reply.

Every route on this page is nested under one mailspace, so the mailspace GUID is always the first path segment. Get it from GET /api/mailspace.

OAuth scopes: reads require mailspace:read, writes require mailspace:write — the same two scopes the plan-level endpoints use, so a token that can resize a mailspace can also delete its mailboxes. Session and API-key credentials bypass scope checks entirely (see OAuth).

There is no step-up authentication on this API. The dashboard re-confirms your identity before permanently erasing a mailbox, setting a mailbox password or minting an app password; the API has no equivalent prompt, because a bearer token re-authenticates on every request. Those three actions each write an audit line naming the acting credential instead. No endpoint on this page accepts the caller's own password in the request body.

Mailboxes are addressed by GUID, not by address

A mailbox is keyed by the GUID of its CloudPress record — .../mailboxes/9f3c…, never .../mailboxes/sales@example.com. Two reasons: the address is mutable (an update can rewrite it), and it contains dots, which a path segment would have to be specially constrained to carry. The same GUID is the mailbox_id segment for app passwords, mail rules and out-of-office replies.

App passwords are the exception: they have no local record, so the path segment is the mail server's own credential id (a String).


Request Guards

Every endpoint on this page runs the same six checks, in this order, before the action does anything. The first one that fails answers the request.

Applies to Condition Response
all the mail server is not configured on the platform 503 stalwart_unavailable
all mailspace GUID unknown, or not visible to your credential 404, empty body
writes only your user has no edit permission on the mailspace's own workspace 403 not_authorized
all — reads included the mailspace is on a staff block or an unpaid-invoice hold 403 mailspace_suspended
writes only the mailspace itself is scheduled for deletion 403 pending_delete
all the mailspace has no mail-server tenant yet 409 not_provisioned

On top of those, every endpoint that names a mailbox looks it up inside this mailspace and answers 404 unknown_mailbox when it is not there — so a GUID belonging to another workspace's mailspace is a 404, not a 403.

Write gating keys on the HTTP verb, not the action name

The two write gates decide on the verb. GET and HEAD are let straight through; everything else is gated. So the custom-named mutating actions — restore, force_delete, toggle, move, adopt — are gated exactly like an ordinary POST or PATCH, and there is no action name that slips past.

Concretely: a view-only member holding a mailspace:write token gets 403 not_authorized on every write on this page, including DELETE .../force_delete and POST .../mail_rules/adopt. The token's scope is not a substitute for the permission check, and the permission check runs against the workspace that owns the mailspace — not the workspace in X-Auth-Account.

A HEAD of a read endpoint is treated as the read it is, so it answers the same status the matching GET would.

Reads survive the mailspace's retention window; writes do not

Soft-deleting the mailspace (DELETE /api/mailspace/:id) does not close this API down. For the whole retention window you can still list mailboxes, read a mailbox, list app passwords, list filter rules and read out-of-office state — but every write answers 403 pending_delete. Restore the mailspace first.

That asymmetry is deliberate: force_delete on a mailbox is irreversible, and letting it run inside the window would erase the very mail that restoring the mailspace is supposed to bring back.

These statuses differ from the plan-level endpoints

The same two error codes carry different HTTP statuses here than they do on Mailspace, because the plan endpoints raise them from their own controller and these come from the shared chain:

Code On this page On mailspace.md
pending_delete 403 422
not_provisioned 409 422

A permission refusal also differs: on this page the body is {"errors":["Not Authorized"],"code":"not_authorized"}, while the plan-level endpoints answer the same message with no code key at all. Branch on the code where there is one, and never on the status alone.

A mailspace that is both on hold and pending deletion behaves differently by verb. The hold check exempts a pending-deletion mailspace deliberately — a soft-deleted mailspace is suspended on the mail server as a matter of course — so a write answers pending_delete rather than mailspace_suspended, and a read is let through by both checks and answers 200.


List Mailboxes

GET /api/mailspace/:mailspace_id/mailboxes

Scope: mailspace:read.

Returns the mailspace's live mailboxes plus, separately, the ones inside their soft-delete grace window. A mailbox scheduled for deletion appears in pending and never in mailboxes, so the two buckets never overlap.

This is a live read against the mail server, and it is not cheap

The mail server is the system of record for which mailboxes exist, so every call costs a JMAP round trip — and it may create local records as a side effect: a mailbox that exists upstream but has drifted out of our table is backfilled here.

There is no pagination, and the underlying principal query is capped at 500 mailboxes. The q filter is applied to that result after the fetch, so it searches within the same 500 rather than querying past them. The standard rate limit applies — poll sparingly.

An empty mailboxes array is authoritative

The upstream read is strict: a mailspace whose mailboxes could not be read answers 503 mailboxes_unavailable, never 200 with an empty array. So mailboxes: [] means the mail server was asked and holds none, and you can reconcile your own records against it.

That code covers every failure of that one read and only that read; a fault anywhere else in the endpoint is a 500. Note the whole response is the error envelope, so pending is not returned alongside it — even though it comes from local records and is still knowable. Retry rather than treating the mailspace as empty. See Read failures.

Params
  • q: String (optional) | case-insensitive substring match on the mailbox's primary address. Applies to mailboxes only — pending is a short, fixed to-do list and is never filtered.
Returned Params
  • mailboxes: Array | live mailboxes
    • id: String | the mailbox GUID — the key for every other endpoint on this page
    • email: String | the primary address
    • display_name: String
    • status: String | active or suspended, read from the local record
    • quota_mb: Integer | 0 means no per-mailbox cap; the mailbox is still bounded by the package's storage pool
    • used_mb: Integer | rounded up to the next whole megabyte
    • aliases: Array | Array<String>, the addresses after the primary one
    • stalwart_id: String | the mail server's own principal id
    • scheduled_deletion_at: DateTime | null on a live mailbox
  • pending: Array | mailboxes inside their soft-delete grace window, oldest deletion date first
    • id: String | the mailbox GUID
    • email: String
    • status: String | always pending_deletion in this bucket
    • scheduled_deletion_at: DateTime
    • days_until_deletion: Integer | never negative; null if no date is set

Purged mailboxes are not listed here

Mailboxes the mail server is still holding after their grace window expired are tracked separately and are deliberately absent from this response.

This endpoint does not even compute that cross-check any more, so a fault in that bucket cannot fail this index — and it saves two mail-server round trips on every call. The recoverable-purged list lives only on GET /purged_mailboxes.

That separation matters more now that this index's own read is strict and does fail it: a bucket this endpoint does not return must never decide its status.

Errors
  • 503 mailboxes_unavailable | the live mailbox read could not be performed. Retriable, and not an empty mailspace — see Read failures

Check an Address

GET /api/mailspace/:mailspace_id/mailboxes/check

Scope: mailspace:read. Read-only — nothing is reserved or created.

Asks whether an address is free before you try to create a mailbox on it. The mail server's address namespace is flat across mailboxes, groups and mailing lists and includes their aliases, so the local table cannot answer this on its own: an address held only as a mailing-list alias reads as free locally and then fails the create.

Params
  • username: String (required) | the local part only — sales, not sales@example.com
  • domain: String (optional) | defaults to the mailspace's own mail domain
Returned Params
  • available: Boolean | true, false, or null — see the warning below
  • used_by: String | "mailbox", "group" or "mailing list", or null when the address is free

available: null means could not verify

When the mail server is unreachable the endpoint still answers 200, with available and used_by both null. That is neither "taken" nor "free". Never branch on !available — a null would read as taken and block a perfectly valid create. Branch on available === false for taken, and treat null as "ask again".

Even a true is advisory: the namespace can change between this call and the create, which is why the create does its own check.

Errors
  • 400 invalid_address | username is blank

View a Mailbox

GET /api/mailspace/:mailspace_id/mailboxes/:id

Scope: mailspace:read. :id is the mailbox GUID.

The full detail payload. Several JMAP round trips per call — display name, quota, usage, aliases, memberships and the sign-in IP restriction all live on the mail server, not in our table.

Returned Params
  • mailbox: Object
    • all fields from List Mailboxes's mailboxes entries, plus:
    • status: String | here also pending_deletion — unlike the list's mailboxes bucket, this endpoint reads a soft-deleted mailbox too, and scheduled_deletion_at is then set rather than null
    • allowed_ips: Array | Array<String>, the addresses and CIDR ranges allowed to sign in with the mailbox's own password. [] means any address. App passwords carry their own separate lists
    • groups: Array | Array<String>, group addresses this mailbox belongs to
    • lists: Array | Array<String>, mailing-list addresses this mailbox is subscribed to
    • two_factor: Object
      • enabled: Boolean
    • app_passwords: Array | the same objects List App Passwords returns
    • created_at: DateTime
    • updated_at: DateTime

Every live field here has an empty failure value

allowed_ips, groups, lists, app_passwords and aliases come from upstream reads that are deliberately tolerant: when the mail server cannot be reached each yields an empty array and this endpoint still answers 200. two_factor.enabled degrades to false the same way, display_name to "", and quota_mb / used_mb to 0.

So an empty allowed_ips does not distinguish "any address may sign in" from "the restriction could not be read", and two_factor.enabled: false does not distinguish "not enrolled" from "unreadable". The same applies to the mailbox object returned by create and update. Do not drive a security decision — or a reconciliation that deletes — off these fields alone.

On List Mailboxes the annotation is safe: those rows are built from principals already fetched in the same call.

Two-step verification is reported as a flag and nothing more

two_factor.enabled is the whole story. The shared secret and the otpauth:// URL are never emitted, and enrolment is not part of this API — a readable secret would turn a stolen read token into a second factor.

App-password secrets are likewise absent here; see Mint an App Password.

Errors
  • 404 unknown_mailbox | no mailbox with that GUID in this mailspace

Create a Mailbox

POST /api/mailspace/:mailspace_id/mailboxes

Scope: mailspace:write. Returns 201 Created.

Params
  • username: String (required) | the local part. Combined with domain to form the address
  • domain: String (optional) | defaults to the mailspace's own mail domain
  • password: String (required) | the mailbox's sign-in password
  • display_name: String (optional)
  • quota_mb: Integer (optional) | per-mailbox cap in megabytes. 0 (the default) means no cap — the mailbox is still bounded by the package's storage pool. A value larger than the whole package is refused
  • aliases: Array (optional) | Array<String> of additional addresses
  • groups: Array (optional) | Array<String> of group addresses in this mailspace. A foreign address is refused
  • lists: Array (optional) | Array<String> of mailing-list addresses in this mailspace. A foreign address is refused

totp_secret is not accepted — two-step enrolment is not part of this API. allowed_ips is not accepted on create either; set it with a follow-up update.

Returned Params (201 Created)
  • mailbox: Object
    • the mailboxes fields from List Mailboxes, plus
    • created_at: DateTime
    • updated_at: DateTime

This is not the full View a Mailbox payload: allowed_ips, groups, lists, two_factor and app_passwords are not in it. Read the mailbox back if you need them.

curl -X POST https://my.cloudpress.com/api/mailspace/$MAILSPACE_ID/mailboxes \
  -H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"username": "sales", "password": "…", "display_name": "Sales", "quota_mb": 2048}'
Errors
  • 400 username_blank | username missing or blank
  • 400 password_blank | password missing or blank. Checked after username_blank, so a request missing both answers username_blank
  • 422 create_failed | the mailbox could not be created. One code covers several causes, distinguished only by the errors message: the package's mailbox limit is reached, quota_mb is larger than the package, the address is already used by a mailbox, group or mailing list in this mailspace, a groups or lists entry does not belong to this mailspace, or the mail server refused the create

A partly-configured mailbox is kept, not rolled back

The mailbox record is written before the alias, group and list calls run. If one of those fails the mailbox still exists, with a 422 create_failed describing what went wrong — deliberately, so the mailbox is visible and fixable with an update rather than stranded on the mail server with nothing pointing at it. Re-read the mailbox after a create_failed before retrying the create.


Update a Mailbox

PATCH /api/mailspace/:mailspace_id/mailboxes/:id

Scope: mailspace:write.

A true partial update: every field is optional, and an omitted field is left untouched. Supplying a field with an empty value clears it — an empty aliases array removes every alias, an empty allowed_ips clears the sign-in restriction.

Params
  • display_name: String (optional)
  • quota_mb: Integer (optional) | an empty string is treated as omitted, not as 0. Send the number 0 to remove the per-mailbox cap
  • status: String (optional) | active or suspended. pending_deletion is refused on a mailbox that is not already pending deletion — scheduling a deletion goes through DELETE, which suspends upstream and starts the retention clock together. Re-sending it against an already-scheduled mailbox is accepted and changes nothing
  • password: String (optional) | blank or omitted keeps the current password
  • aliases: Array (optional) | Array<String>. Replaces the whole list
  • groups: Array (optional) | Array<String>. Replaces the whole list. Every address must already exist in this mailspace — see below
  • lists: Array (optional) | Array<String>. Replaces the whole list. Every address must already exist in this mailspace — see below
  • allowed_ips: Array or String (optional) | Array<String>, or one String separated by commas, semicolons or whitespace. Replaces the whole list; at most 20 entries

Memberships are checked before anything is written

A groups or lists address that does not belong to this mailspace is refused with 422 update_failed, and nothing is written — the check runs ahead of every other change in the request. Without it a foreign list address would be resolved and patched, and a foreign group address would quietly do nothing while the call reported success.

The same 422 also covers the case where the memberships could not be verified at all (an upstream read failure), which is a distinct cause from a rejected address. Neither is retryable without reading the mailbox back first.

A malformed allowed_ips entry would present as an unexplained outage

An IP restriction that does not match the client locks that credential out of webmail and every device, and the mail server never tells the refused client that the address is why — JMAP answers a bare 403 and IMAP simply drops the connection.

So the list is parsed before anything is written. A rejected list is a 400 naming the offending entry, and nothing at all is written — not the display name, not the password, nothing. Entries are normalized and de-duplicated, and a masked range is stored in its masked form: 10.0.0.5/8 is stored as 10.0.0.0/8, which is what it actually matches.

Reactivating through update is a second restore path

It runs the same package mailbox-limit re-check Restore a Mailbox does, and is refused with 422 update_failed when the package has no room. Prefer the explicit restore endpoint — it is the one that reports back in the soft-delete shape.

Setting password writes an audit line naming the mailbox and the acting credential — after the write, and only when it succeeded.

Returned Params

allowed_ips is not in this response even when the request set it. Read the mailbox back with View a Mailbox to see the normalized list the mail server actually stored.

Errors
  • 400 invalid_allowed_ips | an entry is not a valid address or range, or there are more than 20. Nothing was written
  • 404 unknown_mailbox | no mailbox with that GUID in this mailspace
  • 422 update_failed | the mail server refused the change, a groups/lists address does not belong to this mailspace or could not be verified, status: "pending_deletion" was requested on a mailbox that was not already scheduled, quota_mb exceeds the package, or a reactivation would breach the package's mailbox limit

Delete a Mailbox

DELETE /api/mailspace/:mailspace_id/mailboxes/:id

Scope: mailspace:write.

Soft delete. The mailbox is suspended on the mail server immediately — mail stops flowing — and scheduled for permanent erasure 7 days later. Until then restore brings it back with its mail intact.

Idempotent. Deleting an already-scheduled mailbox reports success and does not push its deletion date further out, so a retried request cannot keep a mailbox alive indefinitely.

Returned Params
  • mailbox: Object | the soft-delete state, the same shape as the pending entries in List Mailboxes
    • id: String
    • email: String
    • status: String
    • scheduled_deletion_at: DateTime
    • days_until_deletion: Integer
Errors
  • 404 unknown_mailbox | no mailbox with that GUID in this mailspace
  • 422 delete_failed | the mail server refused the suspend

Restore a Mailbox

POST /api/mailspace/:mailspace_id/mailboxes/:id/restore

Scope: mailspace:write.

Reactivates the mailbox on the mail server and clears its deletion date. Idempotent on a mailbox that is already active.

A downgrade during the grace window can block the restore

The package's mailbox limit is re-checked here. Without that, the limit would be trivially bypassable: soft-delete down to a cheaper tier's limit, downgrade, then restore inside the grace window and sit permanently over the new plan's limit. A restore with no room answers 422 restore_failedresize the mailspace up first.

Returned Params
  • mailbox: Object | the same shape as Delete a Mailbox; scheduled_deletion_at and days_until_deletion are null after a successful restore
Errors
  • 404 unknown_mailbox | no mailbox with that GUID in this mailspace
  • 422 restore_failed | the package's mailbox limit has no room for it, or the mail server refused the reactivation

Permanently Delete a Mailbox

DELETE /api/mailspace/:mailspace_id/mailboxes/:id/force_delete

Scope: mailspace:write.

Irreversible locally, and it asks the mail server to erase the mail now instead of leaving the mailbox recoverable for the rest of its grace window.

The mailbox must already be scheduled for deletion

Call DELETE .../mailboxes/:id first. A live mailbox is refused with 409 not_pending_deletion, so no single call can erase a working mailbox.

A 200 does not prove the mail is gone

Expediting the erase is best effort. When the mail server refuses or cannot be reached, the failure is logged, the mailbox is recorded as recoverable instead, and this endpoint still answers 200. The mail then stays on the server for the remainder of its retention window and appears in List Recoverable Mailboxes.

That fallback is a strict read of the mail server's own pending-erase task, so it can no longer quietly record nothing: if the read fails it raises before the local record is destroyed, and the next sweep retries. Previously it wrote no recoverable entry, destroyed the local record anyway, and left a live mailbox still holding the address and all its mail with no restore path.

That failure has its own status: 503 delete_unavailable, not 422 delete_failed. It means the mail server did accept the removal of the account — mail to the address has already stopped — and only the record of what stays recoverable could not be written, so whether the mail is still recoverable is unknown. The mailbox stays scheduled for deletion, and a retry is safe.

So do not treat a 200 as proof that storage was freed — which also means this is not a reliable way to make room before a downgrade. Check the recoverable list if it matters.

Every call writes an audit line naming the mailbox and the acting credential — after the local delete, and only when it succeeded.

Returned Params
  • status: String | "deleted"
  • email: String | the address whose local record was deleted

The mailbox record is gone, so this reports the deletion rather than a record.

Errors
  • 404 unknown_mailbox | no mailbox with that GUID in this mailspace
  • 409 not_pending_deletion | the mailbox is not scheduled for deletion
  • 422 delete_failed | the erase did not complete — a refusal, or a failure part-way. A plain outage lands here too, because the removal of the account is the first thing that can fail. Not necessarily terminal: retry
  • 503 delete_unavailable | the removal of the account did go through, but CloudPress could not confirm whether the mail is still recoverable. The local record survives and the mailbox is still scheduled for deletion, the sweep retries, and a retry from your side is safe. Not proof that nothing happened

App Passwords

An app password is a standalone IMAP/SMTP/JMAP credential for one mailbox. It keeps working after the mailbox's own password is changed, which is what makes it the right credential for a device or a script.

These endpoints have no local record: every one talks to the mail server, so the :id segment is the mail server's credential id (a String), not a GUID.

A mailbox inside its grace window has no app-password surface

All four endpoints look the mailbox up excluding scheduled-for-deletion mailboxes, so a mailbox you have soft-deleted answers 404 unknown_mailbox here even though View a Mailbox still reads it. Restore it first. Minting an IMAP credential for a mailbox counting down to erasure is the case this closes — the credential would spring back to life along with the mailbox.

The same exclusion applies to mail rules and out-of-office replies.

List App Passwords

GET /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/app_passwords

Scope: mailspace:read.

Returned Params
  • app_passwords: Array
    • id: String | the credential id, used on update and revoke
    • description: String | null — passed through from the mail server exactly as stored. description is required when this API mints a credential, but a credential created directly on the mail server can have none, and then this is null
    • allowed_ips: Array | Array<String>; [] means any address may authenticate with this credential
    • created_at: DateTime | may be null

This list is one of the deliberately tolerant reads

When the mail server cannot be read, this endpoint answers 200 with an empty array rather than a 503 — unlike the mailbox index. That is deliberate and is not going to change: revoking an app password needs its id, so an empty list here cannot be acted on destructively. Do not read it as proof the mailbox holds no credentials. See Read failures.

created_at is the only history an app password has — the mail server records no last-used timestamp, so there is nothing here to tell a live credential from a forgotten one apart from its description. Which is also why a null description is worth handling rather than assuming away.

This response never carries secret; see below.

Errors
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion

Mint an App Password

POST /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/app_passwords

Scope: mailspace:write. Returns 201 Created.

Params
  • description: String (required) | what the credential is for. It is the only way to tell two of them apart when revoking

The secret is returned exactly once, here

secret appears in this response and nowhere else. The mail server keeps no readable copy, so neither List App Passwords nor View a Mailbox can hand it back. Store it on receipt; a lost secret can only be revoked and replaced.

Every mint writes an audit line naming the mailbox and the acting credential.

Returned Params (201 Created)
  • app_password: Object
    • id: String | the new credential id
    • description: String | as submitted, trimmed
    • allowed_ips: Array | always [] on a mint — the new credential is unrestricted. Pin it with Restrict an App Password
    • created_at: DateTime | always null on the mint response; the mail server's own timestamp appears on a later read
    • secret: String | the only time this is emitted
Errors
  • 400 description_blank | description missing or blank. Nothing was created
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 422 app_password_create_failed | the mail server refused the mint

Restrict an App Password

PATCH /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/app_passwords/:id

Scope: mailspace:write. :id is the credential id.

Pins one credential to a set of addresses. This is per credential — it never touches the mailbox's own sign-in password, so it cannot lock anyone out of webmail.

Params
  • allowed_ips: Array or String (required) | Array<String>, or one String separated by commas, semicolons or whitespace. At most 20 entries. Send an empty value to clear the restriction

allowed_ips is required, and an empty list means unrestricted

This endpoint is not a partial update: allowed_ips is the only thing it writes, so omitting the key expresses no intent at all. A PATCH without it is refused with 400 allowed_ips_missing and writes nothing.

Every way of expressing "clear" still works — [], "", null and allowed_ips[]= all remove the restriction. That distinction is the whole point: an empty list is stored upstream as an empty map, which means unrestricted, so reading "not supplied" as "clear" would silently unpin a deliberately restricted credential while answering 200.

Send the full list — it replaces, it does not append.

As on the mailbox, the list is parsed before anything is written: a bad entry is a 400 naming it, with nothing written. Entries are normalized, and a masked range is stored masked.

Returned Params
  • app_password: Object
    • id: String
    • allowed_ips: Array | Array<String> — a normalized echo of what was written, not a read-back of the credential. It can still differ from what you submitted: a masked range is stored masked

Only those two fields. description and created_at are not echoed back.

Errors
  • 400 allowed_ips_missing | the allowed_ips key was absent from the request. Nothing was written — send an empty value to clear the restriction
  • 400 invalid_allowed_ips | an entry is not a valid address or range, or there are more than 20. Nothing was written
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 422 app_password_update_failed | the mail server refused the change. An unknown credential id surfaces here — there is no local 404 for it

Revoke an App Password

DELETE /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/app_passwords/:id

Scope: mailspace:write.

The credential stops authenticating immediately. The mailbox's own password and every other app password are untouched.

Returned Params
  • status: String | "revoked"
  • id: String | the credential id that was revoked
Errors
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 422 app_password_delete_failed | the mail server refused the revoke. An unknown credential id surfaces here — there is no local 404 for it

Mail Rules

A mailbox's filter rules are Sieve. They run top to bottom, so the order the list comes back in is the evaluation order, and stop_processing really does skip everything below the rule that set it — reordering is a behavioural change, not cosmetics.

A rules write is a whole-script rewrite

A mailbox has exactly one active Sieve script, and every rule — plus the out-of-office reply — is a block inside it. There is no "update rule 3" call on the mail server, so every mutation here reads the script, changes the list in memory, and recompiles and re-activates the whole thing.

Two consequences:

  • Every mutating response returns the whole rule list, not just the rule you touched. That is not padding: a client that holds a stale list and writes from it deletes whatever it had forgotten. Re-read the list from every write.
  • Concurrent writers are serialized for you. Each mutating action holds a row lock on the mailbox across the entire read-modify-write, so two clients writing rules on the same mailbox cannot clobber each other. Out-of-office writes take the same lock.

A script written outside CloudPress is never overwritten implicitly

unmanaged: true means the mailbox's active script was not written by our rule builder — hand-edited, or written by another mail client. Saving over it would destroy the customer's own script, so every write on the script (rules and out-of-office alike) is refused with 409 script_unmanaged until Adopt a Filter Script is called explicitly.

List Mail Rules reports the flag, so check it before writing rather than discovering it on the 409. And note the flag can appear between your read and your write — the save re-reads the script, so a script_unmanaged on a write you thought was safe is expected, not a bug.

An unrecognized match_type is silently changed to all

match_type accepts exactly all and any. Anything else — ANY, or, a typo — is not rejected: it is quietly replaced with all, and the write succeeds with a 2xx and no error. So a rule you meant to be "any of these conditions" becomes "every one of them", which usually means it stops matching the mail it was written for, and nothing tells you.

Unlike a bad condition field or action type, which are refused with 400 invalid_rule, this one is not validated. Send it lower-case, and read match_type back off the response.

Rule Vocabulary

A rule is match_type over a list of conditions, plus a list of actions. Several values in one condition are OR'd together whatever match_type says.

Condition fields and the comparators each accepts:

field comparators notes
from, to, cc, subject contains, not_contains, is, not_is, starts_with, ends_with, matches
body contains, is
header as from above also needs header_name
size greater_than, less_than values must be whole numbers of bytes
attachment has_any, has_type has_any needs no values

Action types: move, copy, forward, mark_read, star, add_label, discard, reject, keep, stop. move, copy, forward, add_label and reject each need a value — a folder, an address, a label or a rejection message. A forward value must be a valid email address.

Not everything readable is writable. This API writes only the fields from, to, cc, subject, body and the actions move, forward, mark_read, star, discard. A rule outside that subset is still listed and still runs — it comes back with editable: false, and can be toggled, reordered or deleted, but not rewritten. Submitting one to create or update is refused with 422 unsupported_rule.

Rule names are required and capped at 200 characters.

List Mail Rules

GET /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/mail_rules

Scope: mailspace:read. Costs one script fetch plus a blob download on the mail server.

Returned Params
  • mailbox: Object
    • id: String | the mailbox GUID
    • email: String
  • mail_rules: Array | in evaluation order
    • id: String | the rule's own uuid, from the script's metadata. Rules have no local record
    • name: String | may be empty on a rule written elsewhere
    • display_name: String | name, or "Untitled rule" when it is blank — what a UI should show
    • enabled: Boolean
    • match_type: String | all (every condition must match) or any
    • stop_processing: Boolean
    • editable: Boolean | false when the rule uses Sieve this API does not write. It can still be toggled, reordered or deleted
    • conditions: Array
      • field: String
      • comparator: String
      • values: Array | Array<String>, OR'd together
      • header_name: String | null unless field is header
    • actions: Array
      • type: String
      • value: String | null for the actions that take no value
  • unmanaged: Boolean | true means the script was written outside CloudPress and every write is refused until it is adopted
Errors
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 422 mail_rules_unavailable | the mailbox's script could not be read

Create a Mail Rule

POST /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/mail_rules

Scope: mailspace:write. Returns 201 Created.

The new rule is appended to the end of the list, so it evaluates after every existing rule. There is no way to insert one at a position; create it, then move it.

Params
  • name: String (required) | at most 200 characters
  • enabled: Boolean (optional) | defaults to true
  • match_type: String (optional) | all (default) or any. Any other value is silently coerced to all rather than refused — see the warning above
  • stop_processing: Boolean (optional) | defaults to false
  • conditions: Array (optional) | objects of field, comparator, values (Array<String>) and header_name. A single value may be sent as value instead of values — it is taken whole, not comma-split
  • actions: Array (optional) | objects of type and value

Keys other than those are dropped rather than stored. See the rule vocabulary above for the accepted field, comparator and type values. A rule needs at least one condition and at least one action.

Returned Params (201 Created)
  • mail_rule: Object | the created rule, in the List Mail Rules shape
  • mail_rules: Array | the whole new list, in evaluation order
  • unmanaged: Boolean | always false after a successful write
Errors
  • 400 invalid_rule | a condition field or an action type is not in the vocabulary. Caught in the request rather than silently dropped, which would turn a typo into a rule that matches something else
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 409 script_unmanaged | the script was written outside CloudPress — adopt it first
  • 422 invalid_rule | the rule would compile to Sieve that matches nothing: a blank or over-long name, no conditions, no actions, a condition with no value, a comparator that does not apply to the field, a header condition with no header_name, a non-numeric size, or a forward to a malformed address
  • 422 unsupported_rule | the combination is storable by another mail client but not writable here — the response names the supported fields and actions
  • 422 mail_rules_unavailable | the mailbox's script could not be read
  • 422 save_failed | the recompiled script could not be written

A rejected create writes nothing — the existing script is left exactly as it was.

Update a Mail Rule

PATCH /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/mail_rules/:rule_id

Scope: mailspace:write. Note the segment is rule_id, not id.

Rewrites one rule in place, keeping its id and its position in the evaluation order.

PATCH semantics with one sharp edge: any field you omit keeps its stored value, but sending conditions or actions replaces that whole array. There is no per-element merge — the elements have no ids.

Params
Returned Params
  • mail_rule: Object | the updated rule
  • mail_rules: Array | the whole list
  • unmanaged: Boolean | false
Errors
  • The create errors, plus:
  • 404 unknown_rule | no rule with that id in this mailbox's script
  • 409 rule_not_editable | the stored rule uses Sieve this API cannot rewrite, so rewriting it would silently turn it into a different rule. Toggle, reorder and delete still work on it

Delete a Mail Rule

DELETE /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/mail_rules/:rule_id

Scope: mailspace:write.

Removes one rule and rewrites the script with the rest. Works on a rule that is not editable.

Returned Params
  • deleted_rule_id: String
  • mail_rules: Array | the remaining list, so no follow-up read is needed
  • unmanaged: Boolean | false
Errors
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 404 unknown_rule | no rule with that id in this mailbox's script
  • 409 script_unmanaged | the script was written outside CloudPress — adopt it first
  • 422 mail_rules_unavailable | the script could not be read
  • 422 save_failed | the recompiled script could not be written

Enable or Disable a Mail Rule

PATCH /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/mail_rules/:rule_id/toggle

Scope: mailspace:write.

A disabled rule stays in the script's metadata — it keeps its place and its settings — but emits no Sieve. Works on a rule that is not editable: turning a rule off does not touch what it matches.

Params
  • enabled: Boolean (optional) | set the state explicitly

Omitting enabled flips the rule, and that is not idempotent

With no enabled in the body this endpoint toggles whatever the current state is. A request that times out after the write landed, then gets retried, flips the rule back. Automated callers should always send enabled explicitly.

Returned Params
  • mail_rule: Object | the toggled rule
  • mail_rules: Array | the whole list
  • unmanaged: Boolean | false
Errors

Reorder a Mail Rule

PATCH /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/mail_rules/:rule_id/move

Scope: mailspace:write.

Moves one rule one place up or down. Rules evaluate top to bottom, so this changes behaviour. There is no way to set an absolute position or to reorder the whole list in one call — issue repeated moves. Works on a rule that is not editable.

Params
  • direction: String (required) | up or down
Returned Params
  • mail_rule: Object | the moved rule
  • mail_rules: Array | the whole list, in its new evaluation order
  • unmanaged: Boolean | false
Errors
  • 400 invalid_direction | direction is missing or is not up or down. Checked before the script is read, so it answers ahead of script_unmanaged and unknown_rule
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 404 unknown_rule | no rule with that id in this mailbox's script
  • 409 script_unmanaged | the script was written outside CloudPress — adopt it first
  • 422 invalid_move | the rule is already first and was moved up, or already last and was moved down. Refused rather than silently doing nothing
  • 422 mail_rules_unavailable | the script could not be read
  • 422 save_failed | the recompiled script could not be written

Adopt a Filter Script

POST /api/mailspace/:mailspace_id/mailboxes/:mailbox_id/mail_rules/adopt

Scope: mailspace:write. This is a collection route — there is no rule_id.

Takes over a filter script this API did not write, replacing it with the rules we could parse out of it.

Adopting is destructive and cannot be undone

Anything in the customer's own script that our rule model cannot represent is gone afterwards. That is exactly why nothing else on the script ever adopts implicitly, and why every other write answers 409 script_unmanaged instead. Call List Mail Rules first and show a human what is about to be replaced.

On an already-managed script it is a harmless no-op rewrite, answering 200.

This is the only adopt endpoint. The out-of-office reply lives in the same script, so adopting here is what unblocks Set an Out-of-Office Reply too.

Returned Params
  • adopted: Boolean | true
  • mail_rules: Array | what the script holds now
  • unmanaged: Boolean | false from here on
Errors
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 422 mail_rules_unavailable | the script could not be read
  • 422 save_failed | the recompiled script could not be written

There is no script_unmanaged here — an unmanaged script is this endpoint's entire purpose.


Out-of-Office Replies

One reply per mailbox, and it lives in the same Sieve script as the mailbox's filter rules — not as a separate object. So everything the rules section says about whole-script rewrites, the row lock and script_unmanaged applies here unchanged, and the two surfaces share one adoption.

There is no POST route: Set an Out-of-Office Reply is an upsert.

Reads and writes are keyed by the mailbox GUID, and the route segment is spelled mailbox_id.

List Out-of-Office State

GET /api/mailspace/:mailspace_id/vacation_responses

Scope: mailspace:read.

One row per mailbox in the mailspace, ordered by address. Mailboxes scheduled for deletion are omitted.

This endpoint never touches the mail server, and can be stale

Reading the live reply costs a script fetch plus a blob download per mailbox, which is not a list operation. So this answers from a cached summary each mailbox keeps, refreshed whenever that mailbox's script is read or written through CloudPress. A mailbox whose script was last changed elsewhere is stale here. For live state, read the mailbox's own reply.

Returned Params
  • vacation_responses: Array
    • mailbox_id: String | the mailbox GUID
    • email: String
    • synced: Boolean | false means this mailbox has no cached summary at all — see below
    • configured: Boolean | a reply exists
    • enabled: Boolean | it is switched on
    • from_date: String | YYYY-MM-DD, or null for no start bound
    • to_date: String | YYYY-MM-DD, or null for no end bound

Check synced before believing the row

On a mailbox with no cached summary yet, configured and enabled come back as false and the dates as null — which is byte-for-byte what a mailbox with no reply looks like. The two are only distinguishable by synced. Treat synced: false as "unknown", not as "no reply", and read that mailbox's own reply to find out.

Note that this endpoint reports no unmanaged flag. The per-mailbox read does.

View an Out-of-Office Reply

GET /api/mailspace/:mailspace_id/vacation_responses/:mailbox_id

Scope: mailspace:read. Read live from the mailbox's script.

Returned Params
  • vacation_response: Object
    • mailbox_id: String | the mailbox GUID
    • email: String
    • configured: Boolean | a reply exists
    • enabled: Boolean | it is switched on
    • subject: String | null whenever it is blank — including when a reply exists without one
    • text_body: String | null when unset
    • html_body: String | null when unset. Clients that render HTML prefer this over text_body
    • from_date: String | YYYY-MM-DD, or null
    • to_date: String | YYYY-MM-DD, or null
  • unmanaged: Boolean | true means the script was written outside CloudPress and writes are refused until it is adopted

configured and enabled are separate flags on purpose: a reply can be written, dated and switched off, which no field-presence check could tell apart from having no reply at all. configured: false with null fields means there is no reply.

Errors
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 422 mail_rules_unavailable | the mailbox's script could not be read

Set an Out-of-Office Reply

PATCH /api/mailspace/:mailspace_id/vacation_responses/:mailbox_id

Scope: mailspace:write. An upsert — the same call creates the reply and edits it.

Every field is optional. Omitting a field keeps its stored value; send an empty string to clear one. That distinction matters: {"enabled": false} switches the reply off and leaves its text alone, which is what you almost always want.

Params
  • enabled: Boolean (optional) | whether the reply actually goes out. Defaults to true for a mailbox that has no reply yet
  • subject: String (optional)
  • text_body: String (optional)
  • html_body: String (optional) | send "" to stop sending an HTML version
  • from_date: String (optional) | YYYY-MM-DD. The reply only answers inside the window
  • to_date: String (optional) | YYYY-MM-DD

Dates are validated strictly, and prose is refused

Whole days only. An ISO 8601 datetime is accepted and trimmed to its date; anything that is not ISO 8601 is a 400 invalid_date rather than a guess. Two silent failures that buys you: a date the compiler cannot read at all is dropped, so the reply would answer everyone, forever instead of over the window you asked for — and a permissive parser happily accepts "next tuesday" and resolves it to today, which is worse, because the window then looks set and is wrong.

An enabled reply needs something to say

Enabling one with no subject and no body is refused with 422 vacation_body_required rather than saved as an empty reply — the generator would leave the block out entirely and the save would report success while the mailbox answered no one. Use DELETE to remove a reply.

Returned Params
Errors
  • 400 invalid_date | from_date or to_date is not a date the reply could honour. Nothing was written
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 409 script_unmanaged | the script was written outside CloudPress. The opt-in is the mail-rules adopt endpoint — there is no separate adopt for the reply
  • 422 vacation_body_required | an enabled reply with no subject and no body
  • 422 mail_rules_unavailable | the script could not be read
  • 422 save_failed | the recompiled script could not be written

Remove an Out-of-Office Reply

DELETE /api/mailspace/:mailspace_id/vacation_responses/:mailbox_id

Scope: mailspace:write.

The reply's block drops out of the mailbox's script and its filter rules are left exactly as they were. Idempotent: removing a reply the mailbox does not have is a 200.

Returned Params
Errors
  • 404 unknown_mailbox | no such mailbox in this mailspace, or it is scheduled for deletion
  • 409 script_unmanaged | the script was written outside CloudPress — adopt it first
  • 422 mail_rules_unavailable | the script could not be read
  • 422 save_failed | the recompiled script could not be written

Error Codes

All errors use the standard {"errors": [...], "code": "..."} envelope described in Error Responses. The one exception is an unknown or invisible mailspace GUID, which answers a bare 404 with an empty body — deliberately, so a client cannot probe which mailspace GUIDs exist elsewhere on the platform.

Shared by every endpoint on this page:

Code Status Raised when
stalwart_unavailable 503 the mail server is not configured
(none — empty body) 404 the mailspace GUID is unknown or not yours
not_authorized 403 writes only: no edit permission on the mailspace's workspace
mailspace_suspended 403 reads and writes: the mailspace is on hold
pending_delete 403 writes only: the mailspace is scheduled for deletion
not_provisioned 409 the mailspace has no mail-server tenant yet
unknown_mailbox 404 every endpoint that names a mailbox

Per endpoint group:

Code Status Raised by
invalid_address 400 mailbox check
username_blank 400 mailbox create
password_blank 400 mailbox create
invalid_allowed_ips 400 mailbox update, app-password update
allowed_ips_missing 400 app-password update — the key was not sent at all
create_failed 422 mailbox create
update_failed 422 mailbox update
delete_failed 422 mailbox delete, mailbox force_delete
delete_unavailable 503 mailbox force_delete — the removal went through, the recoverability record did not
mailboxes_unavailable 503 list mailboxes — the live mailbox read could not be performed. Not an empty mailspace
restore_failed 422 mailbox restore
not_pending_deletion 409 mailbox force_delete
description_blank 400 app-password create
app_password_create_failed 422 app-password create
app_password_update_failed 422 app-password update
app_password_delete_failed 422 app-password revoke
invalid_rule 400 / 422 mail-rule create, update — 400 for an unknown field or action, 422 for a rule that would match nothing
unsupported_rule 422 mail-rule create, update
rule_not_editable 409 mail-rule update
unknown_rule 404 mail-rule update, delete, toggle, move
invalid_direction 400 mail-rule move
invalid_move 422 mail-rule move
script_unmanaged 409 every mail-rule write except adopt; out-of-office update, delete
mail_rules_unavailable 422 every mail-rule and out-of-office endpoint except the out-of-office list
save_failed 422 every mail-rule and out-of-office write
invalid_date 400 out-of-office update
vacation_body_required 422 out-of-office update

Error messages on this API are always English, even when the request carries an Accept-Language header — branch on code, not on the message.