Tree
- Tree:
1c208e85823e8fb01371e3ba11c1e20bbaa1ed1c- Date:
- Message:
- NOOP reports changes; describe forwards; check accounts; bound keymgr; set \Seen Change 1 of 6: Report other agents' changes at NOOP, and to IDLE from SELECT on NOOP answered OK and nothing else, so a client that polls rather than idles never learned of new mail, flag changes or expunges made by another session or by smtpd. RFC 9051 section 5.2 requires a mailbox size update whenever a command observes one, and section 6.1.2 names NOOP as the poll. IDLE did report such changes, but it took the mailbox as it found it at IDLE as its starting point, so a change made between SELECT and IDLE, or between DONE and the next IDLE, was never reported at all. The account worker now keeps what it last told each session from SELECT on, rather than from IDLE on. NOOP in the selected state asks it for the same comparison IDLE makes, and completes once the answer is written: EXISTS for arrivals, EXPUNGE for removals, and a FETCH carrying UID and FLAGS for each flag change, with MODSEQ once CONDSTORE is enabled. IDLE compares against the same record instead of starting afresh. The account worker answers every such request, even one it refuses, since NOOP now waits for the answer. A message the session expunges or moves out itself leaves that record as its EXPUNGE is sent, so it is never reported twice. One function now sends that EXPUNGE for EXPUNGE, UID EXPUNGE and both kinds of MOVE, so none can skip the step. A message the session appends to its selected mailbox joins the record. Its own flag changes do not, so a later NOOP or IDLE may report them again. A refresh still in flight when DONE arrives is now answered before IDLE's tagged OK. Its EXPUNGE, FETCH and EXISTS lines used to be dropped, though the account worker counted them as told, so they were never reported. Two errors in IDLE's reporting are fixed on the way. A removal and an arrival seen in one refresh left the count unchanged, so no EXISTS was sent and the client was one message short; EXISTS now follows any EXPUNGE a refresh sends, as RFC 9051 section 6.3.13's example does. And a session that had enabled QRESYNC was sent EXPUNGE for another session's removals, where RFC 7162 section 3.2.10.2 requires VANISHED. Change 2 of 6: Read sequence numbers as the session was told them A session's sequence numbers were read against the mailbox as it stood, not as the session had been told it stood. After another session expunged a message, FETCH 2 returned the message the client called 3, STORE 2 flagged it, and an EXPUNGE that followed removed it. RFC 9051 section 7.5.1 forbids EXPUNGE responses during FETCH, STORE and SEARCH so that the numbers stay in step; imapd kept the rule and lost the step. APPEND's EXISTS could also lower the count, which section 5.2 forbids. The account worker now reads every sequence set through the session's view, the record the first change keeps: number n is the nth message the session was told of, found by its UID, and every response is numbered the same way. Each command that reads the index first tells the session what changed: EXISTS and flag FETCHes always, EXPUNGE only where section 7.5.1 allows it, so UID FETCH and UID STORE may carry one and FETCH, STORE and SEARCH may not. A message expunged elsewhere and not yet reported stays in the view until it is. A command naming such a message answers as RFC 2180 section 4 describes. FETCH returns the others and a tagged NO [EXPUNGEISSUED] (section 4.1.2), and so does STORE without .SILENT (sections 4.2.2 and 4.2.3); STORE .SILENT stores the others and answers OK (4.2.1); SEARCH never matches it (4.3); COPY and MOVE copy the others, send the pending EXPUNGEs and answer OK (4.4.2), as RFC 9051 section 6.4.9 already asks of a UID COPY naming a UID that is gone. APPEND to the selected mailbox takes its EXISTS from the view, after any pending EXPUNGEs. A session's own flag changes are no longer reported back to it: STORE holds the index lock from its report to its save, so the mod-sequence it assigns is its own. Change 3 of 6: Describe and fetch forwarded messages; refuse what FETCH cannot return BODYSTRUCTURE refused any message holding a MESSAGE/RFC822 or MESSAGE/GLOBAL part, at any depth, so fetching the structure of a message with a forwarded message attached ended NO. RFC 9051 section 7.5.2 describes such a part by the envelope, body structure and line count of the message it holds, and its grammar allows no other description. The builder now writes them. The envelope comes from the code that answers ENVELOPE, now split from the read of the header so that both can use it. A forwarded message counts against the depth and part limits as one level, as SEARCH already counts it. Part numbers now reach through such a part, as section 6.4.5.1 describes: BODY[2] of a forward is the whole forwarded message, and BODY[2.1] its first part. HEADER, TEXT and MIME after a part number are still not supported. A FETCH naming such an item beside one imapd supports, or plain BODY[...] or RFC822.TEXT, returned the rest and ended OK, as though the item had been sent. Section 6.4.5 gives NO for data that cannot be fetched, and imapd already answers NO when a supported item cannot be produced. It now does the same here, after sending what it could. A FETCH naming only such items was already refused. Change 4 of 6: Refuse to start without the daemon accounts; document creating them imapd drops privileges to three accounts, _imapd, _imapauth and _imapkey, but nothing created them and README.md never named them, so a fresh install that followed it failed. A missing _imapkey stopped the daemon at startup; a missing _imapd or _imapauth let it start and then failed every connection. Each lookup's message promised an install script that does not exist. The parent now looks up all three before reading its configuration and exits with "unknown user" if one is missing, as bgpd, ldapd, ospfd, ripd, dvmrpd and rad do. The children keep their own lookups, now with the same message, and the three names are defined once in imapd.h. README.md gains the three useradd(8) lines, placed before the first mailbox account since imapduser(8) needs the _imapauth group. Change 5 of 6: Bound the listener's wait on keymgr; keep keymgr from blocking on one A listener asked keymgr for each private-key operation and then waited for the answer with no bound. A keymgr that stopped answering left every new TLS handshake waiting for ever: the login grace timer could not fire, and each stuck connection held an unauthenticated slot until new connections were refused on every port. Its answer is now awaited with poll(2) for at most 10 seconds over the whole exchange, as the store already waits for the parser and as relayd bounds its wait on its ca; after that the operation fails and the handshake ends. A failed RSA operation returned 0. RSA_private_encrypt(3) returns -1 on error, and under TLS 1.3's PSS padding libcrypto took the 0 as a signature of length 0 and sent it. Every failure now returns -1, so the handshake fails at the server with an alert. keymgr's end of each listener channel was a blocking socket, so a listener that sent requests and never read the replies could stop keymgr for everyone. keymgr now sets it non-blocking, as smtpd and relayd make their channels. The two wait loops are now one function. Change 6 of 6: Answer a plain BODY[...] and set \Seen as RFC 9051 requires Only BODY.PEEK[...] was answered. A plain BODY[...], the form RFC 9051 section 6.4.5 defines as setting \Seen and the form its own example uses, was dropped when the FETCH was parsed, so the FETCH ended NO and the message stayed unread. A plain BODY[...] is now answered as its BODY.PEEK form is, for every section that form supports, and sets \Seen. The account worker sets it before the walk, under the exclusive index lock, with the code STORE uses, which now lives in one function both call: one rename per message, one index write per FETCH, no mod-sequence change for a message already \Seen (RFC 7162 section 3.1.11), and the session's own change is not reported back to it. A message whose \Seen the FETCH set is answered with FLAGS, and once CONDSTORE is enabled with UID and MODSEQ, as RFC 7162 section 3.2.4 requires. In a mailbox opened by EXAMINE nothing is set (RFC 9051 section 6.3.3). RFC822, RFC822.HEADER and RFC822.TEXT, which RFC 9051 removed from its grammar, still end NO.
| README.md | commits | blame |
| contrib/ | |
| src/ | |
README.md
# OpenIMAPD
A from-scratch IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)) server for OpenBSD, written in C in the privilege-separated tradition of `smtpd(8)`, `httpd(8)`, and `ntpd(8)`. No third-party IMAP library.
**Status:** 0.1.7 Pre-release, actively developed. Not a port. See [Getting the source](#getting-the-source) below for the repository.
## What it is
- **Privilege-separated**, `smtpd`-style, across six processes: a root *parent* reads configuration and binds the listening sockets; unprivileged *listener* and *auth* children handle the network and credential checks; *keymgr* holds the TLS private key and performs every private-key operation on request, so the process terminating TLS never has the key in its address space; a *parser* child, spun up by a *store* child on demand and retired after a spell idle, does every parse of attacker-reachable content — `SEARCH`'s grammar and `FETCH`'s `ENVELOPE`/`BODYSTRUCTURE`/header-field parsing alike — confined to `pledge(2)` `"stdio recvfd"`: it opens nothing itself, reading each message over a descriptor the store child passes it already open; and a *store* child is forked per authenticated account, shared by all of that account's sessions, chroots into the mail spool, and drops privileges to that account's own user before ever touching a message. `pledge(2)`, `unveil(2)`, and `chroot(2)` enforce these boundaries, not just convention: *parent* is the only process that can pass a file descriptor at all.
- **Storage**: stock maildir format (`tmp/`/`new/`/`cur/`, atomic delivery via `rename(2)`), readable with `ls` and `grep`, and natively understood by `smtpd(8)`'s own `maildir` delivery action. IMAP's extra bookkeeping (UIDs, UIDVALIDITY, per-message mod-sequences, keywords) lives in a small, `flock(2)`-guarded, line-oriented index file per mailbox, plain colon-delimited text, not a database.
- **Transport**: STARTTLS on port 143 and implicit TLS on port 993 ([RFC 8314](https://www.rfc-editor.org/rfc/rfc8314)), via `libtls`. `AUTH=PLAIN` only, refused before TLS is established.
## Protocol coverage
`CAPABILITY`, `STARTTLS`, `AUTHENTICATE`, `ID`, `ENABLE`, `SELECT`, `EXAMINE`, `CREATE`, `DELETE`, `RENAME`, `LIST`, `LSUB`, `NAMESPACE`, `STATUS`, `FETCH` (including `ENVELOPE`, `BODYSTRUCTURE`, and MIME-part-addressed `BODY[<part>]`/`BODY.PEEK[<part>]`), `STORE`, `SEARCH`, `APPEND`, `COPY`, `MOVE`, `EXPUNGE`, `UNSELECT`, `CLOSE`, `SUBSCRIBE`, `UNSUBSCRIBE` (including LIST's [RFC 9051](https://www.rfc-editor.org/rfc/rfc9051) section 6.3.9.1 `SUBSCRIBED` selection option), the `UID`-prefixed form of every command that supports it, `IDLE` (with the cross-session caveat noted below), and the [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) `CONDSTORE`/`QRESYNC` extensions.
ACL/shared-mailbox support is deliberately left out.
**`IDLE` is a poll, not a kernel-driven push.** An `IDLE`ing session rechecks its selected mailbox every `idle poll` seconds (default 5, see `imapd.conf`), so new mail, whether delivered by an external MTA or by another IMAP session, is reported within one interval rather than instantly. Most polls are two `stat(2)` calls and no lock: the store child only re-reads the index and streams UIDs when the mailbox directory or `new/` has actually been touched. Setting `idle poll 0` disables polling entirely, which restores the earlier behaviour where an `IDLE`ing session saw nothing until it sent `DONE`.
## Requirements
OpenBSD only. This depends on `<imsg.h>`, `pledge(2)`, `unveil(2)`, and libutil's `imsgbuf_*` API, none of which exist outside OpenBSD. Links against libevent, libtls/libssl/libcrypto, and libutil, all base-system libraries (see `src/Makefile`).
**imapd requires OpenBSD -current, and will not build on 7.9.**. Building on the most recent stable release is a goal for 1.0.
`keymgr`, the process that isolates the TLS private key from `listener`, additionally depends on `tls_config_use_fake_private_key()` and undocumented ex_data-tagging behavior inside `tls_keypair_load()`, both unexported libtls/LibreSSL internals with no compatibility promise, and neither declared in libtls's public, installed `tls.h`.
## Building and installing
```
cd src
make
doas make install
```
Installs the daemon to `/usr/local/sbin/imapd`, man pages to `/usr/local/man/man8`, the `imapduser` account-provisioning tool alongside the daemon, and a sample config to `/usr/local/share/examples/imapd/imapd.conf`.
The `rc.d(8)` script is not installed automatically. `install(1)`, not `cp(1)`, so the installed copy is executable regardless of the source tree's own permission bits:
```
doas install -o root -g wheel -m 555 src/rc.d/imapd /etc/rc.d/imapd
```
## Configuring
Copy the sample config into place with restrictive permissions. imapd refuses to start against a config that's group- or world-writable, *or* world-readable:
```
doas install -o root -g wheel -m 600 \
/usr/local/share/examples/imapd/imapd.conf /etc/imapd.conf
```
Every directive is documented inline in the sample file; the full reference is in `imapd(8)`'s FILES section.
## Creating the daemon accounts
imapd drops privileges to three accounts of its own and will not start until all three exist. Create them once, before the first mailbox account, since `imapduser(8)` gives the credentials file to the `_imapauth` group:
```
doas useradd -c "IMAP Daemon" -d /var/empty -s /sbin/nologin _imapd
doas useradd -c "IMAP Auth" -d /var/empty -s /sbin/nologin _imapauth
doas useradd -c "IMAP Key Manager" -d /var/empty -s /sbin/nologin _imapkey
```
Each runs in a group of its own name, which `useradd(8)` creates by default unless `/etc/usermgmt.conf` sets `group` otherwise.
## Creating an account
imapd's users aren't real system accounts, `imapduser(8)` manages a bespoke credentials file (`username:passwordhash:uid:gid:maildir`, bcrypt via `crypt_checkpass(3)`) and the matching maildir ownership together, since no combination of `useradd(8)`/`userdel(8)` can safely keep both in sync:
```
doas imapduser -a someuser
```
See `imapduser(8)` for `-d` (revoke login without touching mail) and the `-c`/`-s`/`-u`/`-g` overrides.
## Running
```
doas rcctl enable imapd
doas rcctl start imapd
```
## Known limitations
Beyond the deliberate protocol-scope decisions covered in `imapd(8)`'s CAVEATS:
- If the listener or auth process exits unexpectedly after startup, it is not automatically restarted. Recovery is `rcctl restart imapd`. See `imapd(8)`.
`SIGHUP` reloads `spool`, `append max`, `attachment max`, `account sessions`, `connections max`, `idle poll`, `lock timeout`, `login grace`, `startups`, and the TLS certificate/key without dropping connected sessions, `listen on` and `credentials` changes still require a restart. See `imapd(8)`.
IPv6 is supported (`listen on ::` or `listen on *` for dual-stack) but not the default, see `imapd(8)`'s `listen on` directive.
## Getting the source
The repository is hosted with [Game of Trees](https://gameoftrees.org/) (`got`), read-only anonymous access over SSH:
```
got clone ssh://anonymous@got.openimapd.dev/imapd
```
The repository is also git-compatible; a plain `git clone` against the same URL works too:
```
git clone ssh://anonymous@got.openimapd.dev/imapd
```
## Security
Report security issues to security@openimapd.dev. General questions or feedback: feedback@openimapd.dev.
## License
ISC. See the copyright header in each source file.
## More
`imapd(8)` and `imapduser(8)` are the authoritative technical reference.
