rmail

Production-grade mail server stack in pure Python 3 asyncio — SMTP, IMAP4rev1, outbound relay, and Exchange HTTP API.

Base URL

All API endpoints are served from the Exchange HTTP API. The base URL depends on your configuration:

https://mail.molodetz.nl:9003

All request and response bodies use Content-Type: application/json unless otherwise noted. CORS headers are included on all responses.

Authentication

The API uses stateless JWT tokens. Obtain a token by posting credentials to /auth/login, then include it as a Bearer token in subsequent requests.

Login

curl -s -X POST https://mail.molodetz.nl:9003/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"secret","domain":"example.com"}'

Response:

{
  "token": "eyJ1Ijoi...",
  "username": "alice",
  "domain": "example.com",
  "expires_in": 3600
}

The username field also accepts alice@example.com format; in that case domain can be omitted.

Using the token

curl -s https://mail.molodetz.nl:9003/folders \
  -H "Authorization: Bearer $TOKEN"

Admin access

Certain endpoints (user management, alias management) require admin privileges. A user is admin when their admin field is true in their account record. The first admin user must be created via CLI:

python -c "import asyncio; from rmail import auth; asyncio.run(auth.create_user('./mailstore', 'admin', 'example.com', 'password', admin=True))"

After that, admin users can create other admin users through the API.

Token configuration

The JWT signing secret is set in config.json under exchange_api.token_secret. If omitted, a random secret is generated on each server start (tokens will not survive restarts). Set a fixed value for persistent sessions:

{
  "exchange_api": {
    "token_secret": "your-64-char-hex-string"
  }
}

Error responses

Errors return a JSON body with an error field:

{"error": "Authentication required"}
StatusMeaning
400Bad request (invalid JSON, validation error)
401Missing or invalid token
403Insufficient privileges (admin required)
404Resource not found
413Payload too large / quota exceeded
429Rate limit exceeded
500Internal server error

Rate limiting

Per-IP rate limiting is enabled by default. Auth failures trigger a lockout after 5 attempts (configurable). Localhost is whitelisted by default.

Endpoint Reference

POST /auth/login

Auth: None

FieldTypeRequiredDescription
usernamestringYesUsername or email (user@domain)
passwordstringYesAccount password
domainstringNoDomain (inferred from username@ or first configured domain)

GET /folders

Auth: User

curl -s https://mail.molodetz.nl:9003/folders -H "Authorization: Bearer $TOKEN"

Response: {"folders": [{"name": "INBOX", "total": 42, "unread": 5}, ...]}

GET /folders/{folder}/messages

Auth: User

ParamTypeDefaultDescription
offsetint0Skip first N messages
limitint100Max messages to return (1-1000)
curl -s "https://mail.molodetz.nl:9003/folders/INBOX/messages?offset=0&limit=25" \
  -H "Authorization: Bearer $TOKEN"

POST /folders/{folder}/messages

Auth: User. Append a raw RFC822 message to the folder. Body is raw bytes, not JSON.

curl -s -X POST https://mail.molodetz.nl:9003/folders/Drafts/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: message/rfc822" \
  --data-binary @message.eml

GET /folders/{folder}/messages/{uid}

Auth: User. Returns raw RFC822 message bytes with Content-Type: message/rfc822.

DELETE /folders/{folder}/messages/{uid}

Auth: User. Permanently deletes the message.

curl -s -X DELETE https://mail.molodetz.nl:9003/folders/INBOX/messages/42 \
  -H "Authorization: Bearer $TOKEN"

PATCH /folders/{folder}/messages/{uid}/flags

Auth: User. Replaces the message's IMAP flags.

curl -s -X PATCH https://mail.molodetz.nl:9003/folders/INBOX/messages/42/flags \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"flags":["\\Seen","\\Flagged"]}'

POST /messages/send

Auth: User. Compose and deliver an email to local recipients. A copy is saved to Sent.

curl -s -X POST https://mail.molodetz.nl:9003/messages/send \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"to":["bob@example.com"],"subject":"Hello","body":"Message body."}'
FieldTypeRequiredDescription
tostring[]YesRecipient email addresses
subjectstringNoMessage subject
bodystringNoPlain text body

Alias Management

GET /aliases

Auth: User. Lists regex alias rules for the authenticated user's domain.

POST /aliases

Auth: Admin.

curl -s -X POST https://mail.molodetz.nl:9003/aliases \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"pattern":"sales-.*","target":"bob"}'

DELETE /aliases/{pattern}

Auth: Admin. URL-encode the pattern.

Admin — User Management

