Webmail at /mail signs in here, then speaks JMAP (RFC 8620 and RFC 8621) at /jmap/api, /jmap/upload/{accountId} and /jmap/download/{accountId}/{blobId}/{name} with the session cookie instead of HTTP Basic. Mail apps keep using Basic; a request with an Authorization header is judged by that header alone.
The cookie is __Host-zappocity_mail (zappocity_mail without Secure in dev): HttpOnly, Secure, SameSite=Strict, Path=/. It lasts 7 days and ends after 24 hours without use. Only its SHA-256 is stored.
CSRF. Every JMAP request made with the cookie, and DELETE /api/mail/session, must carry the session's csrfToken in X-CSRF-Token; without it the answer is 403. GET /api/mail/session needs only the cookie and returns the token. No CORS headers are ever sent, so other sites cannot read it.
Who may sign in. Any active account on an active tenant whose plan includes the webmail entitlement. The account and plan are checked again on every request, so suspending an account or tenant, or removing webmail from the plan, ends its sessions at once. A new password signs the account out everywhere. Failed sign-ins share the throttle with IMAP, SMTP and Basic JMAP: 10 failures per address or source in 15 minutes answer 429.
Sending uses JMAP Identity/get, Email/set (create) and EmailSubmission/set under the urn:ietf:params:jmap:submission capability; see docs/kb/webmail.md. Errors use RFC 9457 problem details.
Passwords. A signed-in user changes their password, or sets a recovery address outside the platform, with their current password. A new recovery address counts only once the link mailed to it is opened. A signed-out user can ask for a reset link, which goes only to the confirmed recovery address. Links put a one-time token in the URL fragment (#reset=... or #verify=...); a reset link lasts an hour, a confirmation link 24 hours, and an account gets at most 3 of each per hour. See docs/kb/password-reset.md.
Two-step sign-in. A user can turn on codes from an authenticator app (TOTP, RFC 6238: SHA-1, 6 digits, 30 seconds). Signing in then takes two calls: the password alone answers 401 with twoFactorRequired: true, and the same request again with code signs in. A code is a 6-digit TOTP code, used once, or one of ten single-use recovery codes. A wrong code counts as a failed sign-in for the throttle. With two-step sign-in on, IMAP, SMTP, ManageSieve and JMAP Basic refuse the account password and take app passwords instead. Turning it on, off, or making new recovery codes or app passwords needs the current password; turning it on also needs a code from the new secret. The secret is sealed with the server key. A notice goes to the recovery address for each change. An administrator can turn it off with DELETE /v1/tenants/{tenantId}/accounts/{accountId}/two-factor. See docs/kb/two-step-sign-in.md.
General
/api/mail/aliases
The signed-in user's aliases
The user's aliases, how many they may have (max), how many of those may be on shared domains (maxShared, with usedShared already there), whether they may add their own (canAdd, the self_aliases entitlement) and the domains they may put them on: their tenant's domains and the shared domains open for signup. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The aliases.{ "aliases": [ "sales@acme.example" ], "canAdd": true, "domains": [ "acme.example", "mailo.city" ], "max": 10, "maxShared": 3, "usedShared": 0 }
/api/mail/aliases
Add an alias for the signed-in user
For plans with self_aliases. The name before @ may use letters, digits, '.', '_' and '-' (no '+'). Within the plan's aliases per user (or the user's own limit) and the tenant's total; on a shared domain, reserved names are refused. The alias can be sent from at once.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
201Added.403Not part of the plan.409Taken422Not a valid name
/api/mail/aliases/{address}
Remove one of the signed-in user's aliases
| Parameter | In | Type | |
|---|---|---|---|
address * | path | string | |
X-CSRF-Token * | header | string |
204Removed.403Not part of the plan.404The user has no such alias.
/api/mail/app-passwords
Make an app password
A password for one mail app: 16 letters, shown only now. It signs in to IMAP, SMTP, ManageSieve and JMAP Basic, never to webmail. At most 25 per account. Changing the account password deletes them all.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"currentPassword": "correct horse battery",
"name": "Phone mail app"
}
201Made.403Wrong current password409The account already has 25.422Missing or too long name.429Too many wrong passwords.
/api/mail/app-passwords/{id}
Remove an app password
Mail apps signed in with it are signed out within a minute.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string | |
id * | path | string |
204Removed.404No such app password on this account.
/api/mail/bimi/{domain}
Load a sender's BIMI logo
For a domain named in an Email's bimiDomain property. Needs the session cookie (it is used in <img>, so no CSRF token). The server fetches the logo from the domain's BIMI record over https on port 443 only, through the same public-address-only dialer as remote images, rebuilds the SVG keeping only shapes and gradients (32 KB at most) and caches it for a day. Answered only for accounts that received mail marked with the domain.
| Parameter | In | Type | |
|---|---|---|---|
domain * | path | string |
200The logo, as image/svg+xml with a sandboxing Content-Security-Policy.401Not signed in.404No usable logo
/api/mail/calendar-links
Your calendar subscription links, and the calendars you can make one for
Subscription links are secret iCalendar addresses of one of your own calendars that any calendar app can subscribe to, for people without a Mailocity account. detail is full or busy (times only, every event called "Busy"). Events marked private show as busy in either; secret ones are left out.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The links (without their addresses) and your calendars.{ "calendars": [ { "id": "K12", "name": "Work" } ], "items": [ { "calendarId": "K12", "calendarName": "Work", "createdAt": "2026-10-08T15:00:00Z", "detail": "busy", "id": "3d0c8e52-7a41-4f7e-b6a1-0c2e9d5f8a34", "label": "For clients", "lastUsed": null } ] }
/api/mail/calendar-links
Make a subscription link
The answer has the link's url, shown this once: only a hash of it is kept. At most 50 links each. Not while in someone else's mailbox.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"calendarId": "K12",
"detail": "busy",
"label": "For clients"
}
201The link, with its address.{ "calendarId": "K12", "calendarName": "Work", "createdAt": "2026-10-08T15:00:00Z", "detail": "busy", "id": "3d0c8e52-7a41-4f7e-b6a1-0c2e9d5f8a34", "label": "For clients", "lastUsed": null, "url": "https://mail.mailo.city/cal/cs_cal_Qm9vZ3JhZ3V0aW5nIHRoZSBjYWxlbmRhciBmZWVk.ics" }403In someone else's mailbox.422Not one of your calendars
/api/mail/calendar-links/{id}
Revoke a subscription link
The address stops working at once.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Revoked.404You have no link with that id.
/api/mail/client-settings
What to enter in a mail app, with the signed-in user's username
The IMAP, SMTP (submission), ManageSieve and JMAP settings for mail apps, and the CalDAV and CardDAV settings for calendar and contacts apps (calDav, cardDav). appPassword is true when two-step sign-in is on, so mail apps need an app password rather than the account password. Ports and security follow the server's configuration (None only on a server without TLS, such as a development one). With TLS, smtp is submission over implicit TLS (465) and altPort/altSecurity name STARTTLS on 587 for networks that block 465.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Mail app settings.{ "appPassword": false, "autoconfigUrl": "https://mail.mailo.city/.well-known/autoconfig/mail/config-v1.1.xml?emailaddress=bob@acme.test", "calDav": { "calendarsUrl": "https://mail.mailo.city/dav/6f1c2b9e-0d4a-4c71-9a55-2d7e8f3b1a20/calendars/", "server": "mail.mailo.city", "serverUrl": "https://mail.mailo.city/.well-known/caldav", "username": "bob@acme.test" }, "cardDav": { "contactsUrl": "https://mail.mailo.city/dav/6f1c2b9e-0d4a-4c71-9a55-2d7e8f3b1a20/contacts/", "server": "mail.mailo.city", "serverUrl": "https://mail.mailo.city/.well-known/carddav", "username": "bob@acme.test" }, "imap": { "authentication": "Normal password", "host": "mail.mailo.city", "port": 993, "security": "SSL/TLS", "username": "bob@acme.test" }, "jmap": { "sessionUrl": "https://mail.mailo.city/jmap/session", "username": "bob@acme.test", "webSocketUrl": "wss://mail.mailo.city/jmap/ws", "wellKnownUrl": "https://mail.mailo.city/.well-known/jmap" }, "manageSieve": { "authentication": "Normal password", "host": "mail.mailo.city", "port": 4190, "security": "STARTTLS", "username": "bob@acme.test" }, "smtp": { "altPort": 587, "altSecurity": "STARTTLS", "authentication": "Normal password", "host": "mail.mailo.city", "port": 465, "security": "SSL/TLS", "username": "bob@acme.test" }, "username": "bob@acme.test" }
/api/mail/domain-blocks
The user's domain blocks, and their team's
Mail from other servers that a block matches is refused or filed in Junk: domain matches the sender's domain and its subdomains, mx the mail servers of the sender's domain, and named any domain the message names (From, Sender, Reply-To, links). items are the user's own; team are the team's, which apply too but only team admins change. See docs/kb/domain-blocking.md.
200The blocks.{ "items": [ { "action": "junk", "createdAt": "2026-10-09T12:00:00Z", "createdBy": "bob@acme.test", "id": 12, "kind": "domain", "note": "", "value": "pest.example" } ], "team": [ { "action": "reject", "createdAt": "2026-10-09T11:00:00Z", "createdBy": "admin@acme.test", "id": 7, "kind": "named", "note": "Invoice scam", "tenantId": "0b9f5a52-1f7e-4b0e-8d3c-5e2a9c6d4f10", "value": "phish.example" } ] }
/api/mail/domain-blocks
Block a domain for the signed-in user
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"action": "junk",
"kind": "mx",
"value": "bulkhost.example"
}
201The block.400Not the JSON above.409That is blocked already.422Not a domain
/api/mail/domain-blocks/{id}
Remove one of the user's own blocks
| Parameter | In | Type | |
|---|---|---|---|
id * | path | integer | |
X-CSRF-Token * | header | string |
204Removed.404Not one of the user's own blocks.
/api/mail/image
Load one remote image through the server
Follows a link from POST /api/mail/images; no cookie is needed because the link is signed. The server fetches the image itself, so the sender sees the server's address, not the reader's. Only public internet addresses are contacted (redirects included, at most 3), only PNG, JPEG, GIF, WebP, AVIF, BMP and ICO are passed on (never SVG), up to 10 MB within 15 seconds.
| Parameter | In | Type | |
|---|---|---|---|
u * | query | string | The image URL. |
a * | query | string | The account the link was made for. |
e * | query | integer | Expiry |
s * | query | string | Signature. |
200The image.403The link is altered or expired502The image could not be fetched or is not an allowed image.
/api/mail/images
Get links that load a message's remote images through the server
Webmail calls this when the reader presses Load images. Each http or https URL on port 80 or 443 gets a signed /api/mail/image link for the signed-in account, valid for an hour (or until the server restarts). Other URLs are left out and stay blocked. Needs the session cookie and X-CSRF-Token.
{
"urls": [
"https://img.example.com/logo.png"
]
}
200Proxy links keyed by the original URL.{ "links": { "https://img.example.com/logo.png": "/api/mail/image?a=...\u0026e=...\u0026s=...\u0026u=https%3A%2F%2Fimg.example.com%2Flogo.png" } }400Not the expected JSON401Not signed in.403Missing or wrong CSRF token.
/api/mail/impersonate
Open a support session from a one-time link
The token from a link made with POST /v1/tenants/{tenantId}/accounts/{accountId}/impersonate. Answers like a sign-in, with impersonatedBy and impersonationReason, and sets the cookie for a one-hour session that cannot change sign-in settings (403 there).
201The session.400The link was used or is more than five minutes old.403The account cannot sign in.
/api/mail/imports
The user's imports from other mail servers
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Newest first; fields as MailImport in api/openapi.yaml.{ "items": [ { "foldersDone": 1, "foldersTotal": 6, "host": "imap.example.com", "id": "9c1e...", "messagesDone": 120, "messagesTotal": 900, "status": "running", "username": "amy@example.com" } ] }
/api/mail/imports
Import mail from another server over IMAP
Copies every remote folder in the background: INBOX, Sent, Drafts, Junk, Trash and Archive (by their special-use flags or usual names) into the matching folders here, other folders into folders of the same name. Read, starred, answered and draft marks and the received date come along; deleted messages and virtual folders (All Mail, Starred) are left out. An import resumes where it stopped and skips messages already here (same Message-ID and size), so running it again copies only what is new. It stops when the mailbox is full. One import per account at a time; the user is emailed when it ends.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"host": "imap.gmail.com",
"password": "app password",
"skipFolders": [
"[Gmail]/Spam"
],
"username": "amy@gmail.com"
}
201Queued.409An import is already queued or running.422A value is not valid.
/api/mail/imports/{id}
Cancel a queued or running import
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Cancelled; what was copied stays.404No such import.409It has already ended.
/api/mail/invites
The signed-in user's invitations
How many invitation links the user can still make (after adding what the drip has brought), when more arrive, and the links they made with their status. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The allowance and the links.{ "allowance": { "enabled": true, "left": 1, "max": 5, "nextTopUp": "2026-11-06T20:17:28Z", "perPeriod": 2 }, "invitations": [ { "code": "JsR4bKVKV51X3IFseJvu2g", "createdAt": "2026-10-07T21:17:24Z", "expiresAt": "2026-10-21T21:17:24Z", "link": "https://invite.zappo.city/?invite=JsR4bKVKV51X3IFseJvu2g", "note": "for dave", "plan": "free", "revokedAt": null, "status": "open", "usedAt": null, "usedBy": null } ] }
/api/mail/invites
Make one invitation link
Uses up one of the user's invitations. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
201The new invitation403No invitations left
/api/mail/invites/{code}
Cancel one of the user's unused links
The invitation goes back to the user's allowance. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
code * | path | string | |
X-CSRF-Token * | header | string |
204Cancelled.404No unused invitation of the user's has that code.
/api/mail/later
The signed-in user's snoozed messages and muted conversations
snoozed maps each snoozed message's JMAP id to when it comes back; muted lists the JMAP thread ids of muted conversations. Scheduled sends are JMAP: EmailSubmission/get with undoStatus "pending".
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Snoozes and mutes.{ "muted": [ "T17" ], "snoozed": { "E42": "2026-10-09T08:00:00Z" } }
/api/mail/login-history
The user's recent sign-ins to every service
Up to 200, newest first, within the days the platform keeps (days).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The sign-ins.{ "days": 90, "items": [ { "at": "2026-10-08T11:59:00Z", "id": 7, "ip": "192.0.2.10", "login": "alice@example.com", "reason": "password", "service": "imap", "success": false, "userAgent": "" } ] }
/api/mail/mute
Mute or unmute a conversation
New mail in a muted conversation is marked read and filed in Archive instead of the inbox, on whichever MX server receives it. Mail already there is not moved.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"muted": true,
"threadId": "T17"
}
204Changed.400Not the JSON above.404No such conversation in the user's account.
/api/mail/password
Change the signed-in user's password
Needs the cookie and X-CSRF-Token. Every session ends, mail apps included; this browser gets a new session cookie and CSRF token in the answer. A notice goes to the recovery address if there is one.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Changed. Sets a new session cookie.401Not signed in.403Wrong current password422The new password is too short or too long.429Too many wrong passwords.
/api/mail/password-reset
Ask for a password reset link
Always answers 202 the same way, whether or not the account exists or has a recovery address, so the answer does not reveal accounts. Suspended accounts get no link. At most 10 requests per source address and per account address an hour.
202A link was sent if the account has a recovery address.415Not JSON.429Too many requests.
/api/mail/password-reset/confirm
Set a new password from a reset link
The token from #reset= works once, for an hour, and only while the account is active and its recovery address is unchanged. Every session ends; sign in with the new password.
200The password is set.{ "address": "alice@example.com" }400The link expired or was already used.422The new password is too short or too long; the link still works.
/api/mail/plus-mode
Choose what happens to the user's name+tag@ mail
folders files it in a folder named after the tag (only with the plus_folders entitlement), inbox keeps it in the inbox, refuse turns it away as an unknown address, and an empty string follows the domain's setting.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"mode": "refuse"
}
204Saved.400Not the JSON above.403`folders` without the plus_folders entitlement.422Not one of the modes.
/api/mail/recovery
Set or remove the recovery address
Needs the cookie, X-CSRF-Token and the current password. An empty email removes the recovery address at once. Any other address gets a confirmation link and replaces the old one only when the link is opened.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"currentPassword": "correct horse battery",
"email": "alice.personal@example.net"
}
200Removed.202Confirmation link sent.{ "pending": "alice.personal@example.net", "recoveryEmail": null }403Wrong current password422Not one plain address429Too many wrong passwords502The confirmation could not be sent.
/api/mail/recovery/verify
Confirm a recovery address from its link
No session needed; the token from #verify= proves the mail arrived.
200Confirmed.{ "address": "alice@example.com", "recoveryEmail": "alice.personal@example.net" }400The link expired or was already used.
/api/mail/report
Report messages as spam or phishing
Each message is copied into a report for the team's administrators (and the platform operator) and moved to Junk, which also teaches the spam filter. Messages not in the user's account are skipped. Up to 100 at a time and 200 a day.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"emailIds": [
"E42"
],
"kind": "phishing"
}
200How many were reported.{ "junkId": "M4", "reported": 1 }400Not the JSON above.429Too many reports today.
/api/mail/security
Two-step sign-in state and app passwords
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The signed-in user's security settings.401Not signed in.403Missing or wrong CSRF token.
/api/mail/security-alerts
Which security events the user is emailed about
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The choices.{ "failed": true, "newSignin": true }
/api/mail/security-alerts
Choose which security events are emailed
newSignin: a sign-in from an address the account was not used from before. failed: repeated wrong passwords and lockouts. Alerts go to the account and its recovery address.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Saved.
/api/mail/send
Send a plain-text message as a mailbox, with its API token
For other services' own mail (Zappocity's confirmation and reset links). Authenticate with Authorization: Bearer cs_mt_..., a mailbox API token with the send scope, made in the admin panel. The message is from the mailbox's address and passes the same checks and limits as mail its user sends (the plan's sending limits apply), and it is archived like it; it is not filed in Sent. Each recipient gets a message of their own. The same tokens with the jmap scope sign in to JMAP as the mailbox, as Authorization: Bearer.
{
"fromName": "Zappocity",
"subject": "Confirm your email address",
"text": "Open this link...",
"to": [
"amy@example.com"
]
}
202Sent (or queued for other domains).{ "sent": 1 }400Not JSON with the documented fields.401No mailbox API token with the send scope422Too many recipients, a header on two lines, or the mailbox may not send this (its limits, for example).
/api/mail/session
The signed-in account and its CSRF token
Needs the cookie only.
200Signed in.401No session
/api/mail/session
Sign in to webmail
Body must be application/json; anything else answers 415. When the password is a temporary one an administrator set, the session says passwordTemporary: true, and every other call answers 403 with passwordChangeRequired: true until a new password is set with POST /api/mail/password.
{
"address": "alice@example.com",
"password": "correct horse battery"
}
201Signed in. Sets the session cookie.401Wrong address or password, or the account cannot sign in. The same answer for unknown addresses. With the right password for an account with two-step sign-in, the problem has `twoFactorRequired: true`: send the request again with `code`.{ "detail": "Enter the code from your authenticator app", "status": 401, "twoFactorRequired": true, "type": "about:blank" }403The password was right but the plan does not include webmail.415Not JSON.429Too many failed sign-ins.
/api/mail/session
Sign out
Ends the session on the server and clears the cookie. Needs X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Signed out.401No session.403Missing or wrong CSRF token.
/api/mail/sessions
Where the user is signed in to webmail
Each session's address, browser, start and last use; current marks the one asking. id is a short hash, never the token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The sessions.{ "items": [ { "createdAt": "2026-10-08T09:00:00Z", "current": true, "expiresAt": "2026-10-15T09:00:00Z", "id": "3f2a9c1b7d4e5f60", "ip": "192.0.2.10", "lastSeen": "2026-10-08T12:00:00Z", "userAgent": "Mozilla/5.0 ..." } ] }
/api/mail/sessions/{id}
Sign out one session, or every other one
The id others signs out every session but the one asking.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Signed out.404No such session of the user.
/api/mail/snooze
Snooze messages until a time
Moves each message out of mailboxId to the Snoozed folder (made when first needed) and brings it back to mailboxId, unread, at until, which must be in the next year. Messages not in the user's account are skipped.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"emailIds": [
"E42"
],
"mailboxId": "M1",
"until": "2026-10-09T08:00:00Z"
}
200Snoozed.{ "mailboxId": "M7", "snoozedUntil": "2026-10-09T08:00:00Z" }400Not the JSON above.422`until` is not in the next year, or `mailboxId` is not one of the user's folders.
/api/mail/snooze/{emailId}
Bring a snoozed message back now
| Parameter | In | Type | |
|---|---|---|---|
emailId * | path | string | |
X-CSRF-Token * | header | string |
204Back in its folder404No such message
/api/mail/spam-policy
The user's spam settings and what applies to them
junkScore, allow and block are the user's own; effectiveJunkScore and rejectScore are what applies after the team's and the platform's settings. enabled says whether spam filtering is on at all.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The settings.{ "allow": [ "news.example" ], "block": [], "effectiveJunkScore": 6, "enabled": true, "junkScore": null, "rejectScore": 15 }
/api/mail/spam-policy
Replace the user's own junk score and sender lists
junkScore (null for the team's) is the score at which mail goes to Junk. allow and block hold up to 1000 addresses or domains each: allowed senders are never filed in Junk as spam, blocked ones always are.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"allow": [
"news.example"
],
"block": [
"spammy.example"
],
"junkScore": 5
}
204Saved.400Not the JSON above.422A list entry is not an address or domain
/api/mail/sso
Which ways of signing in to webmail are open
200Whether Sign in with Zappocity is offered, and whether the password sign-in still works.{ "passwordSignin": true, "sso": true }
/api/mail/sso/callback
Where Zappocity sends the browser back
| Parameter | In | Type | |
|---|---|---|---|
code | query | string | |
state | query | string |
302Signed in, to next; to /mail#sso-code when the mailbox has two-step sign-in; or to /mail#sso-error=... (expired, unverified, no-mailbox, suspended, not-in-plan).
/api/mail/sso/code
Finish signing in with Zappocity with the mailbox's two-step code
201Signed in; `session` is as from POST /api/mail/session, `next` where to go.401Wrong code (twoFactorRequired is true), or the sign-in has expired.429Too many failed attempts.
/api/mail/sso/start
Sign in to webmail (or the team manager) with Zappocity
Sends the browser to Zappocity's sign-in; it comes back to /api/mail/sso/callback. The mailbox linked to the Zappocity account (the one with its email as address first) is signed in, when Zappocity has confirmed the email. next is /mail or /manage.
| Parameter | In | Type | |
|---|---|---|---|
next | query | string |
302To Zappocity
/api/mail/tickets
The user's tickets
teamAdmins says the team has other owners or admins to ask.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Their tickets, as Ticket in api/openapi.yaml.{ "items": [ { "id": 12, "priority": "normal", "queue": "team", "status": "pending", "subject": "Phone will not sync" } ], "teamAdmins": true }
/api/mail/tickets
Ask the team's admins or Zappocity support
A team ticket emails the team's owners and admins (up to 20 open tickets per user). to: platform opens a request on the Zappocity help desk instead, as the user, and answers zappocityRef (ZC-41) and url, where the user follows it on their Zappocity account; the user is emailed. On a plan with priority support it starts at high priority.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
201The team ticket, or for Zappocity support its number and link.{ "url": "https://zappo.city/account#ticket=41", "zappocityRef": "ZC-41" }422A value is missing.429Too many open tickets.
/api/mail/tickets/{id}
One of the user's tickets with its replies (never internal notes)
| Parameter | In | Type | |
|---|---|---|---|
id * | path | integer | |
X-CSRF-Token * | header | string |
200The ticket and its messages.404Not one of the user's tickets.
/api/mail/tickets/{id}/close
Close one of the user's tickets
Abuse tickets are closed by the platform's staff only (403).
| Parameter | In | Type | |
|---|---|---|---|
id * | path | integer | |
X-CSRF-Token * | header | string |
204Closed.403An abuse ticket.
/api/mail/tickets/{id}/messages
Reply on a ticket; it opens again
| Parameter | In | Type | |
|---|---|---|---|
id * | path | integer | |
X-CSRF-Token * | header | string |
204Sent.409The ticket is closed.
/api/mail/trackers
Make a read tracker for a message about to be sent
For plans with the read_tracking entitlement (see readTracking in the session). Returns a token and the URL of a 1x1 image to put in the message's HTML; each load of that image counts as an open. At most 1,000 per account per day. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The new tracker.{ "token": "qOgc5Ee4_GC8gG98OT-Sew", "url": "https://mail.example.com/t/qOgc5Ee4_GC8gG98OT-Sew.gif" }403The plan does not include read tracking.429Too many trackers made today.
/api/mail/trackers/stats
Get the open counts of the signed-in user's trackers
Tokens that are not the account's own are left out. proxiedOpens counts loads through a mail provider's image proxy (Gmail, Yahoo), which can happen without anyone reading. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Counts keyed by token.{ "trackers": { "qOgc5Ee4_GC8gG98OT-Sew": { "createdAt": "2026-10-07T20:50:30Z", "firstOpen": "2026-10-07T20:50:32Z", "lastOpen": "2026-10-07T20:50:32Z", "opens": 1, "proxiedOpens": 0, "token": "qOgc5Ee4_GC8gG98OT-Sew" } } }400Not the expected JSON
/api/mail/two-factor/disable
Turn two-step sign-in off
The password alone signs in again, in webmail and mail apps. Recovery codes are deleted; app passwords stay.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Off.403Wrong current password429Too many wrong passwords.
/api/mail/two-factor/enable
Turn two-step sign-in on
Takes a code from the secret made by setup. Returns ten recovery codes, shown only now. Every other session ends, mail apps included; from now on mail apps need app passwords.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"code": "492039"
}
200On.409No setup is waiting422The code is wrong.429Too many wrong codes.
/api/mail/two-factor/recovery-codes
Replace the recovery codes
The old codes stop working. The new ones are shown only now.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200New codes.403Wrong current password409Two-step sign-in is off.429Too many wrong passwords.
/api/mail/two-factor/setup
Make a new authenticator secret
Makes a new secret and returns it as text, an otpauth URI and a QR code. Signing in does not change until enable confirms a code from it. Calling it again replaces the secret that is waiting.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The new secret. Shown only now.403Wrong current password409Two-step sign-in is already on.429Too many wrong passwords.503The server has no key to seal secrets with.
/api/mail/unsubscribe
Unsubscribe from a mailing list message
For one of the user's messages with a List-Unsubscribe header (Email/get's listUnsubscribe extension says what it offers). When it offers RFC 8058 one-click, the server POSTs List-Unsubscribe=One-Click to the https link itself, through the same public-address-only dialer as remote images, and answers done. Otherwise it returns the mailto address (and subject) or the web page for the user to use. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Done, or what to do instead.{ "done": false, "mailto": "leave@list.example", "subject": "unsubscribe", "url": "https://list.example/u/123" }404No such message
/api/mail/usage
Storage and sending used by the signed-in user
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Use and limits.{ "quotaBytes": 32212254720, "sendPerDay": 1000, "sentLastDay": 3, "storageBytes": 16179 }
/t/{file}
The read-tracking image
Public: loaded by the recipient's mail app. file is {token}.gif. Counts an open when the token exists and always answers with the same transparent 1x1 GIF, uncached. Only the count and times are kept, never the reader's address or browser.
| Parameter | In | Type | |
|---|---|---|---|
file * | path | string |
200A 1x1 transparent GIF.