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/forum/forums
The forums the reader can see
The platform's forums, and for a signed-in member (with the cookie and X-CSRF-Token) their team's. No sign-in is needed to read public forums; a request without the token is treated as signed out.
200The forums.{ "moderator": false, "platform": [ { "closed": false, "id": "6f1c2a9e-1b0e-4f3a-9a52-2f4d1e0c7b11", "name": "General", "slug": "general", "topics": 4, "visibility": "public" } ], "signedIn": true, "team": [], "teamCanHaveForums": false }
/api/forum/forums/{forumId}
A forum and its topics (pinned first, then the latest)
| Parameter | In | Type | |
|---|---|---|---|
forumId * | path | string |
200The forum, its topics, and whether the reader may start one or moderate.{ "canPost": true, "forum": { "name": "General" }, "moderator": false, "topics": [ { "author": "Pat", "hidden": false, "id": 7, "lastPostAt": "2026-10-09T12:00:00Z", "locked": false, "mine": false, "pinned": true, "posts": 3, "title": "Welcome" } ] }404No such forum
/api/forum/forums/{forumId}/topics
Start a topic
Posts show the author's display name (or the part of the address before @), never the address. 30 posts an hour at most.
| Parameter | In | Type | |
|---|---|---|---|
forumId * | path | string | |
X-CSRF-Token * | header | string |
{
"body": "The office is closed this **Friday**.",
"title": "Office closed Friday"
}
201The topic.401Not signed in.403Not allowed to start topics here (not a member404No such forum.422A title (up to 200 characters) and a first post (up to 20000) are needed.429Too many posts in the last hour.
/api/forum/posts/{postId}
Edit or hide a post
Its author edits it (not in a locked topic) or hides it; moderators hide or show any post in their team's forums.
| Parameter | In | Type | |
|---|---|---|---|
postId * | path | integer | |
X-CSRF-Token * | header | string |
204Done.403Not the author or a moderator.404No such post.
/api/forum/teams/{tenantId}
A team's forums the reader can see
Its public forums, or all of them for a signed-in member (with the cookie and X-CSRF-Token), while its plan has forums.
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string |
200The forums.{ "forums": [ { "id": "6f1c2a9e-1b0e-4f3a-9a52-2f4d1e0c7b11", "name": "Announcements", "topics": 2, "visibility": "public" } ], "member": false, "signedIn": false }
/api/forum/topics/{topicId}
A topic and its posts
Posts are Markdown. A hidden post's text shows only to moderators.
| Parameter | In | Type | |
|---|---|---|---|
topicId * | path | integer |
200The topic.{ "canReply": true, "moderator": false, "posts": [ { "author": "Pat", "body": "Hello **all**", "createdAt": "2026-10-09T12:00:00Z", "editedAt": null, "hidden": false, "id": 21, "mine": true } ], "topic": { "id": 7, "title": "Welcome" } }404No such topic
/api/forum/topics/{topicId}
Pin, lock or hide a topic (the team's owners and admins)
| Parameter | In | Type | |
|---|---|---|---|
topicId * | path | integer | |
X-CSRF-Token * | header | string |
{
"locked": false,
"pinned": true
}
204Done.403Not a moderator.404No such topic.
/api/forum/topics/{topicId}/posts
Reply in a topic
| Parameter | In | Type | |
|---|---|---|---|
topicId * | path | integer | |
X-CSRF-Token * | header | string |
{
"body": "Thanks!"
}
201The post.403Not allowed to reply here404No such topic.422A reply is 1 to 20000 characters.429Too many posts in the last hour.
/api/mail/account/delete
Whether the mailbox is to be deleted, and how to confirm a deletion
deleteAfter is when the mailbox goes (null: it does not); graceDays is the wait a new deletion gets; confirmWith is password, or address for a mailbox signed in to with a Zappocity account (which has no password here). Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The deletion state.{ "confirmWith": "password", "deleteAfter": null, "graceDays": 14, "requestedBy": "" }401Not signed in.
/api/mail/account/delete
Delete my mailbox
After graceDays the mailbox and everything in it are deleted; until then it works as before and DELETE cancels. With a grace period of 0 it goes at once and the session ends (204). Not from a session staff opened as the user.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Scheduled.{ "deleteAfter": "2026-10-23T12:00:00Z", "requestedBy": "pat@mailo.city (themselves)" }204Deleted at once.401Not signed in.403Wrong password or address409Under a legal hold429Too many wrong passwords.
/api/mail/account/delete
Keep my mailbox
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204No longer to be deleted.401Not signed in.
/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. all lists the aliases of every mailbox the person owns in this tenant, with the one each delivers to, and mailboxes the mailboxes an alias may deliver to (only the current one unless the session was opened through Zappocity). Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The aliases.{ "aliases": [ "sales@acme.example" ], "all": [ { "accountId": "4f1c2a3b-5d6e-4f70-8a91-b2c3d4e5f607", "address": "sales@acme.example", "deliversTo": "amy@acme.example" } ], "canAdd": true, "domains": [ "acme.example", "mailo.city" ], "mailboxes": [ { "accountId": "4f1c2a3b-5d6e-4f70-8a91-b2c3d4e5f607", "address": "amy@acme.example", "current": true } ], "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}
Make an alias deliver to another of the person's mailboxes
| Parameter | In | Type | |
|---|---|---|---|
address * | path | string | |
X-CSRF-Token * | header | string |
200Moved: { address, accountId }.403Not part of the plan of either mailbox.404None of the person's mailboxes has that alias.409The target mailbox has reached its limit of aliases.422Not one of the person's mailboxes in this tenant.
/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, CalDAV/CardDAV and JMAP Basic, never to webmail. At most 25 per account. Changing the account password deletes them all. Confirmed with currentPassword, or, when GET /api/mail/security says appPasswordConfirm is zappocity, by a Zappocity confirmation in the last 10 minutes (currentPassword is then ignored; without one, 403 with confirmUrl).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"currentPassword": "correct horse battery",
"name": "Phone mail app"
}
201Made.403Wrong current password; no recent Zappocity confirmation (confirmUrl says where); or missing CSRF token.409The 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-feeds
Calendars you subscribe to from elsewhere
Calendars added by their iCalendar address (Google Calendar's secret address, a calendar published from Outlook, a webcal or .ics link). The address itself is never shown again, only its host; it is kept sealed. Each is fetched every six hours; error says why the last fetch failed, if it did.
200The subscriptions.{ "items": [ { "calendarId": "K31", "color": "#10b981", "createdAt": "2026-10-21T09:00:00Z", "error": "", "events": 24, "host": "calendar.google.com", "name": "Holidays", "nextAt": "2026-10-21T15:00:00Z", "refreshedAt": "2026-10-21T09:00:00Z" } ] }
/api/mail/calendar-feeds
Subscribe to a calendar by its address
Makes a read-only calendar of yours and fetches the address at once; the answer says how it went (a failed first fetch keeps the subscription, with error set). webcal:// is fetched as https://. Only public web servers on ports 80, 443, 8080 and 8443 are reached, also after redirects; addresses with a user name or password are refused. Of each event only what a calendar shows is kept: guests, organizer, alerts and attachments are left out, so a feed never becomes an invitation. It does not count in your free and busy times. At most 20 subscriptions, and ten fetches you ask for an hour. Not while in someone else's mailbox.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"color": "#10b981",
"name": "Holidays",
"url": "webcal://calendar.example.com/holidays.ics"
}
201Subscribed; the subscription as listed.{ "calendarId": "K31", "color": "#10b981", "createdAt": "2026-10-21T09:00:00Z", "error": "", "events": 24, "host": "calendar.example.com", "name": "Holidays", "nextAt": "2026-10-21T15:00:00Z", "refreshedAt": "2026-10-21T09:00:00Z" }422Not an address that can be subscribed to429Too many fetches this hour.
/api/mail/calendar-feeds/{id}
Replace a subscription's address
For when the calendar's owner reset its secret address. Fetched at once, as when subscribing. Rename it, recolour it or unsubscribe with JMAP Calendar/set (destroying the calendar ends the subscription).
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | The calendar's JMAP id. |
X-CSRF-Token * | header | string |
{
"url": "https://calendar.example.com/new-secret/basic.ics"
}
200Changed; the subscription as listed.404No such subscription of yours.422Not an address that can be subscribed to.429Too many fetches this hour.
/api/mail/calendar-feeds/{id}/refresh
Fetch a subscribed calendar now
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | The calendar's JMAP id. |
X-CSRF-Token * | header | string |
200Fetched; the subscription as listed404No such subscription of yours.429Fetched less than five minutes ago
/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/calendar-looks
How calendars look to you
Your own colour and icon for calendars, by calendar id, and the icons there are. A colour here is yours only (for a team's calendar, or one shared with you); your own calendars' colour for everyone is the JMAP Calendar color.
200The looks.{ "icons": [ "calendar-days", "briefcase", "house", "users", "heart" ], "items": { "K12": { "color": "", "icon": "briefcase" }, "K40": { "color": "#ef4444", "icon": "heart" } } }
/api/mail/calendar-looks/{id}
Set how a calendar looks to you
For any calendar you see: your own, your team's or one shared with you (404 otherwise). color is #rrggbb or empty, icon one of the listed icons or empty; both empty go back to the calendar's own. Nobody else sees it. Not while in someone else's mailbox.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | The calendar's JMAP id. |
X-CSRF-Token * | header | string |
{
"color": "#10b981",
"icon": "briefcase"
}
204Saved.404No such calendar that you see.422Not a colour or not one of the icons.
/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/contact-photo/{email}
The photo of the user's contact with this address
For an img element, so without the CSRF header (the session cookie is SameSite=Strict). Only JPEG, PNG, GIF and WebP photos kept in the card are served.
| Parameter | In | Type | |
|---|---|---|---|
email * | path | string |
200The image.404No contact with this address has a photo.
/api/mail/contact-photos
The addresses of the user's contacts that have a photo
For the message list's sender pictures. Photos are kept in the cards (JSContact media of kind photo).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The addresses, lower case.{ "emails": [ "amy@example.com" ] }
/api/mail/default-from
The address new messages start from
One of the mailbox's addresses or aliases, with an optional +tag, or "" for its own address. Replies keep answering from the address the mail came to. "" too when the saved address is no longer the mailbox's (an alias removed since).
200The default From.{ "address": "amy+shop@acme.test" }401Not signed in.
/api/mail/default-from
Set the address new messages start from
Only an address the mailbox may send as: its own or an alias, with an optional +tag (letters, digits, ., _ and -, starting and ending with a letter or digit); "" clears it. Every message is checked again when it is sent.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"address": "amy+shop@acme.test"
}
204Saved.400No address given.401Not signed in.422Not one of the mailbox's addresses
/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/emails/{emailId}/sender
The Zappocity profile and badges of a message's proven sender
For a message in the signed-in user's mailbox whose From this server proved (DMARC passed, or its owner sent it here) and whose address is a mailbox here linked to a Zappocity account. profile is null otherwise, or when the person shows nothing. With their profile hidden only the Staff and Administrator badges show, so mail from staff is easy to tell from mail that only claims to be. from is the proven From address ("" when not proven), and loadImages says whether the user trusts that sender to show images at once (see /api/mail/image-senders); it is never true for an unproven From. Needs the cookie and X-CSRF-Token.
| Parameter | In | Type | |
|---|---|---|---|
emailId * | path | string | |
X-CSRF-Token * | header | string |
200The profile, or null.{ "from": "pat@acme.example", "loadImages": false, "profile": { "badges": [ { "color": "primary", "icon": "user", "key": "staff", "name": "Staff", "rule": "staff" } ], "bio": "Support lead", "handle": "pat", "name": "Pat Lee", "pronouns": "they/them", "visible": true, "website": "https://pat.example" } }401Not signed in.404No such message in this mailbox.
/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/image-senders
Senders whose images load at once
Addresses, and domains (every address at exactly that domain), whose remote images webmail loads without asking, in mail this server proved to be from them.
200The list.{ "items": [ { "createdAt": "2026-10-21T13:20:00Z", "pattern": "noreply@email.apple.com" }, { "createdAt": "2026-10-21T13:21:00Z", "pattern": "example.com" } ] }
/api/mail/image-senders
Trust a sender's images
An address or a domain (@example.com is the domain); lowercased. At most 500. Not while in someone else's mailbox.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"pattern": "noreply@email.apple.com"
}
200Added (or already there), as stored.{ "pattern": "noreply@email.apple.com" }422Neither an address nor a domain
/api/mail/image-senders
Stop trusting a sender's images
| Parameter | In | Type | |
|---|---|---|---|
pattern * | query | string | |
X-CSRF-Token * | header | string |
204Removed.404Not on the list.
/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/lists
The team's mailing lists the user is on or may join
Lists of the user's team whose joining is team or anyone, and every list the user's own address is on. See docs/kb/mailing-lists.md.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The lists, by name.{ "items": [ { "address": "team@acme.test", "description": "", "id": "6f1c2e8a-3b5d-4c7e-9f10-2a3b4c5d6e7f", "joining": "team", "kind": "discussion", "member": true, "moderator": false, "name": "Team" } ] }401Not signed in.
/api/mail/lists/{id}/membership
Join a list with the user's own address
Only a list of the user's team whose joining is team or anyone.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Joined.404No such list409The list is full (the plan's maxListMembers).
/api/mail/lists/{id}/membership
Leave a list
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Left.404The user is not on the list.
/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/mailboxes
The signed-in person's mailboxes, to switch between
One Zappocity account can own several mailboxes, in one tenant or several. The current mailbox comes first. switchable is true only in a session opened through Zappocity (single sign-on) and not opened by staff or a team admin as the user; otherwise the list holds only the current mailbox. A mailbox that can't be opened now has available: false and a reason.
200The mailboxes.{ "items": [ { "accountId": "4f1c2a3b-5d6e-4f70-8a91-b2c3d4e5f607", "address": "amy@acme.test", "available": true, "current": true, "displayName": "Amy", "tenantName": "Acme" }, { "accountId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "address": "amy@acme-shop.test", "available": true, "current": false, "displayName": "Amy", "tenantName": "Acme" }, { "accountId": "1b2c3d4e-5f60-4718-8293-a4b5c6d7e8f9", "address": "amy@old.test", "available": false, "current": false, "displayName": "Amy", "reason": "suspended", "tenantName": "Old Co" } ], "switchable": true }401Not signed in.
/api/mail/mailboxes/switch
Open another of the person's mailboxes in place of this one
Starts a session for the chosen mailbox (a new cookie and CSRF token, returned as for GET /api/mail/session) from the same Zappocity sign-in, so signing out of Zappocity still ends it, and ends this session. No password is asked: the person proved who they are at zappo.city. Recorded in the mailbox's sign-in history as webmail-switch.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200Switched; the new session.401Not signed in.403The session can't switch (not opened through Zappocity, or opened as the user), or the mailbox is suspended, blocked or has no webmail.404Not one of the person's mailboxes.
/api/mail/managed-tenants
The tenants the signed-in person manages, for /manage
The tenant manager at /manage shows one tenant (organization) at a time and lets someone who manages several choose. A tenant is listed when one of the person's mailboxes is its owner or admin, or has a tenant role (teamRole: delegate, with teamPermissions), on a plan with tenant administration, and is neither suspended nor a shared mailbox. The signed-in mailbox's tenant counts in any session; the others only in a session opened through Zappocity (single sign-on) and not opened by staff or a team admin as the user. accountId and address are the mailbox that manages it (the one with the most rights), which the admin API acts as for that tenant's x-team calls. twoFactorSetup is set while that tenant requires two-step sign-in this session lacks. current marks the signed-in mailbox's tenant. An empty list: nothing to manage.
200The tenants, the signed-in mailbox's first.{ "items": [ { "accountId": "4f1c2a3b-5d6e-4f70-8a91-b2c3d4e5f607", "address": "amy@acme.test", "current": true, "teamRole": "owner", "tenantId": "0b6c7d8e-9f01-4a2b-8c3d-4e5f60718293", "tenantName": "Acme" }, { "accountId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "address": "amy@beta.test", "current": false, "teamPermissions": [ "users", "support" ], "teamRole": "delegate", "tenantId": "7e8f9a0b-1c2d-4e3f-9a4b-5c6d7e8f9a0b", "tenantName": "Beta Club" } ] }401Not signed in.
/api/mail/meetings
The tenant's video meetings that have not ended
Needs the plan's meet feature (meetings in the session). Each meeting has its link (/meet/{id} on webmail's host, for the tenant's people); the meeting's host and the tenant's owners and admins (canManage) also get its guestLink, whose key is in the #fragment. Rooms and keys are never shown otherwise.
200The meetings, soonest first; those without a time last.{ "items": [ { "canManage": true, "endsAt": "2026-11-02T15:30:00Z", "guestLink": "https://mail.acme.test/meet/0b6c7d8e-9f01-4a2b-8c3d-4e5f60718293#k=...", "guestsAllowed": true, "host": "amy@acme.test", "hostName": "Amy", "id": "0b6c7d8e-9f01-4a2b-8c3d-4e5f60718293", "link": "https://mail.acme.test/meet/0b6c7d8e-9f01-4a2b-8c3d-4e5f60718293", "startsAt": "2026-11-02T15:00:00Z", "title": "Weekly planning" } ] }401Not signed in.403Meetings are not in the plan
/api/mail/meetings
Make a meeting, now or scheduled
The signed-in mailbox hosts it (guests of the tenant cannot make one). Webmail puts a scheduled meeting's link in a calendar event, whose invitations carry it to its guests.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"endsAt": "2026-11-02T15:30:00Z",
"startsAt": "2026-11-02T15:00:00Z",
"title": "Weekly planning"
}
201The meeting403Meetings are not in the plan422The title or times are not valid.
/api/mail/meetings/{id}
Change a meeting (its host, or the tenant's owners and admins)
Fields as for creating it. newGuestLink: true replaces the guest key: the old guest link stops working.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
{
"guestsAllowed": false
}
200The meeting.403Not its host404No such meeting in the tenant.
/api/mail/meetings/{id}
Remove a meeting, sending out anyone in it
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Removed.403Not its host404No such meeting in the tenant.
/api/mail/meetings/{id}/end
End a meeting for everyone in it now (it can be joined again)
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Ended.403Not its host
/api/mail/meetings/{id}/join
A token to join one of the tenant's meetings
Opens the room on the LiveKit server (at most the plan's maxMeetingParticipants people) and signs a token for that room only, good for joining for 10 minutes, under the mailbox's own name (it cannot choose another), with host rights (muting and removing others, ending it) for the meeting's host and the tenant's owners and admins. Publishing is the camera, microphone and screen (with its sound).
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
200Where to connect, and the token.{ "host": true, "identity": "m:4f1c2a3b-5d6e-4f70-8a91-b2c3d4e5f607", "name": "Amy", "title": "Weekly planning", "token": "eyJ...", "url": "wss://meet.mailo.city" }404No such meeting in the tenant.502The LiveKit server did not answer.503Meetings are not set up on this server.
/api/mail/messages/{emailId}/reaction
React to a message
Sets the user's reaction to the message to this emoji (one at a time, as in Outlook); the same emoji again takes it back. It is sent as an RFC 9078 reaction message to the message's sender and its other recipients (not the user), who see it on the message if their mail supports reactions, or as a short message if not. With account, the reaction is from a shared mailbox the user may send as.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string | |
emailId * | path | string | |
account | query | string |
{
"emoji": "👍"
}
200The message's reactions now.{ "reactions": [ { "address": "amy@acme.com", "emoji": [ "👍" ], "mine": true, "name": "Amy" } ] }400Not the JSON above.404No such message (or it has no Message-ID), or no such account the user may send as.422Not one emoji
/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/notes
Your notes and the team's
Your personal notes and your team's notes (which every member reads and edits), pinned first, then the latest changed, with a short preview. q searches titles and text. See docs/kb/notes.md.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string | |
q | query | string |
200The notes.{ "items": [ { "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "mine": true, "owner": "amy@acme.test", "pinned": true, "preview": "Network: Acme-Guest…", "team": true, "title": "Office wifi", "updatedAt": "2026-10-09T15:00:00Z", "updatedBy": "bob@acme.test", "version": 3 } ] }
/api/mail/notes
Write a note
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
{
"body": "Network: **Acme-Guest**",
"team": true,
"title": "Office wifi"
}
201The note409You keep as many notes as you can (5000).422A value is not valid.
/api/mail/notes/{id}
A note
With its Markdown text rendered (html, escaped; links only to http(s) and mail).
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
200The note.404No such note
/api/mail/notes/{id}
Change a note
The whole note, with the version it was made to. If someone changed it meanwhile the answer is 409 with the current note (current), so nothing is lost. Only its owner moves it between personal and team.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
{
"body": "Network: **Acme-Guest**, password on the fridge",
"team": true,
"title": "Office wifi",
"version": 3
}
200The note.404No such note.409Changed meanwhile; current is the note as it is now.422A value is not valid.
/api/mail/notes/{id}
Delete a note
Your own, or (team admins) any team note.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Deleted.404No such note
/api/mail/own-mailboxes
What the person may add for themselves
The policy (limit, approval, used, pending; see the admin API's own-mailboxes), whether they can add one now (canAdd, or reason why not), the tenant's own domains to choose from, and their requests, newest first. Adding needs a session opened through Zappocity.
200The policy, domains and requests.{ "canAdd": true, "domains": [ "acme.test", "acme-shop.test" ], "policy": { "approval": true, "limit": 2, "pending": 1, "used": 0 }, "reason": "", "requests": [] }401Not signed in.
/api/mail/own-mailboxes
Add a mailbox, or ask for one
Without approval the mailbox is made at once for the person (it takes a seat and shows in the mailbox picker); with it, a request waits for an owner or admin, who are emailed. The address is localPart on one of the tenant's own domains; displayName defaults to the current mailbox's.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200{ request, created, accountId (when created) }.403Not through Zappocity, turned off for this person, or a shared mailbox.409The limit is reached, the address is taken or asked for, or the tenant has no free seat.422Not one of the tenant's own domains
/api/mail/own-mailboxes/requests/{id}
Withdraw a request still waiting
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Withdrawn.404No such request waiting.
/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. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| 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. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
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. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
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/pgp-key
The user's published OpenPGP public key, if any
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The key (null when none).{ "key": { "armored": "-----BEGIN PGP PUBLIC KEY BLOCK-----...", "fingerprint": "8D0C6E3A...", "published": true, "userIds": [ "Pat Lee \u003cpat@acme.test\u003e" ], "wkdUrl": "https://openpgpkey.acme.test/.well-known/openpgpkey/acme.test/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=pat" } }
/api/mail/pgp-key
Publish a public key, or publish or unpublish the one kept
key is an OpenPGP public key, armored or binary (64 KB at most), with a user ID naming the mailbox's address; a secret key is refused. It is served in the Web Key Directory while published (the default). Without key, published turns publishing on or off.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The key.403A session opened by staff as the user.404No key to publish or unpublish.422Not a public key
/api/mail/pgp-key
Remove the key
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Removed.
/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/reactions
Reactions to messages
Who reacted to these messages with what (RFC 9078 reactions received, and the user's own), by email id; mine marks the user's. Reaction messages themselves carry the $reaction keyword, so clients can leave them out of lists. account names a mailbox shared with the user instead of their own.
| Parameter | In | Type | |
|---|---|---|---|
ids * | query | string | Email ids, comma-separated, up to 200. |
account | query | string |
200The reactions, and the choices webmail offers.{ "choices": [ "👍", "❤️", "😂", "😮", "😢", "🎉" ], "items": { "E17": [ { "address": "bob@acme.com", "emoji": [ "👍" ], "mine": false, "name": "Bob" } ] } }404No such account (or not shared with the user).
/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. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| 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. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
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/resources
The team's rooms and equipment, with the times each is booked
For picking a room in the event editor: busy times only (not what or by whom) between from and to (RFC 3339, at most 31 days; today and the next week by default). Book one by adding its address to the event's participants.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string | |
from | query | string | |
to | query | string |
200The resources.{ "from": "2026-11-02T00:00:00Z", "items": [ { "address": "board@acme.test", "booking": "open", "busy": [ { "end": "2026-11-02T11:00:00Z", "start": "2026-11-02T10:00:00Z" } ], "capacity": 10, "description": "", "id": "6f1c2e8a-3b5d-4c7e-9f10-2a3b4c5d6e7f", "kind": "room", "location": "3rd floor", "maxMinutes": 240, "name": "Board room" } ], "to": "2026-11-03T00:00:00Z" }400from or to are not valid.
/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.{ "appPasswordConfirm": "zappocity", "appPasswords": [], "confirmUrl": "/api/mail/sso/start?confirm=1\u0026next=%2Fmail%23%2Fsettings%2Fapp-passwords", "recoveryCodesLeft": 0, "twoFactor": false }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. When the team requires two-step sign-in and the mailbox has none, the session says twoFactorSetup: here, and every call but this one, GET /api/mail/security, POST /api/mail/two-factor/setup and .../enable answers 403 with twoFactorSetupRequired: here until it is on.
{
"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; a session signed in with Zappocity also signs out of zappo.city (and so everything signed in from there). 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/smime
The user's S/MIME certificate, if any
keysAccepted says the server can keep a private key (to sign).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The certificate (null when none).{ "certificate": { "canSign": true, "fingerprint": "3A1F...", "issuer": "CN=Example CA", "notAfter": "2027-10-09T00:00:00Z", "pem": "-----BEGIN CERTIFICATE-----...", "subject": "CN=Pat Lee" }, "keysAccepted": true }
/api/mail/smime
Keep a certificate (and optionally its private key), or forget the key
certificate is PEM, with its chain; it must be for the mailbox's address, valid now and for email protection. privateKey (unencrypted PEM, matching the certificate) is sealed with the server's key and lets EmailSubmission/set sign with smimeSign. forgetKey alone removes the key and keeps the certificate.
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The certificate.403A session opened by staff as the user.422Not a certificate for this address
/api/mail/smime
Remove the certificate and key
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Removed.
/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; or to /mail#sso-error=... (expired, unverified, no-mailbox, suspended, not-in-plan).
/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 (or a place in them, after #). With confirm=1, Zappocity asks the person to prove who they are again (prompt=login) and the session it comes back to is marked confirmed, as making an app password needs.
| Parameter | In | Type | |
|---|---|---|---|
next | query | string | |
confirm | query | string |
302To Zappocity
/api/mail/team-templates
The team's templates
Names and subjects of the templates the user's team keeps, the most used first, for Templates… when writing. canManage says whether the user may share and delete them (team admins, and members whose role has the templates permission).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
200The templates.{ "canManage": false, "items": [ { "id": "0b8e5d2c-1a3f-4e6d-9c7b-2f1e0d9c8b7a", "name": "Quote follow-up", "subject": "Your quote", "uses": 12 } ] }
/api/mail/team-templates/{id}
A team template, to insert
Its subject, html and text. Counted as a use. Webmail cleans the HTML to plain formatting before inserting it.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
200The template.{ "createdBy": "lead@acme.test", "html": "\u003cp\u003eHi,\u003c/p\u003e", "id": "0b8e5d2c-1a3f-4e6d-9c7b-2f1e0d9c8b7a", "name": "Quote follow-up", "subject": "Your quote", "text": "", "uses": 13 }404No such template in the user's team.
/api/mail/team-templates/{id}
Delete a team template
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string | |
X-CSRF-Token * | header | string |
204Deleted.403The user may not manage team templates.404No such template in the user's team.
/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. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's. Refused (409) while the team requires two-step sign-in; an administrator resets it instead (DELETE /v1/tenants/{tenantId}/accounts/{accountId}/two-factor).
| Parameter | In | Type | |
|---|---|---|---|
X-CSRF-Token * | header | string |
204Off.403Wrong current password409The team requires two-step sign-in.429Too 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. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| 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. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| 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. Refused (403) while single sign-on is on: the password and two-step sign-in are then the Zappocity account's.
| 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 }
/api/meet/{id}
What the meeting page shows before joining
To a signed-in member of the meeting's tenant (no key), or to anyone with its guest key: the title, the host's name and the times; member says the caller joins as a member. Anything else, and a wrong key, answers 404; tries are limited per network address.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string |
{
"key": "..."
}
200The meeting.{ "endsAt": null, "hostName": "Amy", "member": false, "startsAt": "2026-11-02T15:00:00Z", "title": "Weekly planning" }404No such meeting429Too many tries from this address.
/api/meet/{id}/guest
A guest's token to join, with the guest key
For people without a mailbox in the tenant. The name they give (up to 60 characters, without @, < or >) is shown with "(guest)" after it, so nobody can pass for a member; guests never get host rights. Refused when guests are not allowed, the key was replaced, or the meeting ended over an hour ago. Limited per network address.
| Parameter | In | Type | |
|---|---|---|---|
id * | path | string |
{
"key": "...",
"name": "Pat Lee"
}
200As for members, with name such as "Pat Lee (guest)".404No such meeting422The name is not valid.429Too many tries from this address.
/api/sites/here
The team site this host name is
A team's forums (kind forum) or wiki (kind wiki) shown at the root of its own host name, or null.
200The site.{ "site": { "kind": "forum", "team": "acme", "tenantId": "3c1d9a7e-55b2-4b8e-9e1f-0a2b3c4d5e6f" } }
/api/wiki/mine
The signed-in member's team and whether it has a wiki
200The team.{ "enabled": true, "signedIn": true, "team": "acme", "tenantId": "3c1d9a7e-55b2-4b8e-9e1f-0a2b3c4d5e6f" }
/api/wiki/teams/{tenantId}
A team's wiki and its pages
For the team's members (with the cookie and X-CSRF-Token), or anyone when the wiki is public; only when the team's plan has the wiki feature.
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string |
200The settings, the pages (without text) and what the reader may do.{ "admin": false, "canEdit": true, "pages": [ { "revision": 3, "slug": "home", "title": "Home", "updatedAt": "2026-10-09T12:00:00Z", "updatedBy": "Pat" } ], "signedIn": true, "wiki": { "editors": "members", "enabled": true, "public": false, "team": "acme" } }404No such wiki
/api/wiki/teams/{tenantId}/pages/{slug}
A page, with its text rendered (or an old revision with ?revision=N)
[text](other-page) links to another page of the wiki; other links go to web and mail addresses only.
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string | |
slug * | path | string | |
revision | query | integer |
200The page.{ "admin": false, "canEdit": true, "page": { "body": "Welcome. See [onboarding](onboarding).", "html": "\u003cp\u003eWelcome. See \u003ca href=\"#/p/onboarding\"\u003eonboarding\u003c/a\u003e.\u003c/p\u003e", "revision": 3, "slug": "home", "title": "Home", "updatedBy": "Pat" } }404No such page or revision.
/api/wiki/teams/{tenantId}/pages/{slug}
Create a page (base 0) or save a new revision
base is the revision the edit started from; if someone saved since, the save is refused (409) so nothing is overwritten.
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string | |
slug * | path | string | |
X-CSRF-Token * | header | string |
{
"base": 3,
"body": "Welcome!",
"note": "Typo",
"title": "Home"
}
200The page as saved.403The reader cannot edit this wiki.404No such page (a base other than 0 for a new one).409Someone saved the page since base.422A bad name
/api/wiki/teams/{tenantId}/pages/{slug}
Delete a page and its history (owners and admins)
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string | |
slug * | path | string | |
X-CSRF-Token * | header | string |
204Deleted.403Not an owner or admin.404No such page.
/api/wiki/teams/{tenantId}/pages/{slug}/history
A page's revisions, newest first
| Parameter | In | Type | |
|---|---|---|---|
tenantId * | path | string | |
slug * | path | string |
200The revisions (without text).{ "revisions": [ { "author": "Pat", "createdAt": "2026-10-09T12:00:00Z", "note": "Typo", "revision": 3, "title": "Home" } ] }404No such page.
/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.