{
  "openapi": "3.0.3",
  "info": {
    "title": "Mail Service Now — Email Services & API for Private Clients",
    "version": "0.7.0",
    "description": "Email Services & API for Private Clients. Hosted mailboxes and forwarding on a domain you own, plus a sending API and MCP server. Documented 2026-09-26. After a standard key, POST /addDomain, publish DNS, POST /verifyDomain, then POST /addMailbox. A ready key does not add the domain or create mailboxes. Agent sendMail + MCP JSON-RPC 2024-11-05, inbound list/get, inbound webhooks, and MCP mark_read. API key msn_<your-key> via Authorization Bearer or X-Api-Key. If X-Api-Key is present it is the only credential checked. A Bearer value with three dot-separated segments is a Firebase ID token. sendMail, inbox, attachment, and drafts accept that token only for an operator email; any other Firebase user is 403 This mailbox requires an operator account. Drafts reject an agent key with 403. Anything else in Bearer is the agent key. MCP rejects Firebase tokens. Webhooks accept the agent key only; a verified Firebase user is 403. Credits: 1 per agent send, the default agent key starts at 100, people signed in to the web app are not charged. This description does not state a price. Insufficient credits is HTTP 429 error \"Insufficient credits\" (not 402). Rate limit is HTTP 429 error \"Rate limit exceeded\" (send: 5/minute and 50/UTC-day per uid; agent uid is agent:default). Kill switch is 503 Sending temporarily disabled (config/send) or 503 Agent key disabled (agent_keys/default disabled or frozenUntil). GET/POST /credits shows remaining credits and key last4. Auth failure does not spend a credit. Sole outbound MTA: mail.mailservicenow.com:587. The sending address is the SPF value on the domain card. The shared agent key sends as \"Mail Service Now\" <noreply@mailservicenow.com>. Always set from to an address on a verified domain you own, for example support@example-domain.com. If it's omitted and the account has exactly one sendable hosted mailbox, that mailbox is used as a fallback; with more than one, omitting from is an error. Inbound MX mail.mailservicenow.com. The shared inbox@mailservicenow.com mailbox stays with operators. AI Markdown: /docs/mailservicenow-for-ai.md. Generic agent recipe: /developers/any-agent/."
  },
  "servers": [
    { "url": "https://us-central1-mailservicenow.cloudfunctions.net" },
    { "url": "https://mailservicenow.com/api" },
    { "url": "https://mailservicenow.web.app/api" }
  ],
  "paths": {
    "/sendMail": {
      "post": {
        "summary": "Send email",
        "description": "Plain-text body. Always set from to an address on a verified domain you own, for example support@example-domain.com. If it's omitted and the account has exactly one sendable hosted mailbox, that mailbox is used as a fallback; with more than one, omitting from is an error (400 Choose the address to send from). The shared agent key sends as noreply@mailservicenow.com. Optional attachments (max 3, 2MB each, 4MB total; common image, PDF, text, CSV, and Office Open XML types). Agent sends cost 1 credit, consumed after validation and before SMTP (not refunded if SMTP fails). Order: auth, global kill switch, agent-key disable, rate limit, validation, credit, SMTP. Agent key disabled is 503 and does not spend a credit. Max 5 recipients. text max 100KB. MCP send_mail accepts the same from rule and does not accept attachments.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/SendRequest" },
              "example": {
                "to": "recipient@example.com",
                "from": "support@example-domain.com",
                "subject": "Hello from an agent",
                "text": "Sent via MailServiceNow."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Sent. creditsRemaining is present for agent auth only.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SendSuccess" },
                "example": {
                  "ok": true,
                  "messageId": "<id@mail.mailservicenow.com>",
                  "creditsRemaining": 99
                }
              }
            }
          },
          "400": {
            "description": "Missing fields, more than 5 recipients, body over 100KB, or Choose the address to send from when several hosted mailboxes exist and from is omitted",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "401": {
            "description": "Missing or invalid agent key or Firebase ID token",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "405": { "description": "Method not allowed (POST only; OPTIONS is 204)" },
          "429": {
            "description": "Rate limit exceeded, or insufficient agent credits. Distinguish by the error string.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "examples": {
                  "rate": { "value": { "ok": false, "error": "Rate limit exceeded" } },
                  "credits": { "value": { "ok": false, "error": "Insufficient credits" } }
                }
              }
            }
          },
          "500": { "description": "Send failed or MTA is not configured" },
          "503": {
            "description": "Sending temporarily disabled (global kill switch) or Agent key disabled (agent key frozen). Neither spends a credit.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Error" },
                "example": { "ok": false, "error": "Sending temporarily disabled" }
              }
            }
          }
        }
      }
    },
    "/inbox": {
      "get": {
        "summary": "List inbound mail, or read one message",
        "description": "Omit id, messageId, thread, view, and q to list messages. unreadInPage counts unread rows in this page only. Reading by id or messageId marks that row read and updates the thread unreadCount. view=threads lists conversation threads in folder=inbox unless folder is archive or trash. A missing folder field counts as inbox. thread=<64-hex> returns that thread oldest-first and marks its inbound rows read. q= is a case-insensitive substring search of subject, from, and body over the newest 200 inbound and 200 outbound rows in the mailbox. Search does not mark mail read. Optional folder on q= or the flat list limits that result to inbox, archive, or trash. If id or messageId is set, that single-message read wins, then thread, then q, then view. Does not spend credits and is not rate limited. Empty until the VPS ingest poller runs.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "parameters": [
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 25 } },
          { "name": "mailbox", "in": "query", "schema": { "type": "string" }, "description": "Optional @mailservicenow.com mailbox. Needs the composite Firestore index." },
          { "name": "id", "in": "query", "schema": { "type": "string" }, "description": "Firestore document id. Pattern ^[A-Za-z0-9_-]{1,128}$" },
          { "name": "messageId", "in": "query", "schema": { "type": "string" }, "description": "RFC Message-ID" },
          { "name": "view", "in": "query", "schema": { "type": "string", "enum": ["threads"] }, "description": "threads lists mail_threads instead of the flat message page" },
          { "name": "thread", "in": "query", "schema": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, "description": "Thread id. Returns ordered messages." },
          { "name": "q", "in": "query", "schema": { "type": "string", "minLength": 2, "maxLength": 200 }, "description": "Substring search. Wins over view and the flat list. Does not win over id, messageId, or thread." },
          { "name": "folder", "in": "query", "schema": { "type": "string", "enum": ["inbox", "archive", "trash"] }, "description": "On view=threads, omitted means inbox. On the flat list and on q=, omitted means every folder. Threads and messages with no folder field are inbox." }
        ],
        "responses": {
          "200": {
            "description": "List or one message",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    { "$ref": "#/components/schemas/InboxList" },
                    { "$ref": "#/components/schemas/InboxOne" },
                    { "$ref": "#/components/schemas/ThreadList" },
                    { "$ref": "#/components/schemas/ThreadDetail" },
                    { "$ref": "#/components/schemas/SearchResult" }
                  ]
                }
              }
            }
          },
          "400": { "description": "Invalid id, messageId, mailbox, thread, view, or search query" },
          "401": { "description": "Unauthorized" },
          "404": { "description": "Message or thread not found" },
          "405": { "description": "Method not allowed (GET or POST)" }
        }
      },
      "post": {
        "summary": "Mark a thread read or unread, or move it to a folder",
        "description": "Same auth as GET /inbox. No credits and no rate limit. Body is JSON. Pass thread (64 hex), or id, or messageId. Pass unread true or false, folder (inbox, archive, or trash), or both. id wins over messageId and over thread when more than one target is set. unread on a thread sets every inbound row in that thread. unread on an id sets that one inbound row and refreshes the thread unreadCount. Outbound rows stay read. folder moves the whole thread and the messages loaded for it (up to 100 inbound and 100 outbound). A new inbound message files the thread back into inbox. Does not spend credits.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/MailboxStateRequest" },
              "examples": {
                "unread": { "value": { "thread": "ff9b088e3dc69e06daa1346a1e020ffae7bac32de41df5a2c236f406557f4c82", "unread": true } },
                "archive": { "value": { "thread": "ff9b088e3dc69e06daa1346a1e020ffae7bac32de41df5a2c236f406557f4c82", "folder": "archive" } }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated thread, or one message when that row has no thread id",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MailboxStateResult" }
              }
            }
          },
          "400": { "description": "Missing target, invalid thread or id, unread not boolean, or folder not inbox, archive, or trash" },
          "401": { "description": "Unauthorized" },
          "404": { "description": "Thread or message not found" },
          "405": { "description": "Method not allowed (GET or POST)" }
        }
      }
    },
    "/attachment": {
      "get": {
        "summary": "Download one stored attachment",
        "description": "Same auth as inbox. No credits and no rate limit. box=inbound (default) or outbound. index is the attachment index on that message. disposition=inline is honored for image, pdf, and text. Bytes come from Firebase Storage. Firestore stores metadata only.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "parameters": [
          { "name": "id", "in": "query", "schema": { "type": "string" }, "description": "Firestore document id" },
          { "name": "messageId", "in": "query", "schema": { "type": "string" }, "description": "RFC Message-ID. id wins when both are set." },
          { "name": "index", "in": "query", "required": true, "schema": { "type": "integer", "minimum": 0, "maximum": 2 } },
          { "name": "box", "in": "query", "schema": { "type": "string", "enum": ["inbound", "outbound"], "default": "inbound" } },
          { "name": "disposition", "in": "query", "schema": { "type": "string", "enum": ["inline"] } }
        ],
        "responses": {
          "200": { "description": "File bytes. Content-Type is the stored type. Content-Disposition is attachment, or inline when requested and the type can be previewed." },
          "400": { "description": "Invalid box, index, id, or messageId" },
          "401": { "description": "Unauthorized" },
          "404": { "description": "Message or attachment not found" },
          "405": { "description": "Method not allowed (GET only)" }
        }
      }
    },
    "/drafts": {
      "get": {
        "summary": "List or read the signed-in user's drafts",
        "description": "Firebase ID token only. Agent keys are 403. Drafts are stored in Firestore collection drafts and are readable only for that uid. No credits.",
        "security": [{ "BearerAuth": [] }],
        "parameters": [
          { "name": "id", "in": "query", "schema": { "type": "string" }, "description": "One draft. Omit to list the newest 25." }
        ],
        "responses": {
          "200": { "description": "draft or drafts" },
          "400": { "description": "Invalid id" },
          "401": { "description": "Unauthorized" },
          "403": { "description": "Drafts require a signed-in user" },
          "404": { "description": "Draft not found" }
        }
      },
      "post": {
        "summary": "Create or update a draft",
        "description": "Body fields to, subject, text, optional inReplyTo, references, and id. At least one of to, subject, text, or inReplyTo is required. Passing id updates that user's draft. Omit id to create. Maximum 50 drafts per user.",
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "200": { "description": "{ ok, draft } with draft.id" },
          "400": { "description": "Empty draft, invalid id, or field too long" },
          "401": { "description": "Unauthorized" },
          "403": { "description": "Drafts require a signed-in user" },
          "404": { "description": "Draft not found" }
        }
      },
      "delete": {
        "summary": "Delete one draft",
        "security": [{ "BearerAuth": [] }],
        "parameters": [
          { "name": "id", "in": "query", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "{ ok, id }" },
          "400": { "description": "id is required or invalid" },
          "401": { "description": "Unauthorized" },
          "403": { "description": "Drafts require a signed-in user" },
          "404": { "description": "Draft not found" },
          "405": { "description": "Method not allowed" }
        }
      }
    },
    "/credits": {
      "get": {
        "summary": "Read agent credits and key status",
        "description": "Firebase ID token or agent API key. Returns remaining credits (default key starts at 100), agent key last4 and status, send limits, and whether this caller can change the kill switch. Does not spend a credit. Does not return the full key. Humans are not charged on send.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "responses": {
          "200": {
            "description": "Credits and key status",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "credits": { "remaining": 100, "initial": 100, "humansCharged": false, "authFailureBurnsCredit": false },
                  "agentKey": { "id": "default", "last4": "ab12", "status": "active", "disabled": false, "frozenUntil": null },
                  "sending": { "enabled": true },
                  "canControl": true,
                  "canPauseAll": false,
                  "docs": "https://mailservicenow.com/developers/#credits"
                }
              }
            }
          },
          "401": { "description": "Missing or invalid agent key or Firebase ID token. No credit spent." }
        }
      },
      "post": {
        "summary": "Freeze or unfreeze sending",
        "description": "Agent key may set disabled (boolean) and frozenUntil (ISO time or null) on agent_keys/default. An operator Firebase account may also set sendingEnabled, which writes config/send.enabled. Other signed-in users are 403. Checked on the next sendMail and MCP send_mail before any credit spend. Does not itself spend a credit.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "disabled": { "type": "boolean" },
                  "frozenUntil": { "type": "string", "nullable": true },
                  "sendingEnabled": { "type": "boolean" }
                }
              },
              "example": { "disabled": true }
            }
          }
        },
        "responses": {
          "200": { "description": "Updated status. Same body as GET /credits." },
          "400": { "description": "Invalid JSON or control fields" },
          "401": { "description": "Missing or invalid credential. No credit spent." },
          "403": { "description": "Caller cannot change this flag" },
          "405": { "description": "Method not allowed (GET or POST)" }
        }
      }
    },
    "/webhooks": {
      "get": {
        "summary": "Read the default agent webhook",
        "description": "Agent API key only. Returns the HTTPS URL and HMAC secret, or webhook null when none is registered. Does not spend a credit.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "responses": {
          "200": {
            "description": "Current registration",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WebhookRegistration" }
              }
            }
          },
          "401": { "description": "Missing or invalid agent key, or invalid Firebase token" },
          "403": { "description": "Webhooks require an agent API key" }
        }
      },
      "post": {
        "summary": "Register or replace the default agent webhook",
        "description": "Agent API key only. One HTTPS URL. rotateSecret true issues a new whsec_ secret. enabled false keeps the row and skips delivery. Does not spend a credit. Private, loopback, and metadata URLs are rejected.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WebhookWrite" },
              "example": { "url": "https://example.com/hooks/msn" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored registration, including secret",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WebhookRegistration" }
              }
            }
          },
          "400": { "description": "Invalid JSON or URL" },
          "401": { "description": "Missing or invalid agent key, or invalid Firebase token" },
          "403": { "description": "Webhooks require an agent API key" },
          "405": { "description": "Method not allowed" }
        }
      },
      "delete": {
        "summary": "Remove the default agent webhook",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "responses": {
          "200": { "description": "{ ok: true, webhook: null }" },
          "401": { "description": "Missing or invalid agent key" },
          "403": { "description": "Webhooks require an agent API key" },
          "405": { "description": "Method not allowed" }
        }
      }
    },
    "/addMailbox": {
      "post": {
        "summary": "Create a mailbox with a delivery mode",
        "description": "Standard customer key (msn_ from a ready verifyAccessRequest) or Firebase ID token. Account setup must be complete. The shared agent key is 401 Invalid API key when sent as X-Api-Key, or 401 Firebase sign-in required when sent as Bearer. Accounts that cannot send yet are 403 Account setup is not complete yet. A ready key does not create this mailbox by itself. mode is hosted, forward, or both. forwardTo is an array of email addresses (a single string is accepted). Required and non-empty for forward and both. Must be empty for hosted. Lowercased and de-duplicated. At most 5 targets. Rejects an invalid address, a target equal to the mailbox, a target on the same domain that is not a mailbox, a forwarding loop, and mailservicenow.com. This request does not write mail-server maps. The mail server syncs from mtaMailboxExport about every 60 seconds. Response mailbox.delivery is the plan that sync reads: hosted is vmailbox only, forward is a virtual alias only, both is vmailbox plus alias address -> address, forwardTo. mtaApplied and mtaAppliedAt confirm the change is live. Forward-only is not issued IMAP/SMTP credentials.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/MailboxWrite" },
              "example": {
                "domainId": "acme.example",
                "localPart": "sales",
                "mode": "both",
                "forwardTo": ["ada@gmail.com"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Mailbox stored. mtaApplied stays false until the mail server sync, about every 60 seconds. mtaAppliedAt is the confirmation timestamp.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MailboxResult" }
              }
            }
          },
          "400": { "description": "Invalid mode, address, cap of 5, self-forward, missing mailbox on the same domain, or forwarding loop" },
          "401": { "description": "Missing Firebase token, or agent key (Firebase sign-in required)" },
          "403": { "description": "Account setup is not complete yet" },
          "409": { "description": "That mailbox already exists on this domain." }
        }
      }
    },
    "/setMailboxMode": {
      "post": {
        "summary": "Change a mailbox delivery mode",
        "description": "Same auth as addMailbox: standard customer key or Firebase ID token. The shared agent key is refused. Does not delete stored Maildir. Switching to forward does not issue IMAP/SMTP credentials and removes webmail login. Switching back to hosted or both restores access. The mail server syncs the new mode from mtaMailboxExport about every 60 seconds. mtaApplied and mtaAppliedAt confirm it is live.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/MailboxModeWrite" },
              "example": {
                "domainId": "acme.example",
                "localPart": "sales",
                "mode": "forward",
                "forwardTo": ["ada@gmail.com", "bills@example.com"]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated mailbox, including mode, forwardTo, and delivery",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MailboxResult" }
              }
            }
          },
          "400": { "description": "Invalid mode, address, cap of 5, self-forward, or forwarding loop" },
          "401": { "description": "Firebase sign-in required" },
          "403": { "description": "Account setup is not complete yet" },
          "404": { "description": "Mailbox not found" }
        }
      }
    },
    "/deleteMailbox": {
      "post": {
        "summary": "Delete a mailbox record",
        "description": "Standard customer key or Firebase ID token. Account setup must be complete. The shared agent key is 401 Firebase sign-in required on Bearer, or 401 Invalid API key on X-Api-Key. Accounts that cannot send yet are 403 Account setup is not complete yet. A caller who does not own the mailbox gets 404. Body is { address } or { domain, localPart }. domainId and mailboxId are accepted. Deletes the Firestore mailbox record. A missing mailbox is 404. a protected address is 409 and is not deleted. The last active mailbox on a verified domain is 409: This is the last mailbox on <domain>. Add another mailbox first, or contact support to remove the domain. Only active mailboxes count. Unverified domains are not affected. Does not delete the Maildir. Response mailbox.delivery is the removal plan (vmailbox false, virtualAlias null) and mtaApplied is false. Routing drops on the next mail server sync, about every 60 seconds. The Maildir is kept. maildir is leave.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/MailboxDelete" },
              "examples": {
                "address": {
                  "value": { "address": "sales@acme.example" }
                },
                "parts": {
                  "value": { "domain": "acme.example", "localPart": "sales" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Mailbox record removed. The Maildir is kept. Routing drops on the next mail server sync, about every 60 seconds.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/MailboxDeleteResult" }
              }
            }
          },
          "400": { "description": "Missing or invalid address, or address does not match domain and localPart" },
          "401": { "description": "Missing Firebase token, or agent key (Firebase sign-in required)" },
          "403": { "description": "Account setup is not complete yet" },
          "404": { "description": "Mailbox not found, including a mailbox on another account" },
          "409": { "description": "A protected address is not deleted. Or the last active mailbox on a verified domain: This is the last mailbox on <domain>. Add another mailbox first, or contact support to remove the domain. There is no force flag. Operators use POST /operatorRemoveDomain." }
        }
      }
    },
    "/operatorRemoveDomain": {
      "post": {
        "summary": "Operator: mark a domain and its mailboxes removed",
        "description": "Operator Firebase ID token, the same check as the domain allowlist. Standard and request_access keys are 403 even when the email is an operator address. Agent keys are 403. Body is { uid or ownerEmail, domainId, confirm }. When the domain has any mailbox, confirm must be the domain name. Marks the domain removed and every mailbox on it removed. Mail stored for this domain is permanently deleted and cannot be recovered. A protected domain is 409. A domain that contains a protected address is 409. mailservicenow.com is 409. This is the supported path for removing a domain's last mailbox. deleteMailbox stays 409 for that case and has no force flag. A domain whose account record is gone can still be removed. The claim is released in that write when it still names that account.",
        "security": [{ "BearerAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "uid": { "type": "string" },
                  "ownerEmail": { "type": "string" },
                  "domainId": { "type": "string" }
                },
                "required": ["domainId"]
              },
              "example": { "ownerEmail": "ada@example.com", "domainId": "acme.example" }
            }
          }
        },
        "responses": {
          "200": { "description": "Domain and mailboxes marked removed. maildir is leave." },
          "400": { "description": "Missing uid and ownerEmail, or they name different accounts, or domainId is invalid" },
          "401": { "description": "Missing Authorization Bearer token" },
          "403": { "description": "This action is limited to operator accounts." },
          "404": { "description": "Account or domain not found" },
          "409": { "description": "A protected domain is not removed. A domain that contains a protected address is not removed." }
        }
      }
    },
    "/addDomain": {
      "post": {
        "summary": "Add a customer domain",
        "description": "Standard customer key (msn_ from a ready verifyAccessRequest) or Firebase ID token. Account setup must be complete. The shared agent key is 401 Invalid API key on X-Api-Key, or 401 Firebase sign-in required on Bearer. Accounts that cannot send yet are 403 Account setup is not complete yet. Body field domain is the hostname, such as example-domain.com. A ready key does not call this for you, and intendedUse does not create mailboxes. The live function checks DNS immediately. Before MX, SPF, and DKIM match, the response can be 200 with created true, verified false, and domain.status failed. Read domain.dnsInstructions.records and domain.failureReason, publish those records, then POST /verifyDomain. Calling addDomain again for the same normalized name (lowercase, trim, strip a trailing dot) returns 200 created false and does not change mailboxes or DNS. A second domain, including a subdomain, is 409 code one_domain_per_account. A hostname on another account is 409 code domain_claimed. After the only domain is removed, one domain can be added again. Accounts that already hold more than one domain are left unchanged, and a new add is refused. mailservicenow.com is 400. MCP add_domain uses the same rule. The shared agent key is refused. The saved account key is accepted.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["domain"],
                "properties": {
                  "domain": { "type": "string", "description": "Hostname you control, such as example-domain.com." }
                }
              },
              "example": { "domain": "example-domain.com" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Domain stored. dnsInstructions.records includes MX mail.mailservicenow.com priority 10, SPF, and DKIM. verified may already be false when DNS is not published yet.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "created": true,
                  "verified": false,
                  "domain": {
                    "id": "example-domain.com",
                    "name": "example-domain.com",
                    "status": "failed"
                  }
                }
              }
            }
          },
          "400": { "description": "Enter a domain you own, such as example.com. or That domain name is not valid. Product domain mailservicenow.com cannot be added." },
          "401": { "description": "Invalid API key, or Firebase sign-in required for a shared agent key" },
          "403": { "description": "Account setup is not complete yet" },
          "409": { "description": "one_domain_per_account when this account already holds a different domain. domain_claimed when another account holds the name. Body includes code and error." }
        }
      }
    },
    "/getDnsInstructions": {
      "get": {
        "summary": "Read DNS records for one domain",
        "description": "Same auth as POST /addDomain. Query domainId is the hostname. Returns instructions (MX, SPF, DKIM, optional DMARC) for the signed-in account's domain.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "parameters": [
          { "name": "domainId", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Domain id, such as example-domain.com." }
        ],
        "responses": {
          "200": { "description": "domain, status, and instructions" },
          "400": { "description": "domainId is required" },
          "401": { "description": "Invalid API key, or Firebase sign-in required" },
          "403": { "description": "Account setup is not complete yet" },
          "404": { "description": "Domain not found" }
        }
      }
    },
    "/verifyDomain": {
      "post": {
        "summary": "Check DNS and mark the domain verified or failed",
        "description": "Same auth as POST /addDomain. Body domainId (or domain) is the hostname. Call this after publishing dnsInstructions. verified true sets domain.status to verified. verified false sets failed and failureReason. checks.mx, checks.spf, and checks.dkim say which row missed. The preferred MX (lowest priority number) must be mail.mailservicenow.com. MX still on Cloudflare Email Routing fails with MX points at that host. Two SPF TXT records fail with Publish one SPF TXT. transient true means the lookup did not finish; status is left unchanged. The web app button is Check DNS. Moving MX off a previous host, including Cloudflare Email Routing, stops mail that host delivers today.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "domainId": { "type": "string" },
                  "domain": { "type": "string", "description": "Accepted when domainId is omitted." }
                }
              },
              "example": { "domainId": "example-domain.com" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "verified true or false, plus checks. transient true when DNS lookup did not finish.",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "created": false,
                  "verified": true,
                  "transient": false,
                  "domain": { "id": "example-domain.com", "status": "verified" }
                }
              }
            }
          },
          "400": { "description": "domainId is required, or the domain name is not valid" },
          "401": { "description": "Invalid API key, or Firebase sign-in required" },
          "403": { "description": "Account setup is not complete yet" },
          "404": { "description": "Domain not found" }
        }
      }
    },
    "/listDomains": {
      "get": {
        "summary": "List domains and mailboxes",
        "description": "Standard customer key or Firebase ID token. Account setup must be complete. Each mailbox includes mode, forwardTo (array; empty for hosted), and delivery (vmailbox and virtualAlias). Existing hosted mailboxes, including a protected address, read back as hosted. A legacy forwardTo string is returned as a one-element array. The shared agent key is 401 Firebase sign-in required. A ready key does not add domains by itself; call POST /addDomain first.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "parameters": [
          { "name": "uid", "in": "query", "schema": { "type": "string" }, "description": "Operator read of another account. Writes ignore this." }
        ],
        "responses": {
          "200": {
            "description": "domains[].mailboxes[] include mode, forwardTo, and delivery",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/DomainList" }
              }
            }
          },
          "401": { "description": "Firebase sign-in required" },
          "403": { "description": "Account setup is not complete yet" }
        }
      }
    },
    "/requestAccess": {
      "post": {
        "summary": "Ask for an account (no API key)",
        "description": "Public. Emails a one-time code to contactEmail. The code is not in the response. The code expires in 24 hours. The response does not say whether an account is already on file. Rate limit: 5/hour per IP and 3/hour per email. 429 includes retryAfter seconds and a Retry-After header. Same behavior as MCP request_access.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AccessRequest" },
              "example": {
                "contactEmail": "ops@example-domain.com",
                "domain": "example-domain.com",
                "agentName": "ExampleAgent",
                "intendedUse": "Send a notification from an agent"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Code emailed. expiresIn is seconds until the code expires (86400, 24 hours).",
            "content": {
              "application/json": {
                "example": { "ok": true, "requestId": "ar_0123456789abcdef01234567", "expiresIn": 86400, "message": "If the verification code email doesn't arrive within a few minutes, check the spam folder or contact support." }
              }
            }
          },
          "400": { "description": "contactEmail, domain, agentName, or intendedUse is invalid" },
          "429": {
            "description": "Rate limit exceeded. Body includes retryAfter seconds.",
            "content": {
              "application/json": {
                "example": { "ok": false, "error": "Rate limit exceeded", "retryAfter": 3600 }
              }
            }
          },
          "500": { "description": "Could not send the access code" }
        }
      }
    },
    "/verifyAccessRequest": {
      "post": {
        "summary": "Submit the emailed code (no API key)",
        "description": "Public. A matching code verifies the email. A new address uses the same server-side decision as signup. An account already on file is left unchanged: a standard key is returned once when that account can already send, setting_up is returned when setup is still in progress, and any other existing account gets status unavailable with a neutral message and no key. A ready response returns a standard-access API key once (send, domains and mailboxes, webhooks, customer MCP tools, default quotas). A wrong, expired, or locked code never returns a key. Same behavior as MCP verify_access_request.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/AccessVerify" },
              "example": { "requestId": "ar_0123456789abcdef01234567", "code": "12345678" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "ready with apiKey, setting_up with no apiKey, or unavailable with a neutral message and no apiKey",
            "content": {
              "application/json": {
                "examples": {
                  "ready": {
                    "value": {
                      "ok": true,
                      "status": "ready",
                      "requestId": "ar_0123456789abcdef01234567",
                      "access": "standard",
                      "apiKey": "msn_<your-key>",
                      "tokenType": "Bearer",
                      "message": "Save this key now. It is shown once.",
                      "quotas": { "credits": 100, "burstPerMinute": 5, "perUtcDay": 50 }
                    }
                  },
                  "setting_up": {
                    "value": {
                      "ok": true,
                      "status": "setting_up",
                      "requestId": "ar_0123456789abcdef01234567",
                      "message": "We're setting up your account. We'll email you when it's ready."
                    }
                  },
                  "unavailable": {
                    "value": {
                      "ok": true,
                      "status": "unavailable",
                      "requestId": "ar_0123456789abcdef01234567",
                      "message": "This request could not be completed."
                    }
                  }
                }
              }
            }
          },
          "400": { "description": "requestId or code is invalid" },
          "401": { "description": "That code is not valid, or that code is no longer valid" },
          "404": { "description": "Request not found" },
          "409": { "description": "already_complete. That request was already finished. Do not retry it. Once the account is ready, get the key with a fresh request_access plus verify_access_request. The key is shown once. \"Do NOT resend the same requestId\" applies only while the result is pending. Body includes code already_complete." },
          "410": { "description": "That code has expired" }
        }
      }
    },
    "/getRequestStatus": {
      "post": {
        "summary": "Check whether an account request is ready (no API key)",
        "description": "Public. Body is requestId and email (contactEmail is accepted). Unknown email, a real email with the wrong requestId, an unknown requestId, an empty requestId, and a requestId that contains a slash all return HTTP 200 with the same body: ok true, status pending, ready false. A match also includes requestId. status is ready, setting_up, pending, or unavailable. ready is true only when the account is ready. Poll about every 20 minutes. get_request_status allows 10 per hour per IP and 6 per hour per email, counted separately from request_access, so that poll has room for an extra check. The 429 body is the same whether or not the email is on file. Once the account is ready, get the key with a fresh request_access plus verify_access_request. The key is shown once. \"Do NOT resend the same requestId\" applies only while the result is pending. Same behavior as MCP get_request_status.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["requestId", "email"],
                "properties": {
                  "requestId": { "type": "string" },
                  "email": { "type": "string", "description": "contactEmail from request_access" }
                }
              },
              "example": {
                "requestId": "ar_0123456789abcdef01234567",
                "email": "ops@example-domain.com"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A match includes requestId. The three negative cases share the pending example and do not echo requestId.",
            "content": {
              "application/json": {
                "examples": {
                  "unknown": {
                    "value": { "ok": true, "status": "pending", "ready": false }
                  },
                  "setting_up": {
                    "value": {
                      "ok": true,
                      "status": "setting_up",
                      "ready": false,
                      "requestId": "ar_0123456789abcdef01234567"
                    }
                  },
                  "ready": {
                    "value": {
                      "ok": true,
                      "status": "ready",
                      "ready": true,
                      "requestId": "ar_0123456789abcdef01234567"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. The body is the same whether or not the email is on file.",
            "content": {
              "application/json": {
                "example": { "ok": false, "error": "Rate limit exceeded", "retryAfter": 3600 }
              }
            }
          }
        }
      }
    },
    "/getAccountStatus": {
      "post": {
        "summary": "Read this key's own account",
        "description": "Account key only. The shared agent key is 403. The mail-server sync token is 401. A body that names another uid or email is 403. Returns status (ready, setting_up, or unavailable), accessLevel (standard or null), ready, domains with verification mx, spf, dkim, and dmarc (verified, missing, or unchecked), mailboxes (address, mode, ready), and canSend. When canSend is false, reason is a fixed next step and never includes a key or a DNS value. A hosted address needs a domain you own. Same behavior as MCP get_account_status.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": { "type": "object", "properties": {} },
              "example": {}
            }
          }
        },
        "responses": {
          "200": {
            "description": "This key's account. reason is omitted when canSend is true.",
            "content": {
              "application/json": {
                "examples": {
                  "needs_mailbox": {
                    "value": {
                      "ok": true,
                      "status": "ready",
                      "accessLevel": "standard",
                      "ready": true,
                      "domains": [{
                        "name": "example-domain.com",
                        "status": "verified",
                        "verification": { "mx": "verified", "spf": "verified", "dkim": "verified", "dmarc": "unchecked" }
                      }],
                      "mailboxes": [],
                      "canSend": false,
                      "reason": "Add a fully hosted mailbox on a verified domain before sending."
                    }
                  },
                  "can_send": {
                    "value": {
                      "ok": true,
                      "status": "ready",
                      "accessLevel": "standard",
                      "ready": true,
                      "domains": [{
                        "name": "example-domain.com",
                        "status": "verified",
                        "verification": { "mx": "verified", "spf": "verified", "dkim": "verified", "dmarc": "verified" }
                      }],
                      "mailboxes": [{ "address": "support@example-domain.com", "mode": "hosted", "ready": true }],
                      "canSend": true
                    }
                  }
                }
              }
            }
          },
          "401": { "description": "MTA sync token is not accepted here" },
          "403": { "description": "This action is limited to the account key, or this key can only read its own account." },
          "404": { "description": "Account not found" }
        }
      }
    },
    "/mcp": {
      "get": {
        "summary": "MCP discovery / health (no auth)",
        "responses": {
          "200": {
            "description": "Server info",
            "content": {
              "application/json": {
                "example": {
                  "ok": true,
                  "name": "mailservicenow",
                  "version": "0.7.0",
                  "transport": "json-rpc-2.0",
                  "protocolVersion": "2024-11-05",
                  "tools": ["request_access", "verify_access_request", "get_request_status", "get_account_status", "send_mail", "get_credits", "list_inbox", "get_message", "mark_read", "list_domain_allowlist", "add_domain_allowlist", "remove_domain_allowlist", "check_domain_allowlist", "operator_remove_domain", "operator_delete_request", "add_mailbox", "issue_mailbox_credential", "set_mailbox_mode", "delete_mailbox"],
                  "auth": "Authorization: Bearer <MSN_AGENT_API_KEY> or a standard-access customer key. request_access, verify_access_request, and get_request_status need no key. get_account_status needs the account key."
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "MCP JSON-RPC 2.0 (initialize, tools/list, tools/call)",
        "description": "Stateless HTTPS JSON-RPC. Not SSE. request_access, verify_access_request, and get_request_status need no API key. See /requestAccess, /verifyAccessRequest, and /getRequestStatus. get_account_status needs the account key and returns only that account. See /getAccountStatus. The shared agent key is refused on get_account_status. A standard-access customer key may call send, inbox, credits, webhooks, and mailbox tools, and receives 403 on operator allowlist tools. Agent API key (Bearer or X-Api-Key) for send and inbox tools. add_mailbox, issue_mailbox_credential, set_mailbox_mode, and delete_mailbox accept a Firebase ID token once account setup is complete and refuse the agent key with Firebase sign-in required. Tools: send_mail (to, subject, text; always set from to an address on a verified domain you own, for example support@example-domain.com; if from is omitted and the account has exactly one sendable hosted mailbox, that mailbox is used as a fallback; with more than one, omitting from is an error (400 Choose the address to send from); the shared agent key sends as noreply@mailservicenow.com; 1 credit; rate limits), get_credits (credits remaining), list_inbox (limit, mailbox), get_message (id or messageId), mark_read (id or messageId, optional unread boolean; no credit), add_mailbox (domainId, localPart, mode hosted|forward|both, forwardTo array max 5), issue_mailbox_credential (address; returns the IMAP/SMTP password once and stores a SHA512-CRYPT hash), set_mailbox_mode (same mode and forwardTo), delete_mailbox (address, or domain and localPart). delete_mailbox removes the mailbox record, does not delete Maildir, and returns a delivery removal with mtaApplied false. A protected address is not deleted (409). The last active mailbox on a verified domain is 409: This is the last mailbox on <domain>. Add another mailbox first, or contact support to remove the domain. Unverified domains are not affected. A missing mailbox is 404. Mailbox tool output includes mode, forwardTo, and delivery. Forward-only has no IMAP login. More than 5 targets, a self-forward, or a forwarding loop is 400. The mail server syncs mailbox changes from mtaMailboxExport about every 60 seconds. mtaApplied and mtaAppliedAt confirm a mode change is live. delete_mailbox drops routing on the next sync and keeps the Maildir. There is no force flag on delete_mailbox. Admins remove a domain, including its last mailbox, with operator_remove_domain (uid or ownerEmail, plus domainId; confirm is the domain name when any mailbox exists). The claim is released when it still names that account. mailservicenow.com is 409 system_domain. A missing account can still be removed and the audit row sets ownerMissing true. Mail stored for this domain is permanently deleted and cannot be recovered. A protected domain and any domain that contains a protected address are 409. Standard and request_access keys, the shared agent key, and the MTA token are refused on that tool. delete_mailbox does not release the claim. operator_delete_request is admin only. Pass id or ids (at most 200). It deletes those access requests, leaves the account and its key unchanged, writes one request.delete audit row per id, and clears that email's request rate counter. A customer key, the shared agent key, and the MTA token are refused. The call deletes the exact ids given and does not search for duplicates. REST is POST /api/admin/requestDelete. Preview is GET /api/admin/requestDuplicates with email and or domain. HTTP 401 on auth failure before any tool runs, including mark_read and send_mail, so a bad key does not spend a credit. Tool results for 404/429/503 use HTTP 200 with result.isError and structuredContent.status. Other failures use JSON-RPC error -32000. Methods: initialize, notifications/initialized, initialized, ping, tools/list, tools/call.",
        "security": [{ "BearerAuth": [] }, { "ApiKeyAuth": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/JsonRpcRequest" },
              "example": {
                "jsonrpc": "2.0",
                "id": 2,
                "method": "tools/call",
                "params": {
                  "name": "send_mail",
                  "arguments": {
                    "to": "recipient@example.com",
                    "from": "support@example-domain.com",
                    "subject": "Hello from MCP",
                    "text": "Sent via MailServiceNow MCP."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "JSON-RPC response, or a tool error envelope. Check result.isError / structuredContent.ok. Auth failures are 401, not 200." },
          "204": { "description": "JSON-RPC notification acknowledged" },
          "401": { "description": "Missing key, invalid key, or Firebase token (MCP requires the agent key)" },
          "405": { "description": "Method not allowed" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Agent API key (msn_…) or, on sendMail, inbox, attachment, and drafts, an operator Firebase ID token. Any other Firebase user on those routes is 403. Three dot-separated segments are verified as a JWT; any other Bearer value is the agent key. MCP rejects JWTs. Webhooks reject a verified Firebase user with 403."
      },
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-Api-Key",
        "description": "Agent API key msn_…. When this header is present it is the only credential checked."
      }
    },
    "schemas": {
      "AccessRequest": {
        "type": "object",
        "required": ["contactEmail", "domain", "agentName", "intendedUse"],
        "properties": {
          "contactEmail": { "type": "string" },
          "domain": { "type": "string", "description": "Organization hostname, such as example.com" },
          "agentName": { "type": "string" },
          "intendedUse": { "type": "string" }
        }
      },
      "AccessVerify": {
        "type": "object",
        "required": ["requestId", "code"],
        "properties": {
          "requestId": { "type": "string" },
          "code": { "type": "string", "description": "8 digit code from the email" }
        }
      },
      "Error": {
        "type": "object",
        "required": ["ok", "error"],
        "properties": {
          "ok": { "type": "boolean", "enum": [false] },
          "error": { "type": "string" }
        }
      },
      "SendRequest": {
        "type": "object",
        "required": ["to", "subject", "text"],
        "properties": {
          "to": {
            "description": "One address, a comma- or semicolon-separated string, or an array of addresses. Max 5.",
            "oneOf": [
              { "type": "string" },
              { "type": "array", "items": { "type": "string" }, "maxItems": 5 }
            ]
          },
          "subject": { "type": "string" },
          "text": { "type": "string", "description": "Plain text. Max 100KB UTF-8." },
          "from": {
            "type": "string",
            "description": "Always set from to an address on a verified domain you own, for example support@example-domain.com. If it's omitted and the account has exactly one sendable hosted mailbox, that mailbox is used as a fallback; with more than one, omitting from is an error (400 Choose the address to send from). The shared agent key sends as noreply@mailservicenow.com."
          },
          "inReplyTo": { "type": "string", "description": "Optional Message-ID to thread this send." },
          "references": {
            "description": "Optional References chain.",
            "oneOf": [
              { "type": "string" },
              { "type": "array", "items": { "type": "string" } }
            ]
          },
          "attachments": {
            "type": "array",
            "maxItems": 3,
            "description": "Optional. Max 3 files, 2MB each, 4MB total decoded. MCP send_mail does not accept this field.",
            "items": { "$ref": "#/components/schemas/AttachmentUpload" }
          }
        }
      },
      "SendSuccess": {
        "type": "object",
        "required": ["ok", "messageId"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "messageId": { "type": "string" },
          "threadId": { "type": "string", "description": "Present when the send was filed onto a thread." },
          "attachmentCount": { "type": "integer", "description": "Files included on the SMTP message." },
          "attachmentsStored": { "type": "integer", "description": "Blobs stored for a threaded send. 0 when the send was not filed on a thread." },
          "creditsRemaining": { "type": "integer", "description": "Agent auth only." }
        }
      },
      "InboundSummary": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "messageId": { "type": "string" },
          "mailbox": { "type": "string" },
          "from": { "type": "string" },
          "to": { "type": "array", "items": { "type": "string" } },
          "subject": { "type": "string" },
          "preview": { "type": "string" },
          "date": { "type": "string", "nullable": true },
          "ingestedAt": { "type": "string", "nullable": true },
          "unread": { "type": "boolean" },
          "folder": { "type": "string", "enum": ["inbox", "archive", "trash"], "description": "Missing stored folder is returned as inbox." },
          "source": { "type": "string" },
          "attachments": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/AttachmentMeta" }
          }
        }
      },
      "AttachmentUpload": {
        "type": "object",
        "required": ["filename", "contentBase64"],
        "properties": {
          "filename": { "type": "string" },
          "contentType": { "type": "string" },
          "contentBase64": { "type": "string", "description": "Standard base64. Data URLs are accepted." }
        }
      },
      "AttachmentMeta": {
        "type": "object",
        "properties": {
          "index": { "type": "integer" },
          "filename": { "type": "string" },
          "contentType": { "type": "string" },
          "size": { "type": "integer" },
          "sha256": { "type": "string" },
          "preview": { "type": "string", "enum": ["image", "pdf", "text", "download"] }
        }
      },
      "InboxList": {
        "type": "object",
        "required": ["ok", "messages", "unreadInPage"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "messages": { "type": "array", "items": { "$ref": "#/components/schemas/InboundSummary" } },
          "unreadInPage": { "type": "integer" }
        }
      },
      "ThreadSummary": {
        "type": "object",
        "properties": {
          "threadId": { "type": "string" },
          "mailbox": { "type": "string" },
          "subject": { "type": "string" },
          "count": { "type": "integer" },
          "preview": { "type": "string" },
          "from": { "type": "string" },
          "latestAt": { "type": "string", "nullable": true },
          "unread": { "type": "boolean" },
          "unreadCount": { "type": "integer" },
          "folder": { "type": "string", "enum": ["inbox", "archive", "trash"] }
        }
      },
      "ThreadList": {
        "type": "object",
        "required": ["ok", "threads", "unreadThreads"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "threads": { "type": "array", "items": { "$ref": "#/components/schemas/ThreadSummary" } },
          "unreadThreads": { "type": "integer", "description": "Unread threads in this page only." },
          "folder": { "type": "string", "enum": ["inbox", "archive", "trash"] },
          "inboxUnread": { "type": "integer", "description": "Unread inbox threads among the newest 200 thread rows. Not limited to this page." }
        }
      },
      "MailboxStateRequest": {
        "type": "object",
        "properties": {
          "thread": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
          "id": { "type": "string" },
          "messageId": { "type": "string" },
          "unread": { "type": "boolean" },
          "folder": { "type": "string", "enum": ["inbox", "archive", "trash"] }
        }
      },
      "MailboxStateResult": {
        "type": "object",
        "required": ["ok"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "thread": { "$ref": "#/components/schemas/ThreadSummary" },
          "message": { "$ref": "#/components/schemas/InboundSummary" },
          "inboxUnread": { "type": "integer" }
        }
      },
      "ThreadDetail": {
        "type": "object",
        "required": ["ok", "thread", "messages"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "thread": { "$ref": "#/components/schemas/ThreadSummary" },
          "messages": {
            "type": "array",
            "items": {
              "allOf": [
                { "$ref": "#/components/schemas/InboundSummary" },
                {
                  "type": "object",
                  "properties": {
                    "text": { "type": "string" },
                    "direction": { "type": "string", "enum": ["inbound", "outbound"] },
                    "inReplyTo": { "type": "string" },
                    "references": { "type": "array", "items": { "type": "string" } }
                  }
                }
              ]
            }
          }
        }
      },
      "InboxOne": {
        "type": "object",
        "required": ["ok", "message"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "message": {
            "allOf": [
              { "$ref": "#/components/schemas/InboundSummary" },
              {
                "type": "object",
                "properties": {
                  "text": { "type": "string" },
                  "html": { "type": "string" }
                }
              }
            ]
          }
        }
      },
      "SearchHit": {
        "type": "object",
        "required": ["id", "direction", "matched", "snippet"],
        "properties": {
          "id": { "type": "string", "description": "Firestore document id" },
          "direction": { "type": "string", "enum": ["inbound", "outbound"] },
          "threadId": { "type": "string" },
          "messageId": { "type": "string" },
          "mailbox": { "type": "string" },
          "from": { "type": "string" },
          "subject": { "type": "string" },
          "snippet": { "type": "string" },
          "matched": { "type": "array", "items": { "type": "string", "enum": ["subject", "from", "body"] } },
          "date": { "type": "string", "nullable": true }
        }
      },
      "SearchResult": {
        "type": "object",
        "required": ["ok", "query", "hits"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "query": { "type": "string" },
          "scanned": { "type": "integer" },
          "hits": { "type": "array", "items": { "$ref": "#/components/schemas/SearchHit" } }
        }
      },
      "Draft": {
        "type": "object",
        "properties": {
          "id": { "type": "string" },
          "to": { "type": "string" },
          "subject": { "type": "string" },
          "text": { "type": "string" },
          "inReplyTo": { "type": "string" },
          "references": { "type": "string" },
          "createdAt": { "type": "string", "nullable": true },
          "updatedAt": { "type": "string", "nullable": true }
        }
      },
      "WebhookWrite": {
        "type": "object",
        "required": ["url"],
        "properties": {
          "url": { "type": "string", "format": "uri", "description": "HTTPS endpoint. No credentials, fragment, or private host." },
          "enabled": { "type": "boolean" },
          "rotateSecret": { "type": "boolean" }
        }
      },
      "WebhookRegistration": {
        "type": "object",
        "required": ["ok"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "webhook": {
            "nullable": true,
            "type": "object",
            "properties": {
              "url": { "type": "string" },
              "enabled": { "type": "boolean" },
              "secret": { "type": "string", "description": "whsec_ HMAC key. Treat it like the agent API key." },
              "createdAt": { "type": "string", "nullable": true },
              "updatedAt": { "type": "string", "nullable": true }
            }
          }
        }
      },
      "MailboxDelivery": {
        "type": "object",
        "required": ["vmailbox", "virtualAlias"],
        "description": "Postfix plan the mail server sync reads. The sync runs about every 60 seconds. hosted: vmailbox true and virtualAlias null. forward: vmailbox false and virtualAlias the forward targets. both: vmailbox true and virtualAlias the mailbox address followed by forwardTo.",
        "properties": {
          "vmailbox": { "type": "boolean" },
          "virtualAlias": { "type": "string", "nullable": true }
        }
      },
      "Mailbox": {
        "type": "object",
        "required": ["address", "mode", "forwardTo", "delivery"],
        "properties": {
          "id": { "type": "string" },
          "localPart": { "type": "string" },
          "address": { "type": "string" },
          "mode": { "type": "string", "enum": ["hosted", "forward", "both"] },
          "forwardTo": {
            "type": "array",
            "maxItems": 5,
            "items": { "type": "string", "format": "email" },
            "description": "Empty for hosted. One to five addresses for forward and both."
          },
          "delivery": { "$ref": "#/components/schemas/MailboxDelivery" },
          "status": { "type": "string" },
          "mtaApplied": {
            "type": "boolean",
            "description": "True after the mail server sync confirms this mode. The sync runs about every 60 seconds."
          },
          "mtaAppliedAt": {
            "type": "string",
            "nullable": true,
            "format": "date-time",
            "description": "Timestamp of that confirmation. Null until mtaApplied is true."
          }
        }
      },
      "MailboxWrite": {
        "type": "object",
        "required": ["domainId", "localPart", "mode"],
        "properties": {
          "domainId": { "type": "string" },
          "localPart": { "type": "string" },
          "mode": { "type": "string", "enum": ["hosted", "forward", "both"] },
          "forwardTo": {
            "type": "array",
            "maxItems": 5,
            "items": { "type": "string" },
            "description": "Required for forward and both. Must be empty or omitted for hosted. A single string is accepted from older clients."
          }
        }
      },
      "MailboxModeWrite": {
        "type": "object",
        "required": ["domainId", "mode"],
        "properties": {
          "domainId": { "type": "string" },
          "localPart": { "type": "string" },
          "mailboxId": { "type": "string", "description": "Used when localPart is omitted." },
          "mode": { "type": "string", "enum": ["hosted", "forward", "both"] },
          "forwardTo": {
            "type": "array",
            "maxItems": 5,
            "items": { "type": "string" }
          }
        }
      },
      "MailboxResult": {
        "type": "object",
        "required": ["ok", "mailbox"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "mailbox": { "$ref": "#/components/schemas/Mailbox" }
        }
      },
      "MailboxDelete": {
        "type": "object",
        "description": "Provide address, or domain (or domainId) plus localPart (or mailboxId).",
        "properties": {
          "address": { "type": "string", "description": "Full mailbox address." },
          "domain": { "type": "string" },
          "domainId": { "type": "string" },
          "localPart": { "type": "string" },
          "mailboxId": { "type": "string" }
        }
      },
      "MailboxDeleteResult": {
        "type": "object",
        "required": ["ok", "deleted", "mailbox"],
        "properties": {
          "ok": { "type": "boolean", "enum": [true] },
          "deleted": { "type": "boolean", "enum": [true] },
          "mailbox": {
            "type": "object",
            "required": ["address", "removed", "delivery", "mtaApplied", "maildir"],
            "properties": {
              "id": { "type": "string" },
              "localPart": { "type": "string" },
              "address": { "type": "string" },
              "domain": { "type": "string" },
              "removed": { "type": "boolean", "enum": [true] },
              "previousMode": { "type": "string", "enum": ["hosted", "forward", "both"] },
              "delivery": {
                "type": "object",
                "description": "Removal plan for the next mail server sync. vmailbox false and virtualAlias null drop the address from both maps. Routing drops about every 60 seconds. The Maildir is kept.",
                "required": ["vmailbox", "virtualAlias"],
                "properties": {
                  "vmailbox": { "type": "boolean", "enum": [false] },
                  "virtualAlias": { "type": "string", "nullable": true }
                }
              },
              "mtaApplied": { "type": "boolean", "enum": [false] },
              "maildir": {
                "type": "string",
                "enum": ["leave"],
                "description": "Mail already stored on the server is not deleted."
              },
              "updatedAt": { "type": "string", "format": "date-time" }
            }
          }
        }
      },
      "DomainList": {
        "type": "object",
        "required": ["ok", "domains"],
        "properties": {
          "ok": { "type": "boolean" },
          "domains": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string" },
                "name": { "type": "string" },
                "status": { "type": "string" },
                "mailboxes": {
                  "type": "array",
                  "items": { "$ref": "#/components/schemas/Mailbox" }
                }
              }
            }
          }
        }
      },
      "JsonRpcRequest": {
        "type": "object",
        "required": ["jsonrpc", "method"],
        "properties": {
          "jsonrpc": { "type": "string", "enum": ["2.0"] },
          "id": {},
          "method": { "type": "string" },
          "params": { "type": "object" }
        }
      }
    }
  }
}