All endpoints require an admin JWT token (user with admin: true).

GET /admin/users?domain=example.com

List all users in a domain. Domain defaults to admin's own domain if omitted.

curl -s "https://mail.molodetz.nl:9003/admin/users?domain=example.com" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Response:

{
  "users": [
    {
      "username": "alice",
      "domain": "example.com",
      "enabled": true,
      "admin": false,
      "quota_mb": 1024,
      "created": "2025-01-15T10:30:00+00:00"
    }
  ]
}

POST /admin/users

Create a new user account.

curl -s -X POST https://mail.molodetz.nl:9003/admin/users \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"username":"bob","password":"secret123","domain":"example.com","admin":false,"quota_mb":2048}'
FieldTypeRequiredDefaultDescription
usernamestringYesLowercase, a-z0-9._-
passwordstringYes1-1024 bytes
domainstringYesMust be a valid domain
adminboolNofalseGrant admin privileges
quota_mbintNo1024Mailbox quota in MB (1-1048576)

GET /admin/users/{username}?domain=example.com

Get a single user's details.

PATCH /admin/users/{username}?domain=example.com

Update user fields. Only provided fields are changed.

curl -s -X PATCH "https://mail.molodetz.nl:9003/admin/users/bob?domain=example.com" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled":false}'
FieldTypeDescription
enabledboolEnable/disable the account
adminboolGrant/revoke admin privileges
quota_mbintMailbox quota in MB

DELETE /admin/users/{username}?domain=example.com

Permanently delete a user and all their data.

curl -s -X DELETE "https://mail.molodetz.nl:9003/admin/users/bob?domain=example.com" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

PUT /admin/users/{username}/password?domain=example.com

Change a user's password.

curl -s -X PUT "https://mail.molodetz.nl:9003/admin/users/bob/password?domain=example.com" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"password":"new-secure-password"}'

CalDAV

Full CalDAV server with read-write event access, sync tokens, recurrence, free-busy, and iMIP scheduling. Authentication uses HTTP Basic (user@domain:password).

Discovery

/.well-known/caldav 301-redirects to the principal collection per RFC 6764. Clients can autodiscover by configuring the email address only.

Allowed methods

OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, MKCALENDAR, PROPPATCH

OPTIONS returns DAV: 1, 2, 3, calendar-access, calendar-schedule, addressbook.

PROPFIND

Honors the Depth header (0, 1, infinity). Returns standard properties plus D:current-user-principal, C:calendar-home-set, C:calendar-user-address-set, CS:getctag, D:sync-token, I:calendar-color.

REPORT methods

PUT (create or update event)

curl -k -u alice@ex.com:secret -X PUT \
  -H "Content-Type: text/calendar" \
  --data-binary @event.ics \
  https://mail.molodetz.nl:9003/caldav/alice/calendars/default/MY-UID.ics

Honors If-None-Match: * (create-only) and If-Match: "etag" (lost-update protection). Returns the new ETag.

DELETE

Removes an event or an entire calendar. Honors If-Match.

MKCALENDAR

Creates a new calendar collection. Optional XML body sets D:displayname, I:calendar-color, C:calendar-description.

PROPPATCH

Updates calendar metadata (display name, color, description). Returns a multi-status with per-property success.

Sync tokens & ETags

Each calendar has an opaque sync_token that rotates on every write. ETags are content-hashed (SHA-256, 32 hex chars). Calendar ctag is a monotonic counter — clients use it for cheap "did anything change?" checks.

Recurrence

RRULE expansion uses python-dateutil. Time-range REPORTs return matching occurrences. RECURRENCE-ID overrides are stored alongside the master event in the same .ics file.

iMIP scheduling

Events with ATTENDEE properties trigger outgoing METHOD:REQUEST emails via the relay queue. Incoming text/calendar attachments in delivered email are auto-imported into the recipient's Schedule-Inbox calendar (uses RFC 6047 dispatch).

Reminders

A background scheduler scans every 60 seconds for VALARM components whose TRIGGER falls in the upcoming window, expanding RRULEs as needed, and delivers a reminder email to the user's INBOX.

Storage layout

{mail_root}/domains/{domain}/users/{username}/calendars/
├── default/
│   ├── calendar.json       (name, color, description)
│   ├── index.json          (ctag, sync_token, per-event metadata, tombstones)
│   └── events/
│       ├── {uid}.ics
│       └── ...
├── Schedule-Inbox/         (incoming invites land here)
└── ...

Index reads are lock-free; writes use fcntl.flock() on a sidecar lockfile and atomic os.rename().

