Mailbox
Crate hopf-mailbox: mailbox storage SPI (Gumdrop org.bluezoo.gumdrop.mailbox port) — Maildir++ and mbox backends with SEARCH, flags, and optional .gidx indexing. This is not an IMAP wire server; hopf-imap sits on top of these traits.
SMTP local delivery is wired stock via LocalDeliveryService, which APPENDs each accepted message to the recipient's INBOX through this crate's MailboxFactory on the Runtime storage pool. POP3 and IMAP (hopf-pop3, hopf-imap) read from and write to mailboxes the same way, so all three protocols can share one backend in a single composed process — see Composition.
Contents
Standards and layouts
| Reference | Role |
|---|---|
| RFC 9051 | IMAP SEARCH §6.4.4; system flags |
| RFC 822 / Internet Message Format | Message bytes |
| Maildir++ | Hierarchical folders, :2, flags, COPY/MOVE |
| mbox | Single-file mailbox + .flags sidecar |
.gidx |
Gumdrop-compatible index: v1 headers, v2 body |
Architecture
App / hopf-imap / SMTP delivery
│
▼
MailboxFactory → MailboxStore → Mailbox
│
├── mbox: {root}/{user} + .flags + .gidx
└── Maildir++: {root}/{user}/ cur|new|tmp + .uidlist …
│
└── ALWAYS run search/index I/O on StorageExecutor
Never perform heavy mailbox I/O on a reactor thread.
Backends compared
| Capability | mbox | Maildir++ |
|---|---|---|
| Layout | file + .flags + .gidx |
cur/new/tmp, .uidlist, .keywords, .gidx, .subscriptions |
| CREATE / DELETE / RENAME folders | Unsupported (INBOX only) | Yes |
| COPY / MOVE | Unsupported | Yes |
| Subscriptions | no-op-ish | Yes |
| Keywords | via .flags |
Yes |
| CONDSTORE (HIGHESTMODSEQ / MODSEQ / CHANGEDSINCE) | via .flags |
via .uidlist |
| Locking | flock + dotlock (.lock) |
N/A (per-message files, no whole-mailbox lock needed) |
Both backends track a real, persisted, monotonic CONDSTORE mod-sequence per message (bumped on append and on any flag/keyword change, never decreasing even across expunge) — QRESYNC's VANISHED (EARLIER) history is the one CONDSTORE-adjacent piece still out of scope (see Limitations).
Factories:
MboxFactory::new(root).with_index_config(cfg)→ path{root}/{user}MaildirFactory::new(root).with_index_config(cfg)→{root}/{user}/
Public types
| Type | Purpose |
|---|---|
Mailbox |
Open mailbox operations |
MailboxStore |
Per-user store |
MailboxFactory |
Create stores |
MessageDescriptor |
Message metadata |
MailboxInfo / MailboxAttribute |
LIST-style metadata |
Flag |
Seen / Answered / Flagged / Deleted / Draft / Recent |
SearchCriteria / MessageContext |
SEARCH tree + eval |
MessageSet / MessageRange |
1:5,7,* |
IndexConfig |
Body indexing knobs |
MailboxError / MailboxResult |
Io / Unsupported / Invalid / Corrupt / NotFound / ReadOnly |
MailboxNameCodec |
=XX filesystem-safe names |
index::* |
.gidx (IndexFile, MessageIndex, …) |
Consts: INDEX_MAGIC, INDEX_VERSION_HEADERS = 1, INDEX_VERSION_BODY = 2.
Configuration
IndexConfig
| Property | Type | Default | Notes |
|---|---|---|---|
body_indexing |
bool |
false |
Index body for TEXT/BODY SEARCH |
max_body_bytes |
usize |
64 * 1024 |
Cap indexed body bytes |
Builders: IndexConfig::headers_only(), IndexConfig::with_body_indexing().
No Cargo features on this crate.
Mailbox API
Typical flow:
use hopf_mailbox::{IndexConfig, MaildirFactory, SearchCriteria};
let factory = MaildirFactory::new("/var/mail")
.with_index_config(IndexConfig::with_body_indexing());
let mut store = factory.create_store();
store.open("alice")?;
let mut mb = store.open_mailbox("INBOX", false)?; // read_only flag
mb.append_message(rfc822_bytes, &flags, None)?;
let hits = mb.search(&SearchCriteria::text("needle"))?;
mb.close(true)?; // expunge when trueOperations (trait surface): close(expunge), counts/size, messages, read_message, UID / uidvalidity / uidnext, flags / keywords, set/replace flags, append stream, search, copy / move (Maildir).
Streaming reads: read_message is a default method built on a lower-level start_read / read_chunk / end_read triad — callers that don't want a whole message pulled into memory (POP3 RETR/TOP, IMAP FETCH) can drive the triad directly and process fixed-size chunks as they arrive. append_content is likewise chunk-fed rather than taking one whole buffer, so SMTP delivery and IMAP APPEND can stream message bytes straight through to storage. Maildir implements both ends as true streaming (bounded memory regardless of message size); mbox's start_read materializes the one requested message (not the whole mailbox) because its >From-line unescaping is stateful and unsafe to chunk naively — see Limitations.
SEARCH
Criteria include: ALL, system flags, NEW/OLD, KEYWORD, LARGER/SMALLER, BEFORE/ON/SINCE, SENT*, HEADER, BODY, TEXT, UID / sequence sets, MODSEQ, AND / OR / NOT.
BODY / TEXT: use the .gidx body index when body_indexing is enabled; otherwise parse message content on demand.
HEADER: six fields (From/Sender/To/Cc/Bcc/Subject/Message-ID) resolve straight from the index; any other header name falls back to a raw per-message header scan (search::HeaderExtractor) rather than reporting empty.
MODSEQ: matches against the real, persisted per-message mod-sequence described under Backends compared above.
Indexing
.gidx is Gumdrop-compatible:
- Version 1 — headers
- Version 2 — body (when enabled)
Body indexing is off by default (disk cost). Enable with IndexConfig::with_body_indexing().
IndexBuilder::build_streaming feeds any Read to rmimeparser's incremental MessageParser in fixed-size chunks (carrying a small buffer for lines split across chunk boundaries) rather than reading the whole message into a Vec<u8> first; build (whole-slice) is a thin wrapper over it. Maildir's mailbox-open indexing loop uses build_streaming directly against an open File.
Storage pool helpers
use hopf_mailbox::pool::{run_on_storage, search_on_storage};
// Prefer these from protocol servers so SEARCH never blocks a reactor:
search_on_storage(&storage, handle, mailbox_arc, criteria, callback);
run_on_storage(&storage, handle, op, callback);Examples / tests
There is no examples/mailbox binary. Coverage lives in:
cargo test -p hopf-mailbox
# notably: tests/mailbox_integration.rs
# - mbox append / flags / search
# - mbox rejects COPY
# - Maildir body indexWire from SMTP:
// Prefer LocalDeliveryService for stock local MX; custom handlers can also
// APPEND in MessageDataHandler::message_complete via StorageExecutor:
storage.submit_on(handle, move || {
let mut store = factory.create_store();
store.open("alice")?;
let mut mb = store.open_mailbox("INBOX", false)?;
mb.append_message(&bytes, &flags, None)?;
mb.close(false)?;
Ok(())
}, |result| { /* reply 250/550 via ConnHandle */ });Limitations
- Not an IMAP server — storage SPI only;
hopf-imapprovides the wire server (useLocalDeliveryServicefor SMTP → INBOX). - mbox: no hierarchy / COPY / MOVE.
start_readstreams in fixed-size chunks but still materializes the one target message up front (not the whole mailbox) — safe re-chunking of the stateful>From-line unescaping across arbitrary chunk boundaries isn't implemented;open()'s full-mailbox scan and append internals are unchanged. - Body indexing off by default.
- Must keep search/index off the reactor (use
StorageExecutor). Mailbox::expunged_since(QRESYNC VANISHED (EARLIER) history) always returns empty — CONDSTORE's HIGHESTMODSEQ/MODSEQ/CHANGEDSINCE are backed by real per-message tracking, but no backend keeps a tombstone log of past expunges, so a QRESYNC client always falls back to a full resync rather than an incremental one after messages were expunged elsewhere.