Mailspace logs & recovery
These endpoints answer "what happened to my mail?" and "can I get it back?" for one mailspace: the delivery logs, the archive of deleted messages that are still restorable, and the mailboxes whose mail the mail server is still holding after a permanent delete. Buying, inspecting, resizing and deleting the mailspace itself is covered by the plan-level endpoints on Mailspace.
Every path on this page begins /api/mailspace/:mailspace_id/…, where
:mailspace_id is the mailspace GUID from
GET /api/mailspace.
OAuth scopes: reads require mailspace:read, writes require
mailspace:write. Session and API-key credentials bypass scope checks
entirely (see OAuth). An OAuth token that lacks the
required scope is refused with 403
{"error":"insufficient_scope", ...} — the OAuth error envelope, not the
{"errors":[...],"code":"..."} envelope every other failure on this page
uses. See Scope Enforcement Errors.
Everything on this page is read live from the mail server — no local
table backs the delivery logs or the archive — so each call costs one or more
round trips to it. Nothing on this page is paginated: page and per_page
are not read anywhere here. The normal API
rate limit applies, so poll on a human
timescale rather than in a loop.
An empty result is not evidence that nothing is wrong
Three separate mechanisms on this page turn a missing capability or a failed upstream call into an empty list rather than an error, and none of them is reported to the client:
- delivery tracing is an Enterprise feature of the mail server; where it is not licensed the trace lists are always empty while the outbound queue still carries data — see Delivery Logs;
- archiving of deleted mail is likewise Enterprise-gated, and where it is unavailable the archive reads as empty — see Deleted Mail Archive;
- an unreachable or erroring mail server degrades the delivery-log arrays
and the archive listing to empty instead of a
5xx, because those readers swallow the failure and log it internally. Only the recoverable-mailbox listing answers503here — see Read failures.
There is no capability probe. This API cannot tell you which tier the mail server runs, and it does not guess. Never render "no delivery problems" or "nothing to recover" from an empty response alone.
Request Guards
Every endpoint on this page inherits the same gate chain, applied in this order before the action runs. The first gate that fails answers the request.
| Applies to | Condition | Response |
|---|---|---|
| all | mail hosting is not configured on the platform | 503 stalwart_unavailable |
| all | :mailspace_id unknown, or not visible to your credential |
404, empty body |
| writes | user has no edit permission on the mailspace's own workspace | 403 not_authorized |
| all | mailspace is on hold (staff block or unpaid-invoice hold) | 403 mailspace_suspended |
| writes | mailspace is soft-deleted (pending deletion) | 403 pending_delete |
| all | mailspace is not provisioned yet | 409 not_provisioned |
A write is decided by the HTTP verb, not the endpoint
The two write gates decide on the request method: GET and HEAD pass
straight through, everything else is gated. So on this page
Restore an Archived Item is a write because it
is a POST, and Download an Archived Message
is a read because it is a GET — even though the download hands back a
complete message.
A view-only member holding a mailspace:write token can therefore read the
logs, browse the archive, download messages and list recoverable mailboxes,
and gets 403 not_authorized on every restore and delete: the token's
scope is satisfied, the member's permission is not.
HEAD is treated exactly like GET, so probing a read endpoint with HEAD
returns the same status the GET would.
Reads survive the retention window; writes do not
A soft-deleted (pending-deletion) mailspace keeps answering reads for its
whole retention window — you can still read its logs, list its archived mail
and list its recoverable mailboxes. Every mutation is refused with 403
pending_delete until the mailspace is restored. See
Delete a Mailspace.
A held mailspace behaves differently: mailspace_suspended blocks reads
as well as writes. A mailspace that is pending deletion is exempt from that
gate, so the two codes never both apply.
An unknown or out-of-scope :mailspace_id answers 404 with an empty body,
not the usual error envelope — a GUID belonging to another workspace has to be
indistinguishable from one that does not exist.
Delivery Logs
GET /api/mailspace/:mailspace_id/logs
Scope mailspace:read.
Everything the mail server knows about this mailspace's recent mail, in four
separate arrays: incoming and outgoing delivery traces, the live outbound
queue, and issues — the derived list of deliveries that failed or are still
being retried.
This endpoint takes no parameters at all: no filters, no date range, no
pagination. The reader caps what it fetches at the 100 most recent entries
per dataset — at most 100 traces, which are then split across incoming and
outgoing (internal mail counts in both), at most 100 queue entries, and at most
100 issues — and the mail server has nothing to page through. Internally one call
queries traces and the queue once per domain the mailspace hosts, so it is
several round trips: this is not an endpoint to poll tightly.
The four arrays do not have the same availability — read this before building on them
They come from different mail-server objects, and that is exactly why they are returned under separate keys instead of merged into one list:
incoming/outgoingcome from the mail server's delivery tracing, which is an Enterprise-only feature. Where it is not licensed, both arrays are always empty, however much mail flowed.queuecomes from the outbound queue, which exists on every build. It carries data regardless of licensing.issuesis the union of the two — permanent failures come from the traces, still-retrying messages come from the queue — so it is partially degraded wherever tracing is unavailable: retrying messages still show up, completed bounces do not.
Kept apart, a client can see the degradation: queue populated while both
trace arrays are empty means tracing is unavailable on this deployment, not
that no mail moved. Merged, "Enterprise not licensed" would have been
indistinguishable from "no mail problems".
A client must not read empty trace arrays as "no mail problems", and there
is no flag to check instead — the API is not told which tier the mail
server is on, so it cannot tell you. If your integration needs to distinguish
the two, compare the trace arrays against queue and treat "traces
permanently empty" as a property of the deployment.
Internal mail is listed twice, on purpose
A message from one mailbox to another inside the same mailspace is
genuinely both directions: it drains through the local queue, so it
classifies as incoming, while somebody in the mailspace did send it. It is
therefore listed under both incoming and outgoing, carrying
internal: true so you can badge it rather than double-count it. Without
that, a customer looking for a message they sent a colleague would never find
it under Outgoing.
Returned Params
- incoming:
Array<Object>| traces for mail delivered to this mailspace's mailboxes - outgoing:
Array<Object>| traces for delivery attempts to other mail servers, plus every internal message - queue:
Array<Object>| messages still sitting in the outbound queue - issues:
Array<Object>| permanent failures (from traces) and still-retrying messages (from the queue)
Each entry in incoming and outgoing is a trace:
- id: String |
null— the mail server's trace id, passed straight through with no guard, so a trace the mail server recorded without one arrives asnull. Do not key or de-duplicate the trace arrays on it unguarded — the non-null guarantee onqueueandissuesbelow does not extend here - timestamp: String | ISO 8601, as the mail server recorded it
- from: String | the envelope sender, as the mail server recorded it
- to: String | recipients as a comma-separated string, not an array. On mail from a sender outside this mailspace, co-recipients on domains this mailspace does not host are removed before you see it
- subject: String |
nullif the mail server recorded none - size: Integer |
null— bytes - direction: String |
incomingoroutgoing - internal: Boolean |
truefor mailbox-to-mailbox mail inside this mailspace (see above) - status: String | derived from the SMTP log:
delivered,bounced,failed,retrying, orsending - events:
Array<Object>| the SMTP log, in the order the mail server recorded it- name: String | the mail-server event name, e.g.
delivery.attempt-start - timestamp: String | ISO 8601
- key_values:
Array<Object>| the event's detail fields, as a list rather than an object because the same key can legitimately repeat within one event- key: String
- value: String, Integer or Boolean | passed through from the mail server
- name: String | the mail-server event name, e.g.
Each entry in queue and issues is a queued message:
- id: String | the mail server's own queue id on a queue entry, and a stable synthetic
trace-<…>id on an issue derived from a trace. Nevernullon either of these two arrays — that guarantee is specific to them and does not hold for a trace's ownid. See the warning below - source: String |
queueortrace— which dataset the entry came out of. Present on every entry of bothqueueandissues; the trace arrays do not carry it. See the warning below for what it is for - created_at: String | ISO 8601
- return_path: String | the envelope sender. Empty means a null return path (
<>): the message is a bounce notification - subject: String |
null - size: Integer |
null— bytes - flags:
Array<String>| mail-server flags, e.g.dsnSentonce a bounce notification has been sent - recipients:
Array<Object>- address: String
- status: String |
Completed,TemporaryFailureorPermanentFailure;nullwhile the recipient is still queued and nothing has been attempted - retry_due: String |
null— ISO 8601, when the next attempt is due - retry_count: Integer |
null - error: Object |
nullwhen the mail server reported no error detail for that recipient- type: String |
null— the mail server's error type - response_code: Integer |
null— the SMTP status code. Passed straight through, so a trace-derived issue carries whatever the SMTP log recorded and may hand back a String - response_enhanced: String |
null— the enhanced status code, e.g.5.1.1 - response_hostname: String |
null— the server that answered - message: String |
null— the remote server's text
- type: String |
- events:
Array<Object>| the SMTP log in the same shape as a trace's. Empty for a live queue entry, which has no trace yet; populated only on an issue that came from a trace
Derive the message-level status yourself
A queued message has no top-level status field — deliberately, so the
rule lives in one place instead of being duplicated. Derive it from
recipients and flags:
- any recipient
PermanentFailure→bouncedifflagsincludesdsnSent, otherwisefailed; - else any recipient
TemporaryFailure→retrying; - else all recipients
Completed→delivered; - else →
sending.
An issues entry is not a queue entry, even though it has the same shape
issues is rendered in the queued-message shape whichever source it came
from, which makes it uniform to display but easy to over-read:
- An issue derived from a trace (a completed bounce or rejection) has no
queue id to carry, so it carries a stable synthetic
trace-<…>id instead — the same value on every poll, so a client may key or de-duplicate on it. Itsrecipientsarray holds a single synthetic entry whoseaddressis the trace's whole comma-separated recipient string, withstatusPermanentFailure,retry_dueandretry_countnull, anderror.typenull. Itsflagsis["dsnSent"]when a bounce notification was sent and[]when the message failed without one, and it carries the full SMTP log inevents. - An issue derived from the queue is a real queue entry, listed with only
its failing recipients — so its
recipientsarray is a subset of the one underqueue— and itseventsis empty.
source tells the two apart without inspecting them. Every entry of
queue and of issues carries it: "queue" for a row that came out of the
live outbound queue, "trace" for one derived from a trace. It says whether
the row can still change — a queue entry is still being retried, while a
trace-derived issue is terminal, its bounce notification already sent and the
message already flushed from the queue — and it is the only way to tell which
entries carry an events log. The trace arrays (incoming and outgoing)
do not carry it: only the queue-shaped rows are stamped.
Permanent failures only ever come from traces and retrying messages only ever come from the queue, so the two sources never double-count the same message. That also means completed bounces are among the things that disappear where tracing is unlicensed.
curl -H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
-H "X-Auth-Account: $ACCOUNT_ID" \
https://my.cloudpress.com/api/mailspace/$MAILSPACE_ID/logs
Deleted Mail Archive
Mail a user deleted that the mail server is still holding. An item lands here
when a message is deleted and stays until its archived_until deadline, after
which the mail server purges it for good. The retention window is server-wide
and cannot be extended or shortened through this API.
An empty archive means one of two things, not three
The archive is an Enterprise-only object on the mail server, and the
listing behind it is strict: a query that could not be performed answers
503 archived_items_unavailable. So
List Archived Mail answers 200 with an empty
archived_items array in two situations, and nothing in the response tells
them apart:
- nothing is archived;
- the mail server does not license archiving. There is no capability probe, so
"not licensed" and "nothing archived" are deliberately indistinguishable —
and every by-id endpoint then answers
404unknown_archived_item.
"The mail server could not be asked" is no longer one of them: that is the
503. The reason the licence gate still degrades to an empty list is that the
mail server answers a request for a licensed-only object, and an answer is
what the strict read is checking for; a server that gives no answer is the
503. So an unreachable mail server is distinguishable from an empty archive.
An unlicensed one is not.
Treat an empty archive as "nothing to show", never as "nothing was ever deleted".
Item ids are opaque, and they are global at the mail server
An archived item is addressed by the :stalwart_id segment, which is the
opaque id from the list — not a GUID and not anything you can construct.
At the mail server it is an unscoped handle, so CloudPress re-resolves
every id against this mailspace's own mailboxes on every by-id request and
answers 404 unknown_archived_item when it does not belong here. An item
id from another workspace is unreachable on all four by-id endpoints,
download included.
Branch on code, never on errors[0]
All three actions that can fail against the archive — List Archived Mail, Restore an Archived Item and Delete an Archived Item — answer a failure with a curated English sentence and never echo the mail server's own error text, which is upstream internals a client cannot act on.
Those messages are English whatever Accept-Language you send, even though
the header still sets the locale for the rest of the request, and their
wording is free to change. The code is the contract.
List Archived Mail
GET /api/mailspace/:mailspace_id/archived_items
Scope mailspace:read.
Every restorable deleted message across every mailbox in the mailspace, newest-archived first. The archive is queried per mailbox and filtered to this mailspace's own mailboxes, so it can never surface another tenant's deleted mail. Unpaginated: CloudPress caps the query at 500 items per mailbox, and anything past that is dropped without a marker in the response.
Returned Params
- archived_items: Array
- id: String | the opaque item id — this is the
:stalwart_idsegment for every by-id endpoint below - type: String | the item variant.
Emailin practice; the mail server declares others but never writes them - status: String |
archived, orrequestRestoreonce a restore has been queued for it. The mail server omits the field while it holds its default, so it is filled in asarchived - subject: String |
null - from: String |
null - size: Integer |
null— bytes - received_at: String |
null— ISO 8601, when the message originally arrived - archived_at: String |
null— ISO 8601, when it was deleted into the archive - archived_until: String |
null— ISO 8601, the permanent-deletion deadline. Server-wide retention, not per item - mailbox_email: String | the address the message was deleted from
- mailbox_id: String | that mailbox's GUID
- id: String | the opaque item id — this is the
The item's mail-server blob and account handles are deliberately not emitted: both are global, unscoped handles, and the message itself comes back from Download an Archived Message.
Errors
- 503
archived_items_unavailable| the archive query could not be performed. Not a client error, so it is retriable — see Read failures. An unlicensed archive still degrades to the empty200described above; an unreachable mail server does not - plus the shared guards
View an Archived Item
GET /api/mailspace/:mailspace_id/archived_items/:stalwart_id
Scope mailspace:read. One item, in the same field set as the list, under an
archived_item object.
The message body is not returned here — use Download an Archived Message for the original message.
Errors
- 404
unknown_archived_item| no such item, or it does not belong to this mailspace - 503
archived_item_lookup_unavailable| the ownership lookup could not be performed, so the item is neither confirmed nor denied. Retriable, and not a sign the item is gone - plus the shared guards
Restore an Archived Item
POST /api/mailspace/:mailspace_id/archived_items/:stalwart_id/restore
Scope mailspace:write (it is a POST, so it is gated as a write —
see above).
This reports a request, not a finished restore
The mail server has no synchronous restore. The trigger is flipping the
item's status to a restore request, which the server then processes on its
own schedule, and it emits no completion signal — so the response says
restore_queued and never restored, and this API has nothing to poll.
To find out whether a restore happened, re-read the archive: an item that has been restored is no longer listed, and the message returns to the mailbox under a new id. Do not report a restore as complete on the strength of this response.
Returned Params
- status: String | always
"restore_queued" - archived_item: Object | the item's fields, exactly as in the list
Two different status fields
The top-level status describes your request (restore_queued). The
status inside archived_item is the item's own field, as it stood when
the item was looked up — so a first restore shows archived here even though
the request was accepted, and a second one on the same item shows
requestRestore. Re-read the item rather than reading this field as the
outcome.
Errors
- 404
unknown_archived_item| no such item, or it does not belong to this mailspace. Also what you get if the item disappears between the lookup and the restore itself — see the note below - 422
restore_failed| the mail server answered and refused the restore. Nothing was queued - 503
archived_item_lookup_unavailable| the ownership lookup could not be performed, so nothing was sent to the mail server. The item is untouched - 503
restore_unconfirmed| indeterminate. The restore instruction went out and its fate is unknown, so the restore may already be queued - plus the shared guards, including
403not_authorizedand403pending_delete
Two 503s that promise opposite things
archived_item_lookup_unavailable means nothing was attempted.
restore_unconfirmed means something was attempted and the outcome is unknown
— so re-read the item, or the archive, before retrying rather than assuming the
restore failed.
Note this is the opposite promise from
restore_unavailable on the purged-mailbox
restore, which means nothing happened. Do not generalise across the two
surfaces from the word "unavailable" — read the code.
The vanished-item race answers 404, not 422 — a deliberate wire-contract change
The item id is resolved twice: once by the shared guard before the action
runs, and again inside the restore or the erase itself. An item that
disappears between the two — the realistic case is a client retrying its own
timed-out request — now answers 404 unknown_archived_item, the same code
the guard's own miss returns, because it is the same fact.
It previously answered 422 restore_failed, and 422 delete_failed on
Delete an Archived Item. This was changed
deliberately while these endpoints are not yet live in production: a client
that branches on 422 for a vanished item will not see it any more.
restore_failed and delete_failed keep their 422 for a genuine refusal
from the mail server.
Download an Archived Message
GET /api/mailspace/:mailspace_id/archived_items/:stalwart_id/download
Scope mailspace:read — a GET, so it is a read for both the scope and the
write gates, even though it hands back a whole message.
This is the one endpoint here that does not return JSON
A successful download is the original RFC822 message as raw bytes:
Content-Type: message/rfc822 with an attachment disposition. It is not
base64 inside a JSON envelope — that would inflate the payload and force
every client to decode it. The bytes are proxied through CloudPress rather
than linked straight to the mail server, because the mail server's own
download URL is not scoped to your mailspace.
Failures are still JSON, in the usual {"errors":[...],"code":"..."}
envelope, so branch on the response status before treating the body as a
message.
The attachment filename is derived from the message's own stored subject,
then sanitised so a hostile subject cannot steer the saved file or break out of
the header: every character outside letters, digits, underscores, spaces, hyphens
and dots becomes _, runs of _ collapse to one, a leading dot is dropped, and
surrounding whitespace is trimmed. If that leaves nothing — the message carried
no subject at all, or sanitising emptied the stem — the item id is used
instead, so the filename is never a bare .eml. Whichever of the two is used is
then capped at 80 characters before .eml is appended.
The filename does not vary with Accept-Language. It comes from the subject
the mail server stored, never from a translated "no subject" placeholder, so the
same item downloads under the same name for every client.
curl -o message.eml \
-H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
-H "X-Auth-Account: $ACCOUNT_ID" \
https://my.cloudpress.com/api/mailspace/$MAILSPACE_ID/archived_items/$ITEM_ID/download
Errors
- 404
unknown_archived_item| no such item, or it does not belong to this mailspace - 404 / 503
download_unavailable| the message could not be delivered, and the status is the difference:404when the mail server answered that the stored message is gone (terminal — stop),503for any other read failure (the message may still be there — retry). One code, because it is one fact about one resource; the status carries what to do next - 503
archived_item_lookup_unavailable| the ownership lookup could not be performed - plus the shared guards
Delete an Archived Item
DELETE /api/mailspace/:mailspace_id/archived_items/:stalwart_id
Scope mailspace:write.
Irreversible — there is no second archive behind this one
This erases the archived message and its stored copy ahead of the retention deadline. Nothing is recoverable afterwards and there is no confirmation parameter: the only precondition is that the item belongs to this mailspace.
Every successful erase is written to the platform audit log with the credential that made the call, after the mail server confirms it — a log line for a destruction that did not happen would be worse than none.
An item that is already gone answers 404 unknown_archived_item, not 200 —
the id is resolved against the mail server before anything is deleted, so a
duplicate DELETE and an item purged at its own deadline both land there. This
endpoint is not idempotent from the client's point of view.
Returned Params
- deleted: Boolean | always
true - id: String | the item that was erased
- subject: String |
null— captured before the erase
Errors
- 404
unknown_archived_item| no such item, or it does not belong to this mailspace. Also what you get if the item disappears between the lookup and the erase itself — see the note above - 422
delete_failed| the mail server answered and refused the erase. Nothing was destroyed - 503
archived_item_lookup_unavailable| the ownership lookup could not be performed, so nothing was sent. The item is untouched - 503
delete_unconfirmed| indeterminate, and this erase is irreversible: the item may already be gone - plus the shared guards
On delete_unconfirmed, read the item back before doing anything else
It is the one place on this surface where an unknown outcome cannot be undone,
so treating it as "the delete failed" is the worst of the three available
guesses. GET the item: a 404 means the erase went through.
No audit line is written on either failure. The audit line is written after the mail server confirms the erase, so its absence is not evidence the item survived.
Purged Mailboxes
Mailboxes CloudPress has already permanently deleted — the local record is gone — whose mail the mail server is still holding, because deleting a mail account does not erase anything immediately: it drops the account and schedules an erase task for the end of the server's retention window. Until that task fires, the account can be brought back with its original id.
These are deliberately not part of the mailspace's mailbox list, which omits that bucket entirely — that is why they have their own endpoint.
List Recoverable Mailboxes
GET /api/mailspace/:mailspace_id/purged_mailboxes
Scope mailspace:read.
The purged mailboxes that can still be restored, ordered by recoverable_until
so the one about to be lost comes first. Unpaginated.
The list is cross-checked against the mail server, not served from local state alone: the retention window is a server-wide setting staff can shorten, and an expedited erase removes an account at once — either leaves a local record whose deadline is still in the future while the mail is already gone. A record the mail server no longer holds an erase task for is omitted entirely, because offering a restore that cannot be honoured is worse than not offering one. The cross-check costs two round trips — the erase tasks, and the domain lookup that resolves them — and none at all when there is nothing to check.
A listed mailbox is real; an empty list is not proof the mail is gone
The cross-check is a strict read, so a mail server that cannot be asked
at all answers 503 purged_mailboxes_unavailable rather than an empty
200 — see Read failures. That closes one gap
but not the other.
The erase tasks are read 500 rows at a time across the whole mail server
and filtered down to this mailspace afterwards, and the response carries no
total and no truncation flag. On a busy server this mailspace's tasks can
fall outside that window, so a mailbox the mail server would still restore
can be silently missing from a perfectly healthy 200.
Read the list the way it is safe to read: a record that is listed is
genuinely recoverable, and an empty list means "nothing found", not "the
mail is gone". Never drop your own last record of a mailbox on the strength
of it — if you keep a copy of what was deleted, keep it until the record's own
recoverable_until has passed.
Returned Params
- purged_mailboxes: Array
- id: String | the record's GUID — this is the
:guidsegment Restore a Purged Mailbox takes - email: String | the address that would come back
- display_name: String |
null - purged_at: DateTime | when CloudPress permanently deleted the mailbox
- recoverable_until: DateTime | the mail server's own erase deadline, mirrored at delete time — never a locally computed window
- days_remaining: Integer | whole days left, floored at
0
- id: String | the record's GUID — this is the
The stored quota, aliases, memberships and mail-server account id are deliberately not emitted: they are restore input, not a description of the deleted mailbox.
Errors
- 503
purged_mailboxes_unavailable| the mail server could not be asked which accounts it is still holding, so the list cannot be answered. Retriable, and deliberately not an empty200— a client would read that as "nothing to recover" - plus the shared guards
Restore a Purged Mailbox
PATCH /api/mailspace/:mailspace_id/purged_mailboxes/:guid
Scope mailspace:write.
Cancels the mail server's pending erase and rebuilds the mailbox. The mail server reconstructs the account from the erase task alone, which carries only its name and domain, so everything else is written back from the snapshot CloudPress kept at delete time.
password is required, because a recovered mailbox comes back with no credentials
The mail server hands the account back with no password at all, so this
endpoint sets one. That makes it a credential-handing write: it produces a
working mailbox on an address that receives mail. A missing or blank
password is refused with 400 password_blank before anything is
touched, and every success is written to the platform audit log with the
credential that made the call.
Params
- password: String (required) | the password the restored mailbox comes back with. A blank or absent value is
400password_blank
The mailbox comes back with its display name, and with its stored quota — clamped down rather than refused if the plan no longer has room for it, so a mailbox may come back slightly smaller than it was.
Its aliases are re-applied on the same terms as its memberships below: the
alias write silently drops any address whose domain cannot be resolved, and still
reports success. A 200 is not proof every alias came back — read the mailbox
back and compare.
Group and mailing-list memberships are re-applied, but are not guaranteed
The snapshot's group and mailing-list memberships are written back where
those groups and lists still exist; one that has since been deleted is
skipped, and a failure while re-applying them does not fail the restore.
So a 200 here does not mean the memberships came back. Read the mailbox's
groups and lists afterwards and re-add what is missing.
The record is consumed on success, so a second PATCH of the same :guid
answers 404 unknown_purged_mailbox.
Returned Params
- mailbox: Object | the same shape the mailbox delete and restore endpoints return
- id: String | the restored mailbox's GUID
- email: String
- status: String |
active - scheduled_deletion_at: DateTime |
null - days_until_deletion: Integer |
null
A mailbox can stop being recoverable while it is still listed
Recoverability is the mail server's decision, and it can change between your list call and your restore. Three outcomes are possible, and only the last one is worth retrying:
- The record's own deadline has passed →
409not_recoverable. Nothing was touched, and this cannot be retried into success. - The mail server was asked, and answered, that it no longer holds an
erase task for the account (retention was shortened, or the account was
erased on demand) →
409not_recoverable, and the snapshot is discarded, so the same:guidanswers404unknown_purged_mailboxon a retry and the mailbox drops out of the list. This cannot be retried into success either — the mail is genuinely gone. It shares the code, the status and the wording with the expired-deadline case above, because it is the same fact: terminal, do not retry. - The mail server could not be asked — the pre-flight read failed, or
came back degraded →
503restore_unavailable, and the snapshot is left exactly where it is. Retry it.
That read is deliberately strict, and the snapshot is the only copy of the mailbox's quota, display name, aliases and memberships: a failed read must never be mistaken for "the mail server has erased it" and destroy the record of a mailbox the server would still hand back. So the snapshot is discarded on a successful read only — never on a failed one.
Branch on the status, and the three answer three different questions: 409
means nothing can be done, 422 means the mail server refused and a retry may
work, 503 means the check could not be made and nothing was attempted —
the one failure here that can be stated to have changed nothing. There is no
need to re-read the list to work out which failure you got.
The list is ordered soonest-to-expire first for the same reason: restore in that order.
restore_failed is retriable, and it no longer carries the terminal case
503 restore_unavailable covers the pre-flight read only — the one that
decides whether the mail server is still holding the account. A transport
failure later in the restore, after that decision has been made, still
surfaces as 422 restore_failed, so that code does not prove the mail server
said no. The restore is idempotent, which is why a retry is the right move on
anything that looks transient.
What restore_failed no longer covers is the one case where a retry could
never work: an account the mail server has already erased answers 409
not_recoverable instead. So you no longer have to re-read the list to tell a
retriable 422 from a terminal one.
download_unavailable on
Download an Archived Message remains an
exception to the 503 convention
Read failures describes — it carries both
statuses, 404 for a message the mail server says is gone and 503 for a read
it could not perform.
restore_failed also covers a restore the mail server or the plan refuses:
- the address is in use again by a new mailbox — delete or rename that one first, then restore this one;
- the plan's mailbox limit is already full — delete another mailbox or upgrade first;
- any other refusal from the mail server.
curl -X PATCH \
-H "Authorization: Bearer $CLOUDPRESS_TOKEN" \
-H "X-Auth-Account: $ACCOUNT_ID" \
-H "Content-Type: application/json" \
-d '{"password": "a-strong-password"}' \
https://my.cloudpress.com/api/mailspace/$MAILSPACE_ID/purged_mailboxes/$PURGED_ID
Errors
- 404
unknown_purged_mailbox| no such record in this mailspace, or it has already been consumed - 409
not_recoverable| terminal — do not retry. Either the recovery window has closed, or the pre-flight read succeeded and reported that the mail server is no longer holding the account. On the second path the record is consumed on the way out, so a retry answers404 - 400
password_blank| no password supplied - 422
restore_failed| the mail server answered and refused — see above. Retriable - 503
restore_unavailable| the pre-flight read of the mail server's pending-erase list could not be performed, so nothing was attempted. The record is untouched — retry - plus the shared guards
Error Codes
All errors except the OAuth scope failure use the standard
{"errors": [...], "code": "..."} envelope described in
Error Responses. An unknown or out-of-scope
:mailspace_id is the exception in the other direction: 404 with an empty body
and no envelope at all. On
Download an Archived Message the errors are
JSON even though a success is not.
Shared by every endpoint on this page — see Request Guards:
| Code | Status | Raised on |
|---|---|---|
stalwart_unavailable |
503 | all requests |
not_authorized |
403 | POST, PATCH, DELETE |
mailspace_suspended |
403 | all requests |
pending_delete |
403 | POST, PATCH, DELETE |
not_provisioned |
409 | all requests |
Per endpoint:
| Code | Status | Raised by |
|---|---|---|
archived_items_unavailable |
503 | list archived mail |
purged_mailboxes_unavailable |
503 | list recoverable mailboxes |
unknown_archived_item |
404 | view, restore, download, delete an archived item; also a restore or delete whose item vanishes between the two id lookups |
archived_item_lookup_unavailable |
503 | view, restore, download, delete an archived item — the ownership lookup could not be performed, so nothing was sent |
restore_failed |
422 | restore an archived item; restore a purged mailbox |
restore_unconfirmed |
503 | restore an archived item — the instruction went out and its fate is unknown |
restore_unavailable |
503 | restore a purged mailbox — nothing was attempted |
download_unavailable |
404 / 503 | download an archived message — 404 the message is gone (terminal), 503 any other read failure (retriable) |
delete_failed |
422 | delete an archived item |
delete_unconfirmed |
503 | delete an archived item — the erase went out and may already have happened. Irreversible |
unknown_purged_mailbox |
404 | restore a purged mailbox |
not_recoverable |
409 | restore a purged mailbox |
password_blank |
400 | restore a purged mailbox |
The delivery-log endpoint has no failure codes of its own — only the shared guards.