commit 04d4e2d94a86b3470cd5f62105f1558c90c5de47 from: David Williams date: Mon Aug 24 03:21:43 2026 UTC Initial import commit - /dev/null commit + 04d4e2d94a86b3470cd5f62105f1558c90c5de47 blob - /dev/null blob + 3edbe226644eae7c172664c27bb5b9052d7a3ae6 (mode 644) --- /dev/null +++ README.md @@ -0,0 +1,91 @@ +# OpenIMAPD + +A from-scratch IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)) server for OpenBSD, written in traditional C in the privilege-separated tradition of `smtpd(8)`, `httpd(8)`, and `ntpd(8)`. No third-party IMAP library, no borrowed protocol engine. + +**Status:** pre-release, version 0.1. Actively developed. Not yet a port, and not yet publicly hosted — see [Getting the source](#getting-the-source) below. + +## What it is + +- **Privilege-separated**, `smtpd`-style: a root *parent* process reads configuration and binds the listening sockets; unprivileged *listener* and *auth* children handle the network and credential checks; a *store* child is forked per authenticated session, chroots into the mail spool, and drops privileges to that session's own user before ever touching a message. `pledge(2)`, `unveil(2)`, and `chroot(2)` enforce these boundaries, not just convention. +- **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[]`/`BODY.PEEK[]`), `STORE`, `SEARCH`, `APPEND`, `COPY`, `MOVE`, `EXPUNGE`, `UNSELECT`, `CLOSE`, the `UID`-prefixed form of every command that supports it, `IDLE` with real cross-session push, and the [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) `CONDSTORE`/`QRESYNC` extensions. + +`SUBSCRIBE`, `UNSUBSCRIBE`, and ACL/shared-mailbox support are deliberately out of scope, not unfinished — matched against real client behavior and left out on the same "every extra command is attack surface" principle `smtpd(8)` uses to justify skipping `VRFY`/`EXPN`. Full protocol-scope reasoning and other caveats (flat per-user namespace, `IDLE` push triggers, `EXAMINE` read-only enforcement, etc.) are documented in `imapd(8)`'s CAVEATS section — that man page is the authoritative reference, this file is just an overview. + +## Requirements + +OpenBSD only. This depends on ``, `pledge(2)`, `unveil(2)`, and libutil's `imsgbuf_*` API, none of which exist outside OpenBSD, so it will not build on any other host. Developed and tested against OpenBSD 8.0. Links against libevent, libtls/libssl/libcrypto, and libutil — all base-system libraries (see `src/Makefile`). + +## 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)`, matters here 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 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 — a deliberate choice, not an oversight: neither `smtpd(8)` nor `httpd(8)` auto-restarts their own equivalent core processes either. Recovery is `rcctl restart imapd`. See `imapd(8)`. + +`SIGHUP` reloads `spool`, `attachment max`, and the TLS certificate/key without dropping connected sessions, matching `httpd(8)`'s own documented reload behavior — `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 + +Not yet publicly hosted while this is still pre-release. Reach out at the address below and it'll be shared directly. + +## 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. blob - /dev/null blob + e03331f785bb81925fc872c7ba73aeb8050f1a40 (mode 755) --- /dev/null +++ contrib/imapd-teardown @@ -0,0 +1,215 @@ +#!/bin/sh +# +# $OpenIMAPD$ +# +# imapd-teardown -- completely remove an installed imapd, to test +# repeated from-scratch installs. +# +# This is a dev-tree-only tool (unlike contrib/imapduser, it is NOT +# installed by src/Makefile's afterinstall: target -- there's no real +# operational reason a production box would ever want to "uninstall +# itself," so it stays contrib/-only, run straight out of the source +# tree). It exists because the fresh-install pass documented in +# README.skeleton ("Fresh imapd install on premio (task #228)") found +# five real gaps that only showed up by actually attempting an install +# on a genuinely clean system -- this script is what makes "genuinely +# clean system" repeatable without needing an actual fresh box every +# time. +# +# Scope, by design: +# +# - Everything needed to make the NEXT install genuinely fresh -- +# the daemon binary, its own admin tool (imapduser), both man +# pages, the installed sample config, the rc.d script, boot-time +# relink artifacts, the running config file, the credentials file +# (and its directory), the TLS certificate and key, and the +# _imapd/_imapauth system accounts -- is removed. +# +# - The mail spool (real mail data, not install-time cruft) is left +# alone unless -M is given explicitly, and -M has its own separate +# type-the-path confirmation that -y does not bypass. Revoking an +# install and destroying mail are different-consequence decisions, +# same reasoning as imapduser's -a/-d split. +# +# - smtpd.conf's shd_userbase table (a different daemon's config) is +# never touched. If you also want smtpd to stop trying to deliver +# into a wiped spool, that's a separate, deliberate edit -- see +# the smtpd.conf gap in README.skeleton's fresh-install writeup +# for what that table looks like. +# +# Usage: +# doas ./imapd-teardown [-y] [-M] [-c credentials-dir] [-f config-file] +# [-s spool-root] [-T tls-cert] [-K tls-key] +# +# -y skip the general confirmation prompt (still stops to ask +# separately if -M is also given -- see above) +# -M also remove the mail spool +# +# -c/-f/-s/-T/-K override the v1 defaults documented in imapd.8, in +# case this premio install (or some future one) ever diverges from +# them; BINDIR/MANDIR are not overridable here since they're a build- +# time Makefile decision (BINDIR=/usr/local/sbin, MANDIR=/usr/local/ +# man/man in src/Makefile), not a runtime config one. +# +# Stops and disables the running service via rcctl(8) before removing +# anything -- per rcctl(8) itself, "when a package daemon is disabled, +# it is removed from pkg_scripts and its variables are removed if +# any" (man.openbsd.org/rcctl.8): the real mechanism for cleanly +# reversing what "rcctl enable imapd" did, rather than hand-editing +# rc.conf.local's pkg_scripts line the way an earlier pass in this +# project's history had to hand-edit smtpd.conf. +# +# Safe to re-run: every removal is conditional on the target actually +# existing, so a partial or already-torn-down install just reports +# nothing left to do for whatever's already gone. + +set -e + +BINDIR=/usr/local/sbin +MAN8DIR=/usr/local/man/man8 +EXAMPLEDIR=/usr/local/share/examples/imapd +RELINKDIR=/usr/share/relink/usr/local/sbin/imapd +RELINKLOG=/tmp/imapd-relink.log + +CRED_DIR=/etc/imapd +CONF_FILE=/etc/imapd.conf +SPOOL_ROOT=/var/mail/imapd +TLS_CERT=/etc/ssl/imapd.crt +TLS_KEY=/etc/ssl/private/imapd.key + +ASSUME_YES=0 +WIPE_MAIL=0 + +usage() { + echo "usage: ${0##*/} [-y] [-M] [-c credentials-dir] [-f config-file]" 1>&2 + echo " [-s spool-root] [-T tls-cert] [-K tls-key]" 1>&2 + exit 1 +} + +while getopts "c:f:s:T:K:yM" opt; do + case "$opt" in + c) CRED_DIR=$OPTARG ;; + f) CONF_FILE=$OPTARG ;; + s) SPOOL_ROOT=$OPTARG ;; + T) TLS_CERT=$OPTARG ;; + K) TLS_KEY=$OPTARG ;; + y) ASSUME_YES=1 ;; + M) WIPE_MAIL=1 ;; + *) usage ;; + esac +done +shift $((OPTIND - 1)) +[ $# -eq 0 ] || usage + +if [ "$(id -u)" -ne 0 ]; then + echo "${0##*/}: must be run as root" 1>&2 + exit 1 +fi + +FOUND_ANY=0 +DRYRUN=1 + +# $1 = human label, $2 = path, $3 = "f" (plain rm -f) or "r" (rm -rf) +do_rm() { + _label=$1 + _path=$2 + _mode=$3 + if [ -e "$_path" ] || [ -L "$_path" ]; then + FOUND_ANY=1 + if [ "$DRYRUN" -eq 1 ]; then + echo " $_path ($_label)" + else + case "$_mode" in + r) rm -rf "$_path" ;; + *) rm -f "$_path" ;; + esac + echo "${0##*/}: removed $_path" 1>&2 + fi + fi +} + +# $1 = human label, $2 = account name +do_userdel() { + _label=$1 + _acct=$2 + if id "$_acct" >/dev/null 2>&1; then + FOUND_ANY=1 + if [ "$DRYRUN" -eq 1 ]; then + echo " system account $_acct ($_label)" + else + userdel "$_acct" 2>/dev/null || true + # useradd's own default (no -g given) creates a same- + # named group alongside the account -- see README. + # skeleton's account-provisioning writeup, confirmed + # there against the pre-rename _openimap/_openimapd + # accounts. userdel(8) itself makes no mention of + # touching groups, so that group is cleaned up + # separately here, guarded against already being gone. + groupdel "$_acct" 2>/dev/null || true + echo "${0##*/}: removed account $_acct (and its group, if any)" 1>&2 + fi + fi +} + +list_targets() { + do_rm "installed daemon binary" "$BINDIR/imapd" f + do_rm "installed imapduser tool" "$BINDIR/imapduser" f + do_rm "imapd.8 man page" "$MAN8DIR/imapd.8" f + do_rm "imapduser.8 man page" "$MAN8DIR/imapduser.8" f + do_rm "installed sample config directory" "$EXAMPLEDIR" r + do_rm "rc.d script" "/etc/rc.d/imapd" f + do_rm "boot-time relink artifact directory" "$RELINKDIR" r + do_rm "relink boot log" "$RELINKLOG" f + do_rm "config file" "$CONF_FILE" f + do_rm "credentials directory" "$CRED_DIR" r + do_rm "TLS certificate" "$TLS_CERT" f + do_rm "TLS private key" "$TLS_KEY" f + do_userdel "auth's privilege-drop account" "_imapauth" + do_userdel "listener's privilege-drop account" "_imapd" + if [ "$WIPE_MAIL" -eq 1 ]; then + do_rm "mail spool -- REAL MAIL DATA" "$SPOOL_ROOT" r + fi +} + +echo "${0##*/}: the following would be removed:" 1>&2 +list_targets + +if [ "$FOUND_ANY" -eq 0 ]; then + echo "${0##*/}: nothing found -- already torn down" 1>&2 + exit 0 +fi + +if [ "$ASSUME_YES" -ne 1 ]; then + printf "%s: proceed? [y/N] " "${0##*/}" 1>&2 + read -r ANSWER + case "$ANSWER" in + [Yy]|[Yy][Ee][Ss]) ;; + *) echo "${0##*/}: aborted, nothing removed" 1>&2; exit 1 ;; + esac +fi + +if [ "$WIPE_MAIL" -eq 1 ]; then + echo "" 1>&2 + echo "${0##*/}: -M was given -- this will also permanently delete" \ + "$SPOOL_ROOT and every mailbox in it. This cannot be undone." 1>&2 + printf "Type the spool path (%s) to confirm, or anything else to skip it: " \ + "$SPOOL_ROOT" 1>&2 + read -r CONFIRM_SPOOL + if [ "$CONFIRM_SPOOL" != "$SPOOL_ROOT" ]; then + echo "${0##*/}: spool path not confirmed -- leaving $SPOOL_ROOT alone" 1>&2 + WIPE_MAIL=0 + fi +fi + +# Stop and disable the service before touching any of its files. +# rcctl disable's own documented behavior (man.openbsd.org/rcctl.8) is +# what actually reverses "rcctl enable imapd": it strips the +# pkg_scripts entry and any rcctl-set variables from rc.conf.local, +# rather than this script trying to hand-edit that file itself. +rcctl stop imapd >/dev/null 2>&1 || true +rcctl disable imapd >/dev/null 2>&1 || true + +DRYRUN=0 +list_targets + +echo "${0##*/}: done" 1>&2 blob - /dev/null blob + fb06375a9eb329bd597bd0e995475eb338ebe2b7 (mode 755) --- /dev/null +++ contrib/imapduser @@ -0,0 +1,283 @@ +#!/bin/sh +# +# $OpenIMAPD$ +# +# imapduser -- add or delete an imapd mailbox account. +# +# Renamed and given a real -a/-d mode split from its previous identity +# as "newimapuser", a contrib/-only dev script with add-only behavior. +# It's now an actual installed part of the package (${PREFIX}/sbin/ +# imapduser, via src/Makefile's afterinstall: target), because there's +# real OpenBSD ports precedent for exactly this shape of tool: a +# daemon that keeps its own bespoke, non-system credentials store +# needs its own installed admin tool to manage it, since useradd(8)/ +# userdel(8)/vipw(8) don't apply to a store that isn't /etc/passwd. +# Confirmed directly against OpenBSD's own cyrus-sasl2 port PLIST +# (github.com/openbsd/ports, security/cyrus-sasl2/pkg/PLIST, read +# directly rather than assumed): it installs "@bin sbin/saslpasswd2" +# and "@man man/man8/saslpasswd2.8" for precisely this reason -- +# managing sasldb2, SASL's own bespoke secrets store. See +# README.skeleton for the fuller writeup of this design decision. +# +# imapd's credentials file is deliberately self-contained the same +# way: auth.c's cred_lookup() reads "username:passwordhash:uid:gid: +# maildir" lines straight out of it and never calls getpwnam(3) for an +# IMAP end user (see auth.c's own header comment, citing +# openimap-privsep-design.md -- that decision is specifically about +# end users, not about auth's own _imapauth service identity). +# store.c's per-session store child then drops privileges directly to +# that uid/gid (IMSG_STORE_INIT handling) before chroot(2)-ing into +# spool_root and chdir(2)-ing into "/maildir" -- chdir(2) is NOT +# tolerant of a missing directory (it fatal()s), so the maildir itself +# has to already exist, owned by that uid/gid, before the first login; +# store.c only ever mkdir(2)s tmp/new/cur *inside* it on demand. +# +# This script exists purely to keep those three things (a credentials +# line, the on-disk maildir's ownership, and the uid/gid tying them +# together) consistent with each other -- and, in -d mode, to remove +# the credentials line safely without disturbing the mail data it +# pointed at. It does NOT create or remove a real system account: no +# useradd(8)/userdel(8)/adduser(8), nothing written to /etc/passwd or +# /etc/group. That's the whole point of the design this script is +# automating -- see the project's own README.skeleton for the fuller +# writeup. +# +# Usage: +# imapduser -a [-c credentials-file] [-s spool-root] [-u uid] [-g gid] username +# imapduser -d [-c credentials-file] username +# +# -a adds a new mailbox account: creates its maildir (owned by the +# given, or automatically picked, uid:gid) and appends a credentials- +# file line, prompting for a password via encrypt(1) (OpenBSD base, +# man.openbsd.org/encrypt.1), which produces the Blowfish hash +# crypt_checkpass(3) -- and so auth.c's auth_verify() -- expects. +# +# -d removes a mailbox account's credentials-file line ONLY. It +# deliberately does NOT touch the account's maildir or any mail data +# in it: revoking login ability and destroying mail are two very +# different decisions with very different blast radii, and this tool +# doesn't conflate them. The maildir's path is printed on success so +# the operator can remove it by hand if that's actually what's wanted. +# +# Exactly one of -a or -d is required. -c/-s default to imapd.8's own +# documented v1 defaults (/etc/imapd/credentials, /var/mail/imapd). In +# -a mode, if -u/-g are omitted, the same free id is picked +# automatically and used for both (matching the uid==gid==1000 pattern +# already used for the real "dhw" account on premio) -- see +# next_free_id() below for exactly how "free" is decided. -s/-u/-g are +# ignored in -d mode (nothing left to size or own once the credentials +# line is gone). +# +# Must be run as root (or via doas/su): -a chown(8)s a maildir to an +# arbitrary uid/gid and both modes write to a file that should stay +# root-owned, same spirit as parse.y's check_file_secrecy() check on +# imapd.conf itself (that check isn't applied to the credentials file +# by auth.c today, but keeping it root-owned/non-world-readable is +# still the obviously correct posture for a file full of password +# hashes). + +set -e + +CRED_FILE=/etc/imapd/credentials +SPOOL_ROOT=/var/mail/imapd +BASE_ID=2000 +NEWUID= +NEWGID= +MODE= + +usage() { + echo "usage: ${0##*/} -a [-c credentials-file] [-s spool-root] [-u uid] [-g gid] username" 1>&2 + echo " ${0##*/} -d [-c credentials-file] username" 1>&2 + exit 1 +} + +while getopts "ac:dg:s:u:" opt; do + case "$opt" in + a) [ -z "$MODE" ] || usage; MODE=add ;; + d) [ -z "$MODE" ] || usage; MODE=del ;; + c) CRED_FILE=$OPTARG ;; + s) SPOOL_ROOT=$OPTARG ;; + u) NEWUID=$OPTARG ;; + g) NEWGID=$OPTARG ;; + *) usage ;; + esac +done +shift $((OPTIND - 1)) + +[ -n "$MODE" ] || usage +[ $# -eq 1 ] || usage +USERNAME=$1 + +# Field 0 of a credentials-file line, so it can't itself contain ":" or +# a newline; kept to a conservative safe-for-a-bare-maildir-directory- +# name charset besides, since $USERNAME doubles as the maildir name +# in -a mode. +case "$USERNAME" in +*[!A-Za-z0-9_.-]*|"") + echo "${0##*/}: invalid username: $USERNAME (letters, digits, '.', '_', '-' only)" 1>&2 + exit 1 + ;; +esac + +if [ "$(id -u)" -ne 0 ]; then + echo "${0##*/}: must be run as root" 1>&2 + exit 1 +fi + +# Canonical ownership/mode for $CRED_FILE, applied any time this script +# creates or rewrites it (fresh creation in -a mode, or after removing +# a line in -d mode). Real bug found on a genuinely fresh install: +# auth.c's auth_main() chroot()s into this file's directory and drops +# privileges to the fixed _imapauth daemon account BEFORE ever opening +# this file -- cred_lookup()'s fopen() happens later, per auth request, +# already running as _imapauth. A root:wheel/0600 file is therefore +# unreadable by the dropped-privilege process no matter what: auth +# logs "fopen credentials: Permission denied" and every AUTHENTICATE +# fails, surfacing to a real IMAP client as a generic "invalid +# credentials" rather than anything that points at the actual cause. +# Fixed by owning the file root:_imapauth (group-readable by the one +# daemon account that ever needs to read it, not group- or world- +# writable, and not readable by any *other* unprivileged account +# either) instead of root:wheel. If the _imapauth group doesn't exist +# yet (daemon accounts not provisioned before this script ran), chgrp +# fails silently below and the warning tells the operator exactly what +# to fix and why, rather than leaving a working-looking credentials +# file that auth can never actually read. +set_cred_perms() { + chown root:wheel "$CRED_FILE" 2>/dev/null || true + if ! chgrp _imapauth "$CRED_FILE" 2>/dev/null; then + echo "${0##*/}: warning: group '_imapauth' doesn't exist yet --" \ + "left $CRED_FILE group-owned by wheel. auth.c drops" \ + "privileges to the _imapauth account before reading this" \ + "file, so it must be chgrp'd to _imapauth (rerun this" \ + "script, or 'chgrp _imapauth $CRED_FILE' by hand) once that" \ + "account exists, or every AUTHENTICATE will fail with" \ + "\"fopen credentials: Permission denied\" in the auth log." 1>&2 + fi + chmod 640 "$CRED_FILE" +} + +# True (exit 0) if $1 is already claimed by /etc/passwd's or /etc/group's +# id column, or by either the uid or gid column of an existing +# credentials-file line -- checked jointly (not per-namespace) so the +# same free number is always safe to hand out as *both* a uid and a gid +# at once, which is what happens below when neither -u nor -g was given. +id_in_use() { + _id=$1 + awk -F: -v id="$_id" '$3 == id { f=1 } END { exit !f }' /etc/passwd && return 0 + awk -F: -v id="$_id" '$3 == id { f=1 } END { exit !f }' /etc/group && return 0 + awk -F: -v id="$_id" '$3 == id || $4 == id { f=1 } END { exit !f }' \ + "$CRED_FILE" && return 0 + return 1 +} + +next_free_id() { + _id=$BASE_ID + while id_in_use "$_id"; do + _id=$((_id + 1)) + done + echo "$_id" +} + +do_add() { + if [ ! -e "$CRED_FILE" ]; then + # Real gap found on a genuinely fresh install (no leftover + # /etc/imapd/ from an earlier pass): this used to assume + # CRED_FILE's parent directory already existed, and failed + # with a bare "No such file or directory" from the shell + # redirection below if it didn't -- true for a truly fresh + # box, not just a hypothetical. mkdir -p is a no-op if the + # directory is already there, so this is safe either way. + CRED_DIR=${CRED_FILE%/*} + if [ "$CRED_DIR" != "$CRED_FILE" ] && [ ! -d "$CRED_DIR" ]; then + mkdir -p "$CRED_DIR" + chmod 755 "$CRED_DIR" + fi + : > "$CRED_FILE" + set_cred_perms + fi + + if awk -F: -v u="$USERNAME" '$1 == u { found=1 } END { exit !found }' "$CRED_FILE"; then + echo "${0##*/}: $USERNAME already has a line in $CRED_FILE" 1>&2 + exit 1 + fi + + if [ -z "$NEWUID" ] && [ -z "$NEWGID" ]; then + NEWUID=$(next_free_id) + NEWGID=$NEWUID + elif [ -z "$NEWUID" ]; then + NEWUID=$NEWGID + elif [ -z "$NEWGID" ]; then + NEWGID=$NEWUID + fi + + case "$NEWUID" in + *[!0-9]*|"") echo "${0##*/}: invalid uid: $NEWUID" 1>&2; exit 1 ;; + esac + case "$NEWGID" in + *[!0-9]*|"") echo "${0##*/}: invalid gid: $NEWGID" 1>&2; exit 1 ;; + esac + + # Credentials-file maildir field is just the bare directory name + # under spool_root (store.c's own log line for a real session shows + # this exactly: "maildir dhw", not a full or nested path) -- see + # store.c's unveil_path construction ("/" + init.maildir) for why + # store.c itself requires this to resolve as a single path + # component relative to its chroot. + MAILDIR=$USERNAME + MAILDIR_PATH=$SPOOL_ROOT/$MAILDIR + + if [ -e "$MAILDIR_PATH" ]; then + echo "${0##*/}: $MAILDIR_PATH already exists -- not touching it" 1>&2 + exit 1 + fi + + mkdir -p "$MAILDIR_PATH" + chown "$NEWUID:$NEWGID" "$MAILDIR_PATH" + chmod 700 "$MAILDIR_PATH" + + echo "Password for $USERNAME:" 1>&2 + HASH=$(encrypt -b a -p) + if [ -z "$HASH" ]; then + echo "${0##*/}: encrypt(1) produced no output -- aborting" 1>&2 + rmdir "$MAILDIR_PATH" 2>/dev/null || true + exit 1 + fi + + echo "${USERNAME}:${HASH}:${NEWUID}:${NEWGID}:${MAILDIR}" >> "$CRED_FILE" + + echo "${0##*/}: added $USERNAME (uid $NEWUID, gid $NEWGID, maildir $MAILDIR_PATH) to $CRED_FILE" 1>&2 +} + +do_delete() { + if [ ! -e "$CRED_FILE" ]; then + echo "${0##*/}: no credentials file at $CRED_FILE" 1>&2 + exit 1 + fi + + if ! awk -F: -v u="$USERNAME" '$1 == u { found=1 } END { exit !found }' "$CRED_FILE"; then + echo "${0##*/}: $USERNAME has no line in $CRED_FILE" 1>&2 + exit 1 + fi + + # Captured only to tell the operator where the mail data is -- this + # tool never touches it itself, see the usage comment up top for why. + MAILDIR_FIELD=$(awk -F: -v u="$USERNAME" '$1 == u { print $5; exit }' "$CRED_FILE") + + TMP_CRED_FILE=$(mktemp "${CRED_FILE}.XXXXXXXX") || { + echo "${0##*/}: mktemp failed" 1>&2 + exit 1 + } + awk -F: -v u="$USERNAME" '$1 != u' "$CRED_FILE" > "$TMP_CRED_FILE" + mv -f "$TMP_CRED_FILE" "$CRED_FILE" + set_cred_perms + + echo "${0##*/}: removed $USERNAME from $CRED_FILE ($SPOOL_ROOT/$MAILDIR_FIELD" \ + "was left untouched -- remove it by hand if you also want the" \ + "mail data gone)" 1>&2 +} + +case "$MODE" in +add) do_add ;; +del) do_delete ;; +esac blob - /dev/null blob + 1e7591ee676c84c03570f9cffa7293aba421de20 (mode 644) --- /dev/null +++ contrib/imapduser.8 @@ -0,0 +1,190 @@ +.\" $OpenIMAPD$ +.\" +.\" Written for the OpenIMAPD project. Public domain / no rights reserved, +.\" matching the project's ports-oriented, OpenBSD-base-inclusion goal. +.\" +.Dd $Mdocdate: August 19 2026 $ +.Dt IMAPDUSER 8 +.Os +.Sh NAME +.Nm imapduser +.Nd add or delete an imapd mailbox account +.Sh SYNOPSIS +.Nm +.Fl a +.Op Fl c Ar credentials-file +.Op Fl s Ar spool-root +.Op Fl u Ar uid +.Op Fl g Ar gid +.Ar username +.Nm +.Fl d +.Op Fl c Ar credentials-file +.Ar username +.Sh DESCRIPTION +.Nm +adds or removes a mailbox account for +.Xr imapd 8 . +Exactly one of +.Fl a +or +.Fl d +is required. +.Pp +.Xr imapd 8 +end users are not real system accounts: +.Xr imapd 8 Ns 's +.Em auth +child reads +.Sy username : passwordhash : uid : gid : maildir +lines directly out of a credentials file rather than calling +.Xr getpwnam 3 , +and its +.Em store +child drops privileges straight to that line's +.Ar uid : Ns Ar gid +before ever touching mailbox data. +.Nm +exists to keep a credentials-file line, the on-disk maildir's +ownership, and the +.Ar uid : Ns Ar gid +tying them together consistent with each other, since no combination +of +.Xr useradd 8 , +.Xr userdel 8 , +or hand-editing the credentials file can safely do that alone. +It does not create or remove a real system account: nothing is +written to +.Pa /etc/passwd +or +.Pa /etc/group . +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl a +Add +.Ar username . +Creates its maildir under +.Ar spool-root , +owned by +.Ar uid : Ns Ar gid , +and appends a line to the credentials file, prompting for a password +via +.Xr encrypt 1 . +If +.Fl u +and +.Fl g +are both omitted, the same free id is chosen automatically and used +for both. +Fails without effect if +.Ar username +already has a credentials-file line, or if its maildir already exists. +.It Fl d +Delete +.Ar username Ns 's +credentials-file line only. +Does +.Em not +touch its maildir or any mail data in it: revoking login ability and +destroying mail are different decisions with different blast radii, +and +.Nm +does not conflate them. +The maildir's path is printed on success so it can be removed by hand +if that is actually what is wanted. +.Fl s , +.Fl u , +and +.Fl g +are ignored in this mode. +Fails without effect if +.Ar username +has no credentials-file line. +.It Fl c Ar credentials-file +Credentials file to modify. +Defaults to +.Pa /etc/imapd/credentials , +matching +.Xr imapd 8 Ns 's +own default. +.It Fl s Ar spool-root +Mail spool root +.Ar username Ns 's +maildir is created under, in +.Fl a +mode. +Defaults to +.Pa /var/mail/imapd , +matching +.Xr imapd 8 Ns 's +own default. +.It Fl u Ar uid , Fl g Ar gid +User and group id to own +.Ar username Ns 's +maildir and to record in its credentials-file line, in +.Fl a +mode. +.El +.Pp +Must be run as root: adding an account +.Xr chown 8 Ns s +a maildir to an arbitrary +.Ar uid : Ns Ar gid , +and both modes write to a file that must stay root-owned. +.Pp +Every write to the credentials file, whether creating it for the +first account or rewriting it after a deletion, resets its ownership +and mode to +.Sy root : Ns Sy _imapauth , +mode 0640: the +.Em auth +child chroots and drops privileges to +.Sy _imapauth +before ever opening this file, so it must be group-readable by that +account specifically, not just root-readable. +If the +.Sy _imapauth +group does not exist yet, +.Nm +leaves the file group-owned by +.Sy wheel +and prints a warning rather than failing silently; re-run +.Nm , +or run +.Xr chgrp 1 +by hand, once that account is provisioned. +.Sh FILES +.Bl -tag -width "/etc/imapd/credentialsXXX" -compact +.It Pa /etc/imapd/credentials +Default credentials file; see +.Xr imapd 8 . +.It Pa /var/mail/imapd +Default spool root; see +.Xr imapd 8 . +.El +.Sh EXIT STATUS +.Ex -std imapduser +.Sh SEE ALSO +.Xr encrypt 1 , +.Xr crypt_checkpass 3 , +.Xr imapd 8 +.Sh HISTORY +.Nm +was written for the OpenIMAPD project. +It replaces an earlier, add-only, contrib/-only script named +.Nm newimapuser ; +see the project's own +.Pa README.skeleton +for the rationale behind the rename and the +.Fl a Ns / Ns Fl d +split, including the real precedent this follows: OpenBSD's own +.Sy cyrus-sasl2 +port installs +.Xr saslpasswd2 8 +to +.Pa ${PREFIX}/sbin +for the same reason +.Nm +is now installed rather than left as a dev-tree-only script -- +managing a daemon's own bespoke, non-system credentials store. blob - /dev/null blob + 2e079fcefdb9b26541b8babb639326c61e0cb4fa (mode 644) --- /dev/null +++ src/Makefile @@ -0,0 +1,156 @@ +# $OpenIMAPD$ +# +# Targets OpenBSD's make(1) + the base system's bsd.prog.mk. This will not +# build on a non-OpenBSD host: it depends on , pledge(2), unveil(2), +# and libutil's imsgbuf_* API, none of which exist outside OpenBSD. + +# Renamed from "openimap" to "imapd" to match OpenBSD's own naming +# convention for its "Open*" projects: the installed daemon drops the +# "Open" prefix and just goes by "d". Confirmed against +# OpenBSD's own innovations page (openbsd.org/innovations.html): +# OpenNTPD ships ntpd, OpenSMTPD ships smtpd, OpenBGPD ships bgpd, +# OpenIKED ships iked -- and in each case the "D" is already part of +# the *project* name itself, not added at the daemon-naming step. +# OpenSSH is the outlier (no "D"), and only because it ships a whole +# toolkit -- ssh/scp/sftp/ssh-keygen/etc -- not one daemon. This +# project fits the single-daemon shape, so the project itself was +# renamed OpenIMAP -> OpenIMAPD to match. +PROG= imapd +SRCS= main.c parent.c listener.c auth.c store.c log.c imsgev.c \ + parse.y + +# imapd isn't in OpenBSD base and has no ports-framework Makefile of its +# own (no bsd.port.mk, no PREFIX), so BINDIR/MANDIR must be set explicitly: +# neither bsd.own.mk nor bsd.prog.mk defaults BINDIR (confirmed by grepping +# both -- share/mk/bsd.own.mk and share/mk/bsd.prog.mk -- neither contains +# a "BINDIR?=" line; base daemons that aren't building via bsd.port.mk set +# it themselves per-Makefile, e.g. smtpd's own Makefile: BINDIR=/usr/sbin). +# /usr/local is "where to install things in general" for locally- +# administered, non-base software per share/man/man7/ports.7 (PREFIX +# description) -- so /usr/local/sbin + /usr/local/man/man match the same +# convention ports use for daemons, without requiring the ports framework. +BINDIR= /usr/local/sbin +MANDIR= /usr/local/man/man + +# RELINK: a shell command bsd.prog.mk uses to smoke-test a from-source +# relink of ${PROG} at "make install" time. Building this triggers bsd. +# prog.mk's documented re-link-kit mechanism (share/mk/bsd.prog.mk, +# confirmed against the live upstream file): install produces ${PROG}.tar +# (containing ${OBJS} + a generated install.sh that recompiles from those +# objects in random link order, runs this RELINK command against the +# result, then installs it) and drops it at +# /usr/share/relink/${BINDIR}/${PROG}/${PROG}.tar. Matches smtpd's own +# precedent verbatim (RELINK= "./${PROG} -V > /dev/null", confirmed +# against smtpd's live Makefile) -- "-V" prints the version and exits 0 +# with no side effects (see main.c), so this just proves the relinked +# binary starts and runs correctly before anything overwrites the +# installed copy. +# +# NOTE: unlike base's libc/libcrypto/ld.so/sshd, nothing on this system +# automatically *consumes* this tarball at boot -- /etc/rc's reorder_libs() +# has a hardcoded allowlist that doesn't include imapd (confirmed by +# reading etc/rc directly) and won't be patched to add it (fragile against +# base upgrades). rc.d/imapd's own rc_pre() hook is the consumer +# instead -- see that script for the actual relink-at-service-start logic. +# NOTE: deliberately no embedded quotes around this value -- bsd.prog.mk's +# own recipe for the RELINK feature already does "echo \"${RELINK}\" >> $@" +# when generating install.sh, so wrapping this value in its own literal +# quotes (matching smtpd's Makefile precedent verbatim) causes a doubled- +# quote collision: the generated line becomes +# echo ""./${PROG} -V > /dev/null"" >> install.sh, which the shell parses +# as two stacked redirects (> /dev/null, then >> install.sh) rather than +# literal text -- the later redirect wins for the same fd, so "> /dev/null" +# silently drops and install.sh ends up with a bare "./${PROG} -V" instead +# of the intended output-suppressed form. Confirmed by reading the actual +# generated line in a real "make install" transcript on premio: "-V"'s +# stdout leaked into rc.d/imapd's rc_pre() relink log instead of being +# discarded. Harmless (doesn't affect correctness -- install.sh's +# "set -o errexit" still aborts on real failures either way), but not the +# intended behavior, so leaving the quotes off here instead. +RELINK= ./${PROG} -V > /dev/null + +# bsd.prog.mk's built-in .y suffix rule runs yacc(1) on parse.y and +# compiles the result -- no extra machinery needed here, matching every +# other base-system daemon that ships a parse.y (ripd, smtpd, httpd, +# ntpd, etc. all just list it in SRCS the same way). -y (POSIX-mode +# output naming, y.tab.c/y.tab.h) is yacc(1)'s default on OpenBSD, so no +# YFLAGS override is needed either. + +# imsg_init(3): imsgbuf_init/imsgbuf_read/imsgbuf_write/imsg_get/ +# imsg_compose live in libutil on OpenBSD. +LDADD= -lutil +DPADD= ${LIBUTIL} + +# event_init/event_set/event_add/event_del/event_dispatch (, +# used throughout listener.c/parent.c/auth.c/store.c's event loops) live +# in libevent on OpenBSD, which -- like libtls below -- is base-system +# but not linked in automatically. Confirmed against httpd's own +# Makefile: LDADD=-levent -ltls -lssl -lcrypto -lutil. +LDADD+= -levent +DPADD+= ${LIBEVENT} + +# TLS (STARTTLS on 143, implicit TLS on 993 per RFC 8314): listener.c now +# actually terminates TLS via libtls (tls_server/tls_configure/ +# tls_accept_socket/tls_handshake/tls_read/tls_write/tls_close), sourced +# against src/lib/libtls/tls.h and httpd's server_tls_init()/server_tls_ +# handshake(). -ltls pulls in libssl/libcrypto itself on OpenBSD, but +# both are listed explicitly anyway, matching how httpd's own Makefile +# links it. +LDADD+= -ltls -lssl -lcrypto +DPADD+= ${LIBTLS} ${LIBSSL} ${LIBCRYPTO} + +MAN= imapd.8 + +WARNS= 6 +CFLAGS+= -Wall -Wstrict-prototypes -Wmissing-prototypes +CFLAGS+= -Wmissing-declarations -Wshadow -Wpointer-arith +CFLAGS+= -Wsign-compare + +# Explicit -g: bsd.prog.mk's DEBUG?=-g default apparently isn't reaching +# the actual compile line in this tree (the crash-diagnosis gdb session +# on premio showed "no debugging symbols found" against a plain `make` +# build), so force it directly rather than relying on that default. +DEBUG= -g + +# Sample imapd.conf, installed read-only at /usr/local/share/examples/ +# imapd/imapd.conf -- matching the real OpenBSD ports convention for +# sample configs (ports(7), the @sample PLIST keyword: a port installs +# its sample under ${PREFIX}/share/examples/${PKGNAME}/ and pkg_add(1) +# copies it into place on first install). Confirmed by reading share/ +# mk/bsd.prog.mk directly rather than guessed: /etc/examples/ itself is +# NOT an option here -- that mechanism is base-only, populated by base's +# own etc/Makefile during a release build, with no hook for locally- +# installed software at all. Using the ports-convention path now, even +# before this project has an actual port, means the eventual port's +# PLIST can just reference this same path rather than needing rework. +# +# afterinstall: is bsd.prog.mk's own documented extension point for +# exactly this (".if !target(afterinstall)" guards its default no-op, +# so defining it here before the .include below takes over instead). +EXAMPLEDIR= /usr/local/share/examples/imapd + +afterinstall: + install -d -o root -g wheel -m 755 ${DESTDIR}${EXAMPLEDIR} + install -c -o root -g bin -m 444 ${.CURDIR}/imapd.conf.example \ + ${DESTDIR}${EXAMPLEDIR}/imapd.conf + install -c -o root -g bin -m 555 ${.CURDIR}/../contrib/imapduser \ + ${DESTDIR}${BINDIR}/imapduser + install -c -o root -g bin -m 444 ${.CURDIR}/../contrib/imapduser.8 \ + ${DESTDIR}${MANDIR}8/imapduser.8 + +# imapduser: the account-provisioning tool for imapd's own bespoke +# credentials store (see contrib/imapduser's own header comment and +# imapduser.8). It isn't compiled -- it's a shell script -- so it can't +# be a second bsd.prog.mk PROG (that machinery only supports one per +# Makefile); installed here via the same afterinstall: hook as the +# sample config above instead, same ${BINDIR}/${MANDIR} destinations +# and 555/444 modes bsd.prog.mk itself would use for PROG/MAN. Matches +# real OpenBSD ports precedent for a daemon shipping its own bespoke- +# credentials-store admin tool as an installed binary rather than a +# dev-tree-only script: cyrus-sasl2's port PLIST installs saslpasswd2 +# to ${PREFIX}/sbin with its own man page for exactly the same reason +# (confirmed by reading that PLIST directly: github.com/openbsd/ports, +# security/cyrus-sasl2/pkg/PLIST -- "@bin sbin/saslpasswd2" / "@man +# man/man8/saslpasswd2.8"). + +.include blob - /dev/null blob + ea5d4f17b82c25482bae5d578d4e83b4a32d760e (mode 644) --- /dev/null +++ src/auth.c @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2026 David Williams + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +/* + * auth.c -- credential verification process. Implements the "auth" + * section of openimap-privsep-design.md: verifies AUTHENTICATE PLAIN + * credentials against the self-contained flat credential file + * ("username:passwordhash:uid:gid:maildir", bcrypt hashes), using + * crypt_checkpass(3) -- sourced against the local crypt_checkpass(3) man + * page, including its documented timing-mitigation behavior for unknown + * usernames (see auth_verify() below). + * + * API NAMES: checked against the real src/imsg.h this session -- see + * the header comment in parent.c for the full verification note. + * + * getpwnam("_imapauth") below is a DIFFERENT thing from the + * credential-file design decision in openimap-privsep-design.md ("the + * credential file is self-contained... auth never calls getpwnam()"). + * That decision was about IMAP end users (mailbox owners) not needing + * real system accounts. _imapauth is auth's own fixed daemon-user + * identity -- an ordinary OpenBSD system daemon user, expected to exist + * in /etc/passwd like _smtpd/_syslogd/etc. Resolving *that* via + * getpwnam() is unrelated to, and does not reopen, the earlier decision. + * (Renamed from _openimapd along with the rest of the daemon's own + * on-disk/system identity -- see imapd.h's header comment for the + * rename-scoping policy. listener.c's counterpart daemon user is + * _imapd, not _imapauth -- the two roles need distinct names since + * "imapd" alone is already taken by the primary/listener role.) + */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imapd.h" +#include "log.h" + +struct cred_entry { + char username[AUTH_USERNAME_MAX]; + char passwordhash[128]; /* bcrypt "$2b$NN$..." -- generous */ + uid_t uid; + gid_t gid; + char maildir[AUTH_MAILDIR_MAX]; +}; + +static struct imsgev iev_listener; +static char cred_file_basename[256]; + +static int cred_lookup(const char *, const char *username, + struct cred_entry *); +static void auth_verify(struct imsg_auth_request *, + struct imsg_auth_result *); +static void auth_dispatch(int, short, void *); + +__dead void +auth_main(void) +{ + struct imsgbuf ibuf3; + struct imsg imsg; + struct imsg_auth_init init; + struct passwd *pw; + int peer_fd; + char *slash; + char chrootdir[1024]; + ssize_t n; + + if (imsgbuf_init(&ibuf3, 3) == -1) + fatal("imsgbuf_init"); + imsgbuf_allow_fdpass(&ibuf3); /* receives the fd-passed + * IMSG_SETUP_PEER peer fd below -- see + * imsgev.c's imsgev_init() comment. */ + + /* + * IMSG_AUTH_INIT must be the first message read -- same reasoning + * as store.c's IMSG_STORE_INIT: we need cred_file before we can + * even compute a chroot() target, let alone chroot into it. Closes + * the gap flagged in an earlier pass, where this process took a + * struct openimap_config * that main.c never actually populated + * for a re-exec'd child -- conf->cred_file was always an empty + * string. See imapd.h's imsg_auth_init comment. + */ + /* + * imsg_get() before imsgbuf_read() -- not just tidiness. See + * imsgev.c's setup_recv_one_peer() header comment for the real + * deadlock this ordering caused elsewhere (parent's IMSG_SETUP_PEER + * + IMSG_SETUP_DONE coalescing into one recvmsg() on a SOCK_STREAM + * socketpair). The same risk applies here in principle -- parent + * sends IMSG_AUTH_INIT then, later, this channel's IMSG_SETUP_PEER, + * with no synchronization forcing them into separate reads -- so + * this loop checks for an already-buffered message before ever + * issuing a real blocking read. + */ + for (;;) { + if ((n = imsg_get(&ibuf3, &imsg)) == -1) + fatal("imsg_get"); + if (n != 0) + break; + if ((n = imsgbuf_read(&ibuf3)) == -1) + fatal("imsgbuf_read"); + if (n == 0) + fatalx("auth: parent closed channel before INIT"); + } + if (imsg_get_type(&imsg) != IMSG_AUTH_INIT) + fatalx("auth: expected IMSG_AUTH_INIT, got %d", + imsg_get_type(&imsg)); + if (imsg_get_data(&imsg, &init, sizeof(init)) == -1) + fatalx("auth: bad IMSG_AUTH_INIT payload"); + imsg_free(&imsg); + + if ((pw = getpwnam("_imapauth")) == NULL) + fatalx("getpwnam _imapauth: no such user " + "(expected, not yet provisioned by an install script)"); + + /* + * chroot into the directory *containing* the credential file, per + * the design doc -- not the file itself. cred_file_basename is + * kept for the unveil() call below (relative to the new root). + */ + (void)strlcpy(chrootdir, init.cred_file, sizeof(chrootdir)); + if ((slash = strrchr(chrootdir, '/')) == NULL) + fatalx("cred_file must be an absolute path: %s", + init.cred_file); + (void)strlcpy(cred_file_basename, slash + 1, + sizeof(cred_file_basename)); + *slash = '\0'; + + if (chroot(chrootdir) == -1) + fatal("chroot %s", chrootdir); + if (chdir("/") == -1) + fatal("chdir /"); + + if (setgroups(1, &pw->pw_gid) == -1 || + setresgid(pw->pw_gid, pw->pw_gid, pw->pw_gid) == -1 || + setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1) + fatal("cannot drop privileges to _imapauth"); + + /* boot-time handshake: one peer (listener), then SETUP_DONE+ack -- + * see imsgev.c's setup_recv_*() header comments. */ + peer_fd = setup_recv_one_peer(&ibuf3); + setup_recv_done_and_ack(&ibuf3); + + event_init(); + imsgev_init(&iev_listener, peer_fd, auth_dispatch, NULL); + + /* + * unveil() path is relative to the chroot above -- "/" + + * cred_file_basename, per the design doc's "unveil() restricted + * to the single credential-file path, read-only." + */ + { + char unveil_path[512]; + + (void)snprintf(unveil_path, sizeof(unveil_path), "/%s", + cred_file_basename); + if (unveil(unveil_path, "r") == -1) + fatal("unveil %s", unveil_path); + if (unveil(NULL, NULL) == -1) + fatal("unveil lock"); + } + +#ifdef __OpenBSD__ + if (pledge("stdio rpath recvfd sendfd", NULL) == -1) + fatal("pledge"); +#endif + + event_dispatch(); + fatalx("auth: exited event loop"); +} + +/* + * Real bug caught on the first real-hardware run (OpenBSD, not this + * sandbox): imsg_compose() only queues a message in this process's own + * userspace buffer -- it performs no I/O itself. Confirmed directly + * against imsg_init(3)'s own EXAMPLES section: "When the socket is + * ready for writing, queued messages are transmitted with + * imsgbuf_write()." imsgev_add() (imsgev.c) correctly arms EV_WRITE + * whenever imsgbuf_queuelen() is nonzero, but until this fix nothing + * ever handled that event -- every dispatch function in this codebase + * only ever checked "event & EV_READ". The observed symptom: a listener + * process pegged at 25+ minutes of CPU time while otherwise idle (caught + * via `ps`), because listener_dispatch_auth()'s own unconditional + * imsgev_add() at the end of every call kept re-arming EV_WRITE for a + * write that never happened, on a socket that's *always* writable -- + * the textbook busy-loop shape. Confirmed on auth's side via ktrace(1): + * an IMSG_AUTH_REQUEST send from listener produced zero syscalls here, + * because it never actually left listener's own queue. + */ +static void +auth_dispatch(int fd, short event, void *arg) +{ + struct imsgev *iev = arg; + struct imsg imsg; + ssize_t n; + + if (event & EV_WRITE) { + if (imsgbuf_write(&iev->ibuf) == -1) + fatal("imsgbuf_write"); + } + + if (event & EV_READ) { + if ((n = imsgbuf_read(&iev->ibuf)) == -1) + fatal("imsgbuf_read"); + if (n == 0) { + log_warnx("listener closed channel"); + event_del(&iev->ev); + return; + } + } + + for (;;) { + if ((n = imsg_get(&iev->ibuf, &imsg)) == -1) + fatal("imsg_get"); + if (n == 0) + break; + + switch (imsg_get_type(&imsg)) { + case IMSG_AUTH_REQUEST: { + struct imsg_auth_request req; + struct imsg_auth_result res; + + if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) { + log_warnx("bad IMSG_AUTH_REQUEST"); + break; + } + /* F8 fix: imsg_get_data() guarantees payload size but + * not NUL termination; force it before these fields are + * used as C strings by auth_verify(). */ + req.username[sizeof(req.username) - 1] = '\0'; + req.password[sizeof(req.password) - 1] = '\0'; + memset(&res, 0, sizeof(res)); + res.session_id = req.session_id; + auth_verify(&req, &res); + + /* explicit_bzero() the plaintext password out of our + * own stack copy as soon as we're done with it -- + * not itself sourced from any uploaded file this + * session, just good hygiene given the credential + * material involved. */ + explicit_bzero(req.password, sizeof(req.password)); + + if (imsg_compose(&iev->ibuf, IMSG_AUTH_RESULT, 0, 0, + -1, &res, sizeof(res)) == -1) + log_warn("imsg_compose IMSG_AUTH_RESULT"); + imsgev_add(iev); + break; + } + default: + log_debug("auth_dispatch: unhandled %d", + imsg_get_type(&imsg)); + break; + } + imsg_free(&imsg); + } + /* + * Real bug caught on first real-hardware run, right after fixing + * the missing-EV_WRITE gap above: the imsgev_add(iev) inside the + * IMSG_AUTH_REQUEST case only re-arms when this call actually + * processed a message. Once EV_WRITE handling was added, this + * function could now be invoked for a pure EV_WRITE firing with + * nothing new to read -- the for loop above finds nothing, no case + * runs, and without this unconditional call the event lapses for + * good (imsgev_init() is plain EV_READ, not EV_PERSIST -- see + * imsgev.c). auth has exactly one registered event, so losing it + * empties event_dispatch()'s whole watch set, which returns and + * hits this file's own "auth: exited event loop" fatalx() -- + * exactly what happened live: IMSG_AUTH_RESULT successfully sent + * (the EV_WRITE fix working as intended), immediately followed by + * auth exiting because nothing re-armed its read side afterward. + * Matches the same unconditional-re-arm shape already used by + * listener_dispatch_auth()/listener_dispatch_parent()/ + * session_store_dispatch() (listener.c) and store_child_dispatch() + * (parent.c). + */ + imsgev_add(iev); + (void)fd; +} + +/* + * Verifies req->password against the stored hash for req->username, and + * fills *res. Always calls crypt_checkpass() -- with hash == NULL on an + * unknown username -- rather than short-circuiting on a failed + * cred_lookup(), per crypt_checkpass(3)'s documented behavior: "If the + * hash is NULL, authentication will always fail, but a default amount of + * work is performed to simulate the hashing operation." Short-circuiting + * here would let login timing leak whether a username exists in the + * credential file -- exactly what that NULL-hash behavior exists to + * prevent. + */ +static void +auth_verify(struct imsg_auth_request *req, struct imsg_auth_result *res) +{ + struct cred_entry ce; + const char *hash = NULL; + int found; + + found = (cred_lookup(cred_file_basename, req->username, &ce) == 0); + if (found) + hash = ce.passwordhash; + + if (crypt_checkpass(req->password, hash) == 0 && found) { + res->ok = 1; + res->uid = ce.uid; + res->gid = ce.gid; + (void)strlcpy(res->maildir, ce.maildir, + sizeof(res->maildir)); + } else { + res->ok = 0; + } + + explicit_bzero(&ce, sizeof(ce)); +} + +/* + * Scans the credential file (relative to our chroot, so just its + * basename -- see auth_main()) line by line for "username", per the + * "username:passwordhash:uid:gid:maildir" format resolved in + * openimap-privsep-design.md. Linear scan -- fine for v1's expected + * credential-file size (personal-use scope, a handful of users); revisit + * only if that stops being true. + */ +static int +cred_lookup(const char *path, const char *username, struct cred_entry *out) +{ + FILE *fp; + char line[1024]; + int found = 0; + + if ((fp = fopen(path, "r")) == NULL) { + log_warn("fopen %s", path); + return (-1); + } + + while (fgets(line, sizeof(line), fp) != NULL) { + char *p = line; + char *fields[5]; + int i; + char *ep; + + line[strcspn(line, "\n")] = '\0'; + if (line[0] == '\0' || line[0] == '#') + continue; + + for (i = 0; i < 5; i++) { + fields[i] = p; + if (i < 4) { + if ((p = strchr(p, ':')) == NULL) + break; + *p++ = '\0'; + } + } + if (i != 5) + continue; /* malformed line, skip */ + + if (strcmp(fields[0], username) != 0) + continue; + + (void)strlcpy(out->username, fields[0], + sizeof(out->username)); + (void)strlcpy(out->passwordhash, fields[1], + sizeof(out->passwordhash)); + errno = 0; + out->uid = (uid_t)strtoul(fields[2], &ep, 10); + if (*ep != '\0' || errno != 0) + continue; + out->gid = (gid_t)strtoul(fields[3], &ep, 10); + if (*ep != '\0' || errno != 0) + continue; + (void)strlcpy(out->maildir, fields[4], sizeof(out->maildir)); + found = 1; + break; + } + + fclose(fp); + return (found ? 0 : -1); +} blob - /dev/null blob + 6b83b5da1105daeb3c5176264e6566c42ff04ba3 (mode 644) --- /dev/null +++ src/imapd.8 @@ -0,0 +1,529 @@ +.\" $OpenIMAPD$ +.\" +.\" Written for the OpenIMAPD project. Public domain / no rights reserved, +.\" matching the project's ports-oriented, OpenBSD-base-inclusion goal. +.\" +.Dd $Mdocdate: August 16 2026 $ +.Dt IMAPD 8 +.Os +.Sh NAME +.Nm imapd +.Nd Internet Message Access Protocol (IMAP) daemon +.Sh SYNOPSIS +.Nm +.Op Fl dVv +.Op Fl D Ar macro Ns = Ns Ar value +.Op Fl f Ar file +.Sh DESCRIPTION +.Nm +is an Internet Message Access Protocol +.Pq IMAP +daemon implementing the subset of RFC 9051 +.Pq IMAP4rev2 +described below. +It is privilege-separated in the style of +.Xr smtpd 8 : +a root +.Em parent +process reads configuration, binds the listening sockets, and +re-executes unprivileged +.Em listener +and +.Em auth +children over +.Xr imsg 3 +control channels; a +.Em store +child is forked per authenticated session, chroots into the mail +spool, and drops privileges to that session's own user before ever +touching mailbox data. +The +.Fl x +flag referenced internally by these re-executed children is not +meant for direct operator use. +.Pp +.Nm +listens on port 143 +.Pq cleartext, upgradable via STARTTLS +and port 993 +.Pq implicit TLS, per RFC 8314 , +both via +.Xr tls_init 3 . +Authentication is +.Li AUTH=PLAIN +only, and is refused before TLS is established; +.Li LOGINDISABLED +is advertised on the cleartext, pre-TLS connection. +.Pp +The current implementation is intentionally minimal: each user's +mailboxes form a single flat namespace +.Pq no nested hierarchy , Li INBOX +plus zero or more sibling mailboxes created via +.Li CREATE , +and no shared or multi-user mailboxes +.Pq no Li ACL support . +Within that scope it implements +.Li CAPABILITY , +.Li STARTTLS , +.Li AUTHENTICATE , +.Li ID , +.Li ENABLE , +.Li SELECT , +.Li EXAMINE , +.Li CREATE , +.Li DELETE , +.Li RENAME , +.Li LIST , +.Li LSUB , +.Li NAMESPACE , +.Li STATUS , +.Li FETCH , +.Li STORE , +.Li SEARCH , +.Li APPEND , +.Li COPY , +.Li MOVE , +.Li EXPUNGE , +.Li UNSELECT , +.Li CLOSE , +.Li UID , +.Li IDLE , +and the RFC 7162 CONDSTORE and QRESYNC extensions +.Pq mod-sequence tracking, conditional STORE, VANISHED responses . +.Li SUBSCRIBE +and +.Li UNSUBSCRIBE +are deliberately out of scope, not merely unimplemented; see CAVEATS +below. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl d +Log to +.Em stderr +instead of +.Xr syslogd 8 . +.Nm +does not detach from its controlling terminal or otherwise +background itself in either mode; an +.Xr rc.d 8 +script or equivalent supervisor is expected to do so. +An +.Xr rc.d 8 +script is provided in +.Pa rc.d/imapd +in the source tree; it is not installed automatically by +.Ic make install +and must be installed to +.Pa /etc/rc.d/imapd +by hand, owned by +.Sy root : Ns Sy wheel , +mode 555 +.Pq matching every base rc.d 8 script's own installed permissions : +.Bd -literal -offset indent +doas install -o root -g wheel -m 555 rc.d/imapd /etc/rc.d/imapd +.Ed +.Pp +Using +.Xr install 1 +rather than +.Xr cp 1 +matters here: a plain +.Xr cp 1 +preserves the source file's own permission bits, so a source tree +where +.Pa rc.d/imapd +happens to be non-executable produces a non-executable, and therefore +invisible to +.Xr rcctl 8 , +copy at +.Pa /etc/rc.d/imapd +-- +.Xr rcctl 8 +considers a service to not exist at all unless its script is +executable. +.Xr install 1 +sets the destination's mode explicitly instead, independent of +whatever the source happened to be. +It also applies any pending boot-time relink +(see +.Fl V +below) +before starting the daemon. +.It Fl D Ar macro Ns = Ns Ar value +Define +.Ar macro +to +.Ar value , +overriding any definition of the same macro inside the configuration +file, for use with +.Ic $ Ns Ar macro +references there. +.It Fl f Ar file +Specify an alternative configuration file. +Defaults to +.Pa /etc/imapd.conf . +.It Fl V +Print the version and exit. +Performs no other action +.Pq no configuration file is read, no privileged setup occurs ; +this is also the command +.Xr make 1 Ns 's +.Ic RELINK +step in the Makefile uses to smoke-test a relinked binary before +installing it, and what +.Pa rc.d/imapd Ns 's +boot-time relink hook relies on to confirm a relink succeeded. +.It Fl v +Produce more verbose logging. +.El +.Pp +Sending +.Nm +.Dv SIGHUP +rereads +.Pa /etc/imapd.conf +.Pq or the file given via Fl f +and reloads the +.Ic spool , +.Ic attachment max , +.Ic tls certificate , +and +.Ic tls key +directives without disrupting sessions already connected, matching +.Xr httpd 8 Ns 's +own documented SIGHUP behavior. +.Ic listen on +and +.Ic credentials +cannot be changed this way: the listening sockets are already bound and +the +.Em auth +child is already +.Xr chroot 2 Ns d +to the original credentials path by the time SIGHUP arrives, so a change +to either is logged as a warning and otherwise ignored until +.Nm +is restarted. +A malformed configuration file logs a warning and leaves the running +daemon on its current configuration rather than exiting. +.Pp +If the +.Em listener +or +.Em auth +process exits unexpectedly after startup, it is not automatically +restarted, matching +.Xr smtpd 8 Ns 's +own treatment of its equivalent core processes and +.Xr httpd 8 Ns 's +even more hands-off one. +The +.Em listener Ns 's +death makes +.Nm +unreachable entirely, since it owns every listening socket; +.Em auth Ns 's +death leaves already-authenticated sessions unaffected but new +.Ic AUTHENTICATE +attempts will fail. +Either is logged as a warning; recovery is +.Ic rcctl restart imapd . +.Sh FILES +.Bl -tag -width "/etc/imapd/credentialsXXX" -compact +.It Pa /etc/imapd.conf +Default +.Nm +configuration file, read by the parent process only. +Must be owned by root or the current user, and not group- or +world-writable. +Recognized directives, one per line: +.Bl -tag -width Ds -compact +.It Ic listen on Ar address Op Ic tls Ic port Ar port +Bind a listener to +.Ar address . +Specify once without +.Ic tls +for the cleartext/STARTTLS listener (default port 143) and once with +.Ic tls +for the implicit-TLS listener (default port 993). +Both listeners share one +.Ar address ; +a second +.Ic listen +line naming a different address is a configuration error. +.Pp +.Ar address +must be a literal IPv4 address, a literal IPv6 address, +.Ql :: +(all IPv6 interfaces), +.Ql 0.0.0.0 +(all IPv4 interfaces), or +.Ql * +(all IPv4 and IPv6 interfaces, matching +.Xr httpd.conf 5 Ns 's +own convention for the same character). +Hostnames are not accepted: resolving one would add a DNS dependency to +.Nm Ns 's +boot path for no benefit a single-operator personal mail server actually +needs. +Since OpenBSD's IPv6 sockets are always IPv6-only +.Pq no v4-mapped-address dual binding , unlike Linux , +.Ql * +binds two sockets per listener, one +.Dv AF_INET +and one +.Dv AF_INET6 , +rather than one dual-stack socket. +.It Ic spool Ar path +Mail spool root, chrooted into by the +.Em store +child. +Defaults to +.Pa /var/mail/imapd . +.It Ic credentials Ar path +Credentials file (see below). +Defaults to +.Pa /etc/imapd/credentials . +.It Ic tls certificate Ar path +TLS certificate file. +Defaults to +.Pa /etc/ssl/imapd.crt . +.It Ic tls key Ar path +TLS private key file. +Defaults to +.Pa /etc/ssl/private/imapd.key . +Must be owned by root or the current user, mode 0740 or stricter. +.It Ic attachment max Ar bytes +Largest message +.Nm +will read from disk while deriving +.Li BODYSTRUCTURE +or a MIME part-addressed +.Li BODY Ns Bq Ar part +fetch. +A message larger than this is treated the same as any other reason its +structure cannot be produced, not truncated. +.Ar bytes +must be between 12000 and 1073741824 (1 GiB) inclusive; values outside +that range are a configuration error. +Defaults to 41943040 (40 MiB). +.It Ic include Ar path +Parse +.Ar path +as though its contents appeared in place of this line. +.It Ic macro Ns = Ns Ar value +Define a macro, referenced elsewhere in the file as +.Ic $ Ns Ar macro . +See also +.Fl D . +.El +.Pp +A directive not given in the file falls back to its documented default; +an empty or absent +.Pa /etc/imapd.conf +is equivalent to every directive using its default. +Lines beginning with +.Sq # +are comments. +A sample configuration file, with every directive documented inline, is +installed at +.Pa /usr/local/share/examples/imapd/imapd.conf . +It is not read by +.Nm +itself; copy it to +.Pa /etc/imapd.conf +and edit as needed. +.It Pa /etc/imapd/credentials +Default credentials file, read by the +.Em auth +child. +One line per user, colon-delimited: +.Sy username : passwordhash : uid : gid : maildir , +with the password hash in a +.Xr crypt_checkpass 3 Ns Ns -compatible +.Pq bcrypt +form. +Must be owned by +.Sy root +and group-owned by the +.Sy _imapauth +account, mode 0640 or stricter: the +.Em auth +child chroots and drops privileges to +.Sy _imapauth +before ever opening this file, so it must be group-readable by that +account specifically, not just root-readable. +.Xr imapduser 8 +sets this automatically. +.It Pa /var/mail/imapd +Default spool root. +The +.Em store +child +.Xr chroot 2 Ns s +into this directory before dropping privileges. +.El +.Sh NETWORK +.Nm +binds +.Pa 0.0.0.0 +.Pq IPv4 only +port 143 +.Pq cleartext/STARTTLS +and port 993 +.Pq implicit TLS +by default; these, along with the listen address, are read from +.Pa /etc/imapd.conf +(or the file named by +.Fl f ) +at startup, falling back to those v1 defaults for any +.Ic listen +directive the file omits. +IPv6 and dual-stack binding are available but not the default; see the +.Ic listen on +directive under FILES above. +.Sh SEE ALSO +.Xr crypt_checkpass 3 , +.Xr imsg_init 3 , +.Xr tls_init 3 , +.Xr httpd.conf 5 , +.Xr httpd 8 , +.Xr imapduser 8 , +.Xr smtpd 8 +.Sh STANDARDS +.Rs +.%A A. Melnikov +.%A B. Leiba +.%D August 2021 +.%R RFC 9051 +.%T Internet Message Access Protocol (IMAP) - Version 4rev2 +.Re +.Pp +.Rs +.%A A. Melnikov +.%A D. Cridland +.%A C. Newman +.%D May 2014 +.%R RFC 7162 +.%T IMAP Extensions: Quick Flag Changes Resynchronization (CONDSTORE) and Quick Mailbox Resynchronization (QRESYNC) +.Re +.Sh HISTORY +.Nm +is a from-scratch IMAP server written for the OpenIMAPD project, in +the OpenBSD privilege-separation tradition of +.Xr smtpd 8 . +.Sh CAVEATS +This implementation is under active development. +.Li SUBSCRIBE , +.Li UNSUBSCRIBE , +and shared or multi-user mailboxes +.Pq no Li ACL support +are deliberate scope decisions, not gaps awaiting implementation: +matched against real Apple Mail client behavior, both features exist +primarily to manage large numbers of shared or public mailboxes on a +multi-user server, a scenario this single-user, no-shared-mailbox +implementation does not have. +This mirrors +.Xr smtpd 8 Ns 's +own precedent of omitting +.Li VRFY Ns / Ns Li EXPN +despite RFC recommendation, on the same reasoning: every additional +command is attack surface, and neither of these two currently serves +this implementation's actual usage. +Revisit if a real, concrete need for either ever materializes. +Mailboxes form a single flat namespace per user: there is no nested +hierarchy, and +.Li CREATE +refuses a name containing the +.Ql / +hierarchy-delimiter character rather than creating intermediate +levels. +.Li CREATE +and +.Li DELETE +of +.Li INBOX +itself are refused +.Pq Li CANNOT , +as is +.Li RENAME +of +.Li INBOX +as the source +.Pq permitted by RFC 9051 but explicitly sanctioned there as a +server-side refusal ; renaming +.Em to +.Li INBOX +is unreachable, since that name always already exists. +.Li COPY +and +.Li MOVE +may target any real, existing mailbox, not only the one currently +selected; a destination naming a mailbox that does not exist is +refused with +.Li TRYCREATE , +and a syntactically invalid destination name is refused outright +.Pq Li BAD +with no attempt to look it up. +If a mailbox is renamed or deleted while a +.Em different +session +.Pq same user +has it selected, that other session's own notion of what it has +selected can go stale until its next +.Li SELECT , +.Li EXAMINE , +.Li CLOSE , +or +.Li UNSELECT ; +no unsolicited notification of the rename or deletion is sent to it. +.Pp +Plain, non-PEEK +.Li BODY +section forms, which implicitly set the +.Li \eSeen +flag, are not implemented for any content item. +BODYSTRUCTURE and part-addressed +.Li BODY.PEEK +fetches do not address into a MULTIPART container's own combined +content, or into MESSAGE/RFC822 or MESSAGE/GLOBAL nested part +numbering. +A mailbox selected via +.Li EXAMINE +refuses +.Li STORE , +.Li EXPUNGE , +and +.Li MOVE +with a tagged +.Li NO +.Pq Li CANNOT +response; +.Li CLOSE +on such a mailbox instead succeeds without removing anything, per +RFC 9051. +.Li APPEND +is not restricted by the currently selected mailbox's read-only state. +.Pp +.Li IDLE +pushes unsolicited +.Li EXISTS +and +.Li EXPUNGE +responses only; unsolicited +.Li FETCH +on flag change (e.g. another session's +.Li STORE ) +is not sent. +Pushes are triggered only by mutations made through +.Nm +itself +.Pq Li APPEND , Li EXPUNGE , Li "UID EXPUNGE" , a read-write Li CLOSE , and Li MOVE ; +mail delivered directly into a mailbox's maildir by an external MTA or +LDA is not detected until some other command against that session +happens to trigger a refresh. +.Pp +It has been built and run against a real OpenBSD 8.0 system, +including real Apple Mail client traffic exercising most of the +commands listed above as implemented. blob - /dev/null blob + c74a9983b8e5823e65048a325460d25d23be1b18 (mode 644) --- /dev/null +++ src/imapd.conf.example @@ -0,0 +1,60 @@ +# +# imapd.conf example -- see imapd(8) for the full directive list. +# +# This file is installed read-only at /usr/local/share/examples/imapd/ +# imapd.conf (matching the OpenBSD ports convention for sample configs, +# see ports(7) and the @sample PLIST keyword) -- it is NOT read by imapd +# itself. To use it, copy it to /etc/imapd.conf and edit as needed. +# +# imapd.conf must be owned by root (or the user running imapd) and must +# NOT be group- or world-writable -- nor world-readable, which trips +# people up more often, since it's easy to forget: check_file_secrecy() +# (parse.y) rejects a world-readable file too, not just a writable one. +# A plain "cp" or "tee" leaves the copy at the default umask (typically +# 644, world-readable), which imapd will then refuse to start against +# ("group writable or world read/writable"). Set permissions explicitly: +# +# install -o root -g wheel -m 600 \ +# /usr/local/share/examples/imapd/imapd.conf /etc/imapd.conf +# +# (or, after copying some other way: doas chown root:wheel /etc/imapd.conf +# && doas chmod 600 /etc/imapd.conf) +# + +# Listen on all IPv4 interfaces: cleartext/STARTTLS on port 143, implicit +# TLS (RFC 8314) on port 993. Both listeners must share the same address. +# These are also the defaults if "listen on" is omitted entirely. +listen on 0.0.0.0 port 143 +listen on 0.0.0.0 tls port 993 + +# The address may also be "::" (all IPv6 interfaces) or "*" (both IPv4 and +# IPv6 -- binds two sockets per listener, one of each family, matching +# httpd.conf(5)'s own "*" convention), or a single literal IPv4/IPv6 +# address. Hostnames are not accepted -- see imapd(8) for why. For example, +# to listen on both address families: +#listen on * port 143 +#listen on * tls port 993 + +# Mail spool root, chrooted into by the per-session store child. +# Defaults to /var/mail/imapd. +#spool "/var/mail/imapd" + +# Credentials file: one line per user, "username:passwordhash:uid:gid: +# maildir" -- see imapduser(8) and imapd(8) FILES. Defaults to +# /etc/imapd/credentials. +#credentials "/etc/imapd/credentials" + +# TLS certificate and private key. Default to /etc/ssl/imapd.crt and +# /etc/ssl/private/imapd.key respectively. The key must be owned by +# root (or the current user) and mode 0740 or stricter. +#tls certificate "/etc/ssl/imapd.crt" +#tls key "/etc/ssl/private/imapd.key" + +# Largest message imapd will read from disk while deriving BODYSTRUCTURE +# or a MIME part-addressed BODY[] fetch -- see imapd(8). Must be +# between 12000 and 1073741824 (1 GiB) bytes. Defaults to 41943040 +# (40 MiB, sized off Gmail's documented attachment limit plus base64 +# encoding overhead -- see the BODYSTRUCTURE_READ_DEFAULT comment in +# imapd.h for the full rationale). Uncomment and adjust if your mail +# routinely carries larger attachments than that. +attachment max 41943040 blob - /dev/null blob + ac68f76cf15a2f085d826daffe7acd99af15e8b1 (mode 644) --- /dev/null +++ src/imapd.h @@ -0,0 +1,2124 @@ +/* + * Copyright (c) 2026 David Williams + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +/* + * Shared definitions for all four imapd(8) process roles: parent, + * listener, auth, store. See ../openimap-privsep-design.md for the design + * this header implements -- process split, imsg message catalog, pledge + * strings, and the fork-per-session store mechanism are all decided there, + * not here. This header should not drift from that document; if it does, + * one of the two is wrong. + * + * struct/enum names below still say "openimap" in places (struct + * openimap_config, enum openimap_proc_type) even after the imapd(8) + * rename -- those are internal identifiers with no user-visible effect + * (nothing an admin or a client ever sees), touching hundreds of call + * sites across every .c file for a purely cosmetic change, so they were + * deliberately left alone. + * + * "OpenIMAP" (the project/brand name) was itself later renamed to + * "OpenIMAPD" -- a correction, not a reversal, of the daemon-rename + * reasoning above. The original assumption was that OpenSSH/OpenNTPD/ + * OpenSMTPD all "keep Open, daemon drops it," so OpenIMAP should stay + * OpenIMAP the same way. Checked directly against OpenBSD's own + * innovations page (openbsd.org/innovations.html) rather than assumed + * further: OpenNTPD, OpenSMTPD, OpenBGPD, and OpenIKED all carry the + * "D" in the *project* name itself (ships ntpd/smtpd/bgpd/iked) -- + * OpenSSH is the one exception, and only because it ships a whole + * toolkit (ssh/scp/sftp/ssh-keygen/...), not a single daemon. This + * project is shaped like the single-daemon case, so "OpenIMAP" was + * the wrong analogy; see docs/openimap.md and README.skeleton. Only + * the installed daemon's own identity (PROG, man page, rc.d script, + * default file paths), this header's own filename, and its include + * guard/version macro (below) changed as part of that *earlier* + * daemon rename -- unaffected by this later project-name correction. + */ + +#ifndef IMAPD_H +#define IMAPD_H + +#include +#include /* __dead */ +#include + +#include +#include +#include + +/* + * No formal release process yet (this project has never run "make install" + * before task #196's rc.d/RELINK work) -- this exists mainly so "-V" (see + * main.c) has something concrete to print, and so the RELINK smoke-test + * command has stable, greppable output if that's ever wanted. Bump by hand + * until something better (git describe, etc.) is worth wiring in. + */ +#define IMAPD_VERSION "0.1" + +/* + * Process roles, selected at exec time via "-x ". See main.c. + */ +enum openimap_proc_type { + PROC_PARENT, + PROC_LISTENER, + PROC_AUTH, + PROC_STORE +}; + +/* + * imsg message catalog. Mirrors the table in openimap-privsep-design.md + * ("imsg message catalog (draft)") -- keep in sync with that document. + * IMSG_SETUP_PEER / IMSG_SETUP_DONE are the boot-time handshake (also + * reused, per that document, for the per-session store peer-wiring + * handshake after IMSG_STORE_INIT). + */ +enum imsg_type { + IMSG_NONE, + + /* parent <-> listener/auth/store setup handshake */ + IMSG_SETUP_PEER, + IMSG_SETUP_DONE, + + /* parent -> listener, at boot */ + IMSG_LISTENER_SOCKET_CLEARTEXT, /* one bound, listening fd for the + * cleartext/STARTTLS port -- sent + * once per resolved address (1 + * normally, 2 for "listen on *", + * dual-stack -- see struct imsg_ + * listener_init's n_cleartext_addrs) */ + IMSG_LISTENER_SOCKET_TLS, /* same, for the implicit-TLS port */ + IMSG_TLS_CERT, + IMSG_TLS_KEY, + IMSG_LISTENER_INIT, + + /* parent -> auth, at boot */ + IMSG_AUTH_INIT, + + /* listener <-> auth */ + IMSG_AUTH_REQUEST, + IMSG_AUTH_RESULT, + + /* per-session store spawn (listener -> parent -> new store child) */ + IMSG_STORE_FORK, + IMSG_STORE_INIT, + IMSG_STORE_PEER, + IMSG_STORE_SHUTDOWN, + + /* listener <-> store, once a session's store child is wired up */ + IMSG_MBOX_SELECT, + IMSG_MBOX_EXAMINE, + IMSG_MBOX_SELECTED, + IMSG_MBOX_FETCH, + IMSG_MBOX_FETCH_META, + IMSG_MBOX_FETCH_HEADER, /* raw BODY.PEEK[HEADER] bytes for one + * message (store -> listener), sent + * immediately before that message's own + * IMSG_MBOX_FETCH_META -- see struct + * imsg_mbox_fetch_header's comment for + * why this ordering is a contract, not + * a convention */ + IMSG_MBOX_FETCH_BODY, /* raw BODY.PEEK[] / BODY.PEEK[TEXT] bytes for + * one message (store -> listener), same + * "sent immediately before that message's + * IMSG_MBOX_FETCH_META" contract as + * IMSG_MBOX_FETCH_HEADER above -- see + * struct imsg_mbox_fetch_body's comment */ + IMSG_MBOX_FETCH_ENVELOPE, /* pre-formatted ENVELOPE parenthesized- + * list text for one message (store -> + * listener), same "sent immediately + * before that message's IMSG_MBOX_ + * FETCH_META" contract as IMSG_MBOX_ + * FETCH_HEADER above, but -- unlike that + * one -- carrying already-formatted + * response text, not raw message bytes; + * see struct imsg_mbox_fetch_envelope's + * comment */ + IMSG_MBOX_FETCH_BODYSTRUCTURE, /* pre-formatted BODYSTRUCTURE + * parenthesized-list text for one + * message (store -> listener), same + * "sent immediately before that + * message's IMSG_MBOX_FETCH_META" + * contract and same "already-formatted + * response text, not raw bytes" shape + * as IMSG_MBOX_FETCH_ENVELOPE above; + * see struct imsg_mbox_fetch_ + * bodystructure's comment */ + IMSG_MBOX_STORE, + IMSG_MBOX_APPEND, + IMSG_MBOX_APPENDED, + IMSG_MBOX_COPY, + IMSG_MBOX_MOVE, + IMSG_MBOX_COPY_MAPPING, + IMSG_MBOX_EXPUNGE, + IMSG_MBOX_EXPUNGED, + IMSG_MBOX_SEARCH, + IMSG_MBOX_SEARCH_MATCH, + IMSG_MBOX_LIST, + IMSG_MBOX_STATUS, + IMSG_MBOX_STATUS_RESULT, + IMSG_MBOX_CREATE, + IMSG_MBOX_DELETE, + IMSG_MBOX_RENAME, + IMSG_MBOX_RESULT, + IMSG_MBOX_UNSOLICITED, + + /* + * RFC 7162 (CONDSTORE/QRESYNC) additions -- see this header's + * imsg_mbox_select/imsg_mbox_store comments below for the wire shape + * each carries. Both are streamed store -> listener, the same + * "zero or more of these, then one terminal IMSG_MBOX_SELECTED/ + * IMSG_MBOX_RESULT" pattern IMSG_MBOX_FETCH_META/IMSG_MBOX_EXPUNGED/ + * IMSG_MBOX_SEARCH_MATCH already establish. + */ + IMSG_MBOX_SELECT_VANISHED, /* one vanished UID during a QRESYNC + * SELECT resync (store -> listener, + * before IMSG_MBOX_SELECTED) */ + IMSG_MBOX_STORE_MODIFIED, /* one message that failed a STORE's + * UNCHANGEDSINCE test (store -> + * listener, before IMSG_MBOX_RESULT) */ + + /* + * RFC 9051 SS6.3.13 (IDLE) additions. No payload on the request -- + * "refresh this session's view of its already-selected mailbox" is + * fully determined by which store child the request arrives on, same + * as IMSG_STORE_SHUTDOWN needing none. The reply is the same + * "zero or more streamed items, then one terminal reply" shape as + * IMSG_MBOX_FETCH_META/IMSG_MBOX_SELECT_VANISHED above -- see struct + * imsg_mbox_idle_uid/imsg_mbox_idle_refreshed comments below. + */ + IMSG_MBOX_IDLE_REFRESH, /* listener -> store, no payload */ + IMSG_MBOX_IDLE_UID, /* one currently-existing UID, in + * ascending order (store -> listener, + * before IMSG_MBOX_IDLE_REFRESHED) */ + IMSG_MBOX_IDLE_REFRESHED, /* terminal reply (store -> listener) */ + + /* + * RFC 9051 SS6.3.4-SS6.3.6 (CREATE/DELETE/RENAME) and SS6.3.9 (LIST), + * this pass -- flat (non-nested) multi-mailbox support, per the + * design resolved in docs/openimap-storage-backend.md's "Open items" + * #10. IMSG_MBOX_CREATE/IMSG_MBOX_DELETE/IMSG_MBOX_RENAME and + * IMSG_MBOX_LIST itself were already reserved in this enum from an + * earlier skeleton pass (store_dispatch()'s "TODO: none of these + * payload shapes are designed yet" case) -- only their payload + * structs and one new streaming-item type for LIST are added here. + * CREATE/DELETE/RENAME all reply with the existing, already-generic + * struct imsg_mbox_result (only its "ok" field is meaningful for + * these three -- count/highestmodseq stay 0), the same reuse + * CLOSE already gets by riding EXPUNGE's reply shape. LIST follows + * the "stream zero or more items, then one terminal reply" pattern + * IMSG_MBOX_IDLE_UID/IMSG_MBOX_IDLE_REFRESHED above (and IMSG_MBOX_ + * FETCH_META, IMSG_MBOX_SELECT_VANISHED, ...) already establish -- + * its terminal reply also reuses struct imsg_mbox_result, with + * "count" now meaningful (number of IMSG_MBOX_LIST_ITEM messages + * that preceded it), matching that field's existing doc comment + * ("equivalent, for a future op"). + */ + IMSG_MBOX_LIST_ITEM /* one mailbox name (store -> listener), + * before the terminal IMSG_MBOX_RESULT + * -- INBOX itself is never included: + * listener.c already special-cases + * INBOX into every LIST response + * locally (RFC 9051 SS6.3.9: "The + * special name INBOX is included in + * the output from LIST... if INBOX is + * supported by this server for this + * user", true unconditionally in v1), + * so store.c only needs to report the + * *named* mailboxes it actually finds + * on disk */ +}; + +/* + * Standard privsep imsg-over-event(3) wrapper. Not itself quoted from any + * uploaded source file this session -- this is a widely-used pattern in + * OpenBSD privsep daemons (smtpd.c's own use of event_dispatch(3)/ + * evtimer_set(3)/signal_add(3), observed directly this session, is what + * grounds using libevent here at all; the imsgev wrapper struct itself is + * this project's own plumbing on top of that, not copied from a specific + * quoted definition). + */ +struct imsgev { + struct imsgbuf ibuf; + void (*handler)(int, short, void *); + struct event ev; + void *data; + short events; +}; + +/* + * Config, as read from imapd.conf by parent via config_load() (parse.y + * -- a real yacc-based grammar as of this pass, covering exactly these + * eight fields: "listen on [tls] port " x2, "spool ", + * "credentials ", "tls certificate ", "tls key ", + * "attachment max "). See parse.y's header comment for the + * grammar's full design and sourcing. + */ +/* + * Max number of sockets bind_listen_socket() (parent.c) ever binds for a + * single "listen on " line: 1 for a literal IPv4 or IPv6 address, or + * 2 for the "*" wildcard, which binds one IPv4-any and one IPv6-any socket + * -- OpenBSD's IPv6 sockets are always IPv6-only (ip6(4): "With OpenBSD + * IPv6 sockets are always IPv6-only, so the socket option is read-only"), + * so unlike Linux there is no single dual-mapped socket to bind instead; + * this matches how smtpd's own host_v4()/host_v6()/host_dns() (src/ + * usr.sbin/smtpd/parse.y, read directly this pass) build one struct + * listener per resolved address/family rather than one shared socket. + * "*" itself matches httpd.conf(5)'s own documented address semantics + * (man.openbsd.org/httpd.conf.5): "'*' ... listen on all IPv4 and IPv6 + * addresses ... '0.0.0.0' means to listen on all IPv4 addresses and '::' + * all IPv6 addresses." + */ +#define LISTENER_MAX_ADDRS 2 + +struct openimap_config { + char listen_addr[64]; /* "0.0.0.0" (default), "::", a literal + * IPv4/IPv6 address, or "*" for both + * -- see LISTENER_MAX_ADDRS above */ + uint16_t port_cleartext; /* 143, STARTTLS */ + uint16_t port_implicit_tls; /* 993, RFC 8314 */ + char spool_root[1024]; /* mail spool root, store's chroot */ + char cred_file[1024]; /* auth's credential file, see + * openimap-privsep-design.md's + * "auth" section for the + * username:passwordhash:uid:gid: + * maildir format */ + char tls_cert_file[1024]; + char tls_key_file[1024]; + uint32_t bodystructure_read_max; /* "attachment max" directive -- + * see BODYSTRUCTURE_READ_DEFAULT + * below for the default value and + * full rationale; store children get + * their own copy of this via + * struct imsg_store_init, since they + * never read imapd.conf themselves. */ +}; + +/* + * imsg payload wire structs. The design doc's imsg catalog describes + * these only in prose ("mechanism, decoded username, decoded password" / + * "ok/fail, mailbox identifier on success", etc.) -- these fixed-size + * structs are this implementation's concrete choice, not something the + * design doc itself specifies. Fixed-size, no length-prefixed strings, + * for v1 simplicity; revisit if that turns out to be too small anywhere. + */ +#define AUTH_USERNAME_MAX 64 +#define AUTH_PASSWORD_MAX 128 +#define AUTH_MAILDIR_MAX 256 + +/* + * Boot-time config-delivery payloads, closing the gap flagged in an + * earlier pass: listener/auth don't read imapd.conf themselves (kept + * off their rpath/unveil surface deliberately -- see each role's pledge + * discussion in openimap-privsep-design.md), but nothing ever specified + * how they'd get their slice of it otherwise. Modeled directly on + * IMSG_STORE_INIT's existing precedent: parent, which alone reads the + * real config, hands over only the fields that role actually needs, not + * the whole struct openimap_config. + * + * auth's is not optional polish -- auth_main() derives its chroot + * directory from cred_file, so without this message it would chroot + * into the dirname of an empty string. listener's used to be only a + * startup log line, on the theory that the listening fds themselves are + * always fd-passed directly, never rebuilt from listen_addr/ports -- that + * stopped being true the moment dual-stack ("listen on *") support was + * added: listener now needs n_cleartext_addrs/n_tls_addrs from this + * message to know how many IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS messages + * to expect (1 each normally, 2 each for "*") before its boot-time drain + * loop can know it has received all of them. This message must therefore + * arrive before listener can finish that loop, though not necessarily + * before the socket fds themselves -- see listener.c's listener_main() + * for how the loop tolerates any arrival order. + */ +struct imsg_listener_init { + char listen_addr[64]; + uint16_t port_cleartext; + uint16_t port_implicit_tls; + uint8_t n_cleartext_addrs; /* # of IMSG_LISTENER_SOCKET_ + * CLEARTEXT messages to expect, + * 1 or LISTENER_MAX_ADDRS */ + uint8_t n_tls_addrs; /* same, for _TLS */ +}; + +struct imsg_auth_init { + char cred_file[1024]; +}; + +struct imsg_auth_request { + uint32_t session_id; + char username[AUTH_USERNAME_MAX]; + char password[AUTH_PASSWORD_MAX]; +}; + +struct imsg_auth_result { + uint32_t session_id; + int ok; + uid_t uid; + gid_t gid; + char maildir[AUTH_MAILDIR_MAX]; +}; + +/* + * openimap-privsep-design.md's credential-file field (line ~501): "maildir: + * path relative to the spool root store is chroot'd into, so store never + * needs an absolute-path credential field to escape its chroot." The design + * doc's imsg catalog already specified IMSG_STORE_FORK should carry "session + * id, resolved uid/gid, mailbox identifier" -- this field was the "mailbox + * identifier" from day one, it just never actually got added to either + * struct below, so auth.c's imsg_auth_result.maildir (correctly resolved + * per-user from the credential file) was being silently dropped on the + * floor by listener.c's session_request_store() before this pass. + */ +#define STORE_MAILDIR_MAX AUTH_MAILDIR_MAX + +struct imsg_store_fork { + uint32_t session_id; + uid_t uid; + gid_t gid; + char maildir[STORE_MAILDIR_MAX]; +}; + +struct imsg_store_init { + uint32_t session_id; + uid_t uid; + gid_t gid; + char spool_root[1024]; /* store needs this to chroot() + * -- store children don't read + * imapd.conf themselves (see + * main.c's NOTE on why), so + * parent has to hand it over + * explicitly here rather than + * store already having it. */ + char maildir[STORE_MAILDIR_MAX]; /* THIS session's own + * mailbox subdirectory, relative + * to spool_root above -- distinct + * from spool_root itself, which + * is shared by every store child + * regardless of user. store.c + * scopes its unveil(2) to this + * path specifically (not the + * whole chroot), and every + * mailbox file it opens is + * relative to it. */ + uint32_t bodystructure_read_max; /* copied from struct + * openimap_config's field of the + * same name -- see + * BODYSTRUCTURE_READ_DEFAULT's + * comment for what this gates. + * Same "parent read the config, + * child gets only what it needs" + * pattern as spool_root/maildir + * above. */ +}; + +/* + * IMSG_MBOX_SELECT (listener -> store) / IMSG_MBOX_SELECTED (store -> + * listener): the first IMSG_MBOX_* pair to actually get a wire payload + * shape -- the rest of the family (FETCH/STORE/APPEND/...) is still exactly + * as undesigned as store.c's own header comment says. v1 is single-mailbox + * (INBOX only, per openimap-v1-dispatch.md's SELECT row and the still- + * unresolved hierarchy-separator question flagged in listener.c's + * cmd_namespace()) -- readonly distinguishes EXAMINE (RFC 9051 SS6.3.3) + * from SELECT, wired up in listener.c's select_or_examine(). store.c still + * does nothing different for readonly than for a normal SELECT: v1's single + * mailbox returns the identical EXISTS/UIDVALIDITY/UIDNEXT/highestmodseq + * either way, so read-only enforcement (refusing STORE/EXPUNGE/MOVE, + * short-circuiting CLOSE) lives entirely in listener.c via s->mbox_readonly + * -- a pure session-local invariant that doesn't need store.c's + * involvement to check. + */ +#define MBOX_NAME_MAX 256 + +/* + * QRESYNC select-param additions (RFC 7162 SS3.2.5): `"QRESYNC" SP "(" + * uidvalidity SP mod-sequence-value [SP known-uids [SP seq-match-data]] + * ")"`. v1 scope, sourced against this project's own established pattern + * of accepting exactly one sequence-set range (never a comma-separated + * list) everywhere a sequence-set appears (FETCH/STORE/SEARCH's UID + * ranges) -- known-uids gets the same restriction here. seq-match-data is + * parsed and syntax-validated by listener.c but never sent down this wire + * at all: this implementation's chosen QRESYNC state model (see the + * imsg_mbox_select_vanished comment below) never uses it to narrow + * anything, so there's nothing for store.c to do with it -- explicitly + * sanctioned by RFC 7162 SS5.2 ("A client providing message sequence + * match data can reduce the scope as above. In the case where there have + * been no expunges, the server can ignore this data"). + */ +struct imsg_mbox_select { + char mailbox[MBOX_NAME_MAX]; + int readonly; /* 1 = EXAMINE, 0 = SELECT */ + + int qresync; /* 1 if a QRESYNC select-param was + * given and passed listener.c's + * "ENABLE QRESYNC already issued" + * gate (RFC 7162 SS3.2.5) */ + uint32_t qresync_uidvalidity; /* client's last-known + * UIDVALIDITY -- store.c ignores the + * rest of the qresync_* fields below + * if this doesn't match the mailbox's + * actual current UIDVALIDITY (SS3.2.5: + * "the server MUST ignore the + * remaining parameters and behave as + * if no dynamic message data + * changed") */ + uint64_t qresync_modseq; /* client's last-known mailbox + * mod-sequence */ + int qresync_has_uids; /* 0 => client omitted known-uids; + * SS3.2.5.1: "the server acts as if + * the client has specified + * '1:'" -- store.c resolves + * that default itself, since it's the + * one that knows UIDNEXT */ + uint32_t qresync_uid_lo; /* known-uids range, v1's usual + * single-range restriction (no comma + * lists) -- ignored if + * !qresync_has_uids */ + uint32_t qresync_uid_hi; +}; + +struct imsg_mbox_selected { + int ok; /* 0 -- e.g. mailbox isn't INBOX, v1's only + * mailbox -- see openimap-v1-dispatch.md */ + uint32_t exists; /* RFC 9051 SS7.4.1 EXISTS */ + uint32_t uidvalidity; /* RFC 9051 SS2.3.1.1 */ + uint32_t uidnext; /* RFC 9051 SS2.3.1.1 */ + + /* + * RFC 7162 SS3.1.2.1: highest mod-sequence of all messages in the + * mailbox. Always populated (v1's index format now tracks a + * per-mailbox mod-sequence counter unconditionally -- see store.c's + * struct mbox_index comment), whether or not this particular + * session has issued a CONDSTORE-enabling command yet -- listener.c + * is the one that decides whether to actually surface it to the + * client via the HIGHESTMODSEQ OK response code, and also caches it + * in s->mbox_highestmodseq for the "CONDSTORE enabled later, mailbox + * already selected" unsolicited-HIGHESTMODSEQ case (RFC 7162 SS3.1: + * "A first CONDSTORE enabling command executed in the session with a + * mailbox selected MUST cause the server to return HIGHESTMODSEQ"). + * Since v1's only mailbox always supports persistent mod-sequence + * storage, the NOMODSEQ response code (SS3.1.2.2) is simply + * unreachable in this implementation -- not emitted anywhere. + */ + uint64_t highestmodseq; + + /* + * Before this struct, store.c streams (in this order, per RFC 7162 + * SS3.2.6's "VANISHED (EARLIER) responses MUST be returned before + * any FETCH responses" ordering rule, which this implementation + * also applies to the QRESYNC-SELECT resync case by the same + * reasoning): zero or more IMSG_MBOX_SELECT_VANISHED (uid_lo/ + * uid_hi range), then zero or more IMSG_MBOX_FETCH_META (with + * .modseq set, for messages + * in the requested known-uids range whose current mod-sequence is + * greater than qresync_modseq) -- only when req->qresync was set + * and the UIDVALIDITY check passed. See imsg_mbox_select_vanished's + * comment for why the vanished set ignores qresync_modseq entirely + * (this implementation's chosen minimal QRESYNC state model). + */ +}; + +/* + * RFC 9051 SS6.3.11 status-att-val values, plus RFC 7162 SS3.1.7's + * HIGHESTMODSEQ addition (`status-att =/ "HIGHESTMODSEQ"`). Request-parsing + * order only -- listener.c's response formatting uses its own fixed + * canonical order (MESSAGES, UIDNEXT, UIDVALIDITY, UNSEEN, DELETED, SIZE, + * HIGHESTMODSEQ), directly sourced from the RFC's own worked example + * (SS6.3.11: "C: A042 STATUS blurdybloop (UIDNEXT MESSAGES)" answered + * "S: * STATUS blurdybloop (MESSAGES 231 UIDNEXT 44292)" -- the server + * reordered the client's own request order), not from this bitmask's bit + * order. + */ +#define STATUS_ATT_MESSAGES (1U << 0) +#define STATUS_ATT_UIDNEXT (1U << 1) +#define STATUS_ATT_UIDVALIDITY (1U << 2) +#define STATUS_ATT_UNSEEN (1U << 3) +#define STATUS_ATT_DELETED (1U << 4) +#define STATUS_ATT_SIZE (1U << 5) +#define STATUS_ATT_HIGHESTMODSEQ (1U << 6) + +/* + * IMSG_MBOX_STATUS (listener -> store) / IMSG_MBOX_STATUS_RESULT (store -> + * listener): RFC 9051 SS6.3.11 STATUS command. Single-request/single- + * combined-reply pair, same shape as IMSG_MBOX_SELECT/IMSG_MBOX_SELECTED -- + * STATUS's response is one aggregated line, not per-message data, so + * (unlike FETCH/STORE/SEARCH/EXPUNGE) there's no streamed-then-terminal + * shape here. + * + * mailbox field added for RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 (flat multi- + * mailbox support -- see docs/openimap-storage-backend.md item 10): this + * comment previously argued a mailbox-name field was unnecessary, "v1 has + * exactly one mailbox (INBOX) and no CREATE" -- no longer true. SS6.3.11 + * itself requires STATUS to target *any* named mailbox independent of + * whatever this session currently has selected ("asks the server to + * return... status of a mailbox... without... opening a mailbox"), so + * store.c's handle_mbox_status() can't just answer for whatever cwd + * happens to be (that would incorrectly reflect the *selected* mailbox, + * conflating two independent concepts). It resolves this field the same + * way handle_mbox_append() resolves its own independent destination: + * temporarily visiting the target via select_mailbox_dir() and restoring + * whatever was selected before, rather than leaving the session's actual + * selection state changed by a STATUS call. + */ +struct imsg_mbox_status { + char mailbox[MBOX_NAME_MAX]; + uint32_t attrs; /* STATUS_ATT_* bitmask -- MESSAGES/UIDNEXT/ + * UIDVALIDITY/HIGHESTMODSEQ are always computed + * by store.c regardless of this mask (all four + * are free reads from the index header, same + * "always compute, listener decides whether to + * print" precedent as imsg_mbox_selected's own + * fields); UNSEEN/DELETED/SIZE are the + * deliberate exception -- store.c only runs the + * per-message locate_message_file() scan their + * computation requires when at least one of + * the three is set here, per RFC 9051 + * SS6.3.11's own warning: "the STATUS command + * SIZE...can take a significant amount of + * time...clients should use STATUS SIZE + * cautiously". Once that scan does run, all + * three are computed together regardless of + * which subset was actually requested -- + * locate_message_file() already returns both + * the flag suffix and the size in one call, so + * there's no marginal cost to computing all + * three vs. one. */ +}; + +struct imsg_mbox_status_result { + int ok; /* 0 if the open/flock/index_load sequence + * itself failed -- can't happen due to a bad + * mailbox name (listener.c's gate above + * already ruled that out), but store.c can + * still fail for the same reasons handle_mbox_ + * select() can */ + uint32_t messages; /* STATUS_ATT_MESSAGES */ + uint32_t uidnext; /* STATUS_ATT_UIDNEXT */ + uint32_t uidvalidity; /* STATUS_ATT_UIDVALIDITY */ + uint64_t highestmodseq; /* STATUS_ATT_HIGHESTMODSEQ, RFC 7162 + * SS3.1.7 */ + uint32_t unseen; /* STATUS_ATT_UNSEEN -- 0 if not + * requested, see imsg_mbox_status.attrs + * comment */ + uint32_t deleted; /* STATUS_ATT_DELETED, same as above */ + uint64_t size; /* STATUS_ATT_SIZE, same as above */ +}; + +/* + * IMSG_MBOX_SELECT_VANISHED (store -> listener, zero or more, before the + * terminal IMSG_MBOX_SELECTED or IMSG_MBOX_RESULT): one *range* + * [uid_lo, uid_hi] (inclusive) of UIDs no longer present in the mailbox, + * drawn from the range being resolved. Despite the name (kept from when + * this only existed for QRESYNC SELECT resync), also reused as of this + * pass for RFC 7162 SS3.2.6's VANISHED UID FETCH modifier -- same "report + * gaps in a UID range" computation, same wire shape, just a different + * range source (a UID FETCH's own seq_lo/seq_hi instead of QRESYNC's + * known-uids) and a different terminal message (IMSG_MBOX_RESULT, since + * a UID FETCH isn't a SELECT). listener.c tells the two apart by s->state + * (SESSION_SELECTING vs SESSION_FETCHING) when it arrives. + * + * A range, not a single UID: store.c computes these by walking its + * *present*-message list once (bounded by mailbox size, this file's usual + * "personal use, modest mailbox size" scale) and reporting the gaps + * between consecutive present UIDs -- never by iterating the requested + * UID range one number at a time, which a client could set arbitrarily + * large (e.g. "known-uids 1:4000000000" against a five-message mailbox) + * independent of real mailbox size. Since UIDs are assigned strictly + * sequentially and never reused (RFC 9051 SS2.3.1.1), every UID gap in + * the present-message list genuinely was assigned-then-expunged at some + * point, so this is exactly the vanished set, computed in O(mailbox + * size) rather than O(requested range size). + * + * Deliberately reports every such range regardless of qresync_modseq -- + * this implementation adopts RFC 7162 SS5.1's explicitly-sanctioned + * minimal-state QRESYNC model ("a server implementation that doesn't + * remember mod-sequences associated with expunged messages can be + * considered compliant... Such implementations return all expunged + * messages specified in the UID set... every time, without paying + * attention to the specified CHANGEDSINCE mod-sequence"), rather than + * persisting a queue of expunge history (SS5.3's + * "Additional State Required" option) -- v1's index format has no place + * to put that history, and the RFC treats the simpler behavior as fully + * compliant, just less bandwidth-optimal than a server that remembers + * more. listener.c formats these ranges directly into the VANISHED + * (EARLIER) response's known-uids list. + */ +struct imsg_mbox_select_vanished { + uint32_t uid_lo; + uint32_t uid_hi; +}; + +/* + * IMSG_MBOX_FETCH (listener -> store) / IMSG_MBOX_FETCH_META (store -> + * listener, one per matching message, sent in ascending sequence-number + * order) / IMSG_MBOX_RESULT (store -> listener, exactly once, after the + * last IMSG_MBOX_FETCH_META -- "no more responses coming for this FETCH, + * safe to send the tagged OK/NO"). + * + * v1 FETCH scope: message METADATA (FLAGS, UID, INTERNALDATE, RFC822.SIZE), + * plus, across five successive real-client-testing passes, six content + * items -- BODY.PEEK[HEADER] (see MBOX_FETCH_BODY_HEADER / IMSG_ + * MBOX_FETCH_HEADER below); BODY.PEEK[] and BODY.PEEK[TEXT] (see MBOX_ + * FETCH_BODY_WHOLE/MBOX_FETCH_BODY_TEXT / IMSG_MBOX_FETCH_BODY below); + * BODY.PEEK[HEADER.FIELDS (...)]/BODY.PEEK[HEADER.FIELDS.NOT (...)] (see + * MBOX_FETCH_HEADER_FIELDS below, which reuses IMSG_MBOX_FETCH_HEADER + * wholesale rather than adding a fifth imsg type) -- all four raw-byte + * extraction/filtering with no MIME awareness; ENVELOPE (see MBOX_ + * FETCH_ENVELOPE / IMSG_MBOX_FETCH_ENVELOPE below), the first content item + * that's a *parsed*, structured response rather than raw or filtered + * message bytes; and BODYSTRUCTURE/bare BODY (see MBOX_FETCH_BODYSTRUCTURE + * / IMSG_MBOX_FETCH_BODYSTRUCTURE below), the first content item that + * requires real MIME parsing -- deliberately scoped out of the same pass + * that added ENVELOPE (user choice, via AskUserQuestion, to land ENVELOPE + * first and take up BODYSTRUCTURE as its own, later pass), then + * implemented as full recursive multipart parsing bounded by depth/part- + * count caps (a second AskUserQuestion, favoring correctness for the very + * common nested multipart/mixed(multipart/alternative(...), attachment) + * shape over a simpler single-level or single-part-only implementation), + * with RFC 9051's optional extension data (body MD5/disposition/language/ + * location) omitted entirely -- see MBOX_FETCH_BODYSTRUCTURE's own comment + * for the full scoping story. A seventh content item, BODY[]/ + * BODY.PEEK[] (see MBOX_FETCH_BODY_PART below), followed as + * real-hardware testing of BODYSTRUCTURE surfaced the natural next gap: a + * client that knows (via BODYSTRUCTURE) a message has an attachment still + * had no way to actually retrieve that attachment's bytes -- BODYSTRUCTURE + * only ever describes the part tree, never returns part content. Bundled + * into the same pass: <> byte-range support (SS6.4.5's + * "" suffix), applying uniformly to whole/TEXT/section-part + * BODY[...] fetches -- added after real Apple Mail traffic was observed + * issuing "BODY.PEEK[TEXT]<0.16384>", which this server silently failed to + * handle at all (no "<...>" parsing existed anywhere in listener.c before + * this pass). Everything else content-related -- BODY[]/BODY[TEXT]/ + * BODY[HEADER.FIELDS...]/BODY[] without .PEEK (the \Seen- + * setting side effect, still not implemented for any content item, this + * one included -- same scoping as every .PEEK-only item above) and part + * addressing into a MULTIPART container or a MESSAGE/RFC822|GLOBAL part's + * own nested numbering (matching BODYSTRUCTURE's own established message/ + * rfc822 scope cut) -- remains deliberately out of this pass. See + * listener.c's cmd_fetch() comment and README.skeleton's entries for each + * item above for the full scoping reasoning. Also v1-scoped: exactly one + * sequence-set range per request (a single number, "a:b", or "*" at either + * end) -- listener.c rejects a comma-separated sequence-set before ever + * sending this message, rather than silently fetching only the first + * sub-range. + */ +#define MBOX_FLAGS_MAX 256 /* generous -- the five standard flags plus a + * handful of keywords comfortably fits; + * truncated (not rejected) if a message + * somehow has more, same truncate-rather- + * than-overflow style as listener.c's + * session_reply() */ + +#define MBOX_FETCH_FLAGS (1U << 0) +#define MBOX_FETCH_UID (1U << 1) +#define MBOX_FETCH_INTERNALDATE (1U << 2) +#define MBOX_FETCH_RFC822_SIZE (1U << 3) +#define MBOX_FETCH_MODSEQ (1U << 4) /* RFC 7162 SS3.1.4.2 MODSEQ + * fetch-att -- set whenever the client + * named MODSEQ explicitly, used + * CHANGEDSINCE (which "implicitly adds + * the MODSEQ FETCH message data item", + * SS3.1.4.1), or (this implementation's + * simplifying choice, see cmd_fetch()'s + * comment) the session already has + * CONDSTORE enabled at all */ +#define MBOX_FETCH_BODY_HEADER (1U << 5) /* BODY.PEEK[HEADER] only -- + * RFC 9051 SS6.4.5: "the [RFC5322] + * header of the message" for the + * HEADER section-msgtext specifier, i.e. + * the raw, unparsed header block, not a + * structured ENVELOPE. Deliberately not + * set for plain BODY[HEADER] (without + * .PEEK) -- that variant "implicitly + * sets the \Seen flag" per the same + * section, and this pass doesn't + * implement that side effect (would need + * the same flag-rename + modseq-bump + * machinery STORE already has, plus + * reflecting the change back in this + * FETCH's own response -- scoped out, + * see README.skeleton). listener.c's + * parse_fetch_atts() only recognizes the + * exact token "BODY.PEEK[HEADER]"; plain + * BODY[HEADER] still degrades like every + * other unsupported BODY[...] variant. */ +#define MBOX_FETCH_BODY_WHOLE (1U << 6) /* BODY.PEEK[] only -- RFC 9051 + * SS6.4.5: "If BODY[] is specified + * (the section specification is + * omitted), the FETCH is requesting the + * [RFC5322] expression of the entire + * message." Raw bytes, header and body + * together, no MIME parsing -- same + * .PEEK-only, exact-token-match scoping + * as MBOX_FETCH_BODY_HEADER above, for + * the same \Seen-side-effect reason. */ +#define MBOX_FETCH_BODY_TEXT (1U << 7) /* BODY.PEEK[TEXT] only -- SS6.4.5.1: + * "The TEXT part specifier refers to + * the text body of the message, + * omitting the [RFC5322] header." + * store.c's read_message_body() finds + * the same header/body blank-line + * separator read_message_header() + * already scans for, just returns + * everything after it instead of + * everything through it. If a client + * requests both MBOX_FETCH_BODY_WHOLE + * and MBOX_FETCH_BODY_TEXT in the same + * FETCH (legal per SS6.4.5, unseen from + * any real client so far), store.c + * answers WHOLE and silently drops + * TEXT -- one IMSG_MBOX_FETCH_BODY per + * message keeps the wire protocol + * symmetric with IMSG_MBOX_FETCH_HEADER + * rather than needing an array; see + * struct imsg_mbox_fetch_body's is_text + * field and handle_mbox_fetch()'s + * comment. */ +#define MBOX_FETCH_HEADER_FIELDS (1U << 8) /* BODY.PEEK[HEADER.FIELDS + * (name ...)] or BODY.PEEK[HEADER. + * FIELDS.NOT (name ...)] -- SS6.4.5.1. + * Reuses IMSG_MBOX_FETCH_HEADER/struct + * imsg_mbox_fetch_header wholesale (see + * that struct's comment): from store.c's + * and listener.c's wire-protocol point of + * view this is just "header-region bytes, + * possibly filtered", the same shape as + * plain BODY.PEEK[HEADER], just produced + * by store.c's read_message_header_ + * fields() instead of read_message_ + * header(). req->header_fields_not and + * req->header_fields (below) carry the + * NOT flag and the space-joined field- + * name list; the exact client-typed + * label text ("HEADER.FIELDS (DATE + * FROM)", etc.) never crosses the imsg + * boundary at all -- listener.c already + * has it from parsing the client's own + * command line, and echoes it back + * verbatim in the FETCH response rather + * than reconstructing it (see listener.c's + * parse_header_fields_att() and struct + * session's pending_header_label + * comment). If a client requests both + * plain BODY.PEEK[HEADER] and a HEADER. + * FIELDS variant in the same FETCH (legal + * per SS6.4.5, unseen from any real + * client so far), listener.c's parse_ + * fetch_atts() has HEADER win and drops + * HEADER_FIELDS -- same "more general + * variant wins" precedent as MBOX_FETCH_ + * BODY_WHOLE vs. MBOX_FETCH_BODY_TEXT. */ +#define MBOX_FETCH_ENVELOPE (1U << 9) /* RFC 9051 SS7.5.2 ENVELOPE -- + * "computed by the server by + * parsing the [RFC5322] header + * into the component parts, + * defaulting various fields as + * necessary." Unlike every MBOX_ + * FETCH_BODY_* item above, this is + * a *parsed*, structured response + * (date/subject/address lists), + * built entirely by store.c's + * build_envelope() -- see struct + * imsg_mbox_fetch_envelope below + * for why the wire payload is + * already-formatted response text + * rather than raw bytes. No .PEEK + * variant exists for ENVELOPE (SS6.4.5's + * fetch-att grammar has no "ENVELOPE. + * PEEK" production) and it has no + * \Seen-setting side effect to avoid + * in the first place, unlike the BODY[...] + * family -- so, unlike MBOX_FETCH_BODY_*, + * this bit is set directly from the + * bare "ENVELOPE" token. */ +#define MBOX_FETCH_BODYSTRUCTURE (1U << 10) /* RFC 9051 SS7.5.2 + * BODYSTRUCTURE, and its non- + * extensible sibling "BODY" + * (SS9's fetch-att: `"BODY" + * ["STRUCTURE"]` -- bare "BODY", + * no brackets, is a synonym for + * BODYSTRUCTURE-without- + * extension-data, distinct from + * "BODY[section]", which is + * content). This implementation + * never emits extension data + * (body MD5/disposition/ + * language/location) even for + * BODYSTRUCTURE -- RFC 9051 SS7.5.2 + * says extension data "can be + * returned" with BODYSTRUCTURE, + * not that it MUST be, so BODY + * and BODYSTRUCTURE produce + * byte-identical output in this + * server, both setting this one + * bit. Like ENVELOPE, this is a + * *parsed* response -- here, + * recursive MIME structure + * parsing via store.c's build_ + * bodystructure()/build_body_ + * structure() (see struct imsg_ + * mbox_fetch_bodystructure's + * comment) -- not raw or + * filtered bytes, and has no + * .PEEK variant or \Seen side + * effect, so (like MBOX_FETCH_ + * ENVELOPE, unlike MBOX_FETCH_ + * BODY_*) this bit is set + * directly from the bare token. */ +#define MBOX_FETCH_BODY_PART (1U << 11) /* BODY.PEEK[] + * only -- same .PEEK-only + * scoping as every other + * MBOX_FETCH_BODY_* bit (plain + * BODY[], which + * would implicitly set \Seen, + * is not implemented, same as + * plain BODY[]/BODY[TEXT]/ + * BODY[HEADER...] already + * aren't). SS6.4.5.1's numeric-only + * section-part grammar + * (`section-part = nz-number + * *("." nz-number)`, e.g. "2" or + * "3.1"), addressing one specific + * leaf MIME part's raw (still + * transfer-encoded -- SS6.4.5's + * BODY[] never decodes Content- + * Transfer-Encoding, that's + * BINARY[]'s job, out of scope + * here same as BODYSTRUCTURE's own + * extension-data cut) body bytes. + * Distinct bit from MBOX_FETCH_ + * BODY_WHOLE/_TEXT since it needs + * an extra parameter (req-> + * section_part below) those don't. + * Deliberately v1-scoped to leaf + * parts only: a section-part + * naming a MULTIPART container + * itself, or reaching into a + * MESSAGE/RFC822 or MESSAGE/GLOBAL + * part's own nested numbering + * (SS6.4.5.1: "also has nested + * part numbers, referring to + * parts of the MESSAGE part's + * body") is "not found" for this + * item -- matching BODYSTRUCTURE's + * own established message/rfc822 + * scope cut (store.c's build_ + * body_structure() already refuses + * to describe such a message's + * structure at all, so this + * implementation was never going + * to be able to name a part inside + * one). The non-numeric part + * specifiers (HEADER, HEADER. + * FIELDS[.NOT], MIME, TEXT) + * standing alone are already + * MBOX_FETCH_BODY_HEADER/_TEXT/ + * HEADER_FIELDS; this bit is only + * for the purely-numeric form. */ + +/* + * Cap on the dotted-numeric section-part string (imapd.h's own + * MBOX_FETCH_BODY_PART comment) a BODY[]/BODY.PEEK[] fetch-att + * carries from listener.c to store.c (struct imsg_mbox_fetch's + * section_part below). Sized for MIME_MAX_DEPTH (10) levels of + * MIME_MAX_PARTS (64, i.e. up to 2 digits per level) numbers plus + * separating dots: 10*2 + 9 = 29 characters worst case: 40 leaves + * comfortable headroom without being large enough to matter for the + * imsg-size arithmetic every other MAX constant in this file cares + * about. listener.c's tokenizer rejects (BAD) a section-part that + * would exceed this rather than truncate it, same precedent as every + * other MAX constant here. + */ +#define SECTION_PART_MAX 40 + +/* + * Cap on the raw header bytes IMSG_MBOX_FETCH_HEADER can carry, for the same + * reason APPEND_LITERAL_MAX exists in listener.c: struct imsg_mbox_fetch_ + * header's fixed fields plus this many trailing bytes need to fit under + * MAX_IMSGSIZE (16384, imsg.h) alongside the imsg header itself. Real-world + * RFC 5322 headers are essentially always well under 8192 bytes even with a + * long Received:/DKIM-Signature: chain; a header that somehow exceeds this + * is treated as "not found" for BODY.PEEK[HEADER] purposes (that one + * message's header is silently omitted, same as a message missing on disk + * -- see handle_mbox_fetch()'s existing "indexed but missing on disk" + * skip), not truncated, matching APPEND_LITERAL_MAX's own "reject rather + * than silently do something the client didn't ask for" precedent. + */ +#define FETCH_HEADER_MAX 8192 + +/* + * Cap on a whole message's size, both when APPEND writes one and when + * BODY.PEEK[]/BODY.PEEK[TEXT] read one back out. Originally listener.c- + * local (only APPEND needed it); moved here when store.c's read_message_ + * body() needed the identical number, so the two enforcement points share + * one symbol instead of two independently-maintained constants that could + * silently drift apart. See listener.c's own comment at this symbol's + * former definition site for the full MAX_IMSGSIZE arithmetic (12000 + * leaves headroom under imsg's 16384-byte ceiling alongside either + * struct's own fixed fields and the imsg header itself). A message larger + * than this needs real fd-passing, not implemented this pass; rejected + * with a plain NO/omitted from the FETCH response (RFC 9051 defines no + * response code for a size cap) rather than truncating. + */ +#define APPEND_LITERAL_MAX 12000 + +/* + * Cap on the bytes any single BODY[
]/BODY.PEEK[
] + * response (whole message, TEXT-only, or a numeric section-part) can + * carry on one IMSG_MBOX_FETCH_BODY, reusing APPEND_LITERAL_MAX's own + * value and "comfortable headroom under imsg's MAX_IMSGSIZE" reasoning + * rather than a new constant, since it's the same underlying limit + * (one struct imsg_mbox_fetch_body's fixed fields plus this many + * trailing bytes have to fit under MAX_IMSGSIZE alongside the imsg + * header itself). Before this pass, only whole/TEXT fetches existed + * and this cap was simply "the message is too big, fail the whole + * item" (APPEND_LITERAL_MAX's original framing). Section-part fetches + * change the picture: MIME parts (attachments especially) routinely + * exceed this on their own -- that's the entire reason BODYSTRUCTURE_ + * READ_MAX exists as a separate, much larger cap on what store.c is + * willing to *read* off disk. A client fetching a large part without + * a <> range still can't get more than this many bytes back + * in one response (treated as "not found" for that item, same reject- + * not-truncate precedent as everywhere else) -- real clients handle + * this by re-fetching in <> ranged chunks instead (confirmed + * against real Apple Mail traffic this pass, which already issues + * BODY.PEEK[TEXT]<0.16384>-style ranged fetches unprompted). A + * <> request's own requested count is silently clamped down + * to this cap rather than rejected outright if it's larger -- RFC 9051 + * SS6.4.5's BODY[]<> semantics already require truncating a + * range that runs past the end of the available text, so a server- + * side response-size cap truncating a too-large *count* the same way + * (returning fewer bytes than asked, letting the client re-fetch the + * remainder at a later origin octet) is consistent with that existing + * "truncate the count, don't fail the fetch" spirit, not a new kind of + * behavior this cap invents. + */ +#define FETCH_PART_MAX APPEND_LITERAL_MAX + +/* + * Cap on the space-joined header-field-name list a BODY.PEEK[HEADER. + * FIELDS (...)]/BODY.PEEK[HEADER.FIELDS.NOT (...)] fetch-att carries from + * listener.c to store.c (struct imsg_mbox_fetch's header_fields below). + * 256 bytes comfortably covers any realistic request (RFC 9051 SS6.4.5's + * own example asks for two: "DATE FROM"; even a dozen longish field names + * like "Content-Type"/"Message-Id" fit easily) -- listener.c's parse_ + * header_fields_att() rejects (BAD) a field-name list that would exceed + * this rather than truncate it, same "reject rather than silently do + * something the client didn't ask for" precedent as every other MAX + * constant in this file. + */ +#define HEADER_FIELDS_MAX 256 + +/* + * Cap on the fully-formatted ENVELOPE parenthesized-list text store.c's + * build_envelope() can carry on one IMSG_MBOX_FETCH_ENVELOPE (struct imsg_ + * mbox_fetch_envelope below). Sized off the same reasoning as FETCH_HEADER_ + * MAX (8192): every field in an envelope is extracted from a header that + * itself can't exceed FETCH_HEADER_MAX, and while IMAP quoted-string + * escaping (backslash/double-quote doubling) plus the address-structure + * parenthesization overhead can inflate the formatted size somewhat versus + * the raw header, a header dense enough with backslashes/quotes/addresses + * to actually approach 8192 bytes of *formatted* envelope text from a + * header that's itself under 8192 bytes is already an extreme case -- + * matching FETCH_HEADER_MAX's own "essentially always well under" framing. + * A message whose formatted envelope somehow exceeds this is treated as + * "not found" for ENVELOPE purposes (same reject-rather-than-truncate + * precedent as FETCH_HEADER_MAX/APPEND_LITERAL_MAX), not truncated. + */ +#define ENVELOPE_MAX 8192 + +/* + * Caps on store.c's recursive BODYSTRUCTURE builder (build_body_structure(), + * store.c), bounding both the work it does and the size of what it can ever + * produce -- a message is entirely attacker/sender-controlled data (MIME + * part count and multipart nesting depth are both just numbers the message's + * own headers claim), so both need a hard ceiling rather than trusting + * whatever a message says about its own structure. MIME_MAX_DEPTH (10) is + * generous for any real mail this personal-use server will see -- deeply + * nested multipart is already unusual beyond 2-3 levels (e.g. multipart/ + * mixed containing a multipart/alternative) -- while still bounding + * recursion (and hence worst-case stack use) at a small, fixed number. + * MIME_MAX_PARTS (64) similarly bounds total part count across the whole + * recursive walk (a running counter threaded through every recursive call, + * not a per-multipart-parent limit), bounding both output size and total + * work independent of depth. Exceeding either cap is treated as "not found" + * for this message's BODYSTRUCTURE (reject, not silently truncate the part + * tree into something that no longer accurately describes the message) -- + * same precedent as every other MAX constant in this header. + */ +#define MIME_MAX_DEPTH 10 +#define MIME_MAX_PARTS 64 + +/* + * Cap on the fully-formatted BODYSTRUCTURE parenthesized-list text store.c's + * build_bodystructure() can carry on one IMSG_MBOX_FETCH_BODYSTRUCTURE + * (struct imsg_mbox_fetch_bodystructure below). Unlike ENVELOPE_MAX, this + * isn't derived from a single header's own size cap -- a BODYSTRUCTURE's + * size instead scales with MIME_MAX_PARTS (each part contributing its own + * type/subtype/parameter-list/encoding/octet-count fields) and MIME_MAX_ + * DEPTH (each nesting level adding its own wrapping parens and multipart + * subtype). 12000 mirrors APPEND_LITERAL_MAX's own reasoning: comfortable + * headroom under imsg(3)'s MAX_IMSGSIZE (16384) alongside this struct's own + * fixed fields and the imsg header itself, generous enough that MIME_MAX_ + * PARTS/MIME_MAX_DEPTH -- not this byte cap -- are expected to be the + * limiting factor in practice for any real message. Same reject-rather- + * than-truncate handling as every other MAX constant here if somehow + * exceeded anyway. + */ +#define BODYSTRUCTURE_MAX 12000 + +/* + * Cap on the raw on-disk bytes store.c's build_bodystructure() (via read_ + * message_body(), store.c) will read into memory while deriving a + * message's MIME structure. Deliberately independent from APPEND_LITERAL_ + * MAX/FETCH_BODY_MAX: that constant's "no on-disk message can legally + * exceed this" reasoning only holds for messages that arrived through + * this server's own APPEND command, not for mail delivered by an + * external MTA into the spool directly, which this server doesn't + * control the size of at all. Real-hardware testing (an Apple Mail + * message with a small image attachment) confirmed this in practice -- + * base64-encoded attachment data routinely pushes even a modest image + * past 12000 bytes, so sharing that cap made BODYSTRUCTURE fail on + * essentially any real attachment-bearing mail. + * + * build_bodystructure() only *derives* a small, MIME_MAX_PARTS/MIME_MAX_ + * DEPTH/BODYSTRUCTURE_MAX-bounded structure summary from these bytes -- + * it never sends the raw bytes themselves back to the client over the + * wire -- so, unlike BODY.PEEK[]/BODY.PEEK[TEXT] (still capped at + * APPEND_LITERAL_MAX, since those responses do carry the raw bytes + * whole on a single imsg and are therefore still bound by imsg's own + * MAX_IMSGSIZE ceiling), this read can afford to be much larger. + * + * 41943040 (40 MiB) is sized off Gmail's own documented 25MB attachment + * limit (https://support.google.com/mail/answer/6584), the most common + * real-world ceiling a personal mailbox is likely to receive mail under, + * plus headroom for base64's ~37% encoding overhead (a 25MB attachment + * becomes roughly 34MB once base64-encoded and wrapped in MIME headers) + * -- not a hard protocol requirement, just a generous, cited real-world + * bound rather than an arbitrary guess. Exceeding it is "not found" for + * this message's BODYSTRUCTURE (reject, not truncate -- same precedent + * as every other MAX constant in this header), same as any other reason + * this message's structure can't be produced. + * + * As of this pass, this value is operator-configurable via imapd.conf's + * "attachment max " directive (parse.y), since the whole reason + * this cap is sized off Gmail's own attachment limit rather than derived + * from any protocol constant is that it's inherently a judgment call + * about the operator's own expected mail, not a fixed property of the + * implementation -- see parse.y's grammar rule for the directive and + * struct openimap_config's bodystructure_read_max field. This macro + * changed meaning accordingly: no code reads it directly anymore (store.c + * uses the runtime value received via IMSG_STORE_INIT instead); it now + * exists solely as config_load()'s default when the directive is absent + * from imapd.conf, so an empty/absent config file still gets the same + * behavior this implementation always had. + */ +#define BODYSTRUCTURE_READ_DEFAULT 41943040 + +struct imsg_mbox_fetch { + uint32_t seq_lo; /* 1-based, inclusive; ignored if + * lo_is_star */ + uint32_t seq_hi; /* 1-based, inclusive; ignored if + * hi_is_star */ + int lo_is_star; + int hi_is_star; /* "*" is resolved by store against + * its own live message count at + * fetch time, not against listener's + * -- possibly stale -- count from the + * last SELECT reply (mail could have + * arrived since) */ + uint32_t attrs; /* bitmask of MBOX_FETCH_* above */ + + /* + * RFC 7162 SS3.1.4.1 CHANGEDSINCE fetch-modifier: "The information + * described by message data items is only returned for messages + * that have a mod-sequence bigger than ." has_ + * changedsince distinguishes "not specified" from a legal value of + * 0, the same has_/value pairing this header already uses for + * APPEND's optional date-time (see imsg_mbox_append's append_has_ + * date, in listener.c's struct session). + */ + int has_changedsince; + uint64_t changedsince; + + /* + * RFC 9051 SS6.4.9 (UID command): "the numbers in the sequence-set + * argument are unique identifiers instead of message sequence + * numbers" for a UID FETCH -- by_uid tells store.c to resolve seq_lo/ + * seq_hi (and "*") against UID space rather than 1-based index + * position. listener.c separately forces MBOX_FETCH_UID into attrs + * whenever by_uid is set (SS6.4.9: "server implementations MUST + * implicitly include the UID message data item as part of any FETCH + * response caused by a UID command"), so store.c itself needs no + * special-casing for *that* part -- meta.uid is already unconditionally + * populated regardless (see imsg_mbox_fetch_meta below). + * + * want_vanished is RFC 7162 SS3.2.6's VANISHED UID FETCH modifier + * (only legal alongside CHANGEDSINCE, and only on UID FETCH -- + * listener.c's parse_fetch_modifiers()/fetch_dispatch() enforce both + * restrictions before this ever reaches store.c). Per this + * implementation's RFC 7162 SS5.1 minimal-state QRESYNC decision + * (see imsg_mbox_select_vanished below), store.c doesn't track + * expunge-event history, so it can't actually filter by CHANGEDSINCE + * here either -- it reports every UID in [seq_lo, seq_hi] that isn't + * currently present, unconditionally, which SS3.2.6's own note + * explicitly sanctions ("A server that receives a mod-sequence + * smaller than ... MUST behave as if it was requested to + * report all expunged messages from the provided UID set parameter" -- + * this implementation always behaves that way, having no memory of + * at all). + */ + int by_uid; + int want_vanished; + + /* + * BODY.PEEK[HEADER.FIELDS (...)]/BODY.PEEK[HEADER.FIELDS.NOT (...)] + * (see MBOX_FETCH_HEADER_FIELDS above): header_fields_not is 0 for + * HEADER.FIELDS (include only the listed names), 1 for HEADER. + * FIELDS.NOT (exclude the listed names). header_fields is the + * requested field-name list, space-joined, exactly as the client + * typed each name (matching is ASCII-range case-insensitive per + * SS6.4.5.1, done by store.c's read_message_header_fields() -- + * this field is not itself normalized to any particular case). + * Both are only meaningful alongside attrs & MBOX_FETCH_HEADER_ + * FIELDS; listener.c's parse_header_fields_att() has already fully + * validated the header-list grammar before either field is ever + * populated, so store.c can assume header_fields is well-formed + * (non-empty, space-separated, no embedded quotes -- see that + * function's comment for why a bare-atom-only field name is this + * implementation's own scope cut). + */ + int header_fields_not; + char header_fields[HEADER_FIELDS_MAX]; + + /* + * BODY[]/BODY.PEEK[] (MBOX_FETCH_BODY_ + * PART above): section_part is the client-typed dotted-numeric part + * path verbatim (e.g. "3.1"), non-empty only alongside attrs & + * MBOX_FETCH_BODY_PART -- listener.c's tokenizer has already + * validated it's 1*(digit) *("." 1*digit) with no leading zeros + * before this is ever populated, so store.c can assume it parses + * cleanly into a path of nz-numbers. Single shared field, same "one + * instance assumed per FETCH command" simplification as header_ + * fields above -- a client requesting two different section-parts + * in one FETCH (legal per SS6.4.5.1, unseen from any real client so + * far) only gets the first one honored, matching that same + * precedent rather than restructuring this struct into an array for + * a case no real client actually does. + * + * has_partial/partial_start/partial_count carry a <> range + * (SS6.4.5's "" suffix, e.g. "BODY.PEEK[3.1]<0.65536>" + * or "BODY.PEEK[TEXT]<0.16384>" -- the latter is real, observed + * Apple Mail traffic this project's own real-hardware testing + * turned up, previously silently unhandled since no "<...>" parsing + * existed anywhere in listener.c at all before this pass). Applies + * uniformly to whichever BODY[...]/BODY.PEEK[...] variant attrs + * selects (whole, TEXT, or section_part) -- store.c slices the + * already-extracted content to [partial_start, partial_start + + * partial_count) before ever composing the response imsg, clamped + * to FETCH_PART_MAX and to the content's own actual length (RFC + * 9051 SS6.4.5: "If the starting octet is beyond the end of the + * text, an empty string is returned... Any partial fetch that + * attempts to read beyond the end of the text is truncated as + * appropriate"). has_partial distinguishes "no range requested" + * from a legal partial_start of 0, same has_/value pairing this + * header already uses for CHANGEDSINCE above. + */ + char section_part[SECTION_PART_MAX]; + int has_partial; + uint32_t partial_start; + uint32_t partial_count; +}; + +struct imsg_mbox_fetch_meta { + uint32_t seqno; /* 1-based */ + uint32_t uid; + uint64_t size; /* on-disk file size, octets -- + * RFC822.SIZE (RFC 9051 SS2.3.4). + * F13 fix: 64-bit so a message larger + * than 4 GiB reports a correct size. */ + int64_t internaldate; /* Unix timestamp, parsed from the + * maildir basename's own leading + * delivery-time field -- see store.c's + * handle_mbox_fetch() comment for why + * that's used instead of the file's + * mtime */ + char flags[MBOX_FLAGS_MAX]; /* space-separated IMAP flag + * names, e.g. "\Seen \Flagged foo" -- + * pre-formatted by store.c, the only + * side that has both the maildir + * flag-suffix letters and the index's + * keyword list */ + uint64_t modseq; /* RFC 7162 per-message mod-sequence -- + * always populated by store.c (cheap: + * it's already parsed the index line), + * same "always compute, let listener.c + * decide whether to print it" split as + * every other struct imsg_mbox_fetch_ + * meta field; shared by FETCH, STORE's + * FETCH echo, and QRESYNC SELECT + * resync's FETCH-with-UID responses, + * all three of which reuse this struct + * (see imsg_mbox_selected's comment) */ +}; + +/* + * IMSG_MBOX_FETCH_HEADER: sent by store.c immediately before the + * IMSG_MBOX_FETCH_META for the same message, if and only if req->attrs & + * MBOX_FETCH_BODY_HEADER -- listener.c relies on this exact ordering + * (rather than matching on seqno/uid) to fold the header bytes into the + * same untagged "* N FETCH (...)" response line as the message's other + * requested data items, per RFC 9051's own SS6.4.5/SS7.5.2 worked examples, + * which always show every FETCH data item for a message on one response + * line, not spread across several. This mirrors imsg_mbox_append's + * "fixed struct, then trailing variable-length bytes on the same imsg" + * shape (listener.c's session_finish_append(): malloc(sizeof(struct) + + * len), memcpy both pieces in, one imsg_compose() call) -- just sent in + * the opposite direction (store -> listener) and read back the same way + * imsg_mbox_append already is (store.c's handle_mbox_append(): + * imsg_get_buf() for the fixed struct, then imsg_get_len()/imsg_get_buf() + * again for whatever trailing bytes remain). + */ +struct imsg_mbox_fetch_header { + uint32_t seqno; /* 1-based -- matches the seqno on the + * IMSG_MBOX_FETCH_META that follows */ + uint32_t uid; + int found; /* 0 if locate_message_file() (well, + * store.c's own open_message_file(), + * see its comment for why this is a + * separate lookup rather than a shared + * one) couldn't find the message file, + * or its header exceeded + * FETCH_HEADER_MAX -- listener.c omits + * BODY[HEADER] from this one message's + * response rather than failing the + * whole FETCH, same as a metadata + * lookup failure already does. hdrlen + * and the trailing bytes are only + * meaningful when found is 1. */ + uint32_t hdrlen; /* length of the trailing raw header + * bytes on this same imsg, capped at + * FETCH_HEADER_MAX */ +}; + +/* + * IMSG_MBOX_FETCH_BODY: sent by store.c immediately before the + * IMSG_MBOX_FETCH_META for the same message, if and only if req->attrs & + * (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT) -- same ordering contract, + * same "fixed struct + trailing variable-length bytes on one imsg" shape, + * as struct imsg_mbox_fetch_header above (see that struct's comment); this + * is the same wire idiom used a third time, not a new one. + */ +struct imsg_mbox_fetch_body { + uint32_t seqno; /* 1-based -- matches the seqno on the + * IMSG_MBOX_FETCH_META that follows */ + uint32_t uid; + int found; /* 0 if open_message_file() couldn't find + * the message file, the file exceeded + * APPEND_LITERAL_MAX (listener.c), the + * content contained a NUL byte, or (for + * is_text only) no header/body separator + * could be found within the file -- + * listener.c omits BODY[]/BODY[TEXT] from + * this one message's response rather than + * failing the whole FETCH, same precedent + * as imsg_mbox_fetch_header's found field. + * bodylen and the trailing bytes are only + * meaningful when found is 1. */ + int is_text; /* 0 -- these are BODY.PEEK[] bytes (whole + * message, header and body together); 1 -- + * these are BODY.PEEK[TEXT] bytes (body + * only, header omitted). Set by store.c + * based on which of MBOX_FETCH_BODY_WHOLE/ + * MBOX_FETCH_BODY_TEXT req->attrs actually + * had set (WHOLE wins if a client somehow + * requested both, see MBOX_FETCH_BODY_ + * TEXT's comment) -- listener.c uses this + * to label the literal "BODY[]" vs + * "BODY[TEXT]" correctly rather than + * re-deriving it from its own fetch_attrs, + * which could have both bits set. */ + uint32_t bodylen; /* length of the trailing raw body bytes on + * this same imsg, capped at + * APPEND_LITERAL_MAX (listener.c) -- same + * cap APPEND itself already enforces when + * writing a message, reused here rather + * than inventing a second constant, since + * no message on disk can legally exceed it + * in the first place */ +}; + +/* + * IMSG_MBOX_FETCH_ENVELOPE: sent by store.c immediately before the + * IMSG_MBOX_FETCH_META for the same message, if and only if req->attrs & + * MBOX_FETCH_ENVELOPE -- same ordering contract as struct imsg_mbox_fetch_ + * header/imsg_mbox_fetch_body above. Unlike those two, whose trailing bytes + * are raw message content that listener.c wraps in a `{n}` literal, the + * trailing bytes here are the *complete*, already-formatted RFC 9051 + * SS7.5.2 envelope parenthesized-list text -- e.g. `("Wed, 17 Jul 1996 + * 02:23:25 -0700 (PDT)" "IMAP4rev1 WG mtg summary and minutes" (("Terry + * Gray" NIL "gray" "cac.washington.edu")) (("Terry Gray" NIL "gray" + * "cac.washington.edu")) (("Terry Gray" NIL "gray" "cac.washington.edu")) + * ((NIL NIL "imap" "cac.washington.edu")) ((NIL NIL "minutes" + * "CNRI.Reston.VA.US")("John Klensin" NIL "KLENSIN" "MIT.EDU")) NIL NIL + * "")`, RFC 9051's own SS7.5.2 worked + * example -- fully quoted/escaped and ready to splice verbatim into the + * FETCH response right after the literal string "ENVELOPE ". store.c does + * all the RFC 5322 header parsing, field unfolding, and address-list + * decomposition (build_envelope(), store.c) -- that's where the raw header + * bytes already live and where BODY.PEEK[HEADER.FIELDS...]'s folding-aware + * parsing already lives, so this keeps parsing logic in one place rather + * than splitting RFC 5322 semantics across both processes; listener.c's job + * stays purely wire framing, same division of labor as every other FETCH + * content item this project has implemented. No literal-block wrapping + * needed (unlike BODY[HEADER]/BODY[]/BODY[TEXT]): envelope fields are short + * quoted strings built by build_envelope()'s own IMAP-quoted-string escaper, + * never raw message bytes, so they're never CRLF-bearing and always fit + * directly on the response line. + */ +struct imsg_mbox_fetch_envelope { + uint32_t seqno; /* 1-based -- matches the seqno on the + * IMSG_MBOX_FETCH_META that follows */ + uint32_t uid; + int found; /* 0 if open_message_file()/read_message_ + * header() couldn't find or read the + * message's header, or the formatted + * envelope text exceeded ENVELOPE_MAX -- + * listener.c omits ENVELOPE from this one + * message's response rather than failing + * the whole FETCH, same precedent as + * imsg_mbox_fetch_header/imsg_mbox_fetch_ + * body's own found fields. envlen and the + * trailing bytes are only meaningful when + * found is 1. */ + uint32_t envlen; /* length of the trailing, already- + * formatted envelope text on this same + * imsg, capped at ENVELOPE_MAX */ +}; + +/* + * IMSG_MBOX_FETCH_BODYSTRUCTURE: sent by store.c immediately before the + * IMSG_MBOX_FETCH_META for the same message, if and only if req->attrs & + * MBOX_FETCH_BODYSTRUCTURE -- same ordering contract and same "already- + * formatted response text, not raw bytes" shape as struct imsg_mbox_fetch_ + * envelope above (see that struct's comment). The trailing bytes are the + * *complete* RFC 9051 SS7.5.2 BODYSTRUCTURE parenthesized-list text, e.g. + * `("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 2279 48)` for a + * simple message (RFC 9051's own SS7.5.2 example), or, for a multipart + * message, a nested structure like `(("TEXT" "PLAIN" ("CHARSET" "US-ASCII") + * NIL NIL "7BIT" 1152 23)("TEXT" "PLAIN" ("CHARSET" "US-ASCII" "NAME" + * "cc.diff") "<...>" "Compiler diff" "BASE64" 4554 73) "MIXED")` (also RFC + * 9051's own example) -- built entirely by store.c's build_bodystructure()/ + * build_body_structure(), which do all the MIME parsing (Content-Type/ + * Content-Transfer-Encoding/Content-ID/Content-Description extraction, + * multipart boundary splitting, recursion into sub-parts bounded by MIME_ + * MAX_DEPTH/MIME_MAX_PARTS) -- same "all the semantic parsing lives in one + * process, listener.c does pure wire framing" division of labor as + * envelope. This implementation deliberately never emits RFC 9051's + * optional extension data (body MD5/disposition/language/location -- see + * MBOX_FETCH_BODYSTRUCTURE's own comment for why), so a BODYSTRUCTURE fetch + * and a bare BODY fetch (the explicitly non-extensible form) produce + * identical text in this server. No literal-block wrapping needed, same + * reasoning as envelope (build_body_structure()'s own IMAP-quoted-string + * escaping, reused from envbuf_append_nstring(), guarantees no raw CRLF). + */ +struct imsg_mbox_fetch_bodystructure { + uint32_t seqno; /* 1-based -- matches the seqno on the + * IMSG_MBOX_FETCH_META that follows */ + uint32_t uid; + int found; /* 0 if the message's raw bytes couldn't + * be read, MIME_MAX_DEPTH/MIME_MAX_PARTS + * was exceeded, the message contains a + * message/rfc822 or message/global part + * (see MBOX_FETCH_BODYSTRUCTURE's comment + * for why those are scoped out), or the + * formatted text exceeded BODYSTRUCTURE_ + * MAX -- listener.c omits BODYSTRUCTURE + * from this one message's response rather + * than failing the whole FETCH, same + * precedent as every other content item's + * own found field. bslen and the trailing + * bytes are only meaningful when found is + * 1. */ + uint32_t bslen; /* length of the trailing, already- + * formatted BODYSTRUCTURE text on this + * same imsg, capped at BODYSTRUCTURE_MAX */ +}; + +/* Generic per-operation completion signal -- FETCH is the first user, + * but the name (and the enum's own placement of IMSG_MBOX_RESULT after + * every other IMSG_MBOX_* type) suggests it's meant to close out + * STORE/APPEND/etc. too once those exist. */ +struct imsg_mbox_result { + int ok; + uint32_t count; /* number of IMSG_MBOX_FETCH_META (or + * equivalent, for a future op) messages that + * preceded this one */ + + /* + * RFC 7162: the mailbox's HIGHESTMODSEQ after this operation. + * Always populated for STORE/EXPUNGE (the two operations that can + * change it); left 0 for a plain FETCH, which never mutates + * anything. listener.c is the one that decides whether/how to + * surface it: EXPUNGE's tagged OK MAY include it (SS3.2.7, "If at + * least one message got expunged and QRESYNC was enabled, the + * server MUST send" it -- this implementation does so whenever + * CONDSTORE is enabled at all, a safe superset, see cmd_expunge()'s + * comment), STORE's tagged OK/NO doesn't need to (SS3.1.3's own + * examples show it appearing on some responses and not others -- + * "presumably because this was the first CONDSTORE enabling + * command", i.e. it's the same "first enabling command" case + * covered by session_condstore_enable(), not something every STORE + * repeats), and CLOSE's tagged OK MUST NOT include it (SS3.2.8, + * explicit "MUST NOT ... as this might cause loss of + * synchronization on the client" -- cmd_close()'s existing + * was_close branch just never reads this field). + */ + uint64_t highestmodseq; + + /* + * RFC 9051 SS7.1's COPYUID response code: "the UIDVALIDITY of the + * destination mailbox". Populated only for COPY/MOVE (see imapd.h's + * imsg_mbox_copy comment) -- v1 has no CREATE, so the destination is + * always the same mailbox as the source (the currently selected + * mailbox), making this identical to that mailbox's own UIDVALIDITY; + * left 0 for FETCH/STORE/EXPUNGE/SEARCH, which have no COPYUID to + * report. Same "populated for the operations that need it, 0 + * otherwise, listener.c decides what to do with it" split as + * highestmodseq above. + */ + uint32_t uidvalidity; + + /* + * COPY/MOVE only (0 for every other operation that shares this + * struct, same "populated for the operations that need it" split as + * highestmodseq/uidvalidity above): 1 distinguishes "destination + * mailbox doesn't exist" from any other failure -- listener.c must + * send the tagged NO with a "[TRYCREATE]" prefix per SS6.4.7/SS6.4.8 + * for this case specifically, same distinction imsg_mbox_appended's + * own no_such_mailbox field already makes for APPEND. + */ + int no_such_mailbox; +}; + +/* + * IMSG_MBOX_STORE (listener -> store): RFC 9051 SS6.4.6 STORE command -- + * `store = "STORE" SP sequence-set SP store-att-flags`, `store-att-flags = + * (["+" / "-"] "FLAGS" [".SILENT"]) SP (flag-list / (flag *(SP flag)))`. + * Replies reuse IMSG_MBOX_FETCH_META (one per modified message, sent only + * if !silent) and the terminal IMSG_MBOX_RESULT -- SS6.4.6 itself says + * STORE's only response is "untagged responses: FETCH", the exact same + * shape FETCH already produces ("* FETCH (FLAGS (...))"), so + * there was no reason to invent a second reply pair. + * + * System flags are a fixed 5-bit set (v1 supports exactly the five RFC + * 9051 SS2.3.2 system flags: \Answered \Flagged \Deleted \Seen \Draft -- + * \Recent is explicitly excluded from the `flag` ABNF production itself, + * and any other "\"-prefixed token is a `flag-extension` this server + * doesn't define, so listener.c rejects both before ever building this + * struct). Keywords (arbitrary non-"\" atoms, SS2.3.2's `flag-keyword`) + * are carried separately as a comma-separated list -- matching the + * index's own on-disk keyword delimiter (openimap-storage-backend.md) so + * store.c can merge them directly without a format conversion; listener.c + * rejects any client-supplied keyword containing ':' or ',' (both valid + * in IMAP's `atom` grammar, but the index format has no escaping + * mechanism for its own field/record delimiters -- see cmd_store_cmd()'s + * comment) rather than silently corrupting the index. + */ +#define MBOX_FLAG_ANSWERED (1U << 0) +#define MBOX_FLAG_FLAGGED (1U << 1) +#define MBOX_FLAG_DELETED (1U << 2) +#define MBOX_FLAG_SEEN (1U << 3) +#define MBOX_FLAG_DRAFT (1U << 4) + +#define MBOX_STORE_SET 0 /* FLAGS -- replace outright */ +#define MBOX_STORE_ADD 1 /* +FLAGS -- union in */ +#define MBOX_STORE_REMOVE 2 /* -FLAGS -- subtract out */ + +struct imsg_mbox_store { + uint32_t seq_lo; + uint32_t seq_hi; + int lo_is_star; + int hi_is_star; + int mode; /* MBOX_STORE_* above */ + int silent; /* 1 if the ".SILENT" suffix was given + * -- suppress the untagged FETCH + * response per message */ + uint32_t sysflags; /* MBOX_FLAG_* bitmask named in this + * STORE (the flags being set/added/ + * removed, not the message's + * resulting flags -- store.c computes + * that) */ + char keywords[MBOX_FLAGS_MAX]; /* comma-separated keyword + * atoms named in this STORE, "" if + * none */ + + /* + * RFC 7162 SS3.1.3 UNCHANGEDSINCE store-modifier. has_unchangedsince + * distinguishes "not specified" from the legal value 0 (SS3.1.3 + * Example 8: "Use of UNCHANGEDSINCE with a modification sequence of + * 0 always fails if the metadata item exists" -- a deliberate, + * always-fails conditional test, not the same as omitting the + * modifier entirely). + */ + int has_unchangedsince; + uint64_t unchangedsince; + + /* + * RFC 9051 SS6.4.9: same UID-vs-sequence-number resolution switch as + * imsg_mbox_fetch's by_uid, for UID STORE. meta.uid (in the shared + * imsg_mbox_fetch_meta STORE echo) is already unconditionally + * populated regardless of this flag -- only listener.c's decision to + * *print* it changes based on whether the in-flight command was UID + * STORE (see struct session's cmd_by_uid in listener.c). + */ + int by_uid; +}; + +/* + * IMSG_MBOX_STORE_MODIFIED (store -> listener, zero or more, only when + * req->has_unchangedsince, before the terminal IMSG_MBOX_RESULT): one + * message whose mod-sequence exceeded the UNCHANGEDSINCE value, so the + * requested STORE operation was *not* performed for it (RFC 7162 SS3.1.3: + * "the message number (or unique identifier in the case of the UID STORE + * command) is added to the list of messages that failed the UNCHANGEDSINCE + * test"). listener.c range-compacts these into the tagged response's + * MODIFIED response code, same compaction helper SEARCH/VANISHED already + * use. + */ +struct imsg_mbox_store_modified { + uint32_t seqno; + uint32_t uid; +}; + +/* + * IMSG_MBOX_EXPUNGE (listener -> store) / IMSG_MBOX_EXPUNGED (store -> + * listener, one per removed message, streamed in the order store.c + * actually removes them) / IMSG_MBOX_RESULT (store -> listener, exactly + * once, terminal -- same generic completion signal FETCH/STORE already + * use). + * + * RFC 9051 SS6.4.3: EXPUNGE "permanently removes all messages that have + * the \Deleted flag set from the currently selected mailbox," sending one + * untagged EXPUNGE response per removed message before the tagged OK. + * SS7.5.1 (the EXPUNGE response itself): "The message sequence number for + * each successive message in the mailbox is immediately decremented by 1" + * -- so which sequence number gets reported for each removal depends on + * removal order; this server removes lowest-numbered first ("a 'lower to + * higher' server," SS7.5.1's own term), matching SS6.4.3's own worked + * example exactly (message 3, then 3 again, then 5, then 8, for original + * positions 3/4/7/11) -- see store.c's handle_mbox_expunge() for the + * compaction algorithm that produces those numbers. + * + * silent exists so CLOSE (RFC 9051 SS6.4.1: "permanently removes all + * messages that have the \Deleted flag set ... No untagged EXPUNGE + * responses are sent") can reuse this exact request/reply pair instead of + * inventing a second one -- the same ".SILENT"-suffix pattern STORE + * already uses for the same "same operation, client doesn't want the + * per-message notifications" reason. + */ +struct imsg_mbox_expunge { + int silent; /* 1 for CLOSE, 0 for a real EXPUNGE command */ + + /* + * RFC 9051 SS6.4.9's second UID command form: "the UID command takes + * an EXPUNGE command with an extra parameter that specifies a + * sequence set of UIDs to operate on... permanently removes all + * messages that have both the \Deleted flag set and a UID that is + * included in the specified sequence set... If a message either does + * not have the \Deleted flag set or has a UID that is not included in + * the specified sequence set, it is not affected." by_uid is never + * set together with silent=1 -- CLOSE has no UID-restricted form + * (there is no "UID CLOSE"), only plain EXPUNGE does. seq_lo/seq_hi/ + * lo_is_star/hi_is_star mirror imsg_mbox_fetch's own naming exactly + * (same seq-range/UID-range resolution convention throughout this + * header); meaningless when !by_uid, since a plain EXPUNGE takes no + * arguments at all (RFC 9051 SS6.4.3: "Arguments: none"). + */ + int by_uid; + uint32_t seq_lo; + uint32_t seq_hi; + int lo_is_star; + int hi_is_star; +}; + +struct imsg_mbox_expunged { + uint32_t seqno; /* the message's sequence number at the moment + * of removal, per SS7.5.1's "immediately + * decremented" rule -- NOT its UID, and NOT + * its pre-EXPUNGE sequence number */ + uint32_t uid; /* RFC 7162 addition: the same message's UID, + * needed once QRESYNC is enabled -- SS3.2.10.2 + * replaces the untagged EXPUNGE (seqno-based) + * response with VANISHED (UID-based) in that + * case, and listener.c has no other way to + * learn the removed message's UID (the index + * line is already gone by the time this imsg + * is sent -- see store.c's handle_mbox_ + * expunge()) */ +}; + +/* + * IMSG_MBOX_COPY (listener -> store, RFC 9051 SS6.4.7 COPY) / IMSG_MBOX_MOVE + * (listener -> store, SS6.4.8 MOVE) share this exact request shape -- same + * "one struct, two message types, differ only in which store.c handler + * fires" pattern imsg_mbox_expunge already established for EXPUNGE/CLOSE + * (distinguished there by .silent; here by which IMSG_MBOX_* type arrived). + * + * destname: the destination mailbox, resolved and validated by listener.c's + * copy_move_dispatch() exactly the way cmd_rename()'s oldname/newname + * already are -- mailbox_name_is_inbox()/mailbox_name_valid() first, with + * zero store round trip for a syntactically invalid name. Before flat + * multi-mailbox support (RFC 9051 SS6.3.4-SS6.3.6, docs/openimap-storage- + * backend.md item 10) this struct had no destination field at all: v1 had + * no CREATE, so the destination was *always* the same mailbox as the + * source (the currently selected mailbox, itself always INBOX) -- + * explicitly RFC-sanctioned even then (SS6.4.8: "moving a message to the + * currently selected mailbox... is allowed when copying the message to the + * currently selected mailbox is allowed"). Now that named mailboxes are + * real, a genuinely different destination is real too; store.c's handle_ + * mbox_copy()/handle_mbox_move() still fast-path the "destname names the + * mailbox already selected" case as a single-index operation identical to + * that original v1 code, and only take the new two-index cross-mailbox + * path (see those functions' own header comments, including the lock- + * ordering discussion) when it genuinely differs. + */ +struct imsg_mbox_copy { + int by_uid; + uint32_t seq_lo; + uint32_t seq_hi; + int lo_is_star; + int hi_is_star; + char destname[MBOX_NAME_MAX]; +}; + +/* + * IMSG_MBOX_COPY_MAPPING (store -> listener, zero or more, before the + * terminal IMSG_MBOX_RESULT): one message's COPYUID mapping -- RFC 9051 + * SS7.1's COPYUID response code carries "a UID set containing the UIDs of + * the message(s) in the source mailbox that were copied... followed by + * another UID set containing the UIDs assigned... in the destination + * mailbox... in the order the message(s) was copied." Streamed one pair + * per message, ascending, rather than pre-compacted into ranges here -- + * matching this header's own established "store streams raw values, + * listener compacts into ranges at formatting time" split (format_seq_ + * list() already does exactly this for ESEARCH/MODIFIED); listener.c + * accumulates src_uid/dest_uid into two parallel growable arrays and + * range-compacts each independently once the terminal reply arrives. + * + * Used for both COPY and MOVE. For MOVE, listener.c also buffers each + * IMSG_MBOX_EXPUNGED that arrives during the same round trip (rather than + * writing it to the client immediately, which is what happens for a real + * EXPUNGE/CLOSE) and flushes both buffers, in order, only once the + * terminal reply arrives -- COPYUID first, then EXPUNGE/VANISHED -- per + * SS6.4.8: "servers are also REQUIRED to send the COPYUID response code in + * an untagged OK before sending EXPUNGE". This mirrors session_handle_ + * mbox_selected()'s existing vanished_ranges/qresync_fetches dual-buffer- + * then-flush-in-fixed-order pattern for a QRESYNC SELECT resync -- same + * underlying problem (control final wire order across two streamed + * sub-types that arrive interleaved with other traffic), same solution. + */ +struct imsg_mbox_copy_mapping { + uint32_t src_uid; + uint32_t dest_uid; +}; + +/* + * IMSG_MBOX_APPEND (listener -> store) / IMSG_MBOX_APPENDED (store -> + * listener, exactly once). APPEND handles exactly one message per + * request in v1 (no [MULTIAPPEND]), so unlike FETCH/STORE/EXPUNGE there's + * no per-message streaming reply -- one request, one reply. + * + * RFC 9051 SS6.3.12: `append = "APPEND" SP mailbox [SP flag-list] [SP + * date-time] SP literal`. The literal -- the message body itself -- is + * carried as variable-length trailing data appended directly after this + * fixed struct within the SAME imsg, not as a separate message and not + * fd-passed. Verified directly against the real imsg.c/imsg-buffer.c this + * session: imsg_get_data() requires an *exact* length match (its own + * source: `if (ibuf_size(imsg->buf) != len) { errno = EBADMSG; return + * (-1); }`), so it cannot be used to read a fixed header out of a longer + * imsg. imsg_get_buf() is the sequential-read alternative (no length + * check, just `ibuf_get()`, which advances the ibuf's internal read + * position); imsg_get_len() reflects bytes *remaining*, not total size, + * because it calls ibuf_size(), which is literally `wpos - rpos`. So + * store.c's handle_mbox_append() reads this struct with imsg_get_buf(), + * then treats whatever imsg_get_len() reports afterward as the message + * body length and reads that with a second imsg_get_buf() call. + * + * This one-message-one-imsg design only works because the message is + * capped at APPEND_LITERAL_MAX (listener.c) to fit comfortably under + * MAX_IMSGSIZE (16384, imsg.h) alongside this struct's own ~550 bytes -- + * see APPEND_LITERAL_MAX's comment in listener.c for the exact arithmetic + * and the imsg_create() source check ("datalen += IMSG_HEADER_SIZE; if + * (datalen > imsgbuf->maxsize) ... return NULL") it's based on. A message + * larger than that needs real fd-passing -- the same mechanism BODY[] + * FETCH still needs and doesn't have -- not implemented this pass; + * listener.c rejects an oversized literal announcement with a plain NO + * (RFC 9051 defines no response code for a size cap) before ever reading + * it, rather than truncating or crashing. + */ +struct imsg_mbox_append { + char mailbox[MBOX_NAME_MAX]; + uint32_t sysflags; /* MBOX_FLAG_* bitmask -- an omitted or + * empty "()" flag-list both mean 0, + * per SS6.3.12: "otherwise the flag + * list of the resulting message is + * set to 'empty' by default" */ + char keywords[MBOX_FLAGS_MAX]; /* comma-separated, same + * convention as imsg_mbox_store's */ + int has_date; /* 0 -- SS6.3.12: "otherwise the + * internal date... is set to the + * current date and time" -- store.c + * uses time(NULL) at delivery time + * in that case */ + int64_t date; /* Unix timestamp; meaningful only if + * has_date */ + uint32_t msglen; /* length of the trailing message + * bytes -- redundant with what + * imsg_get_len() reports after the + * header is read, kept anyway as an + * explicit value store.c cross-checks + * against that, rather than trusting + * a single source for something this + * consequential (a mismatch likely + * means a build-time struct-layout + * skew between listener and store, + * or a truncated imsg). */ +}; + +struct imsg_mbox_appended { + int ok; + int no_such_mailbox; /* 1 distinguishes "not INBOX" -- + * listener.c must send the tagged NO + * with a "[TRYCREATE]" prefix per + * SS6.3.12 -- from any other failure + * (plain NO, no response code) */ + uint32_t uidvalidity; + uint32_t uid; /* the appended message's own UID -- + * together with uidvalidity, this is + * SS7.1's APPENDUID response code */ + uint32_t exists; /* mailbox's new total message count, + * so listener.c can send SS6.3.12's + * "SHOULD notify the client + * immediately via an untagged EXISTS + * response" -- sent only if this + * session currently has the mailbox + * selected; see cmd_append()'s and + * session_handle_mbox_appended()'s + * comments */ +}; + +/* + * IMSG_MBOX_SEARCH (listener -> store) / IMSG_MBOX_SEARCH_MATCH (store -> + * listener, one per matching message, streamed in ascending sequence + * order -- same per-message streaming shape as IMSG_MBOX_FETCH_META and + * IMSG_MBOX_EXPUNGED) / IMSG_MBOX_RESULT (store -> listener, exactly + * once, terminal -- the same generic completion signal FETCH/STORE/ + * EXPUNGE/APPEND already reuse; `count` is the number of matches + * streamed, which listener.c uses directly as COUNT if requested). + * + * RFC 9051 SS6.4.4 SEARCH's `search-key` grammar nests arbitrarily (NOT + * wraps one key, OR takes two, a parenthesized list ANDs N of them), so + * the parsed criteria can't be a single fixed-size struct the way + * STORE's flag-list or FETCH's fetch-att bitmask can. Instead, + * listener.c's parse_search_key()/parse_search_key_list() compile the + * whole search-program into a flat postfix (reverse Polish) array of + * struct search_node, sent as variable-length trailing data on this + * imsg after a small fixed header -- the same imsg_get_buf()/imsg_get_ + * len() wire technique IMSG_MBOX_APPEND already established and had + * verified against the real imsg-buffer.c this project (see that + * struct's own comment above): the header's nnodes times sizeof(struct + * search_node) tells store_dispatch() exactly how many trailing bytes + * to expect. SEARCH_PROGRAM_MAX_NODES bounds both the array's wire size + * (comfortably under imsg's MAX_IMSGSIZE even with every node using its + * full fixed-size keyword field) and store.c's postfix-evaluation stack + * depth -- generous for a hand-typed v1 query, not a hard IMAP-mandated + * ceiling; listener.c rejects a query that compiles to more nodes than + * this with a plain BAD ("search criteria too complex"), the same "cap + * and refuse cleanly" choice APPEND makes for an oversized message. + * + * v1 scope, matching this project's smaller-feature-set design + * philosophy (openimap-privsep-design.md): search keys that need actual + * message content or headers -- BCC/BODY/CC/FROM/HEADER/SENTBEFORE/ + * SENTON/SENTSINCE/SUBJECT/TEXT/TO -- are rejected by listener.c's + * parser with a specific NO before this imsg is ever built, the same + * "recognized, can't do it right now" category FETCH's BODY[] rejection + * already uses; store.c never sees a SEARCH_OP_* for any of them because + * none exists. The "UID SEARCH" command-level wrapper (RFC 9051 + * SS6.4.9's generic UID prefix, which would make ESEARCH's data refer to + * UIDs instead of sequence numbers) is also out of scope this pass -- + * IMSG_MBOX_SEARCH_MATCH always carries both seqno and uid, but + * listener.c's v1 ESEARCH formatter only ever uses seqno. The "UID + * " *search key* (filtering by UID range, one of many + * possible criteria within an ordinary sequence-number-space SEARCH) is + * unrelated to that command-level wrapper and is fully supported + * (SEARCH_OP_UIDSET) -- SS6.4.4's own example list includes it as a + * plain search key, not as a UID-command variant. + * + * Exactly one sequence-set range per SEQSET/UIDSET node (no internal + * comma-separated list) -- the same v1 restriction already established + * for FETCH/STORE's own sequence-set argument (see imsg_mbox_fetch's + * comment above); listener.c rejects a comma inside a SEARCH sequence- + * set token before ever building a node for it. + */ +#define SEARCH_PROGRAM_MAX_NODES 100 +#define SEARCH_KEYWORD_MAX 64 /* one flag-keyword atom, not + * a list -- MBOX_FLAGS_MAX is + * sized for STORE's comma- + * joined keyword *list* and + * would be the wrong constant + * to reuse here */ + +#define SEARCH_OP_ALL 0 /* leaf: matches every message */ +#define SEARCH_OP_ANSWERED 1 +#define SEARCH_OP_UNANSWERED 2 +#define SEARCH_OP_DELETED 3 +#define SEARCH_OP_UNDELETED 4 +#define SEARCH_OP_DRAFT 5 +#define SEARCH_OP_UNDRAFT 6 +#define SEARCH_OP_FLAGGED 7 +#define SEARCH_OP_UNFLAGGED 8 +#define SEARCH_OP_SEEN 9 +#define SEARCH_OP_UNSEEN 10 +#define SEARCH_OP_KEYWORD 11 /* operand: keyword */ +#define SEARCH_OP_UNKEYWORD 12 /* operand: keyword */ +#define SEARCH_OP_BEFORE 13 /* operand: num (UTC day-start epoch) */ +#define SEARCH_OP_ON 14 /* operand: num (UTC day-start epoch) */ +#define SEARCH_OP_SINCE 15 /* operand: num (UTC day-start epoch) */ +#define SEARCH_OP_LARGER 16 /* operand: num (octets) */ +#define SEARCH_OP_SMALLER 17 /* operand: num (octets) */ +#define SEARCH_OP_SEQSET 18 /* operand: seq_lo/seq_hi/lo_is_star/ + * hi_is_star, matched against sequence + * number */ +#define SEARCH_OP_UIDSET 19 /* operand: seq_lo/seq_hi/lo_is_star/ + * hi_is_star, matched against UID */ +#define SEARCH_OP_AND 20 /* postfix binary combinator */ +#define SEARCH_OP_OR 21 /* postfix binary combinator */ +#define SEARCH_OP_NOT 22 /* postfix unary combinator */ +#define SEARCH_OP_MODSEQ 23 /* RFC 7162 SS3.1.5 -- operand: num + * (mod-sequence-valzer threshold, + * message matches if its own + * mod-sequence is >= this). The + * optional / prefix (SS3.1.5: "If the server + * doesn't store separate mod-sequences + * for different metadata items, it + * MUST ignore and ") is parsed by listener.c's + * parse_search_key() for syntax only + * and never reaches this struct -- v1's + * per-message mod-sequence (see store.c's + * struct mbox_index comment) is exactly + * the "doesn't store separate mod- + * sequences per metadata item" case the + * RFC anticipates, so there's nothing + * for store.c to narrow by */ + +struct search_node { + int op; /* SEARCH_OP_* above */ + int64_t num; /* BEFORE/ON/SINCE/LARGER/SMALLER/MODSEQ + * operand */ + uint32_t seq_lo; /* SEQSET/UIDSET operand -- see + * imsg_mbox_fetch's seq_lo/seq_hi/ + * lo_is_star/hi_is_star comment for + * the "*" resolution convention this + * mirrors; store.c resolves it here + * against its own live idx.nlines + * (SEQSET) or highest in-use UID + * (UIDSET) up front, once, before + * scanning messages */ + uint32_t seq_hi; + int lo_is_star; + int hi_is_star; + char keyword[SEARCH_KEYWORD_MAX]; /* KEYWORD/UNKEYWORD + * operand */ +}; + +struct imsg_mbox_search { + uint32_t nnodes; /* number of struct search_node entries + * in this imsg's trailing data */ +}; + +struct imsg_mbox_search_match { + uint32_t seqno; + uint32_t uid; + uint64_t modseq; /* RFC 7162 SS3.1.6: "If a client + * specifies a MODSEQ criterion in a + * SEARCH ... command and the server + * returns a non-empty SEARCH result, + * the server MUST also append ... the + * highest mod-sequence for all messages + * being returned." Always populated + * (cheap, store.c already has it + * per-message) -- listener.c only + * tracks the running max across + * matches when the client's search + * program actually used SEARCH_OP_ + * MODSEQ, same "store always computes, + * listener decides whether to use it" + * split as imsg_mbox_fetch_meta.modseq */ +}; + +/* + * RFC 9051 SS6.3.13 (IDLE). IMSG_MBOX_IDLE_REFRESH (listener -> store, no + * payload) / IMSG_MBOX_IDLE_UID (store -> listener, one per currently- + * existing message, in ascending UID order) / IMSG_MBOX_IDLE_REFRESHED + * (store -> listener, exactly once, terminal). + * + * Used two ways by listener.c: once, synchronously after "+ idling" is + * sent, purely to seed s->idle_known_uids with a baseline (no diffing or + * pushing yet, since there's nothing to diff against the first time); and + * again, any time session_notify_idle_peers() (triggered by another same- + * uid session's successful EXPUNGE/UID EXPUNGE/CLOSE-with-removal/APPEND/ + * MOVE) asks an idling session to recheck its mailbox. Both cases reuse + * the identical request/reply shape -- only what listener.c does with the + * result differs. store.c doesn't need to know or care which case this + * is; it just reports current, authoritative state, via the same refresh_ + * index() helper handle_mbox_select() itself uses (including that + * function's new/ directory scan for undiscovered message files), so an + * idle-refresh is exactly as fresh as a fresh SELECT would be -- including + * picking up mail placed directly in new/ by something other than this + * daemon's own APPEND, if an idle-refresh happens to run after it landed. + * + * v1 scope decision (confirmed with the user): EXISTS and EXPUNGE only. + * RFC 9051 SS6.3.13 says the server is merely "free to" send EXISTS/ + * EXPUNGE/FETCH while idling, none of the three is mandatory -- pushing + * unsolicited FETCH (e.g. for a flag changed by another session) is + * deferred to a follow-up pass, since it needs the same per-message + * mod-sequence diffing this server already has for QRESYNC, just re- + * triggered the same way, and RFC 9051 doesn't require it. That's why + * this reply carries only the UID list (enough for listener.c to compute + * seqno-based EXPUNGE lines and an EXISTS count) and not per-message + * flags/modseq. + */ +struct imsg_mbox_idle_uid { + uint32_t uid; +}; + +struct imsg_mbox_idle_refreshed { + int ok; + uint32_t exists; + uint32_t uidvalidity; + uint32_t uidnext; + uint64_t highestmodseq; +}; + +/* + * RFC 9051 SS6.3.4/SS6.3.5 (CREATE/DELETE) and SS6.3.9 (LIST), this pass -- + * see docs/openimap-storage-backend.md's "Open items" #10 for the full + * design (flat, non-nested mailboxes as sibling subdirectories of the + * session's own per-user maildir root; CREATE/DELETE/RENAME all reply with + * the existing struct imsg_mbox_result, only "ok" meaningful). listener.c + * has already validated the name syntactically (non-empty, not "INBOX", + * no hierarchy-delimiter character, within MBOX_NAME_MAX) before either of + * these is ever sent -- store.c re-validates independently rather than + * trusting that, the same defense-in-depth every other mailbox-name- + * carrying imsg in this file already gets across the privsep boundary. + */ +struct imsg_mbox_create { + char mailbox[MBOX_NAME_MAX]; +}; + +struct imsg_mbox_delete { + char mailbox[MBOX_NAME_MAX]; +}; + +/* + * RFC 9051 SS6.3.6 (RENAME). oldname/newname, not "mailbox"/"destination", + * to avoid any confusion with imsg_mbox_copy's identically-purposed but + * differently-named destination field -- RENAME's two names are peers + * (both mailbox names in the same flat namespace), not a source-message- + * range-plus-destination-mailbox pairing the way COPY/MOVE's is. + */ +struct imsg_mbox_rename { + char oldname[MBOX_NAME_MAX]; + char newname[MBOX_NAME_MAX]; +}; + +/* + * RFC 9051 SS6.3.9 (LIST). One IMSG_MBOX_LIST_ITEM per real, on-disk + * mailbox subdirectory store.c finds under the session's maildir root + * (INBOX itself excluded -- see the IMSG_MBOX_LIST_ITEM enum comment + * above), in no particular guaranteed order; listener.c does its own + * wildcard matching (list_pattern_match()) against each name plus the + * literal "INBOX" it already handles locally. Terminal reply reuses struct + * imsg_mbox_result ("count" = number of names streamed, "ok" = 0 only on a + * real I/O error opening the maildir root itself, not on finding zero + * mailboxes -- an account with no named mailboxes yet is not a failure). + */ +struct imsg_mbox_list_item { + char mailbox[MBOX_NAME_MAX]; +}; + +/* main.c */ +const char *log_procname(enum openimap_proc_type); + +/* parse.y */ +int config_load(const char *, struct openimap_config *); +int cmdline_symset(char *); + +/* parent.c */ +/* + * First argument is the config file path, threaded through from main.c's + * "conffile" local (default "/etc/imapd.conf" or -f's argument) -- added + * for SIGHUP reload support: parent.c's sighup_handler() needs to re-run + * config_load() against the exact same path main() originally used, and + * had no way to reach it before (main()'s "conffile" was a local, never + * passed down; parent.c only ever received the already-parsed struct). + */ +__dead void parent_main(const char *, int, char *[], + struct openimap_config *); + +/* + * listener.c / auth.c / store.c take no struct openimap_config * -- + * none of the three re-exec'd child roles read imapd.conf, and as of + * this pass none of them receive it as a stub parameter either (an + * earlier draft did, unused/misleadingly for listener and store, and + * actively wrong for auth -- see the imsg_listener_init/imsg_auth_init + * comment above). Each gets exactly the config it needs over its fd-3 + * channel from parent instead: IMSG_LISTENER_INIT, IMSG_AUTH_INIT, + * IMSG_STORE_INIT respectively. + */ +__dead void listener_main(void); +__dead void auth_main(void); +__dead void store_main(void); + +/* + * imsg helpers shared by all roles -- see each role's setup_proc(). The + * "handler" passed to imsgev_init() is the real libevent callback: it's + * expected to do its own imsgbuf_read()/imsg_get() loop, matching + * parent.c's parent_dispatch_child() as the reference shape. This mirrors + * the imsg_event_add()-style pattern common to OpenBSD privsep daemons + * (relayd, httpd) -- not itself quoted from any uploaded source file this + * session, flagged as a standard-pattern choice, not a sourced one. + */ +void imsgev_init(struct imsgev *, int, + void (*)(int, short, void *), void *); +void imsgev_init_from_ibuf(struct imsgev *, struct imsgbuf *, + void (*)(int, short, void *), void *); +void imsgev_add(struct imsgev *); + +/* + * Boot-time setup-loop helpers, sourced against smtpd.c's setup_proc() + * shape (see openimap-privsep-design.md's "Peer-wiring handshake" + * section): a freshly exec'd child blocks reading fd 3 for zero or more + * IMSG_SETUP_PEER messages, then an IMSG_SETUP_DONE, and acks. v1's + * boot-time wiring is 1:1 (listener gets exactly one peer, auth gets + * exactly one) so setup_recv_one_peer() covers both; store's later, + * per-session peer wiring happens post-boot, through the normal event + * loop, not through these blocking helpers -- see store.c. + */ +int setup_recv_one_peer(struct imsgbuf *); +void setup_recv_done_and_ack(struct imsgbuf *); + +#endif /* IMAPD_H */ blob - /dev/null blob + 2afc427465067cbf551fb84c000c437ffdacda0a (mode 644) --- /dev/null +++ src/imsgev.c @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2026 David Williams + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +/* + * Small shared wrapper around imsgbuf + event(3), used identically by + * parent.c, listener.c, auth.c, and store.c. Not itself sourced from any + * uploaded file this session -- see the comment on struct imsgev in + * openimap.h. + * + * API NAMES: checked against the real src/imsg.h this session (see + * parent.c's header comment) -- imsgbuf_set_maxsize() and + * imsgbuf_queuelen() both match the real header exactly. + * MAX_IMSGSIZE itself *is* sourced (imsg_init(3), cited throughout + * openimap-privsep-design.md). + */ + +#include + +#include +#include +#include + +#include "imapd.h" +#include "log.h" + +void +imsgev_init(struct imsgev *iev, int fd, void (*handler)(int, short, void *), + void *data) +{ + if (imsgbuf_init(&iev->ibuf, fd) == -1) + fatal("imsgbuf_init"); + imsgbuf_set_maxsize(&iev->ibuf, MAX_IMSGSIZE); + + /* + * Every channel wrapped by struct imsgev either sends or receives + * fd-passed messages somewhere in its lifetime (listening sockets, + * SETUP_PEER peer fds, later store-peer fds) -- imsgbuf_allow_fdpass() + * is real (confirmed against src/imsg.h) and was missing from this + * skeleton entirely until this pass. Unconditional here rather than + * per-caller since there's no channel in this design that *never* + * passes an fd. + */ + imsgbuf_allow_fdpass(&iev->ibuf); + + iev->handler = handler; + iev->data = data != NULL ? data : iev; + iev->events = EV_READ; + + event_set(&iev->ev, fd, iev->events, iev->handler, iev->data); + event_add(&iev->ev, NULL); +} + +/* + * Like imsgev_init(), but for a channel whose struct imsgbuf has already + * been imsgbuf_init()'d and read from -- e.g. a fd-3 boot channel that a + * child process drained synchronously (before event_init() was even + * callable) and now wants to hand off to the event loop for the rest of + * its life. Takes ownership of *ibuf by copying it into iev->ibuf, NOT by + * calling imsgbuf_init() again. + * + * This distinction matters: unlike struct imsgev (which embeds a struct + * event -- see the struct store_child comment in parent.c for why THAT + * must never be copied once registered with libevent), struct imsgbuf + * itself holds no libevent registration and its only heap state (`w`) is + * a pointer, so copying it by value is safe and standard -- but calling + * imsgbuf_init() a *second* time on the same fd would silently discard + * any bytes imsgbuf_read() had already buffered beyond the messages the + * caller happened to have consumed so far (parent doesn't wait for acks + * between sends, so more than one message can already be sitting in the + * kernel socket buffer -- and possibly already pulled into ibuf's + * internal state -- by the time a child gets around to reading it). + * imsgev_init() alone would have that bug; this function exists so + * listener.c's fd-3 channel (parent) doesn't hit it. See listener.c's + * listener_main() for the caller. + */ +void +imsgev_init_from_ibuf(struct imsgev *iev, struct imsgbuf *ibuf, + void (*handler)(int, short, void *), void *data) +{ + iev->ibuf = *ibuf; + + iev->handler = handler; + iev->data = data != NULL ? data : iev; + iev->events = EV_READ; + + event_set(&iev->ev, iev->ibuf.fd, iev->events, iev->handler, + iev->data); + event_add(&iev->ev, NULL); +} + +/* + * Re-arm after composing an outgoing message: watch EV_WRITE too if the + * imsgbuf has queued, unflushed output. Callers' dispatch handlers should + * call this at the end of any code path that calls imsg_compose(). + */ +void +imsgev_add(struct imsgev *iev) +{ + iev->events = EV_READ; + if (imsgbuf_queuelen(&iev->ibuf) > 0) + iev->events |= EV_WRITE; + + event_del(&iev->ev); + event_set(&iev->ev, iev->ibuf.fd, iev->events, iev->handler, + iev->data); + event_add(&iev->ev, NULL); +} + +/* + * Blocks (no event loop running yet) for exactly one IMSG_SETUP_PEER on + * ibuf3 (already imsgbuf_init()'d on fd 3 by the caller) and returns the + * fd-passed peer fd. fatalx()s on anything else -- matches smtpd's + * setup_proc() treating an unexpected message during setup as fatal + * ("bad imsg %d"). + * + * Real deadlock caught on first real-hardware run (OpenBSD, not this + * sandbox): parent.c sends IMSG_SETUP_PEER and IMSG_SETUP_DONE back to + * back on the same socketpair fd (setup_peer_send() then + * setup_done_send(), both flushed immediately, no wait in between). On a + * SOCK_STREAM socketpair the kernel is free to coalesce both sends into + * one readable chunk -- confirmed via ktrace(1) on the live hang: a + * single recvmsg() here returned 32 bytes (both 16-byte imsg headers) + * instead of 16. imsg_get() correctly peels off just the first message + * and leaves the second one fully buffered in ibuf3's own userspace + * state -- but the *caller* (setup_recv_done_and_ack(), immediately + * next) used to call imsgbuf_read() unconditionally before ever checking + * whether a complete message was already sitting there, so it issued a + * second blocking recvmsg() for bytes that were never coming, while + * parent sat blocked reading this process's now-overdue SETUP_DONE ack. + * Fixed by checking imsg_get() FIRST in both this loop and + * setup_recv_done_and_ack()'s below -- imsgbuf_read() (an actual + * blocking syscall) only happens when nothing is already buffered. + */ +int +setup_recv_one_peer(struct imsgbuf *ibuf3) +{ + struct imsg imsg; + ssize_t n; + int fd; + + for (;;) { + if ((n = imsg_get(ibuf3, &imsg)) == -1) + fatal("imsg_get"); + if (n != 0) + break; + if ((n = imsgbuf_read(ibuf3)) == -1) + fatal("imsgbuf_read"); + if (n == 0) + fatalx("setup_recv_one_peer: parent closed channel"); + } + + if (imsg_get_type(&imsg) != IMSG_SETUP_PEER) + fatalx("setup_recv_one_peer: expected IMSG_SETUP_PEER, got %d", + imsg_get_type(&imsg)); + + if ((fd = imsg_get_fd(&imsg)) == -1) + fatalx("setup_recv_one_peer: IMSG_SETUP_PEER carried no fd"); + + imsg_free(&imsg); + return (fd); +} + +/* + * Blocks for IMSG_SETUP_DONE on ibuf3, then sends one back as an ack. + * Sourced against smtpd.c's setup_proc() loop (IMSG_SETUP_DONE case sets + * a "done" flag and exits the loop; the ack-back send is the same + * imsg_compose(ibuf, IMSG_SETUP_DONE, 0, 0, -1, NULL, 0) shape quoted in + * the design doc from setup_proc()'s tail). + * + * imsg_get()-before-imsgbuf_read() ordering: see setup_recv_one_peer()'s + * header comment above -- this is the specific call site where the real + * deadlock happened (IMSG_SETUP_DONE arrives already-buffered, coalesced + * with the preceding IMSG_SETUP_PEER read by the caller). + */ +void +setup_recv_done_and_ack(struct imsgbuf *ibuf3) +{ + struct imsg imsg; + ssize_t n; + + for (;;) { + if ((n = imsg_get(ibuf3, &imsg)) == -1) + fatal("imsg_get"); + if (n != 0) + break; + if ((n = imsgbuf_read(ibuf3)) == -1) + fatal("imsgbuf_read"); + if (n == 0) + fatalx("setup_recv_done_and_ack: parent closed channel"); + } + + if (imsg_get_type(&imsg) != IMSG_SETUP_DONE) + fatalx("setup_recv_done_and_ack: expected IMSG_SETUP_DONE, " + "got %d", imsg_get_type(&imsg)); + imsg_free(&imsg); + + if (imsg_compose(ibuf3, IMSG_SETUP_DONE, 0, 0, -1, NULL, 0) == -1) + fatal("imsg_compose IMSG_SETUP_DONE"); + if (imsgbuf_flush(ibuf3) == -1) + fatal("imsgbuf_flush"); +} blob - /dev/null blob + a42a003990cf0f34ffcf910cd350f587df7783e9 (mode 644) --- /dev/null +++ src/listener.c @@ -0,0 +1,10619 @@ +/* + * Copyright (c) 2026 David Williams + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ + +/* + * listener.c -- protocol/network process. Implements the "listener" + * section of openimap-privsep-design.md: owns client TCP sockets, runs + * the IMAP command parser/dispatch table for the "any state" and "not + * authenticated state" command sets, and now terminates TLS for real + * (implicit-TLS port 993 per RFC 8314, and STARTTLS on the cleartext + * port) via libtls -- tls_server()/tls_configure() built once at boot + * from cert/key bytes parent sends (IMSG_TLS_CERT/IMSG_TLS_KEY), then + * tls_accept_socket()+tls_handshake() per connection, sourced directly + * against src/lib/libtls/tls.h and httpd's server_tls_init()/server_ + * tls_handshake() (openbsd_source/src/usr.sbin/httpd/server.c). AUTHENTICATE + * PLAIN is now real too: cmd_authenticate() handles both the inline- + * initial-response and continuation-request forms (RFC 9051 SS6.2.2), + * decodes via b64_pton() (, sourced against openbsd_source/src/ + * lib/libc/net/base64.c), splits per RFC 4616's authzid/authcid/passwd + * framing (fetched directly from https://www.rfc-editor.org/rfc/rfc4616.txt + * this session -- not present in research/ or openbsd_source/), and sends + * IMSG_AUTH_REQUEST to auth over iev_auth; the tagged OK/NO back to the + * client is sent from whichever of listener_dispatch_auth()'s IMSG_AUTH_ + * RESULT or listener_dispatch_parent()'s IMSG_SETUP_PEER/IMSG_STORE_FORK + * cases actually resolves the async round trip. A session can now + * genuinely reach SESSION_AUTHENTICATED. imap_cmds[] also now has a real, + * correctly state-gated entry for every command-auth (RFC 9051 SS6.3) and + * command-select (SS6.4) command -- ENABLE, SELECT, LIST, FETCH, STORE, + * EXPUNGE, CLOSE, UNSELECT, APPEND, and SEARCH are fully implemented + * (cmd_enable()/cmd_select()/cmd_list()/cmd_fetch()/cmd_store_cmd()/cmd_ + * expunge()/cmd_close()/cmd_unselect()/cmd_append()/cmd_search(), FETCH + * message-metadata-only: FLAGS/UID/INTERNALDATE/RFC822.SIZE, not BODY[] + * -- see cmd_fetch()'s own comment for why). STORE reuses FETCH's + * IMSG_MBOX_FETCH_META/IMSG_MBOX_RESULT reply pair, since RFC 9051 + * SS6.4.6 says STORE's only response is itself an untagged FETCH; CLOSE + * reuses EXPUNGE's IMSG_MBOX_EXPUNGE/IMSG_MBOX_EXPUNGED/IMSG_MBOX_RESULT + * wholesale (silent=1, no untagged EXPUNGE responses, and a different + * post-completion state -- see session_request_expunge()/session_handle_ + * mbox_result()); UNSELECT needs no store round trip at all, since + * store.c holds no per-selection state to free. LIST (SS6.3.9, basic + * syntax only -- extended selection/return options get a flagged NO) + * needs no store round trip either: v1 has no CREATE, so INBOX's + * existence is never in question, making LIST pure string/wildcard + * matching against the fixed name "INBOX" (list_pattern_match()). + * APPEND (SS6.3.12) required this file's first real IMAP literal + * ({n}/{n+}) support -- a raw-byte read phase (s->literal_pending, in + * session_dispatch_client()'s read loop, intercepted before the CRLF + * line parser since literal bytes can contain embedded CRLFs) followed + * by a store round trip in the new SESSION_APPENDING state; message + * bytes travel to store.c as variable-length trailing data on a single + * imsg (imsg_get_buf()/imsg_get_len(), verified against the real imsg- + * buffer.c this session), capped at APPEND_LITERAL_MAX (12000 bytes) to + * stay under imsg's MAX_IMSGSIZE -- larger messages need real fd-passing, + * not implemented this pass, same deferral as BODY[] FETCH. SEARCH + * (SS6.4.4, ESEARCH responses per SS7.3.4) compiles the flag/date/size/ + * sequence-number/UID-range/NOT/OR/parenthesized-list search-key grammar + * into a flat postfix bytecode (parse_search_key()/parse_search_key_ + * list()) sent to store.c as variable-length trailing data on IMSG_MBOX_ + * SEARCH (the same imsg technique APPEND established), which streams + * back matching sequence numbers via IMSG_MBOX_SEARCH_MATCH for session_ + * finish_search() to assemble into MIN/MAX/ALL/COUNT per RFC 9051's own + * worked examples; content-and-header-based search keys (BCC/BODY/CC/ + * FROM/HEADER/SENTBEFORE/SENTON/SENTSINCE/SUBJECT/TEXT/TO), the SAVE/"$" + * result variable, and the "UID SEARCH" command wrapper are all + * deliberately out of scope this pass -- see imapd.h's imsg_mbox_ + * search comment. Everything else in those two command sets still + * replies NO via stub_not_implemented() pending store.c's still- + * undesigned IMSG_MBOX_* wire protocol for that operation. A session can + * now genuinely reach SESSION_SELECTED, issue a LIST from either + * Authenticated or Selected, and round-trip a FETCH, STORE, EXPUNGE, + * CLOSE, UNSELECT, APPEND, or SEARCH. + * + * The boot-time setup handshake, receiving the listening socket fds + + * TLS cert/key from parent, the per-session table, requesting + wiring + * a per-session store child on successful auth (IMSG_STORE_FORK / + * IMSG_SETUP_PEER / IMSG_SETUP_DONE from parent, demuxed by session_id), + * its own privilege drop, and full session teardown (including + * notifying a wired store child via IMSG_STORE_SHUTDOWN, and closing + * a TLS session correctly -- see session_teardown()'s comment on why + * tls_close() sometimes needs a manual close(2) fallback and sometimes + * doesn't) are all implemented for real too. + * + * Two distinct persistent channels exist here, fixed this pass (see the + * "channel-identity gap" note that used to be here as a flagged TODO): + * - iev_auth: the boot-time SETUP_PEER-wired channel to the AUTH + * process (peer_fd below). Carries IMSG_AUTH_REQUEST out / + * IMSG_AUTH_RESULT in. + * - iev_parent: this process's own fd-3 channel back to PARENT, kept + * alive for the process's whole lifetime (parent never closes its + * end -- "parent isn't on this path once wiring completes" only + * applies to STORE, not to listener/auth themselves). Carries + * IMSG_STORE_FORK out, and IMSG_SETUP_PEER (a new store child's + * peer fd, demuxed by session_id via imsg_get_id()) / IMSG_STORE_FORK + * (parent replying with a *failure*) in. + * + * An earlier draft of this file used ONE struct imsgev (also named + * iev_parent) fed from peer_fd -- i.e. it was actually the AUTH channel + * despite the name -- and never turned fd 3 itself into a persistent, + * event-driven channel at all past the synchronous boot-time drain loop. + * That meant IMSG_STORE_FORK was being sent to auth, not parent, and + * nothing ever read fd 3 again after boot. Fixed below by keeping fd 3's + * already-populated struct imsgbuf alive across the transition into the + * event loop (imsgev_init_from_ibuf() in imsgev.c) instead of a second, + * fresh imsgbuf_init() on the same fd, which would have silently dropped + * any bytes already buffered from parent (parent doesn't wait for acks + * between sends, so more than one message can already be in flight by + * the time this process gets around to reading it). + * + * API NAMES: checked against the real src/imsg.h this session -- see + * parent.c's header comment for the full verification note. + */ + +#include +#include +#include + +#include + +/* + * (below, for b64_pton()) uses "struct sockaddr_in", + * "struct in_addr", and "struct in6_addr" without defining them itself -- + * confirmed against openbsd_source/src/include/resolv.h, which only + * includes , , and . + * must come first, matching the ordering smtpd's util.c and httpd's + * server_http.c both use for the same pairing. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imapd.h" +#include "log.h" + +enum session_state { + SESSION_NOT_AUTH, + SESSION_AUTHENTICATING, /* IMSG_AUTH_REQUEST sent, awaiting reply */ + SESSION_STORE_PENDING, /* IMSG_STORE_FORK sent, awaiting peer */ + SESSION_AUTHENTICATED, + SESSION_SELECTING, /* IMSG_MBOX_SELECT sent, awaiting reply -- + * see cmd_select()/session_store_dispatch() */ + SESSION_SELECTED, + SESSION_FETCHING, /* IMSG_MBOX_FETCH sent, awaiting the + * IMSG_MBOX_FETCH_META stream + terminal + * IMSG_MBOX_RESULT -- see cmd_fetch()/ + * session_handle_mbox_result() */ + SESSION_STORING, /* IMSG_MBOX_STORE sent, awaiting the same + * IMSG_MBOX_FETCH_META stream + terminal + * IMSG_MBOX_RESULT shape SESSION_FETCHING + * uses -- see cmd_store_cmd()'s comment on + * why STORE reuses FETCH's reply types */ + SESSION_EXPUNGING, /* IMSG_MBOX_EXPUNGE sent (by EXPUNGE itself, + * or by CLOSE with silent=1), awaiting the + * IMSG_MBOX_EXPUNGED stream + terminal + * IMSG_MBOX_RESULT -- see cmd_expunge()/ + * cmd_close()/session_handle_mbox_result() */ + SESSION_APPENDING, /* IMSG_MBOX_APPEND sent, awaiting the single + * terminal IMSG_MBOX_APPENDED reply -- see + * cmd_append()/session_finish_append()/ + * session_handle_mbox_appended(). Distinct + * from the *client-literal-read* phase that + * precedes it (s->literal_pending -- see that + * field's comment): this state only covers + * the store round trip, once the full + * message has already been read off the + * wire. */ + SESSION_SEARCHING, /* IMSG_MBOX_SEARCH sent, awaiting the + * IMSG_MBOX_SEARCH_MATCH stream + terminal + * IMSG_MBOX_RESULT -- see cmd_search()/ + * session_finish_search(). Same per-match- + * stream-then-terminal-result reply shape as + * SESSION_FETCHING/STORING/EXPUNGING, so it's + * handled by the same session_handle_mbox_ + * result() entry point, which branches to + * session_finish_search() first. */ + SESSION_STATUSING, /* IMSG_MBOX_STATUS sent, awaiting the single + * terminal IMSG_MBOX_STATUS_RESULT reply -- + * see cmd_status()/session_handle_mbox_ + * status_result(). Same single-request/single- + * reply shape as SESSION_SELECTING, but + * (unlike SELECT) never changes s->state's + * SELECTED-ness -- STATUS "does not change the + * currently selected mailbox" (RFC 9051 + * SS6.3.11) -- so s->status_prev_state records + * whichever ST_AUTH state was current when + * cmd_status() was called, for session_handle_ + * mbox_status_result() to restore. */ + SESSION_COPYING, /* IMSG_MBOX_COPY or IMSG_MBOX_MOVE sent + * (s->cmd_is_move says which), awaiting the + * IMSG_MBOX_COPY_MAPPING stream (plus, for a + * MOVE, an interleaved IMSG_MBOX_EXPUNGED + * stream -- see s->move_expunged's comment) + + * terminal IMSG_MBOX_RESULT -- see cmd_copy()/ + * cmd_move()/session_finish_copy_or_move(). + * Same per-message-stream-then-terminal-result + * shape as SESSION_FETCHING/STORING/EXPUNGING/ + * SEARCHING, so it's handled by the same + * session_handle_mbox_result() entry point, + * which branches to session_finish_copy_or_ + * move() first, the same way it already + * branches to session_finish_search(). */ + + /* + * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 additions (flat multi-mailbox + * support, docs/openimap-storage-backend.md item 10). All four are + * command-auth (valid in Authenticated or Selected state) and never + * change s->state's SELECTED-ness -- same "record whichever ST_AUTH + * state was current before, restore it after" pattern SESSION_ + * STATUSING's s->status_prev_state already established, reused here + * as s->mbox_op_prev_state (one shared field: only one of these four + * can ever be in flight for a given session at once, so there's no + * need for four separate fields the way SESSION_APPENDING has its + * own append_prev_state). + */ + SESSION_CREATING, /* IMSG_MBOX_CREATE sent, awaiting the single + * terminal IMSG_MBOX_RESULT reply -- see + * cmd_create()/session_finish_mbox_op(). */ + SESSION_DELETING, /* IMSG_MBOX_DELETE sent, same single-terminal- + * reply shape as SESSION_CREATING -- see + * cmd_delete()/session_finish_mbox_op(). */ + SESSION_RENAMING, /* IMSG_MBOX_RENAME sent, same single-terminal- + * reply shape as SESSION_CREATING -- see + * cmd_rename()/session_finish_mbox_op(). */ + SESSION_LISTING /* IMSG_MBOX_LIST sent, awaiting the + * IMSG_MBOX_LIST_ITEM stream + terminal + * IMSG_MBOX_RESULT -- see list_dispatch()/ + * session_finish_list(). Same per-item-stream- + * then-terminal-result shape as SESSION_ + * SEARCHING/COPYING, so it's handled by the + * same session_handle_mbox_result() entry + * point too, branching to session_finish_ + * list() first. */ +}; + +/* + * Generous line-length cap for the raw CRLF-delimited read buffer below. + * RFC 9051 doesn't mandate a specific limit -- SS7.1.3's "* BAD Command + * line too long" is example text, not a normative value -- but any real + * server needs one to bound memory for a client that never sends CRLF. + * Revisit once IMAP literals ({n}-prefixed octet counts, SS4.3) are + * implemented: those need a different, larger mechanism than a flat line + * buffer, since a literal's byte count is part of the command syntax + * itself, not just "a longer line". + */ +#define SESSION_INBUF_MAX 8192 + +/* + * Generous bound for a client-chosen tag we need to remember across an + * async IMSG_AUTH_REQUEST/IMSG_AUTH_RESULT (and, on success, IMSG_STORE_ + * FORK/IMSG_SETUP_PEER) round trip -- RFC 9051 SS9 doesn't specify a tag + * length limit (`tag = 1*`), so this is the + * same kind of deliberate, flagged simplification as session_reply()'s + * 512-byte response buffer: truncated via strlcpy() rather than rejected, + * matching this file's existing truncate-rather-than-overflow style. + */ +#define IMAP_TAG_MAX 64 + +/* + * Cap on the verbatim label text (e.g. "HEADER.FIELDS (DATE FROM)" or + * "HEADER.FIELDS.NOT (X-SPAM-STATUS)") stashed on struct session's + * pending_header_label and echoed back into the FETCH response's + * "BODY[