Read-Only REST API (v1)

July 12, 2026 · View on GitHub

Mail Archiver ships an optional, read-only REST API for programmatic access to archived mail. It is designed for automation and integration use cases (reporting, e-discovery exports, monitoring) without going through the web UI.

The point is to let a script or AI agent read archived mail without ever exposing mailbox credentials or talking to the mail provider. A scoped per-user API key is a capability to read the archive, not a credential to be the mailbox — it inherits only its owner's mailbox permissions, grants no write path, and can be revoked instantly without touching the underlying mail account.

⚠️ The API is disabled by default. It must be explicitly enabled in configuration (see Enabling the API). When disabled, every /api/* route returns 404 Not Found.

The API is read-only by design: it exposes accounts, folders, message search, message detail and attachment downloads. It never creates, modifies or deletes data.

Table of contents

Enabling the API

The API is configured under the Api section of appsettings.json (or the equivalent environment variables, e.g. Api__Enabled=true):

"Api": {
  "Enabled": false,
  "AllowAttachmentDownloads": true,
  "EnableSwaggerUi": true,
  "DefaultPageSize": 20,
  "MaxPageSize": 100,
  "RateLimitPerMinute": 120
}

Key lifecycle

  1. Create — a user creates a key with a descriptive Name and an optional expiry. The response shows the plaintext key once.
  2. Use — send the key as a bearer token. Each accepted request refreshes the key's LastUsedAt (writes are throttled to at most once every ~5 minutes to avoid a write on every call).
  3. Expire — if ExpiresAt is set and in the past, the key is rejected.
  4. Revoke — the owning user or an administrator revokes the key (RevokedAt is set); it is immediately and permanently rejected.

A key is active when it is not revoked, not expired, and its owning user is still active. A key inactive for any of these reasons is rejected with 401.

Permission model

An API key inherits the exact permissions of the user who owns it. The authenticated principal carries the same claims a normal web login issues, so all existing authoriz

SettingDefaultDescription
EnabledfalseMaster switch. When false, all /api/* routes return 404 and the OpenAPI document / Swagger UI are not mapped.
AllowAttachmentDownloadstrueWhen false, the attachment download endpoint returns 403. Metadata about attachments is still returned by the message detail endpoint.
EnableSwaggerUitrueWhen true (and Enabled is true), mounts Swagger UI at /apidocs and the OpenAPI document at /apidocs/spec/v1.json. Both require an authenticated web (cookie) session — they are not part of /api/ and are not reachable with an API key.
DefaultPageSize20Page size used when a request omits pageSize.
MaxPageSize100Upper bound; larger pageSize values are clamped to this.
RateLimitPerMinute120Fixed-window request budget per API key per minute (see Rate limiting).

Authentication

The API authenticates with per-user API keys sent as a bearer token:

Authorization: Bearer ma_<43 url-safe base64 characters>

/api/v1 accepts API keys only. Browser cookies are not honoured, so the API has no CSRF surface. Authentication failures return 401 with a WWW-Authenticate: Bearer header and a problem+json body — the API never redirects to the login page.

Keys are managed self-service in the web UI under API Keys (each user manages their own keys; administrators can view and revoke any user's keys).

API key format

A key is the string ma_ followed by 43 URL-safe base64 characters encoding 32 random bytes (256 bits of entropy):

ma_8Kf3pQ2sV9xT1bN6mW0rZ4yH7jL5dC8gA3eU2iO1kP
└┬┘└──────────────────────┬──────────────────────┘
prefix          43 chars base64url(32 random bytes)

Storage:

  • KeyPrefix — the first ~11 characters (e.g. ma_8Kf3pQ2), stored in plaintext. Used to identify a key in the UI and in logs. Not a secret.
  • KeyHash — the hex-encoded SHA-256 of the full key, stored under a unique index. The plaintext key is never stored.

The full key is shown to the user exactly once, at creation time. It cannot be retrieved afterwards; a lost key must be revoked and replaced.

Key lifecycle

  1. Create — a user creates a key with a descriptive Name and an optional expiry. The response shows the plaintext key once.
  2. Use — send the key as a bearer token. Each accepted request refreshes the key's LastUsedAt (writes are throttled to at most once every ~5 minutes to avoid a write on every call).
  3. Expire — if ExpiresAt is set and in the past, the key is rejected.
  4. Revoke — the owning user or an administrator revokes the key (RevokedAt is set); it is immediately and permanently rejected.

A key is active when it is not revoked, not expired, and its owning user is still active. A key inactive for any of these reasons is rejected with 401.

Permission model

An API key inherits the exact permissions of the user who owns it. The authenticated principal carries the same claims a normal web login issues, so all existing authorization logic applies unchanged:

  • Administrators (IsAdmin) see all mail accounts and all messages.
  • Regular users see only the mail accounts assigned to them (UserMailAccount). Accounts and messages outside that set are treated as non-existent: requests for them return 404, not 403, so the API does not leak the existence of accounts a caller cannot see.

Security rationale

  • 2FA bypass. API keys bypass two-factor authentication by design — this is inherent to non-interactive tokens (the same is true of personal access tokens on GitHub/GitLab). It is mitigated by: keys being off by default, revocable, optionally expiring, scoped to the user's permissions, and every access being recorded in the access log.
  • No cookies on /api. Cookie authentication is not accepted on /api routes, eliminating CSRF concerns for the API.
  • Transport. Always serve the API over HTTPS in production so bearer tokens are not exposed on the wire.

Conventions

Base URL and versioning

All endpoints are under /api/v1. The version is in the path so future breaking changes can ship as /api/v2 without disturbing existing clients.

Responses are application/json unless noted (attachment downloads return the attachment's own content type). All timestamps are UTC, ISO 8601 (2026-06-11T09:30:00Z).

Pagination

List endpoints return a pagination envelope:

{
  "items": [ /* ... */ ],
  "page": 1,
  "pageSize": 20,
  "totalItems": 137,
  "totalPages": 7
}
  • page is 1-based. Out-of-range pages return an empty items array (not an error).
  • pageSize defaults to Api:DefaultPageSize and is clamped to [1, Api:MaxPageSize].

