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
mailboxesonly —pendingis 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 |
activeorsuspended, read from the local record - quota_mb: Integer |
0means 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 |
nullon 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_deletionin this bucket - scheduled_deletion_at: DateTime
- days_until_deletion: Integer | never negative;
nullif 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, notsales@example.com - domain: String (optional) | defaults to the mailspace's own mail domain
Returned Params
- available: Boolean |
true,false, ornull— see the warning below - used_by: String |
"mailbox","group"or"mailing list", ornullwhen 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|usernameis 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
mailboxesentries, plus: - status: String | here also
pending_deletion— unlike the list'smailboxesbucket, this endpoint reads a soft-deleted mailbox too, andscheduled_deletion_atis then set rather thannull - 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
- all fields from List Mailboxes's
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
domainto 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
mailboxesfields from List Mailboxes, plus - created_at: DateTime
- updated_at: DateTime
- the
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|usernamemissing or blank - 400
password_blank|passwordmissing or blank. Checked afterusername_blank, so a request missing both answersusername_blank - 422
create_failed| the mailbox could not be created. One code covers several causes, distinguished only by theerrorsmessage: the package's mailbox limit is reached,quota_mbis larger than the package, the address is already used by a mailbox, group or mailing list in this mailspace, agroupsorlistsentry 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 number0to remove the per-mailbox cap - status: String (optional) |
activeorsuspended.pending_deletionis 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
- mailbox: Object | identical to Create a Mailbox
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, agroups/listsaddress 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_mbexceeds 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
pendingentries 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_failed —
resize the mailspace up first.
Returned Params
- mailbox: Object | the same shape as Delete a Mailbox;
scheduled_deletion_atanddays_until_deletionarenullafter 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.descriptionis required when this API mints a credential, but a credential created directly on the mail server can have none, and then this isnull - 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
nullon 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|descriptionmissing 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| theallowed_ipskey 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 local404for 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 local404for 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) orany - stop_processing: Boolean
- editable: Boolean |
falsewhen 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 |
nullunlessfieldisheader
- actions: Array
- type: String
- value: String |
nullfor the actions that take no value
- unmanaged: Boolean |
truemeans 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) orany. Any other value is silently coerced toallrather than refused — see the warning above - stop_processing: Boolean (optional) | defaults to
false - conditions: Array (optional) | objects of
field,comparator,values(Array<String>) andheader_name. A single value may be sent asvalueinstead ofvalues— it is taken whole, not comma-split - actions: Array (optional) | objects of
typeandvalue
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
falseafter a successful write
Errors
- 400
invalid_rule| a conditionfieldor an actiontypeis 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, aheadercondition with noheader_name, a non-numericsize, or aforwardto 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
- The same fields as Create a Mail Rule, all optional
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
- Identical to Delete a Mail Rule
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) |
upordown
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|directionis missing or is notupordown. Checked before the script is read, so it answers ahead ofscript_unmanagedandunknown_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 |
falsefrom 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 |
falsemeans 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, ornullfor no start bound - to_date: String |
YYYY-MM-DD, ornullfor 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 |
nullwhenever it is blank — including when a reply exists without one - text_body: String |
nullwhen unset - html_body: String |
nullwhen unset. Clients that render HTML prefer this overtext_body - from_date: String |
YYYY-MM-DD, ornull - to_date: String |
YYYY-MM-DD, ornull
- unmanaged: Boolean |
truemeans 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
truefor 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
- The same shape as View an Out-of-Office Reply, with
unmanagedalwaysfalseafter a successful write
Errors
- 400
invalid_date|from_dateorto_dateis 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
- The same shape as View an Out-of-Office Reply, with the reply now cleared —
configuredandenabledfalse, every other fieldnull
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.