Ferroma
Internals · Documentation

Storage

Who should read this: anyone touching ferroma-storage, anyone writing SQL against a Ferroma database, and any operator reasoning about quota, disk usage, garbage collection or restore.

Ferroma keeps two stores. PostgreSQL holds every fact the application queries — who exists, which message is in which folder, what state a delivery is in. The filesystem holds the bytes: RFC 5322 messages in a Maildir and attachments in a content-addressed blob store. This document states which store is authoritative for what, walks the schema table by table with the index that serves each query, specifies the Maildir delivery algorithm and its durability guarantee, explains quota accounting and where it is enforced, describes attachment content-addressing and GC, and gives the backup model and the integrity-check story.

Status: describes implemented code. ferroma-storage is complete: crates/ferroma-storage/src/{database,maildir,attachment,error,models}.rs and crates/ferroma-storage/src/repository/*.rs. The schema is migrations/0001_initial.sql, which is the single migration in the repository and the source of every table and column named below. Two things are (planned): the ferroma storage CLI subcommands and the API endpoints that drive GC and integrity checks. Where a pg_dump/psql command is given instead, it is a real command you can run today.


1. Two stores, one truth

                        ┌──────────────────────────────┐
                        │        PostgreSQL            │
   authoritative for:   │  users, domains, mailboxes,  │
   *what exists*        │  folders, messages,          │
                        │  message_recipients,         │
                        │  attachments, mail_queue,    │
                        │  delivery_attempts, devices, │
                        │  sessions, client_sync_      │
                        │  states, drafts, operations, │
                        │  change_log, audit_logs,     │
                        │  login_attempts, settings    │
                        └──────────────┬───────────────┘
                                       │  messages.storage_path
                                       │  attachments.storage_path
                                       ▼
                        ┌──────────────────────────────┐
   authoritative for:   │        Filesystem            │
   *the bytes*          │                              │
                        │  Maildir:                    │
                        │    <root>/<domain>/<local>/  │
                        │      Maildir/{cur,new,tmp}   │
                        │      Maildir/.Folder/{…}     │
                        │                              │
                        │  Blob store:                 │
                        │    <root>/ab/cd/<sha256>     │
                        └──────────────────────────────┘

The division is stated in migrations/0001_initial.sql's header and implemented in crates/ferroma-storage/src/lib.rs:

The database is authoritative for what exists; the filesystem is authoritative for the bytes. A row without its file is a StorageError::BodyMissing; a file without its row is garbage collected by AttachmentStore::gc and Maildir::sweep_tmp.

QuestionAnswer comes from
Does [email protected] exist?mailboxes (+ domains.enabled)
Which folders does she have?folders
How many unread?folders.unseen_count
What is message 4821's subject?messages.subject
What is message 4821's UID?messages.uid
What are message 4821's bytes?the file at messages.storage_path under the Maildir root
What is attachment 9's content?the file at attachments.storage_path under the blob root
Was it delivered?mail_queue.status

Roots come from the config, not from the database: Config::maildir_root() is storage.maildir_root or <server.data_dir>/mail, and Config::attachment_root() is storage.attachment_root or <server.data_dir>/attachments.

Every path column is relative. messages.storage_path looks like example.com/alice/Maildir/cur/1758012751.M4821P3210.mail:2,S — relative to the Maildir root, and in a fixed form: forward slashes, always (Maildir::relative joins with /). That is what makes a data directory relocatable: move the root, update server.data_dir, and every path still resolves.


2. The schema, table by table

One migration: migrations/0001_initial.sql, 440 lines, applied at startup when database.run_migrations = true. It targets PostgreSQL 14+ and uses only core gen_random_uuid()-era features — no extensions to install.

2.1 Identity

users

ColumnTypeNotes
idBIGSERIAL PK
emailTEXT NOT NULLunique, lower-cased
password_hashTEXT NOT NULLan Argon2id PHC string — see security.md §2
display_nameTEXT
enabledBOOLEAN NOT NULL DEFAULT TRUEa disabled account cannot log in
is_adminBOOLEAN NOT NULL DEFAULT FALSE
quota_bytesBIGINT NOT NULL DEFAULT 10737418241 GiB
used_bytesBIGINT NOT NULL DEFAULT 0denormalised cache of the Maildir total
failed_loginsINTEGER NOT NULL DEFAULT 0consecutive failures
locked_untilTIMESTAMPTZset by record_login_failure
last_login_atTIMESTAMPTZ
created_at, updated_atTIMESTAMPTZ NOT NULL DEFAULT NOW()

Constraints: users_email_lowercase CHECK (email = lower(email)), users_email_not_blank CHECK (length(btrim(email)) > 3), users_quota_sane CHECK (quota_bytes >= 0), users_used_sane CHECK (used_bytes >= 0).

Type: User in crates/ferroma-storage/src/models.rs; helper User::is_login_allowed(now) reads enabled and locked_until.

domains

ColumnTypeNotes
idBIGSERIAL PK
nameTEXT NOT NULLunique, lower-cased
descriptionTEXT
enabledBOOLEAN NOT NULL DEFAULT TRUEa disabled domain receives no mail
catch_allTEXTlocal part that receives mail addressed to a non-existent mailbox in this domain
dkim_selectorTEXTper-domain selector
dkim_private_key, dkim_public_keyTEXTPEM; see security.md §9
created_at, updated_atTIMESTAMPTZ

domains_name_lowercase, domains_name_not_blank mirror the users checks.

DKIM private keys live in the database here, and in [dkim] private_key_path in the config. Both are supported; the database column is what GET /api/v1/domains/:id/dkim reads, and the file is what the signer prefers when dkim.enabled is set. Back up both — see §8.

2.2 Addresses, aliases, folders

mailboxes — an address, not a folder

ColumnTypeNotes
idBIGSERIAL PK
user_idBIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE
domain_idBIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE
local_partTEXT NOT NULLlower-cased
display_nameTEXT
enabledBOOLEAN NOT NULL DEFAULT TRUE
is_primaryBOOLEAN NOT NULL DEFAULT FALSE
quota_bytesBIGINTNULL = inherit the owner's
created_at, updated_atTIMESTAMPTZ

This is the SMTP RCPT TO target and the unit of quota accounting. It is not an IMAP folder; see architecture.md §9.1 for why the specification's §37 sketch was split.

aliases

ColumnTypeNotes
idBIGSERIAL PK
domain_idBIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE
local_partTEXT NOT NULLlower-cased
targetTEXT NOT NULLfull destination address; a bare local part means "same domain"
enabledBOOLEAN NOT NULL DEFAULT TRUE
created_atTIMESTAMPTZ

folders — the IMAP folder

ColumnTypeNotes
idBIGSERIAL PK
mailbox_idBIGINT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADEthe owning address
nameTEXT NOT NULLIMAP name, e.g. Archive/2026
parent_idBIGINT REFERENCES folders(id) ON DELETE CASCADEhierarchy
special_useTEXT\Sent, \Drafts, \Trash, \Junk, \Archive, \All, \Flagged, or NULL
subscribedBOOLEAN NOT NULL DEFAULT TRUELSUB
uid_validityBIGINT NOT NULL DEFAULT 1IMAP UID generation
uid_nextBIGINT NOT NULL DEFAULT 1the UID allocator
highest_modseqBIGINT NOT NULL DEFAULT 1reserved for CONDSTORE
message_countINTEGER NOT NULL DEFAULT 0counter, kept by recount
unseen_countINTEGER NOT NULL DEFAULT 0counter
total_bytesBIGINT NOT NULL DEFAULT 0counter
created_at, updated_atTIMESTAMPTZ

Constraints: folders_name_not_blank, and folders_special_use_known restricting special_use to the seven values above.

2.3 Messages

messages

ColumnTypeNotes
idBIGSERIAL PKtyped MessageId; returned by the API as message_id
folder_idBIGINT NOT NULL REFERENCES folders(id) ON DELETE CASCADEthe authoritative parent
mailbox_idBIGINT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADEdenormalised copy of folders.mailbox_id
uidBIGINT NOT NULLIMAP UID, unique per folder
rfc_message_idTEXTthe RFC 5322 Message-ID header — architecture.md §9.2
thread_idTEXTroot Message-ID of the References chain
subjectTEXTdecoded
senderTEXTthe From address
sender_nameTEXTthe From display name
snippetTEXTshort, body-free preview for list views and notifications
size_bytesBIGINT NOT NULL
storage_pathTEXT NOT NULLrelative to the Maildir root
checksum_sha256TEXTlower-case hex, when storage.checksum = true
flagsTEXT NOT NULL DEFAULT ''Flags::to_db_string() form, e.g. seen,flagged
internal_dateTIMESTAMPTZ NOT NULL DEFAULT NOW()IMAP INTERNALDATE
received_atTIMESTAMPTZ NOT NULL DEFAULT NOW()when Ferroma accepted it
sent_atTIMESTAMPTZthe Date header, when parseable
has_attachments, attachment_countBOOLEAN / INTEGERdenormalised for list views
is_draftBOOLEAN NOT NULL DEFAULT FALSE
modseqBIGINT NOT NULL DEFAULT 1reserved for CONDSTORE
deleted_atTIMESTAMPTZsoft delete (\Deleted), before expunge
expunged_atTIMESTAMPTZgone from the client's view; the row and file may still exist
created_at, updated_atTIMESTAMPTZ

Constraints: messages_size_sane CHECK (size_bytes >= 0), messages_uid_sane CHECK (uid > 0).

The two-stage deletion is worth spelling out, because it is the difference between "the user marked it deleted" and "the message is gone":

live            expunged_at IS NULL AND deleted_at IS NULL
\Deleted        deleted_at  IS NOT NULL   (IMAP STORE +FLAGS \Deleted, API DELETE)
expunged        expunged_at IS NOT NULL   (IMAP EXPUNGE, API DELETE ?permanent=true)

Every read path filters expunged_at IS NULLfind_by_uid, list_by_uids, list_by_folder, list_unexpunged, count_by_folder, newest, search. Rows are removed by hard_delete, and only after the Maildir file has been unlinked.

message_recipients

ColumnTypeNotes
idBIGSERIAL PK
message_idBIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE
kindTEXT NOT NULLto, cc, bcc, reply-to, sender
addressTEXT NOT NULL
display_nameTEXT
ordinalINTEGER NOT NULL DEFAULT 0preserves the header's order

message_recipients_kind_known restricts kind. This table exists so that IMAP SEARCH TO/CC/BCC, the API's recipient filter and the Admin search are one indexed query instead of a header re-parse per message.

attachments

ColumnTypeNotes
idBIGSERIAL PKtyped AttachmentId
message_idBIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE
filenameTEXT
content_typeTEXT NOT NULL DEFAULT 'application/octet-stream'
size_bytesBIGINT NOT NULL
storage_pathTEXT NOT NULLab/cd/<sha256>, relative to the blob root
content_idTEXTContent-ID for inline parts
is_inlineBOOLEAN NOT NULL DEFAULT FALSE
checksum_sha256TEXTthe same digest as the path
created_atTIMESTAMPTZ

Many rows, one blob. An attachment row is (message, part, metadata); the file it points at is shared with every other row holding the same bytes. Deleting a message removes its rows but not necessarily the blob — §6 explains why.

2.4 Outbound queue

mail_queue

ColumnTypeNotes
idBIGSERIAL PKtyped QueueId
message_idBIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE
user_idBIGINT REFERENCES users(id) ON DELETE SET NULLNULL for system mail
senderTEXT NOT NULLenvelope reverse-path
recipientTEXT NOT NULLone row per recipient
statusTEXT NOT NULL DEFAULT 'pending'pending, delivering, delivered, retry, failed, cancelled
attemptsINTEGER NOT NULL DEFAULT 0
max_attemptsINTEGER NOT NULL DEFAULT 12queue.max_attempts
next_attempt_atTIMESTAMPTZwhen the dispatcher may retry
last_attempt_atTIMESTAMPTZ
delivered_atTIMESTAMPTZ
last_errorTEXT
last_status_codeINTEGERthe remote SMTP code
last_status_textTEXTthe remote's text
remote_mxTEXTthe host that was tried
created_at, updated_atTIMESTAMPTZ

mail_queue_status_known restricts status to the six values above, and mail_queue_attempts_sane CHECK (attempts >= 0). Retry timing is smtp.md §11.3.

delivery_attempts

One row per attempt: queue_id (FK, cascade), attempt, remote_mx, status_code, status_text, error, duration_ms, created_at. This is the history the Admin "Delivery Logs" screen reads. It grows with every retry, which is why queue.retention_days (30) exists and why the index is (queue_id, attempt) rather than (created_at).

2.5 Sessions, devices, client sync state

devices

id, user_id (FK, cascade), device_uid TEXT (client-generated, stable per installation), name, platform, client_version, protocol_version, last_seen_at, last_ip, created_at, revoked_at.

devices_uid_key UNIQUE (user_id, device_uid) is what makes device registration idempotent: DevicesRepository::upsert can be called on every client start.

sessions

id, user_id, kind (web, api, client, imap, smtp — restricted by sessions_kind_known), token_hash TEXT, device_id BIGINT REFERENCES devices(id) ON DELETE SET NULL, ip, user_agent, created_at, last_seen_at, expires_at, revoked_at.

The raw token is never stored. sessions.token_hash is the SHA-256 of an opaque refresh token (TokenService::hash), so a database dump does not hand an attacker working sessions. Access tokens are stateless JWTs and are not in this table at all.

client_sync_states

id, device_id (FK, cascade), mailbox_id (FK, cascade), folder_id (FK, cascade; NULL = account level), cursor BIGINT NOT NULL DEFAULT 0, updated_at.

client_sync_states_key UNIQUE (device_id, mailbox_id, COALESCE(folder_id, 0)). The COALESCE is load-bearing: PostgreSQL treats NULLs as distinct in a unique index, so without it one device could accumulate unlimited account-level rows. SyncStatesRepository::get returns 0 for a missing row, which is exactly "has never synced".

2.6 Drafts

drafts

id, user_id, mailbox_id, folder_id, message_id (the mirrored copy in the Drafts folder), subject, body_text, body_html, recipients JSONB DEFAULT '[]', attachments JSONB DEFAULT '[]', in_reply_to, reference_ids JSONB DEFAULT '[]', created_at, updated_at.

Drafts are JSON in the row and a real message in the Drafts folder, so an IMAP client and the official client see the same draft — fcp.md §7.

2.7 Operations, change log, audit

operations — idempotency

operation_id TEXT PRIMARY KEY, user_id, kind, status (applied or failed), result JSONB (the cached response), created_at, completed_at.

The primary key is the op_… string the client generated. begin() is a single INSERT … ON CONFLICT DO NOTHING RETURNING *, so the claim is atomic.

change_log — the sync journal

seq BIGSERIAL PK, user_id, mailbox_id, folder_id, message_id BIGINT, kind, payload JSONB, created_at.

message_id has no foreign key, deliberately. The schema comment says why: "tombstones must outlive rows". A message_deleted entry has to remain readable after the messages row is gone, or an offline client would never learn that the message disappeared. seq is the sync cursor — see sync.md.

audit_logs

id, actor_user_id (FK ON DELETE SET NULL — an audit row outlives the account it names), action, target_type, target_id, ip, user_agent, details JSONB DEFAULT '{}', created_at.

2.8 Login throttling and settings

login_attempts

id, email, ip, kind TEXT NOT NULL DEFAULT 'password', success BOOLEAN NOT NULL, created_at. Every login attempt writes a row, success or failure; AuthService::login reads them through LoginAttemptsRepository::count_failures_for_ip(ip, window) before doing any Argon2 work, so a credential flood cannot burn the CPU.

settings

key TEXT PRIMARY KEY, value JSONB NOT NULL, updated_at. DB-backed settings that the Admin panel can change without a restart. They do not override ferroma.toml: a value here is a runtime knob, not configuration, and nothing in Config reads this table.


3. Indexes, and the query each one serves

An index that no query uses is a write cost for nothing. This is the full list from migrations/0001_initial.sql, with the query it exists for.

IndexTableServes
users_email_keyuserslogin: UsersRepository::find_by_email
users_admin_idxWHERE is_adminuserspartial: the (small) admin list
domains_name_keydomainsRCPT TO domain resolution, DomainsRepository::find_by_name
mailboxes_address_keymailboxesthe SMTP delivery lookup: (domain_id, local_part) — unique, so an address cannot be duplicated
mailboxes_user_idxmailboxeslist_by_user, the caller's address list
mailboxes_primary_keyWHERE is_primarymailboxespartial unique: at most one primary address per user
aliases_keyaliases(domain_id, local_part) — the alias lookup on RCPT TO
folders_name_keyfolders(mailbox_id, name) unique — find_by_name, and the guard that stops duplicate folders
folders_mailbox_idxfolderslist(mailbox_id)
folders_special_use_keyWHERE special_use IS NOT NULLfolderspartial unique: at most one \Sent (etc.) per address
messages_folder_uid_keymessages(folder_id, uid) uniquefind_by_uid, list_by_uids, FETCH/STORE by UID
messages_live_idxmessages(folder_id, internal_date DESC) WHERE expunged_at IS NULL — the IMAP SELECT view and the folder message list
messages_mailbox_date_idxmessages(mailbox_id, internal_date DESC) — "all mail of this address", the Webmail All-Mail view
messages_folder_date_idxmessages(folder_id, internal_date DESC) — folder listing including expunged rows (integrity checks, Admin)
messages_rfc_id_idxWHERE rfc_message_id IS NOT NULLmessagespartial, not unique: find_by_rfc_message_id for threading and dedup
messages_thread_idxWHERE thread_id IS NOT NULLmessagespartial: conversation grouping
messages_sender_idxmessagesSEARCH FROM, the API's sender filter
messages_subject_fts_idxmessagesGIN over to_tsvector('simple', coalesce(subject, ''))SEARCH SUBJECT, the API's ?query=
message_recipients_message_idxmessage_recipientsrecipients of one message
message_recipients_address_idxmessage_recipientsSEARCH TO/CC/BCC, "mail to this address"
attachments_message_idxattachmentsattachments of one message
mail_queue_due_idxmail_queue(next_attempt_at) WHERE status IN ('pending','retry') — the dispatcher's "what is due now?" hot path
mail_queue_message_idxmail_queuequeue rows of one message
mail_queue_status_idxmail_queueAdmin queue filtering by status
mail_queue_user_idxmail_queue(user_id, created_at DESC) WHERE user_id IS NOT NULL — a user's own Outbox view
delivery_attempts_queue_idxdelivery_attempts(queue_id, attempt) — the attempt history of one queue row
devices_uid_keydevices(user_id, device_uid) unique — idempotent device upsert
devices_user_idxdevicesa user's device list
sessions_token_keysessions(token_hash) unique — refresh-token lookup
sessions_user_idxsessions"active sessions" in Admin
sessions_expiry_idxWHERE revoked_at IS NULLsessionspartial: the expiry sweeper
client_sync_states_keyclient_sync_states(device_id, mailbox_id, COALESCE(folder_id, 0)) unique — cursor read/write
client_sync_states_device_idxclient_sync_states(device_id, updated_at DESC) — "what is this device syncing?"
drafts_user_idxdrafts(user_id, updated_at DESC) — the draft list
operations_user_idxoperations(user_id, created_at DESC) — operation history
operations_created_idxoperations(created_at)purge_older_than
change_log_cursor_idxchange_log(user_id, seq)changes_since(user_id, after, limit), the sync query
change_log_mailbox_idxchange_log(mailbox_id, seq) — per-address sync
change_log_folder_idxchange_log(folder_id, seq) — per-folder sync
change_log_created_idxchange_log(created_at) — retention pruning
audit_logs_actor_idx, audit_logs_action_idx, audit_logs_created_idxaudit_logsthe three Admin audit filters
login_attempts_email_idx, login_attempts_ip_idxlogin_attempts(email, created_at DESC) and (ip, created_at DESC) — throttling windows
login_attempts_created_idxlogin_attempts(created_at) — the retention sweep

The five bolded ones are on the critical path. If a query plan shows a sequential scan on messages_live_idx or mail_queue_due_idx, something is wrong with the query, not the index.

Two things to know before adding an index: there is no index on messages.flags, so SEARCH UNSEEN is a filtered scan within a folder — fine for mailbox-sized folders, not fine for a million-row folder; and there is no body index, which is why SEARCH BODY is (planned) (imap.md §8).


4. Maildir layout and the delivery algorithm

4.1 Layout

storage.layout = "maildir" (the default) produces the layout from specification §13:

<maildir_root>/
└── example.com/                       <- domains.name, lower-cased, sanitised
    └── alice/                         <- mailboxes.local_part, lower-cased, sanitised
        └── Maildir/                   <- INBOX
            ├── cur/                   seen by a mail client; flags in the file name
            ├── new/                   delivered but not yet seen by a client
            ├── tmp/                   half-written files; never read
            ├── .Sent/{cur,new,tmp}
            ├── .Drafts/{cur,new,tmp}
            ├── .Trash/{cur,new,tmp}
            ├── .Junk/{cur,new,tmp}
            ├── .Archive/{cur,new,tmp}
            └── .Archive.2026/{cur,new,tmp}   <- IMAP "Archive/2026"

storage.layout = "maildirperfolder" produces one Maildir per folder instead: <root>/example.com/alice/INBOX/{cur,new,tmp}, …/Sent/{cur,new,tmp}, with no dotted directory names. Choose it on filesystems with unusual name rules — see imap.md §10.

Maildir::SUBDIRS is the literal ["cur", "new", "tmp"]; Maildir::INBOX is "INBOX".

4.2 The file name

1758012751.M4821P3210.mail:2,S
└────┬───┘ └──┬───┘ └─┬─┘ │ └┬┘
  unix secs   pid _    host │  flags: S = \Seen
              counter      version marker "2,"

Maildir::unique_filename builds <secs>.<pid>_<counter>.<hostname> and appends {sep}2,{flags} through with_info. Uniqueness is guaranteed in-process by the AtomicU64 counter and across processes by the pid; the hostname is sanitize_componentd first, falling back to ferroma.

The separator is : on Unix and ; on Windows, and reads accept both — the full explanation is in imap.md §10.

4.3 The delivery algorithm

Maildir::store(domain, local_part, folder, bytes, flags):

 1. dir = folder_dir(domain, local_part, folder)
       └─ sanitize_component on the domain and the local part,
          maildir_folder_name on the folder  (path-traversal defence)
 2. create dir/{cur,new,tmp} if missing
 3. maildir_flags = flags_to_maildir(flags)      // "seen" -> "S"
 4. filename = unique_filename(maildir_flags)
 5. tmp_path   = dir/tmp/<filename>.tmp
 6. final_sub  = if maildir_flags.is_empty() { "new" } else { "cur" }
    final_path = dir/<final_sub>/<filename>
 7. std::fs::write(tmp_path, bytes)                  ← the whole message
 8. if storage.fsync_on_write: sync_all() on tmp_path
 9. std::fs::rename(tmp_path, final_path)            ← the atomic step
10. if storage.fsync_on_write: sync_all() on dir/<final_sub>
11. return StoredMessage { path (relative), size, sha256 }

Three properties, and why each matters:

Atomicity. rename(2) within one filesystem is atomic: a reader sees either no file or the complete file. A message is therefore never observed half-written. This is the entire reason for the tmp/ step and it is why a reader must never look in tmp/Maildir::iter_messages skips anything ending in .tmp, and Maildir::sweep_tmp(older_than_secs) deletes leftovers.

Durability. Steps 8 and 10 flush the data and the directory entry, so a power loss cannot leave a rename pointing at unflushed blocks. fsync costs throughput; storage.fsync_on_write = true is the default because a mail server that acknowledges DATA and then loses the message has broken its only promise. An operator with battery-backed storage can turn it off.

Idempotence of the name. store never overwrites: every call allocates a new counter value. set_flags is the idempotent one — it computes the target name and returns the original path unchanged when nothing needs to move.

4.4 The rest of the Maildir API

FunctionBehaviour
read(relative_path)the whole message; a missing file is StorageError::BodyMissing, not a generic IO error
read_prefix(relative_path, limit)the first limit bytes, for header-only FETCH
delete(relative_path)unlink; already-missing is Ok(()), so it is retry-safe
set_flags(relative_path, flags)rename the file and move it between new/ and cur/ as the flag set becomes non-empty or empty; returns the possibly-new path
move_message(relative_path, domain, local_part, to_folder, flags)read, store in the destination, delete the source
iter_messages(domain, local_part, folder)every file in cur/ and new/, skipping .tmp; returns MaildirEntry { path, size, maildir_flags, modified_secs }
usage(domain, local_part)total bytes under the mailbox root, for quota reconciliation
sweep_tmp(older_than_secs)delete abandoned tmp/ files older than the threshold
ensure_mailbox / create_folder / delete_folder / rename_folder / list_folders / folder_existsfolder lifecycle; INBOX is protected from deletion and renaming
absolute(relative_path)the path-traversal gate — see §7

Note the asymmetry in move_message: it is a copy-then-delete, not a rename. A cross-folder move may cross a filesystem boundary, and the copy path is also what lets the destination get a freshly encoded file name for the new flag set. The window in which both copies exist is harmless — the database row is updated in one statement afterwards, so nothing points at the source once the transaction commits.


5. Quota accounting

Two numbers: users.quota_bytes (default limits.mailbox_quota = 1073741824 = 1 GiB) and mailboxes.quota_bytes (NULL = inherit the owner's). FoldersRepository does not participate; quota is per address, because that is the unit SMTP delivers to.

StepWhere
Read the effective limitMailboxesRepository::quota(mailbox_id)
Read current usageMailboxesRepository::used_bytes(mailbox_id)SELECT used_bytes FROM users
DecideMailboxesRepository::check_quota(mailbox_id, needed) — returns StorageError::QuotaExceeded { mailbox_id, used, needed, limit }
Adjust after a writeMailboxesRepository::add_usage(mailbox_id, delta_bytes)
Reconcile from diskMailboxesRepository::recompute_usage(mailbox_id) — walks the Maildir with Maildir::usage and writes the total back

Where it is enforced, and what the enforcement looks like from outside:

PathChecked atResult
Inbound SMTPbefore the Maildir write, after DATA is complete452 4.2.2 Mailbox fulltemporary, so the sender retries (smtp.md §12.1)
POST /api/v1/messages (send)before the Sent copy is written and before queueing413 limit_exceeded
POST /api/v1/attachmentsagainst the target mailbox when the attachment is attached413 limit_exceeded
IMAP APPENDbefore the literal is storedNO [OVERQUOTA]
IMAP/API flag changes and movesnot checked — they do not change the total

used_bytes is a cache, and recompute_usage is how it is corrected. It can drift after a crash between the file write and the counter update, or after an operator adds files to the Maildir by hand. The reconciliation is:

-- What the database believes, per address.
SELECT m.id, d.name || '@' || m.local_part AS address, u.used_bytes
  FROM mailboxes m
  JOIN domains d ON d.id = m.domain_id
  JOIN users   u ON u.id = m.user_id
 ORDER BY u.used_bytes DESC;
# What is actually on disk for one address.
du -sb /var/lib/ferroma/mail/example.com/alice

If they disagree, recompute_usage is the repair, and the discrepancy is worth investigating: a large positive difference means files were deleted behind Ferroma's back, a large negative one means an interrupted write.

storage.enforce_quota = false turns the check off entirely. It exists for migrations and for operators who would rather deliver everything and sort it out later; a server in that mode will fill its disk.


6. Attachments: content addressing and GC

AttachmentStore in crates/ferroma-storage/src/attachment.rs.

6.1 Addressing

<attachment_root>/ab/cd/abcdef0123…      <- SHA-256 of the content, hex, lower-case
                  └┬┘└┬┘└─────┬─────┘
              byte 0-1  2-3   the full digest

AttachmentStore::path_for_digest(digest_hex) builds that path and rejects a digest shorter than four characters or containing a non-hex character with StorageError::Invalid. Two levels of sharding keep any one directory from holding hundreds of thousands of entries, which is what makes readdir on the blob root cheap on ext4 and NTFS alike.

Consequences of addressing by content:

PropertyEffect
Identical content is stored oncea PDF forwarded around an organisation occupies one blob, however many attachments rows point at it
store is idempotentstoring the same bytes twice returns StoredBlob { deduplicated: true } without writing
ETag is the SHA-256GET /api/v1/attachments/:id can serve a strong validator with no extra work, and repeated downloads never re-transfer (fcp.md §6)
A blob cannot be corrupted silentlythe file name is the checksum — sha256sum over the tree verifies the whole store

6.2 Writing a blob

1. digest   = sha256(data)
2. relative = path_for_digest(digest)          // "ab/cd/<sha256>"
3. if <root>/<relative> is already a file: return { deduplicated: true }
4. create_dir_all(parent)
5. tmp = parent/".{digest[4..16]}.{pid}.tmp"
6. write(tmp, data)
7. if storage.fsync_on_write: sync_all(tmp)
8. rename(tmp, final)      // on Unix: atomic overwrite with identical content
                           // on Windows: the first writer wins
9. return { path, size, sha256, deduplicated: false }

Step 8 is a race with a benign outcome: if two concurrent writers produce the same digest, rename either overwrites with identical bytes (Unix) or fails because the destination exists (Windows), and the Err(_) if final_path.is_file() arm cleans up the temporary file and reports success. Either way the bytes match the name.

6.3 Garbage collection

Deleting an attachments row does not delete the blob. It cannot: another message may reference the same digest, and the store has no refcount. That is why AttachmentStore::gc(keep) takes a set and does the job in one pass:

for every file under the blob root:
    if the name ends in .tmp          -> remove  (an interrupted write)
    if keep contains the file name    -> keep
    if keep contains the relative path-> keep    (both forms are accepted)
    otherwise                         -> remove

The keep set is produced by AttachmentsRepository::referenced_paths() (SELECT DISTINCT storage_path FROM attachments), and the caller is POST /api/v1/storage/gc (planned) or, today, a direct call. Two rules for anyone re-implementing it:

Maildir::sweep_tmp(older_than_secs) is the equivalent for messages, but with a threshold: a tmp/ file younger than the threshold might belong to a delivery that is still in progress on another task, so sweeping with a zero threshold is only safe on a stopped server.

6.4 Verifying the blob store

# Every blob's file name must equal the SHA-256 of its content.
cd /var/lib/ferroma/attachments
find . -type f ! -name '*.tmp' -printf '%f %p\n' | while read digest path; do
    actual=$(sha256sum "$path" | cut -d' ' -f1)
    [ "$actual" = "$digest" ] || echo "CORRUPT: $path (name $digest, content $actual)"
done

Illustrative output when everything is fine — the command prints nothing. Attachments are not covered by messages.checksum_sha256; the file name is the checksum, which is why this check needs no database at all.


7. Path-traversal defences

Three places take a string from outside and turn it into a filesystem path: domain names, local parts, folder names, message storage_paths and attachment storage_paths. All three go through one of two gates.

7.1 sanitize_component

/// Reject anything that could be used to escape the mail root.
pub fn sanitize_component(component: &str) -> Result<String> {
    let trimmed = component.trim();
    if trimmed.is_empty() {
        return Err(StorageError::Invalid("empty path component".into()));
    }
    if trimmed == "." || trimmed == ".." {
        return Err(StorageError::Invalid(format!("invalid path component: {trimmed}")));
    }
    if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') {
        return Err(StorageError::Invalid(format!(
            "path component contains a separator: {trimmed}"
        )));
    }
    if trimmed.contains(':') {
        return Err(StorageError::Invalid(format!(
            "path component contains a colon: {trimmed}"
        )));
    }
    Ok(trimmed.to_string())
}

Called on every component of a folder path before it is joined (maildir_folder_name), on the domain and local part in Maildir::mailbox_dir, and on the hostname in Maildir::new. Its test covers the interesting inputs:

for bad in ["..", ".", "a/b", "a\\b", "", "  ", "a:b", "x\0y"] {
    assert!(sanitize_component(bad).is_err(), "should reject {bad:?}");
}

The : rejection is deliberate even though : is legal on Unix: it is the Maildir info separator, and a folder named a:b would produce a directory whose name is ambiguous with a flagged file. On Windows it is also an NTFS alternate data stream.

A name is validated per segment, so an IMAP folder Archive/2026 is fine (each segment is clean) while Archive/../../etc is rejected on its third segment rather than by pattern-matching the whole string.

7.2 absolute — the relative-path gate

Both stores have one, and both do the same thing:

/// Turn a relative path into an absolute one, refusing to escape the root.
pub fn absolute(&self, relative_path: &str) -> Result<PathBuf> {
    let candidate = Path::new(relative_path);
    if candidate.is_absolute() {
        return Err(StorageError::Invalid(format!(
            "storage path must be relative: {relative_path}"
        )));
    }
    for component in candidate.components() {
        match component {
            std::path::Component::ParentDir
            | std::path::Component::RootDir
            | std::path::Component::Prefix(_) => {
                return Err(StorageError::Invalid(format!(
                    "storage path escapes the mail root: {relative_path}"
                )));
            }
            _ => {}
        }
    }
    Ok(self.root.join(candidate))
}

Maildir::absolute (mailroot) and AttachmentStore::absolute (blob root) are the only functions that turn a stored storage_path into a real path. read, read_prefix, delete, set_flags and exists all call it first, so no code path can open a file outside the root even if the database is compromised:

assert!(m.absolute("../../etc/passwd").is_err());
assert!(m.absolute("/etc/passwd").is_err());
assert!(s.absolute("../../secret").is_err());

Component::Prefix(_) matters on Windows: without it, C:\Windows\... would be treated as a relative path and joined onto the root.

The complementary function is Maildir::relative, which strips the root and falls back to with_info-style normalisation; a path that does not start with the root is StorageError::Invalid("path outside the mail root") rather than a silently stored absolute path.


8. Backup and restore

Full operator procedure is in deployment.md §8. The principle belongs here.

8.1 Restore the two halves together

scripts/backup.sh produces one timestamped directory containing:

FileContents
ferroma.dumppg_dump --format=custom --compress=6 — the whole database
schema.sqlpg_dump --schema-only — an empty but correct structure
maildir.tar.gztar -czf of the mail root
config.tar.gzferroma.toml, DKIM keys, TLS material (excluding *.env and credentials*)
MANIFESTferroma_backup_version=1, created_at, database, postgres_version, ferroma_version, hostname
SHA256SUMSsha256sum ./*, so a restore can prove the archive survived the trip

The script's own header states the rule:

A backup that contains only one of the first two is not a backup: the database says a message exists and the Maildir holds its bytes, and restoring either alone gives you a mailbox full of dangling rows or a directory of orphaned files.

Concretely, the two failure modes are:

RestoredResult
Database onlyevery messages.storage_path points at a file that is not there. Every read is StorageError::BodyMissing; users see an inbox of subjects with no bodies
Maildir onlythe files exist and nothing knows about them. Folders appear empty; the bytes are invisible until an operator re-imports them by hand

Neither is recoverable by the application, which is why scripts/restore.sh prints a warning when you ask for --db-only or --mail-only and why the compose backup sidecar mounts both.

8.2 Consistency of a live backup

If you need a perfectly consistent pair, stop the container:

docker compose stop ferroma
docker compose run --rm backup
docker compose start ferroma

That is the only way to guarantee no in-flight transaction is split across the two halves. For most deployments the live backup is correct enough, because an orphan file is benign and a missed message is retried by the sending MTA.

8.3 Restore ordering

scripts/restore.sh follows specification §47 and does it in the order the dependency graph demands:

0. verify      sha256sum -c SHA256SUMS   (abort on mismatch)
               print MANIFEST
1. database    pg_restore --no-owner --no-privileges --exit-on-error
               refuses a non-empty database unless FORCE_RESTORE=1
2. mail store  tar -xzf maildir.tar.gz
3. config      tar -xzf config.tar.gz

The database goes first because it is the half that defines what should exist; the Maildir second so that by the time the server starts, every row already has its file. Configuration last, so a restore that fails halfway does not leave a running server pointed at the wrong TLS certificate.

restore_db refuses to restore over a populated database, with a message that names the count of tables it found:

database ferroma is not empty (19 tables). Set FORCE_RESTORE=1 to overwrite,
or restore into a fresh database.

(19 is the number of tables 0001_initial.sql creates; the count in the real message is whatever information_schema.tables reports for public.)

That guard is the single most valuable line in the script. Silently merging two mail stores is how an operator loses a week of mail, and no automated tool can tell "restore on top" from "oops, wrong database".

8.4 What else you must keep

ItemWhyWhere it lives
ferroma.tomllimits, ports, TLS paths, api.public_url, server.hostnameconfig/ferroma.toml, mounted read-only
[api] jwt_secret / FERROMA_JWT_SECRETwithout it every session is invalidated on restartenvironment, not in the config archive
DKIM private keyslosing them means every signature breaks and DMARC starts failingdomains.dkim_private_key and /etc/ferroma/dkim/*.private
TLS certificate and keylosing them means a TLS outage until reissue/etc/ferroma/tls/
PostgreSQL role and databasea restore needs somewhere to restore intothe postgres service

The JWT secret is deliberately excluded from config.tar.gz (--exclude='*.env' --exclude='credentials*') because the backup volume may be less protected than the secret store. Keep it in whatever you use for secrets — see security.md §10 and deployment.md §4.


9. Integrity checks and repair

The invariant to verify: every live messages row has a readable file at its storage_path, and every file under the mail root belongs to a row.

9.1 Finding rows without bodies

-- The candidate set: live messages. Compare against the filesystem.
SELECT m.id, m.mailbox_id, m.uid, m.size_bytes, m.storage_path
  FROM messages m
 WHERE m.expunged_at IS NULL
 ORDER BY m.id;
# Rows whose file is missing.
psql "$DATABASE_URL" -Atc \
  "SELECT storage_path FROM messages WHERE expunged_at IS NULL" |
while read -r p; do
    [ -f "/var/lib/ferroma/mail/$p" ] || echo "MISSING: $p"
done

A missing body surfaces in production as StorageError::BodyMissing, which becomes NO [SERVERBUG] message body missing over IMAP and a 404 not_found from the API. It means the filesystem was modified behind Ferroma, or a restore put back a database newer than the Maildir.

9.2 Finding files without rows

# Orphan candidates under the mail root: files whose relative path is in no row.
psql "$DATABASE_URL" -Atc \
  "SELECT storage_path FROM messages" | sort > /tmp/known.txt
cd /var/lib/ferroma/mail
find . -type f ! -path '*/tmp/*' | sed 's|^\./||' | sort > /tmp/on_disk.txt
comm -13 /tmp/known.txt /tmp/on_disk.txt        # on disk, not in the database

Orphans are the expected residue of an interrupted delivery and of hard_delete after a crash, and they are the safe failure direction to have. They are not automatically deleted: a file that looks orphaned because a restore was half-applied is the only copy of somebody's mail.

9.3 Checking the checksums

messages.checksum_sha256 is written when storage.checksum = true (StoredMessage.sha256, hex lower-case):

psql "$DATABASE_URL" -Atc \
  "SELECT storage_path, checksum_sha256 FROM messages
    WHERE expunged_at IS NULL AND checksum_sha256 IS NOT NULL" |
while IFS='|' read -r p want; do
    got=$(sha256sum "/var/lib/ferroma/mail/$p" | cut -d' ' -f1)
    [ "$got" = "$want" ] || echo "CORRUPT: $p (want $want, got $got)"
done

9.4 Checking the counters

folders.message_count, unseen_count and total_bytes, and users.used_bytes, are caches. They are correctable from the data they summarise:

-- What the counters should be, per folder.
SELECT f.id, f.name, f.message_count,
       COUNT(m.id) FILTER (WHERE m.expunged_at IS NULL) AS actual,
       COUNT(m.id) FILTER (WHERE m.expunged_at IS NULL
                            AND m.flags NOT LIKE '%seen%') AS actual_unseen,
       COALESCE(SUM(m.size_bytes) FILTER (WHERE m.expunged_at IS NULL), 0) AS actual_bytes
  FROM folders f
  LEFT JOIN messages m ON m.folder_id = f.id
 GROUP BY f.id, f.name, f.message_count
HAVING f.message_count <> COUNT(m.id) FILTER (WHERE m.expunged_at IS NULL)
 ORDER BY f.id;

FoldersRepository::recount(folder_id) is the repair: it recomputes all three counters and returns the updated Folder. MailboxesRepository::recompute_usage does the same for users.used_bytes.

A wrong message_count is cosmetic — the folder list shows the wrong number. A wrong uid_next is not: it is the UID allocator, and MessagesRepository::max_uid (SELECT COALESCE(MAX(uid), 0) FROM messages WHERE folder_id = $1) is the value it must be at least. If uid_next is ever behind max_uid, the next delivery allocates a UID that already exists and the unique index on (folder_id, uid) rejects it — which is a loud failure, not a silent overwrite, because of messages_folder_uid_key.

9.5 What to do about each finding

FindingAction
Missing bodyrestore from the backup that contains it, or hard-delete the row and log it. There is no way to reconstruct RFC 5322 bytes from metadata
Orphan fileleave it, or move it to a quarantine directory. Do not delete before confirming the restore was complete
Checksum mismatchthe file changed on disk. Restore it; do not "fix" the database
Counter driftFoldersRepository::recount / MailboxesRepository::recompute_usage
uid_next behind max_uidset uid_next = max_uid + 1 and bump uid_validity — the UID space has been tampered with (imap.md §5.3)
Unreferenced blobAttachmentStore::gc with a freshly collected keep-set
Leftover tmp/ fileMaildir::sweep_tmp(3600) on a live server, sweep_tmp(0) on a stopped one

The scripts and the API refer to a ferroma storage verify subcommand that wraps all of the above and a POST /api/v1/storage/gc endpoint that runs the blob collector. Both are (planned) — the ferroma binary does not implement a subcommand interface yet (server/src/main.rs prints the build banner and exits). Until they exist, the psql and shell snippets in this section are the procedure.


10. Configuration that shapes storage

KeyDefaultEffect
server.data_dir./database for both roots
storage.maildir_root<data_dir>/mailMaildir root
storage.attachment_root<data_dir>/attachmentsblob root
storage.fsync_on_writetruefsync the message and its directory entry before acknowledging DATA
storage.layout"maildir""maildir" (Maildir++ dotted folders) or "maildirperfolder"
storage.checksumtruestore the SHA-256 of every message and attachment
storage.enforce_quotatruerefuse writes past the quota
storage.soft_deletetruemove to Trash instead of unlinking immediately
database.urlpostgres://ferroma:ferroma@localhost:5432/ferromaconnection string
database.max_connections / min_connections20 / 2pool bounds
database.run_migrationstrueapply migrations/*.sql at startup
database.log_statementsfalsenever enable in production — it prints message subjects
limits.mailbox_quota1073741824default quota for a new user

Config::validate() refuses to boot if storage.maildir_root is set to an empty path, if database.url is not a postgres:///postgresql:// URL, or if database.min_connections > database.max_connections.


TopicDocument
Why mailboxes and folders are separate, and rfc_message_idarchitecture.md §9
Maildir++ folder naming, UID/UIDVALIDITY, flag mappingimap.md §4, §5, §6
The change log, cursors, tombstonessync.md
Quota replies over SMTP, retry scheduling, bouncesmtp.md §7, §11
Backup commands, DNS, TLS, restore drilldeployment.md §8
What is never logged, secrets handlingsecurity.md §10
Ferroma · MIT OR Apache-2.0 · built from docs/