Errors

Errors use RFC 7807 application/problem+json:

{
  "type": "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1",
  "title": "Bad Request",
  "status": 400,
  "detail": "Unknown sortBy value 'foo'.",
  "instance": "/api/v1/emails"
}
StatusWhen
400 Bad RequestMalformed parameters (e.g. unparseable date, invalid sortBy).
401 UnauthorizedMissing, malformed, expired, revoked or otherwise invalid key. Includes WWW-Authenticate: Bearer.
403 ForbiddenAttachment downloads requested while AllowAttachmentDownloads=false.
404 Not FoundResource does not exist or is outside the caller's permitted accounts. Also returned for every /api/* route when Api:Enabled=false.
429 Too Many RequestsRate limit exceeded.
500 Internal Server ErrorUnexpected server error (problem+json, never the HTML error page).

Rate limiting

Requests are limited by a fixed-window policy of Api:RateLimitPerMinute requests per minute (default 120), partitioned by API key prefix (falling back to client IP when no key is present). Exceeding the budget returns 429.

Endpoints

All examples assume the API is enabled and $KEY holds a valid key.

List accounts

GET /api/v1/accounts

Returns the mail accounts visible to the caller. Credential fields (IMAP/OAuth username, password, client secret, …) are never included.

curl -H "Authorization: Bearer $KEY" https://host/api/v1/accounts
[
  {
    "id": 1,
    "name": "Support Mailbox",
    "emailAddress": "support@example.com",
    "provider": "IMAP",
    "isEnabled": true,
    "lastSync": "2026-06-11T08:15:00Z"
  }
]

List folders

GET /api/v1/accounts/{id}/folders

Returns the folder tree for one account as a nested structure. Returns 404 if the account does not exist or is not visible to the caller.

[
  {
    "name": "INBOX",
    "fullPath": "INBOX",
    "totalCount": 4213,
    "level": 0,
    "children": [
      { "name": "Work", "fullPath": "INBOX/Work", "totalCount": 512, "level": 1, "children": [] }
    ]
  }
]

