Skip to content

Read-Only REST API (v1)

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 | Setting | Default | Description | | --- | --- | --- | | Enabled | false | Master switch. When false, all /api/* routes return 404 and the OpenAPI document / Swagger UI are not mapped. | | AllowAttachmentDownloads | true | When false, the attachment download endpoint returns 403. Metadata about attachments is still returned by the message detail endpoint. | | EnableSwaggerUi | true | When 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. | | DefaultPageSize | 20 | Page size used when a request omits pageSize. | | MaxPageSize | 100 | Upper bound; larger pageSize values are clamped to this. | | RateLimitPerMinute | 120 | Fixed-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"
}
Status When
400 Bad Request Malformed parameters (e.g. unparseable date, invalid sortBy).
401 Unauthorized Missing, malformed, expired, revoked or otherwise invalid key. Includes WWW-Authenticate: Bearer.
403 Forbidden Attachment downloads requested while AllowAttachmentDownloads=false.
404 Not Found Resource does not exist or is outside the caller's permitted accounts. Also returned for every /api/* route when Api:Enabled=false.
429 Too Many Requests Rate limit exceeded.
500 Internal Server Error Unexpected 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 parameter Type Description
q string Full-text query over subject, body, from, to, cc, bcc. Supports field syntax, e.g. subject:invoice, from:alice@example.com.
from date Earliest sent date (inclusive), YYYY-MM-DD.
to date Latest sent date (inclusive), YYYY-MM-DD.
accountId int Restrict to one account (must be visible to the caller).
folder string Restrict to one folder by full path.
direction string incoming or outgoing.
page int 1-based page number (default 1).
pageSize int Results per page (default Api:DefaultPageSize, clamped to Api:MaxPageSize).
sortBy string One of sentDate (default), receivedDate, subject, from, to.
sortOrder string asc 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) → BodyUntruncatedHtml → HtmlBody, 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

Stats

GET /api/v1/stats

Returns aggregate archive statistics visible to the caller: total archived emails, visible mail accounts, attachments on those emails, and the total PostgreSQL database size in megabytes. For non-admin users the counts are scoped to the mail accounts assigned to them (the same scoping the web dashboard applies); administrators see global totals.

For non-admin API keys, databaseSizeInMB is an empty string: the database size describes the whole instance, not the key's scope. Emails, Accounts and Attachments are always scoped to the accounts the key may access.

curl -H "Authorization: Bearer $KEY" https://host/api/v1/stats
{
  "emails": 160132,
  "accounts": 4,
  "attachments": 43540,
  "databaseSizeInMB": "5432"
}

StatsDto

Field Type Notes
emails int Archived messages visible to the caller
accounts int Mail accounts visible to the caller
attachments int Attachments on those visible messages
databaseSizeInMB string Whole PostgreSQL database size in MB; empty for non-admin keys

Data types

MailAccountDto

Field Type Notes
id int
name string
emailAddress string
provider string IMAP, M365, …
isEnabled bool
lastSync datetime UTC

No credential fields are ever serialized.

FolderNodeDto

Field Type Notes
name string Leaf name
fullPath string Full path
totalCount int Messages in folder
level int Nesting depth (0 = root)
children FolderNodeDto[] Sub-folders

EmailSummaryDto

Field Type
id int
accountId int
subject string
from string
to string
sentDate datetime
isOutgoing bool
hasAttachments bool
folderName string

EmailDetailDto

All of EmailSummaryDto plus:

Field Type Notes
cc string
bcc string
receivedDate datetime
messageId string
textBody string Resolved via the body fallback chain
htmlBody string Resolved via the body fallback chain
attachments AttachmentDto[] Metadata only

AttachmentDto

Field Type Notes
id int
fileName string
contentType string
size long Bytes

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:

Endpoint Logged type
Search messages Search
Get message Open
Download attachment Download

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.

Endpoint reference on this site

The endpoints and data types are also published here as a generated page: API Reference. It is rendered from the same OpenAPI document — a copy is kept at doc/assets/openapi-v1.json — by tools/gen_api_reference.py. CI fails when the page and the specification fall out of sync, so the reference always describes the API that actually ships.

Regenerating

The page is generated output. Change the API (or the generator), then run python3 tools/gen_api_reference.py and commit the result.

Refreshing the vendored specification itself means re-running the build-time document generation described in tools/README.md.