curl -k -u "alice@example.com:password" \
  -X PROPFIND -H "Depth: 1" https://mail.molodetz.nl:9003/caldav/alice/calendars/

Drive (per-user file storage)

Each user has a private file store at https://mail.molodetz.nl:9003/drive/{path}. Same authentication as the rest of the API: HTTP Basic with Base64-encoded email:password, or Bearer JWT. The Basic flow makes the drive usable from arbitrary HTTP clients (curl, browsers via password prompt, automation scripts).

Auth example

EMAIL_B64=$(printf "alice@example.com:secret" | base64)
curl -k -H "Authorization: Basic $EMAIL_B64" https://mail.molodetz.nl:9003/drive/

Allowed methods

OPTIONS, GET, HEAD, PUT, POST, DELETE, MOVE, MKCOL, PROPFIND

Endpoints

MethodPathBehaviour
GET/drive/JSON listing of root directory
GET/drive/{path}Directory listing OR file content (depending on path type). Honors If-None-Match for files (returns 304).
HEAD/drive/{path}Metadata only.
PUT/drive/{path}Create or overwrite a file. Auto-creates missing parent directories. Honors If-Match and If-None-Match: *.
POST/drive/{path}?action=mkdirCreate a directory and any missing parents.
POST/drive/{path}multipart/form-data upload. Each filename= part lands at {path}/{filename}. Filenames may contain / for nested upload — useful for folder uploads from browsers using <input type="file" webkitdirectory>; each path component is validated independently so traversal remains impossible.
MKCOL/drive/{path}WebDAV-style mkdir.
DELETE/drive/{path}Delete a file or recursively a directory.
MOVE/drive/{src}With Destination: /drive/{dst} header. Rename or relocate. Overwrite: F blocks clobbering.
PROPFIND/drive/{path}JSON metadata + child listing for directories, metadata for files.

Storage layout

Each user's drive lives at {mail_root}/domains/{domain}/users/{username}/drive/ with this structure:

drive/
├── _meta.json               (ctag, sync_token, total_files, total_dirs, total_bytes)
└── files/
    ├── _index.json          (per-directory index, see below)
    ├── _index.lock          (fcntl flock sidecar for atomic updates)
    ├── docs/
    │   ├── _index.json
    │   └── readme.txt
    └── photos/
        ├── _index.json
        └── 2026/
            ├── _index.json
            └── trip.jpg

Per-directory index

Every directory has an _index.json with both self counters (children at this level) and total counters (recursive). Updates propagate from the leaf up to the root on every write, so any directory's totals are read-only and O(1):

{
  "version": 1,
  "name": "photos",
  "ctag": 12,
  "mtime": 1777191634.93,
  "self_files": 1,    "self_dirs": 1,    "self_bytes": 4823,
  "total_files": 5,   "total_dirs": 3,   "total_bytes": 1842917,
  "children": {
    "2026": {"type": "dir", "mtime": 1777191634.4},
    "thumb.jpg": {"type": "file", "size": 4823, "etag": "\"...\"",
                   "mtime": 1777191634.93, "content_type": "image/jpeg"}
  }
}

Writes use fcntl.flock() on a sidecar _index.lock file. File contents are written via temp + atomic os.rename(). ETags are content-hashed (SHA-256, 32 hex chars).

Path validation

Every path component is rejected if it contains .., /, \\, null bytes, or control characters. Reserved names (_index*, _meta.json, *.tmp) are blocked. Maximum path depth is 64 components, max component length 255 bytes (filesystem limit).

Quota integration

Drive bytes count toward the user's quota_mb alongside mailbox storage. Exceeding quota returns 413. The user-storage cache is invalidated on every drive write so quota checks stay accurate.

Webmail UI

Tile-based browser at https://mail.molodetz.nl:9003/webmail/drive/ with three upload controls in the toolbar:

Other features: breadcrumb navigation, folder tiles, file-type icons (photo, audio, video, archive, document, code), per-tile delete with confirmation, multi-select bulk delete via the same :has(input:checked) CSS pattern as the mail list, and a stats footer showing files / folders / bytes.

To avoid HTML5's nested-form restriction the bulk and per-tile delete forms are siblings of the toolbar; checkboxes and per-tile × buttons attach to them via the form="..." attribute.

Scraping selectors

section.drive-grid[data-path][data-total-files][data-total-dirs][data-total-bytes], .tile-wrap[data-path][data-type], .tile-name, .tile-meta, .drive-breadcrumb .crumb, .drive-stats, .tile-dir / .tile-file.

Sharing