Search messages

GET /api/v1/emails

Searches archived messages with the same capabilities as the web UI.

Query parameterTypeDescription
qstringFull-text query over subject, body, from, to, cc, bcc. Supports field syntax, e.g. subject:invoice, from:alice@example.com.
fromdateEarliest sent date (inclusive), YYYY-MM-DD.
todateLatest sent date (inclusive), YYYY-MM-DD.
accountIdintRestrict to one account (must be visible to the caller).
folderstringRestrict to one folder by full path.
directionstringincoming or outgoing.
pageint1-based page number (default 1).
pageSizeintResults per page (default Api:DefaultPageSize, clamped to Api:MaxPageSize).
sortBystringOne of sentDate (default), receivedDate, subject, from, to.
sortOrderstringasc or desc (default desc).

Returns a paged envelope of EmailSummaryDto. Logs an access log entry of type Search.

curl -H "Authorization: Bearer $KEY" \
  "https://host/api/v1/emails?q=subject:invoice&from=2026-01-01&pageSize=50"
{
  "items": [
    {
      "id": 9876,
      "accountId": 1,
      "subject": "Invoice #4711",
      "from": "billing@vendor.com",
      "to": "support@example.com",
      "sentDate": "2026-03-02T11:04:00Z",
      "isOutgoing": false,
      "hasAttachments": true,
      "folderName": "INBOX"
    }
  ],
  "page": 1,
  "pageSize": 50,
  "totalItems": 3,
  "totalPages": 1
}

Get message

GET /api/v1/emails/{id}

Returns the full message including bodies. The body uses the same fallback chain as the web UI: OriginalBodyHtml (raw bytes) → BodyUntruncatedHtmlHtmlBody, and the equivalent for the text body. Returns 404 if the message does not exist or belongs to an account outside the caller's permissions. Logs an access log entry of type Open.

Returns an EmailDetailDto.

Download attachment

GET /api/v1/emails/{id}/attachments/{attachmentId}

Streams the attachment bytes with its stored content type and filename. Returns 404 if the message or attachment does not exist or is not visible to the caller. Returns 403 (problem+json) when Api:AllowAttachmentDownloads=false. Logs an access log entry of type Download.

curl -H "Authorization: Bearer $KEY" -OJ \
  https://host/api/v1/emails/9876/attachments/12

Data types

MailAccountDto

FieldTypeNotes
idint
namestring
emailAddressstring
providerstringIMAP, M365, …
isEnabledbool
lastSyncdatetimeUTC

No credential fields are ever serialized.

FolderNodeDto

FieldTypeNotes
namestringLeaf name
fullPathstringFull path
totalCountintMessages in folder
levelintNesting depth (0 = root)
childrenFolderNodeDto[]Sub-folders

EmailSummaryDto

FieldType
idint
accountIdint
subjectstring
fromstring
tostring
sentDatedatetime
isOutgoingbool
hasAttachmentsbool
folderNamestring

EmailDetailDto

All of EmailSummaryDto plus:

FieldTypeNotes
ccstring
bccstring
receivedDatedatetime
messageIdstring
textBodystringResolved via the body fallback chain
htmlBodystringResolved via the body fallback chain
attachmentsAttachmentDto[]Metadata only

AttachmentDto

FieldTypeNotes
idint
fileNamestring
contentTypestring
sizelongBytes

Attachment bytes are obtained from the download endpoint, not from this DTO.

Access logging

Every data access through the API is recorded in the same access log used by the web UI (visible under Logs), attributed to the key's owning user:

EndpointLogged type
Search messagesSearch
Get messageOpen
Download attachmentDownload

OpenAPI and Swagger UI

When the API is enabled and Api:EnableSwaggerUi=true:

  • The OpenAPI v1 document is served at /apidocs/spec/v1.json.
  • Swagger UI is served at /apidocs.

Both paths sit outside /api/ and are therefore gated by the normal web (cookie) authentication — a logged-in browser session is required to view them, and they cannot be reached with an API key.