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 returns404 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
- Authentication
- Conventions
- Endpoints
- Data types
- Access logging
- OpenAPI and Swagger UI
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
- Create — a user creates a key with a descriptive
Nameand an optional expiry. The response shows the plaintext key once. - 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). - Expire — if
ExpiresAtis set and in the past, the key is rejected. - Revoke — the owning user or an administrator revokes the key
(
RevokedAtis 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
- Create — a user creates a key with a descriptive
Nameand an optional expiry. The response shows the plaintext key once. - 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). - Expire — if
ExpiresAtis set and in the past, the key is rejected. - Revoke — the owning user or an administrator revokes the key
(
RevokedAtis 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 return404, not403, 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/apiroutes, 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
}
pageis 1-based. Out-of-range pages return an emptyitemsarray (not an error).pageSizedefaults toApi:DefaultPageSizeand 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
$KEYholds 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
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.