Any file or folder (except the drive root) can be shared via a public link with a CRUD permission set. The link has the form https://mail.molodetz.nl:9003/drive/{uid}/{name}; the {name} segment is decorative — resolution uses only the random 32-character UID. Sub-paths under a shared folder use https://mail.molodetz.nl:9003/drive/{uid}/{name}/{subpath}.

Permission letters:

LetterAllows
RRead file content / list directory (always implicit; included automatically)
CCreate new files via PUT, new directories via POST/MKCOL (only meaningful for directory shares)
UOverwrite existing files
DDelete files / sub-directories beneath the share root (the share root itself cannot be deleted via the link)

Share API

MethodPathBehaviour
POST/drive/_shareCreate a share. JSON body: {"target_path":"docs/foo.txt","name":"Foo","permissions":"RU","expires":null}. Name defaults to the last component of the target. Returns the share record including the public URL.
GET/drive/_shareList the caller's shares.
GET/drive/_share/{uid}Get a single share's metadata (owner-only).
PATCH/drive/_share/{uid}Update name/permissions/expiry (owner-only).
DELETE/drive/_share/{uid}Revoke a share.

Share access

The same drive verbs work against the shared resource without authentication, gated by the share's permissions:

EMAIL_B64=$(printf "alice@example.com:secret" | base64)
# Create a public read-only link to a file
curl -k -X POST -H "Authorization: Basic $EMAIL_B64" \
     -H "Content-Type: application/json" \
     --data '{"target_path":"docs/budget.xlsx","name":"Q3 Budget","permissions":"R"}' \
     https://mail.molodetz.nl:9003/drive/_share

# Share a folder with read+create+delete permission, no overwrites
curl -k -X POST -H "Authorization: Basic $EMAIL_B64" \
     -H "Content-Type: application/json" \
     --data '{"target_path":"projects/alpha","permissions":"RCD"}' \
     https://mail.molodetz.nl:9003/drive/_share

# Anyone can download the file (no auth needed)
curl -k https://mail.molodetz.nl:9003/drive/QZGu_wPlp_OXbgKmw4aPreneMO3-scJi/Q3%20Budget

Storage layout for shares

{mail_root}/shares/{uid[:2]}/{uid}.json    (sharded by 2-char prefix; one file per share)
{mail_root}/domains/{domain}/users/{username}/drive/_shares.json  (per-owner index of UIDs)

Lookup by UID is O(1); listing the caller's shares is O(N_owner). Each per-share file holds the entire share record (owner, target_path, name, permissions, created/expires timestamps); the per-owner index holds only UIDs.

Path-traversal safety in shares

Webmail UI for sharing

Webmail Calendar UI

Available at https://mail.molodetz.nl:9003/webmail/calendar. Supports four views — month, week, day, agenda — switchable via /webmail/calendar/{view}. Multi-calendar visibility is per-user persistent.

Scraping selectors: .cal-grid.month, .cal-grid.week, .cal-grid.day, .agenda-list, .cal-event[data-uid][data-calendar], .event-detail[data-uid][data-calendar], .cal-row[data-calendar][data-visible].

Webmail

A server-rendered HTML webmail interface is available at:

https://mail.molodetz.nl:9003/webmail/login

Features: folder browsing, message reading with HTML rendering, attachments, compose, reply, forward, delete, move, flag management, calendar view, and user registration.

Scraping and Automation

The webmail HTML is designed to be machine-readable. All links use absolute URLs (including scheme, hostname, and port), so scrapers do not need to resolve relative paths. Semantic HTML5 elements and data attributes provide stable selectors for automated extraction.

Page structure

ElementSelectorDescription
Sidebar<aside class="sidebar">Folder navigation panel
Main content<main class="content">Primary content area
Current user<span class="current-user">Logged-in email address

Folder list (sidebar)

Each folder is an <li> with class and data attributes:

<li class="folder-item active"
    data-folder="INBOX"
    data-total="42"
    data-unread="5">
  <a href="https://mail.example.com:9003/webmail/INBOX">INBOX <span class="count">5</span></a>
</li>
AttributeDescription
data-folderFolder name
data-totalTotal message count
data-unreadUnread message count
.activePresent when this folder is selected

Message list page

The message table is wrapped in a <section> with folder metadata:

<section class="message-list" data-folder="INBOX" data-total="42">

Each message is a table row with structured data:

<tr class="message-row unread flagged"
    data-uid="137"
    data-folder="INBOX"
    data-flags="\Flagged">
  <td class="col-check">...</td>
  <td class="col-star flagged">...</td>
  <td class="col-from"><span class="message-from">alice@example.com</span></td>
  <td class="col-subject"><a class="message-link" href="..."><span class="message-subject">Hello</span></a></td>
  <td class="col-date"><time class="message-date">Apr 13</time></td>
</tr>
SelectorDescription
tr.message-rowEach message row
tr.unreadMessage has not been read
tr.flaggedMessage is starred/flagged
data-uidIMAP UID of the message
data-folderContaining folder name
data-flagsSpace-separated IMAP flags
.message-fromSender display name or address
.message-subjectSubject line text
.message-dateDate display string
a.message-linkAbsolute URL to message detail page

Pagination

<span class="page-info" data-total="42" data-offset="0" data-limit="50">1–42 of 42</span>
<nav class="pagination">
  <a class="pagination-prev" href="...">...</a>
  <a class="pagination-next" href="...">...</a>
</nav>

Message detail page

The message body is wrapped in an <article> with metadata:

<article class="msg-detail" data-uid="137" data-folder="INBOX">
  <header class="msg-header">
    <h2 class="msg-subject">Hello</h2>
    <div class="msg-meta">
      <div class="msg-from">...<span class="message-from">alice@example.com</span></div>
      <div class="msg-to">...<span class="message-to">bob@example.com</span></div>
      <div class="msg-cc">...<span class="message-cc">...</span></div>
      <div class="msg-date">...<time class="message-date">Sun, 13 Apr 2025 14:30:00</time></div>
    </div>
  </header>
  <div class="msg-body">...</div>
</article>

Attachments

<section class="attachments" data-count="2">
  <span class="attachment-item" data-index="0" data-filename="report.pdf">
    <a class="attachment-link" href="https://...">report.pdf</a> (1.2 MB)
  </span>
</section>

Action buttons

Toolbar actions on the detail page use distinct CSS classes for targeting:

ClassAction
.action-replyReply link
.action-forwardForward link
.action-deleteDelete button (form submit)
.action-moveMove button (form submit)
.action-toggle-readToggle read/unread (form submit)
.action-toggle-starToggle starred flag (form submit)

Autoconfig / Autodiscover

GET /.well-known/autoconfig/mail/config-v1.1.xml

Thunderbird autoconfig XML.

POST /autodiscover/autodiscover.xml

Microsoft Exchange autodiscover XML.

Complete Endpoint Table

MethodPathAuthDescription
POST/auth/loginNoneAuthenticate, returns JWT
GET/foldersUserList folders with counts
GET/folders/{folder}/messagesUserList messages (offset, limit)
POST/folders/{folder}/messagesUserAppend raw RFC822 message
GET/folders/{folder}/messages/{uid}UserFetch raw message
DELETE/folders/{folder}/messages/{uid}UserDelete message
PATCH/folders/{folder}/messages/{uid}/flagsUserReplace flags
POST/messages/sendUserCompose and send
GET/aliasesUserList domain aliases
POST/aliasesAdminAdd alias rule
DELETE/aliases/{pattern}AdminRemove alias
GET/admin/usersAdminList users
POST/admin/usersAdminCreate user
GET/admin/users/{username}AdminGet user details
PATCH/admin/users/{username}AdminUpdate user
DELETE/admin/users/{username}AdminDelete user
PUT/admin/users/{username}/passwordAdminChange password
PROPFIND/caldav/{user}/BasicUser principal
PROPFIND/caldav/{user}/calendars/BasicCalendar home
PROPFIND/caldav/{user}/calendars/{cal}/BasicCalendar collection
GET/caldav/{user}/calendars/{cal}/{event}.icsBasicSingle event
REPORT/caldav/{user}/calendars/{cal}/BasicCalendar report
GET/docs/{page}NoneDocumentation
GET/webmail/*CookieWebmail interface
GET/.well-known/autoconfig/mail/config-v1.1.xmlNoneThunderbird autoconfig
POST/autodiscover/autodiscover.xmlNoneExchange autodiscover
OPTIONS*NoneCORS preflight (204)

Domain Configuration Reference

For each configured domain, clients should use the following settings:

ProtocolServerPortSecurity
IMAPhttps://mail.molodetz.nl:9003993SSL/TLS
IMAPhttps://mail.molodetz.nl:9003143STARTTLS
SMTP (send)https://mail.molodetz.nl:9003587STARTTLS
SMTP (send)https://mail.molodetz.nl:9003465SSL/TLS
Exchange APIhttps://mail.molodetz.nl:9003
CalDAVhttps://mail.molodetz.nl:9003/caldav/{username}/calendars/
Webmailhttps://mail.molodetz.nl:9003/webmail/login