Commit Diff


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[<part>]`/`BODY.PEEK[<part>]`), `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 `<imsg.h>`, `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 <imsg.h>, 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 "<protocol>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 (<event.h>,
+# 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 <bsd.prog.mk>
blob - /dev/null
blob + ea5d4f17b82c25482bae5d578d4e83b4a32d760e (mode 644)
--- /dev/null
+++ src/auth.c
@@ -0,0 +1,397 @@
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * 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 <sys/types.h>
+
+#include <ctype.h>
+#include <errno.h>
+#include <event.h>
+#include <grp.h>
+#include <imsg.h>
+#include <pwd.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#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[<part>] 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 <dhw@openimapd.dev>
+ *
+ * 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 <sys/types.h>
+#include <sys/cdefs.h>		/* __dead */
+#include <sys/queue.h>
+
+#include <event.h>
+#include <imsg.h>
+#include <stdint.h>
+
+/*
+ * 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 <role>". 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 <addr> [tls] port <port>" x2, "spool <path>",
+ * "credentials <path>", "tls certificate <path>", "tls key <path>",
+ * "attachment max <bytes>"). 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 <addr>" 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:<maxuid>'" -- 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 <UID set, mod-sequence> 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[<section-part>]/
+ * BODY.PEEK[<section-part>] (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: <<partial>> byte-range support (SS6.4.5's
+ * "<start.count>" 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[<section-part>] 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[<section-part>]
+						 * only -- same .PEEK-only
+						 * scoping as every other
+						 * MBOX_FETCH_BODY_* bit (plain
+						 * BODY[<section-part>], 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[<n>]/BODY.PEEK[<n>] 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[<section>]/BODY.PEEK[<section>]
+ * 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 <<partial>> 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 <<partial>> ranged chunks instead (confirmed
+ * against real Apple Mail traffic this pass, which already issues
+ * BODY.PEEK[TEXT]<0.16384>-style ranged fetches unprompted). A
+ * <<partial>> 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[]<<partial>> 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 <bytes>" 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 <mod-sequence>." 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 <minmodseq> ... 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
+	 * <minmodseq> 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[<section-part>]/BODY.PEEK[<section-part>] (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 <<partial>> range
+	 * (SS6.4.5's "<start.count>" 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
+ * "<B27397-0100000@cac.washington.edu>")`, 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 ("* <seqno> 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
+ * <sequence-set>" *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 <entry-name>/<entry-type-
+					 * req> prefix (SS3.1.5: "If the server
+					 * doesn't store separate mod-sequences
+					 * for different metadata items, it
+					 * MUST ignore <entry-name> and <entry-
+					 * type-req>") 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 <dhw@openimapd.dev>
+ *
+ * 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 <sys/types.h>
+
+#include <event.h>
+#include <imsg.h>
+#include <stdlib.h>
+
+#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 <dhw@openimapd.dev>
+ *
+ * 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() (<resolv.h>, 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 <sys/types.h>
+#include <sys/queue.h>
+#include <sys/socket.h>
+
+#include <netinet/in.h>
+
+/*
+ * <resolv.h> (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 <sys/types.h>, <sys/socket.h>, and <stdio.h>. <netinet/in.h>
+ * must come first, matching the ordering smtpd's util.c and httpd's
+ * server_http.c both use for the same pairing.
+ */
+#include <ctype.h>
+#include <errno.h>
+#include <event.h>
+#include <grp.h>
+#include <imsg.h>
+#include <poll.h>
+#include <pwd.h>
+#include <resolv.h>
+#include <stdarg.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <tls.h>
+#include <unistd.h>
+
+#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*<any ASTRING-CHAR except "+">`), 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[<label>]" -- see that field's own comment for the full
+ * reasoning. Defined up here (rather than down near parse_fetch_atts(),
+ * where it's actually used) because struct session, just below,
+ * declares a fixed array of this size -- needs to be visible before that
+ * point. Never crosses the imsg boundary (listener.c has this text
+ * straight from the client's own command line), so this is a
+ * listener.c-local constant, not a shared imapd.h one like HEADER_
+ * FIELDS_MAX. Sized generously above HEADER_FIELDS_MAX (256, imapd.h)
+ * to cover the "HEADER.FIELDS.NOT (...)" wrapper text around whatever
+ * field-name list already fits under that cap.
+ */
+#define HEADER_FIELDS_LABEL_MAX	288
+
+/* One RFC 7162 VANISHED (EARLIER) range -- see struct session's vanished_
+ * ranges comment and format_range_list(). Named (not anonymous) so format_
+ * range_list() can take it as a parameter type. */
+struct vanished_range {
+	uint32_t	lo;
+	uint32_t	hi;
+};
+
+struct session {
+	uint32_t		 id;
+	int			 client_fd;
+	struct event		 client_ev;
+	enum session_state	 state;
+	int			 implicit_tls;	/* accepted on port 993, per
+						 * RFC 8314 -- vs. port 143,
+						 * where TLS only starts after
+						 * STARTTLS */
+	struct imsgev		*store_iev;	/* NULL until STORE_PENDING
+						 * resolves */
+	int			 client_ev_added; /* client_ev is only safe to
+						   * event_del() once this is
+						   * set -- see listener_accept()'s
+						   * implicit-TLS early-teardown
+						   * path, which never reaches
+						   * the event_set()/event_add()
+						   * call below it. */
+	int			 tls_active;	/* 1 once a real TLS handshake
+						 * (implicit-TLS accept or
+						 * STARTTLS) has completed --
+						 * threaded through CAPABILITY's
+						 * state-appropriate response and
+						 * AUTHENTICATE's TLS gate. */
+	struct tls		*tls_ctx;	/* NULL until tls_accept_socket()
+						 * -- non-NULL during handshake
+						 * too, not just once tls_active */
+	int			 pending_greeting; /* implicit-TLS only: the
+						 * greeting can't be sent until
+						 * after the handshake completes
+						 * (RFC 8314 -- no protocol bytes
+						 * before TLS), so listener_accept()
+						 * defers it and session_tls_
+						 * handshake() sends it on success */
+	char			 inbuf[SESSION_INBUF_MAX];
+	size_t			 inbuflen;	/* bytes of unparsed input
+						 * currently in inbuf */
+	int			 auth_cont;	/* 1 while the next raw client
+						 * line is a SASL continuation-
+						 * response (base64 or "*"), not
+						 * a fresh tagged command -- set
+						 * by cmd_authenticate() when the
+						 * client sends bare "AUTHENTICATE
+						 * PLAIN" with no inline initial
+						 * response, cleared by session_
+						 * handle_auth_continuation() as
+						 * soon as that line arrives. */
+	int			 idling;	/* 1 while the next raw client
+						 * line is IDLE's "DONE"
+						 * continuation, not a fresh
+						 * tagged command -- set by
+						 * cmd_idle(), cleared by
+						 * session_handle_idle_
+						 * continuation() (RFC 9051
+						 * SS6.3.13). Same shape as
+						 * auth_cont above, checked
+						 * second in session_dispatch_
+						 * client()'s read loop. */
+	uid_t			 uid;		/* set once, in session_request_
+						 * store(), from the same imsg_
+						 * auth_result the store-fork
+						 * request itself uses -- exists
+						 * purely so session_notify_
+						 * idle_peers() can find this
+						 * session's *other* concurrent
+						 * sessions (same user, v1 has
+						 * no shared mailboxes so that's
+						 * the only case that can ever
+						 * matter) without a second round
+						 * trip through auth just to ask
+						 * "whose session is this." */
+	char			 pending_tag[IMAP_TAG_MAX]; /* copy of whichever
+						 * command's tag is waiting on an
+						 * async imsg round trip right now
+						 * (AUTHENTICATE -> auth, or SELECT
+						 * -> store) -- tag itself is a
+						 * pointer into inbuf, which gets
+						 * overwritten by the next read()
+						 * long before IMSG_AUTH_RESULT (or
+						 * the later IMSG_SETUP_PEER/
+						 * IMSG_STORE_FORK store-spawn
+						 * reply, or IMSG_MBOX_SELECTED)
+						 * comes back, so it has to be
+						 * saved out. Only ever one such
+						 * round trip in flight at a time
+						 * per session (SESSION_
+						 * AUTHENTICATING/STORE_PENDING/
+						 * SELECTING/FETCHING are all
+						 * mutually exclusive states), so
+						 * one field is enough. */
+	uint32_t		 fetch_attrs;	/* MBOX_FETCH_* bitmask for
+						 * the FETCH currently in flight
+						 * (SESSION_FETCHING) -- needed
+						 * because struct imsg_mbox_
+						 * fetch_meta's fields (uid,
+						 * size, ...) are always
+						 * populated by store regardless
+						 * of what was actually
+						 * requested, so session_send_
+						 * fetch_response() needs to
+						 * know which ones to print. */
+	char			*pending_header_buf; /* malloc(3)'d raw header
+						 * bytes from the most recent
+						 * IMSG_MBOX_FETCH_HEADER, held
+						 * here until the very next
+						 * IMSG_MBOX_FETCH_META (same
+						 * message, always sent
+						 * immediately after -- see that
+						 * struct's comment in
+						 * imapd.h) folds it into one
+						 * FETCH response line; freed and
+						 * NULL'd there. Never allocated
+						 * for more than one message at a
+						 * time -- store.c's ordering
+						 * contract means this is never
+						 * still set when the next
+						 * IMSG_MBOX_FETCH_HEADER
+						 * arrives, but session_teardown()
+						 * frees it defensively anyway in
+						 * case a FETCH is torn down
+						 * mid-stream. */
+	uint32_t		 pending_header_len; /* valid only alongside
+						 * pending_header_buf != NULL */
+	int			 pending_header_found; /* 0 -- this message's
+						 * header couldn't be read; 1
+						 * -- pending_header_buf/_len are
+						 * valid. Distinguishes "BODY[HEADER]
+						 * was requested but this message
+						 * has none to give" from "BODY[HEADER]
+						 * wasn't requested at all", the
+						 * latter never populating this or
+						 * pending_header_buf in the first
+						 * place. */
+	char			 pending_header_label[HEADER_FIELDS_LABEL_MAX];
+						/* verbatim client-typed section-spec
+						 * text -- "HEADER" for plain BODY.PEEK
+						 * [HEADER], or e.g. "HEADER.FIELDS
+						 * (DATE FROM)" for that variant --
+						 * echoed back as-is in the FETCH
+						 * response's "BODY[<label>]" rather than
+						 * reconstructed, since RFC 9051 doesn't
+						 * mandate an exact echo format and
+						 * verbatim is simplest and trivially
+						 * correct. Set once in fetch_dispatch(),
+						 * read by session_send_fetch_response()
+						 * -- never needs its own free()/reset
+						 * since it's a fixed-size buffer, not a
+						 * malloc(3)'d pointer like pending_
+						 * header_buf. */
+	char			*pending_body_buf; /* same stash-until-
+						 * the-next-IMSG_MBOX_FETCH_META
+						 * shape as pending_header_buf above,
+						 * just for IMSG_MBOX_FETCH_BODY
+						 * (BODY.PEEK[]/BODY.PEEK[TEXT])
+						 * instead -- see struct imsg_mbox_
+						 * fetch_body's comment in
+						 * imapd.h. Freed and NULL'd by
+						 * session_send_fetch_response();
+						 * session_teardown() frees it
+						 * defensively too, same reasoning as
+						 * pending_header_buf. */
+	uint32_t		 pending_body_len; /* valid only alongside
+						 * pending_body_buf != NULL */
+	int			 pending_body_found; /* same found/not-found
+						 * distinction as pending_header_
+						 * found, for BODY.PEEK[]/
+						 * BODY.PEEK[TEXT] */
+	char			 pending_body_label[SECTION_PART_MAX];
+						/* verbatim section text for the
+						 * "BODY[<label>]" response --
+						 * "" for BODY.PEEK[] (whole
+						 * message), "TEXT" for BODY.PEEK
+						 * [TEXT], or the client-typed
+						 * dotted-numeric path (e.g. "3.1")
+						 * for BODY.PEEK[<section-part>] --
+						 * same "precompute the full
+						 * verbatim label at dispatch time"
+						 * idiom as pending_header_label,
+						 * unifying what used to be a
+						 * pending_body_is_text boolean
+						 * (adequate when only WHOLE/TEXT
+						 * existed, not once a third,
+						 * client-supplied-text variant --
+						 * PART -- joined them). Set once in
+						 * fetch_dispatch() from whichever of
+						 * MBOX_FETCH_BODY_WHOLE/_TEXT/_PART
+						 * attrs selects (same WHOLE > TEXT >
+						 * PART precedence store.c's
+						 * handle_mbox_fetch() applies --
+						 * see that function's comment),
+						 * read by session_send_fetch_
+						 * response(). */
+	int			 pending_body_has_partial; /* 1 if the
+						 * client's own BODY.PEEK[...]
+						 * token carried a <<start.count>>
+						 * suffix (RFC 9051 SS6.4.5) for
+						 * whichever of WHOLE/TEXT/PART was
+						 * actually selected -- see struct
+						 * imsg_mbox_fetch's has_partial
+						 * comment in imapd.h. Only
+						 * pending_body_partial_origin (the
+						 * *requested* start octet, not
+						 * store.c's own bodylen -- SS6.4.5:
+						 * "The origin octet facility MUST
+						 * NOT be used ... unless the client
+						 * specifically requested it", and
+						 * the response echoes only the
+						 * origin, never the count) is echoed
+						 * back; set alongside pending_body_
+						 * label in fetch_dispatch(). */
+	uint32_t		 pending_body_partial_origin;
+	char			*pending_envelope_buf; /* same stash-
+						 * until-the-next-IMSG_MBOX_
+						 * FETCH_META shape as pending_
+						 * header_buf/pending_body_buf
+						 * above, for IMSG_MBOX_FETCH_
+						 * ENVELOPE -- see struct imsg_
+						 * mbox_fetch_envelope's comment
+						 * in imapd.h. Unlike those
+						 * two, holds already-formatted
+						 * "(...)" envelope response
+						 * text, not raw message bytes,
+						 * so session_send_fetch_
+						 * response() writes it directly
+						 * rather than wrapping it in a
+						 * `{n}` literal. Freed and
+						 * NULL'd by session_send_fetch_
+						 * response(); session_teardown()
+						 * frees it defensively too, same
+						 * reasoning as pending_header_buf. */
+	uint32_t		 pending_envelope_len; /* valid only
+						 * alongside pending_envelope_buf
+						 * != NULL */
+	int			 pending_envelope_found; /* same found/
+						 * not-found distinction as
+						 * pending_header_found, for
+						 * ENVELOPE */
+	char			*pending_bodystructure_buf; /* same stash-
+						 * until-the-next-IMSG_MBOX_
+						 * FETCH_META shape, same
+						 * already-formatted-text shape,
+						 * as pending_envelope_buf above,
+						 * for IMSG_MBOX_FETCH_
+						 * BODYSTRUCTURE -- see struct
+						 * imsg_mbox_fetch_bodystructure's
+						 * comment in imapd.h. */
+	uint32_t		 pending_bodystructure_len; /* valid only
+						 * alongside pending_
+						 * bodystructure_buf != NULL */
+	int			 pending_bodystructure_found; /* same found/
+						 * not-found distinction as
+						 * pending_header_found, for
+						 * BODYSTRUCTURE */
+	char			 pending_bodystructure_label[16]; /*
+						 * "BODY" or "BODYSTRUCTURE" --
+						 * RFC 9051 SS9's msg-att-static:
+						 * `"BODY" ["STRUCTURE"] SP body`
+						 * means the response label
+						 * itself echoes whichever bare
+						 * token the client used (both
+						 * set the same MBOX_FETCH_
+						 * BODYSTRUCTURE bit and produce
+						 * identical body text in this
+						 * implementation -- see that
+						 * bit's imapd.h comment --
+						 * but the label still has to
+						 * match what was asked for).
+						 * Set once in fetch_dispatch(),
+						 * read by session_send_fetch_
+						 * response() -- same fixed-
+						 * buffer, no-free-needed shape
+						 * as pending_header_label. */
+	int			 close_after_expunge; /* 1 if the in-flight
+						 * SESSION_EXPUNGING round trip
+						 * was started by CLOSE, not a
+						 * real EXPUNGE command -- both
+						 * send the identical IMSG_MBOX_
+						 * EXPUNGE (CLOSE with silent=1)
+						 * and get back the identical
+						 * IMSG_MBOX_RESULT, so this is
+						 * the only way session_handle_
+						 * mbox_result() can tell which
+						 * tagged completion text/next
+						 * state applies: CLOSE goes to
+						 * SESSION_AUTHENTICATED, a real
+						 * EXPUNGE goes back to SESSION_
+						 * SELECTED. */
+	int			 literal_pending; /* 1 while the next bytes off
+						 * the wire are a client
+						 * literal's raw octets (RFC 9051
+						 * SS4.3), not a CRLF-delimited
+						 * line -- set by cmd_append()
+						 * when it finds a trailing
+						 * "{n}"/"{n+}" on the APPEND
+						 * command line, cleared once
+						 * literal_buf_len reaches
+						 * literal_len. Checked at the
+						 * very top of session_dispatch_
+						 * client()'s read loop, *before*
+						 * the normal CRLF search -- so
+						 * unlike every async-imsg-wait
+						 * state above, this needs no
+						 * ST_* dispatch-table exclusion:
+						 * while it's set, session_
+						 * handle_line() is never reached
+						 * at all, regardless of s->state,
+						 * so no new complete "tag SP
+						 * command CRLF" can ever be
+						 * mis-parsed out of literal
+						 * bytes. */
+	char			*literal_buf;	/* malloc(3)'d accumulator for
+						 * the literal currently being
+						 * read, literal_len bytes, freed
+						 * once handed off to session_
+						 * finish_append() (or on early
+						 * teardown -- see session_
+						 * teardown()) */
+	uint64_t		 literal_len;	/* total announced literal size
+						 * (what "{n}" both this and
+						 * literal_remaining start from) */
+	uint64_t		 literal_remaining; /* bytes of the literal still
+						 * needed -- literal_len minus
+						 * however much has landed in
+						 * literal_buf so far, across
+						 * possibly many reads */
+	char			 append_mailbox[MBOX_NAME_MAX]; /* APPEND's
+						 * arguments, parsed by
+						 * cmd_append() *before* the
+						 * literal is read (mailbox/
+						 * flags/date all precede the
+						 * literal in RFC 9051 SS6.3.12's
+						 * grammar) and held here across
+						 * the literal-read phase until
+						 * session_finish_append() needs
+						 * them to build IMSG_MBOX_
+						 * APPEND */
+	uint32_t		 append_sysflags;
+	char			 append_keywords[MBOX_FLAGS_MAX];
+	int			 append_has_date;
+	int64_t			 append_date;
+	enum session_state	 append_prev_state; /* SESSION_AUTHENTICATED or
+						 * SESSION_SELECTED -- whichever
+						 * s->state was when cmd_append()
+						 * was first called (APPEND is
+						 * valid in either, RFC 9051
+						 * command-auth), so session_
+						 * handle_mbox_appended() knows
+						 * which one to restore once
+						 * IMSG_MBOX_APPENDED arrives --
+						 * unlike FETCH/STORE/EXPUNGE,
+						 * which are only ever dispatched
+						 * from (and so only ever return
+						 * to) SESSION_SELECTED, APPEND's
+						 * "go back to" state isn't
+						 * fixed. */
+	uint32_t		 status_attrs;	/* STATUS_ATT_* bitmask for the
+						 * STATUS currently in flight
+						 * (SESSION_STATUSING) -- which
+						 * status-att values to include
+						 * in the response, in listener.c's
+						 * fixed canonical order; store.c
+						 * always computes MESSAGES/
+						 * UIDNEXT/UIDVALIDITY/
+						 * HIGHESTMODSEQ regardless (see
+						 * imapd.h's imsg_mbox_status
+						 * comment), same "store always
+						 * computes, listener decides
+						 * whether to print" split as
+						 * s->fetch_attrs. */
+	char			 status_mailbox[MBOX_NAME_MAX]; /* the mailbox
+						 * name STATUS was asked about
+						 * (RFC 9051 SS6.3.4-SS6.3.6's
+						 * flat multi-mailbox support --
+						 * before that, STATUS could only
+						 * ever mean INBOX, so this field
+						 * didn't need to exist) --
+						 * stashed here so session_
+						 * handle_mbox_status_result() can
+						 * echo it back in the untagged
+						 * "* STATUS <mailbox> (...)"
+						 * response; store.c's own reply
+						 * (struct imsg_mbox_status_
+						 * result) has no mailbox field of
+						 * its own to read it back from,
+						 * since listener.c already knows
+						 * what it asked. */
+	enum session_state	 status_prev_state; /* SESSION_AUTHENTICATED or
+						 * SESSION_SELECTED -- same
+						 * "STATUS is command-auth, valid
+						 * in either state" reasoning as
+						 * s->append_prev_state above, and
+						 * for the identical reason:
+						 * session_handle_mbox_status_
+						 * result() needs to know which
+						 * one to restore. */
+	enum session_state	 mbox_op_prev_state; /* SESSION_AUTHENTICATED or
+						 * SESSION_SELECTED -- same
+						 * "record whichever ST_AUTH
+						 * state was current before,
+						 * restore it after" pattern as
+						 * s->status_prev_state just
+						 * above, shared across all four
+						 * of SESSION_CREATING/DELETING/
+						 * RENAMING/LISTING (see that enum
+						 * block's own comment) since only
+						 * one of the four can ever be in
+						 * flight at once for a given
+						 * session. session_finish_mbox_
+						 * op()/session_finish_list() both
+						 * restore it. */
+	char			 list_pattern[2 * MBOX_NAME_MAX]; /* canonical
+						 * LIST/LSUB pattern (reference
+						 * concatenated with the mailbox
+						 * pattern by list_dispatch(),
+						 * same construction the old
+						 * synchronous 'canon' local
+						 * used) stashed across the
+						 * SESSION_LISTING round trip so
+						 * session_handle_mbox_list_
+						 * item() can test each IMSG_
+						 * MBOX_LIST_ITEM name against it
+						 * as it streams in, one at a
+						 * time -- unlike s->search_
+						 * matches, nothing needs to be
+						 * accumulated first, since each
+						 * match can be turned into its
+						 * untagged response
+						 * immediately. */
+	int			 list_is_lsub;	/* 1 if the in-flight SESSION_
+						 * LISTING round trip was started
+						 * by LSUB rather than LIST --
+						 * picks the untagged response
+						 * keyword and the tagged
+						 * completion text, same is_lsub
+						 * parameter list_dispatch()
+						 * already threads through
+						 * synchronously today. */
+	char			 rename_oldname[MBOX_NAME_MAX]; /* RENAME's
+						 * two arguments, stashed by
+						 * cmd_rename() across the
+						 * SESSION_RENAMING round trip
+						 * purely so session_finish_
+						 * mbox_op() can compare
+						 * rename_oldname against s->
+						 * selected_mailbox on success --
+						 * if this session had the
+						 * just-renamed mailbox itself
+						 * selected, its own s->selected_
+						 * mailbox needs to follow along
+						 * to rename_newname, the same
+						 * way store.c's handle_mbox_
+						 * rename() already makes its own
+						 * cwd/current_mailbox_dir follow
+						 * (see that function's own real-
+						 * hardware-bug comment). Neither
+						 * name is otherwise needed here
+						 * -- req.oldname/req.newname
+						 * already made the trip to
+						 * store.c in the imsg itself. */
+	char			 rename_newname[MBOX_NAME_MAX];
+	uint32_t		 search_return_opts; /* SEARCH_RETURN_* bitmask
+						 * for the SEARCH currently in
+						 * flight (SESSION_SEARCHING) --
+						 * which of MIN/MAX/ALL/COUNT
+						 * session_finish_search() should
+						 * include in the ESEARCH
+						 * response; store.c never needs
+						 * this, since it only computes
+						 * *which* messages match, not
+						 * how the client wants that
+						 * reported. */
+	uint32_t		*search_matches; /* malloc(3)'d/realloc(3)'d,
+						 * growable -- ascending sequence
+						 * numbers streamed in via
+						 * IMSG_MBOX_SEARCH_MATCH, kept
+						 * in full (not range-compacted
+						 * until formatting time) so
+						 * MIN/MAX/COUNT/ALL can all be
+						 * derived from one array. Freed
+						 * by session_finish_search() once
+						 * the ESEARCH response is sent,
+						 * or by session_teardown() on
+						 * early disconnect. */
+	uint32_t		 search_nmatches; /* entries actually in
+						 * search_matches */
+	uint32_t		 search_matches_cap; /* allocated capacity of
+						 * search_matches, >= search_
+						 * nmatches */
+	int			 search_alloc_failed; /* 1 if a realloc(3) in
+						 * session_handle_mbox_search_
+						 * match() ever failed for this
+						 * SEARCH -- makes session_
+						 * finish_search() send a NO
+						 * instead of a silently
+						 * incomplete ESEARCH response;
+						 * see that function's comment on
+						 * why "some matches dropped" is
+						 * treated as a hard failure, not
+						 * a best-effort partial result. */
+	int			 search_used_modseq; /* 1 if the SEARCH program
+							 * currently in flight
+							 * contained a MODSEQ
+							 * search-key -- RFC 7162
+							 * SS3.1.5/SS3.1.8: makes
+							 * this SEARCH a CONDSTORE-
+							 * enabling command, and
+							 * makes session_finish_
+							 * search() append "(MODSEQ
+							 * n)" using the running
+							 * max below. */
+	uint64_t		 search_max_modseq; /* highest modseq seen
+							 * across all matches of
+							 * the SEARCH currently in
+							 * flight, tracked by
+							 * session_handle_mbox_
+							 * search_match() --
+							 * store.c always sends
+							 * modseq per match (cheap,
+							 * see imapd.h's struct
+							 * imsg_mbox_search_match
+							 * comment), so this is
+							 * just a running max, not
+							 * conditional on search_
+							 * used_modseq itself. */
+
+	/*
+	 * RFC 7162 (CONDSTORE/QRESYNC) session state, added this pass.
+	 * condstore_enabled/qresync_enabled are both sticky for the whole
+	 * connection once set (SS3.1: "Once a client issues a CONDSTORE
+	 * enabling command, it has announced itself... until the connection
+	 * is closed"; SS3.2.3 says the same for QRESYNC) -- never cleared
+	 * back to 0 anywhere in this file. qresync_enabled implies
+	 * condstore_enabled is also 1 (SS3.2.3: "the presence of the
+	 * 'QRESYNC' capability implies support for the CONDSTORE IMAP
+	 * extension"), enforced at every place qresync_enabled gets set.
+	 */
+	int			 condstore_enabled;
+	int			 qresync_enabled;
+
+	/*
+	 * Best-known HIGHESTMODSEQ of the currently selected mailbox --
+	 * cached from every IMSG_MBOX_SELECTED/IMSG_MBOX_RESULT reply that
+	 * carries one (store.c always populates it; see imapd.h's
+	 * imsg_mbox_selected/imsg_mbox_result comments), regardless of
+	 * whether this session is CONDSTORE-aware yet. Exists specifically
+	 * for session_condstore_enable()'s "CONDSTORE enabling command
+	 * issued while a mailbox is already selected" case (RFC 7162 SS3.1:
+	 * the server MUST emit an unsolicited HIGHESTMODSEQ OK response at
+	 * that moment) -- without a cached value there would be nothing to
+	 * report without an extra store round trip just for this edge case.
+	 */
+	uint64_t		 mbox_highestmodseq;
+
+	/*
+	 * RFC 9051 SS6.3.3: whether the currently selected mailbox was
+	 * opened via EXAMINE (1) rather than SELECT (0) -- meaningful only
+	 * while s->state == SESSION_SELECTED (and, transiently, SESSION_
+	 * SELECTING/SESSION_EXPUNGING while a select_or_examine()/session_
+	 * request_expunge() round trip is in flight). Set synchronously by
+	 * select_or_examine() before the IMSG_MBOX_SELECT round trip even
+	 * starts -- like s->state = SESSION_SELECTING itself, this doesn't
+	 * need to wait for store.c's reply, since it's derived purely from
+	 * which command the client typed, not from anything store.c knows.
+	 * Consulted by session_handle_mbox_selected() (README/OK response
+	 * code, PERMANENTFLAGS) and by every command that would otherwise
+	 * mutate the selected mailbox's permanent state -- store_do(),
+	 * session_request_expunge(), copy_move_dispatch()'s is_move case --
+	 * to refuse (or, for CLOSE specifically, silently no-op) per SS6.3.3
+	 * ("No changes to the permanent state of the mailbox... are
+	 * permitted") and SS6.4.1's CLOSE exception.
+	 */
+	int			 mbox_readonly;
+	char			 selected_mailbox[MBOX_NAME_MAX]; /* which
+					 * mailbox SESSION_SELECTED actually
+					 * refers to -- didn't need to exist
+					 * before RFC 9051 SS6.3.4-SS6.3.6's flat
+					 * multi-mailbox support (docs/openimap-
+					 * storage-backend.md item 10), since
+					 * "selected" and "INBOX is selected"
+					 * were the same fact. Written
+					 * optimistically by select_or_examine()
+					 * at the same point s->state moves to
+					 * SESSION_SELECTING -- safe even though
+					 * the SELECT/EXAMINE might still fail,
+					 * because every reader of this field
+					 * also gates on s->state ==
+					 * SESSION_SELECTED first, and a failed
+					 * SELECT/EXAMINE never reaches that
+					 * state (RFC 9051 SS6.3.2: "the session
+					 * is in authenticated state" on
+					 * failure, deselected same as before
+					 * the attempt). Consulted so far by
+					 * session_handle_mbox_appended()'s own
+					 * "is the mailbox APPEND just targeted
+					 * the one actually selected right now"
+					 * check -- see that function's
+					 * comment. */
+
+	/*
+	 * RFC 9051 SS6.3.13 (IDLE) v1 scope: EXISTS and EXPUNGE only (see
+	 * imapd.h's imsg_mbox_idle_uid comment for the sourcing/reasoning
+	 * behind not also pushing unsolicited FETCH). idle_known_uids is this
+	 * session's own cached, ordered snapshot of "which UIDs existed as of
+	 * the last time this session's view was authoritative" -- populated
+	 * from an IMSG_MBOX_IDLE_REFRESH round trip, either the baseline one
+	 * cmd_idle() triggers right after sending "+ idling" (idle_baseline_
+	 * valid is 0 until that first one lands, so session_handle_idle_
+	 * refreshed() knows not to diff against garbage), or a later one
+	 * triggered by session_notify_idle_peers() when some *other* session
+	 * belonging to the same uid successfully mutates the mailbox.
+	 * Diffing this array against a freshly streamed IMSG_MBOX_IDLE_UID
+	 * list is what lets session_handle_idle_refreshed() compute correct
+	 * seqno-based "* N EXPUNGE" lines for whatever UIDs dropped out --
+	 * same "position in the shrinking list, removed in ascending order"
+	 * algorithm handle_mbox_expunge() already needs on the store side,
+	 * just recomputed here in listener.c from two UID arrays instead of
+	 * from the index file directly.
+	 *
+	 * idle_refresh_pending/idle_refresh_again exist because a refresh is
+	 * itself an async imsg round trip: if a second trigger arrives while
+	 * one is already in flight for this session (e.g. two sibling
+	 * sessions both mutate in quick succession), there's no way to ask
+	 * store.c to hurry up or to safely start a second overlapping
+	 * request on the same store_iev channel -- idle_refresh_again just
+	 * remembers to immediately re-request once the in-flight one's
+	 * IMSG_MBOX_IDLE_REFRESHED reply lands, rather than either blocking
+	 * or silently dropping the second trigger.
+	 */
+	uint32_t		*idle_known_uids;
+	uint32_t		 idle_known_nuids;
+	uint32_t		 idle_known_cap;
+	int			 idle_baseline_valid;
+	int			 idle_refresh_pending;
+	int			 idle_refresh_again;
+
+	/*
+	 * Accumulates the freshly streamed IMSG_MBOX_IDLE_UID list for an
+	 * in-flight refresh -- deliberately a *separate* array from idle_
+	 * known_uids above, not built in place over it, since session_
+	 * handle_idle_refreshed() needs both the old and the new list at
+	 * once to diff them. Same "held back until the terminal reply"
+	 * shape as s->vanished_ranges/s->qresync_fetches during a QRESYNC
+	 * SELECT resync.
+	 */
+	uint32_t		*idle_incoming_uids;
+	uint32_t		 idle_incoming_n;
+	uint32_t		 idle_incoming_cap;
+
+	/*
+	 * Accumulates VANISHED (EARLIER) ranges streamed in via zero or more
+	 * IMSG_MBOX_SELECT_VANISHED during an in-flight QRESYNC SELECT
+	 * resync (s->state == SESSION_SELECTING) -- store.c already reports
+	 * these pre-compacted into ranges (see imsg_mbox_select_vanished's
+	 * comment in imapd.h), so this array just collects them in
+	 * arrival order (already ascending -- store.c streams its single
+	 * forward pass in increasing-UID order) for session_handle_mbox_
+	 * selected() to join into one combined "* VANISHED (EARLIER) ..."
+	 * line once IMSG_MBOX_SELECTED arrives -- RFC 7162 SS3.2.6 requires
+	 * every VANISHED (EARLIER) response to precede any FETCH response
+	 * in the same resync, which is only guaranteed by holding all of it
+	 * (and the FETCH data below) until the terminal reply, not by
+	 * relaying each piece to the client as it streams in.
+	 */
+	struct vanished_range	*vanished_ranges;
+	uint32_t		 vanished_nranges;
+	uint32_t		 vanished_cap;
+
+	/*
+	 * Accumulates the QRESYNC resync's per-message FETCH-with-UID-and-
+	 * MODSEQ data, streamed in via IMSG_MBOX_FETCH_META while s->state
+	 * == SESSION_SELECTING (see session_store_dispatch()'s IMSG_MBOX_
+	 * FETCH_META case) -- held back for the same "VANISHED must precede
+	 * FETCH" ordering reason vanished_ranges is. Holds full struct
+	 * imsg_mbox_fetch_meta copies (not just seqno/uid, unlike search_
+	 * matches) because session_send_qresync_fetch_response() needs
+	 * FLAGS too -- heavier per-entry than search_matches, but v1-scale
+	 * resyncs are small (this project's usual "personal use, modest
+	 * mailbox size" tolerance).
+	 */
+	struct imsg_mbox_fetch_meta *qresync_fetches;
+	uint32_t		 qresync_nfetches;
+	uint32_t		 qresync_fetches_cap;
+
+	/*
+	 * COPY/MOVE (RFC 9051 SS6.4.7/SS6.4.8), SESSION_COPYING. Two parallel
+	 * growable arrays accumulating each IMSG_MBOX_COPY_MAPPING streamed
+	 * in during the round trip -- src_uid/dest_uid pairs, already
+	 * ascending (store.c streams its single forward pass in increasing
+	 * order, same guarantee vanished_ranges above relies on), range-
+	 * compacted independently via format_seq_list() into COPYUID's two
+	 * UID sets once the terminal reply arrives. cmd_is_move distinguishes
+	 * which of COPY/MOVE is in flight (same session-state-plus-flag
+	 * pattern as s->close_after_expunge for EXPUNGE/CLOSE) -- both share
+	 * SESSION_COPYING since their in-flight bookkeeping is otherwise
+	 * identical, differing only in the terminal formatting (see session_
+	 * finish_copy_or_move()). move_expunged buffers each IMSG_MBOX_
+	 * EXPUNGED that arrives during a MOVE specifically (rather than
+	 * writing it to the client immediately, the way a real EXPUNGE/CLOSE
+	 * does) -- SS6.4.8 requires COPYUID to precede any EXPUNGE/VANISHED
+	 * for the same operation, which (like vanished_ranges/qresync_fetches
+	 * above) is only guaranteed by holding everything until the terminal
+	 * reply, not by relaying each piece as it streams in.
+	 */
+	uint32_t		*copy_src_uids;
+	uint32_t		*copy_dest_uids;
+	uint32_t		 copy_n;
+	uint32_t		 copy_cap;
+	int			 copy_alloc_failed; /* same "don't silently drop
+						 * a mapping" reasoning as
+						 * s->search_alloc_failed */
+	int			 cmd_is_move;
+	struct imsg_mbox_expunged *move_expunged;
+	uint32_t		 move_expunged_n;
+	uint32_t		 move_expunged_cap;
+
+	/*
+	 * Accumulates messages that failed a STORE's UNCHANGEDSINCE test,
+	 * streamed in via zero or more IMSG_MBOX_STORE_MODIFIED while
+	 * s->state == SESSION_STORING, for session_handle_mbox_result() to
+	 * range-compact (format_seq_list(), same helper SEARCH/ESEARCH
+	 * already use) into the tagged response's MODIFIED response code
+	 * (RFC 7162 SS3.1.3). Holds sequence numbers for a plain STORE, UIDs
+	 * for a UID STORE (SS3.1.3: "the message number (or unique
+	 * identifier in the case of the UID STORE command)") -- a single
+	 * array, not a parallel uid array, since exactly one of the two
+	 * values is ever meaningful for a given STORE; see session_handle_
+	 * store_modified()'s use of s->cmd_by_uid to pick which.
+	 */
+	uint32_t		*store_modified;
+	uint32_t		 store_modified_n;
+	uint32_t		 store_modified_cap;
+
+	/*
+	 * RFC 9051 SS6.4.9 (UID command), added this pass: 1 if the async
+	 * operation currently in flight (SESSION_FETCHING/STORING/SEARCHING/
+	 * EXPUNGING) was dispatched via UID <cmd> rather than the bare
+	 * command. Every entry point that starts one of those four states
+	 * sets this explicitly (fetch_dispatch()/store_do()/search_dispatch()/
+	 * session_request_expunge()), so a stale value from a previous,
+	 * different-mode command can never leak into the next one -- there is
+	 * no "clear it back to 0" step anywhere else, matching this struct's
+	 * existing condstore_enabled/qresync_enabled precedent of "every
+	 * setter is unconditional, so no separate reset is needed."
+	 *
+	 * Consulted by: session_send_store_fetch_response() (UID STORE's
+	 * FETCH echo must include UID, SS6.4.9's "MUST implicitly include
+	 * the UID message data item"), session_handle_mbox_search_match()/
+	 * session_finish_search() (UID SEARCH reports UIDs instead of
+	 * sequence numbers, plus the "UID" ESEARCH correlator token),
+	 * session_handle_store_modified() (UID STORE's MODIFIED response
+	 * code lists UIDs, not sequence numbers -- RFC 7162 SS3.1.3: "the
+	 * message number (or unique identifier in the case of the UID STORE
+	 * command)"), and session_handle_mbox_result() (every one of the four
+	 * commands' tagged completion text becomes "UID <CMD> completed" --
+	 * RFC 9051's own SS6.4.9 example shows "UID FETCH completed"
+	 * verbatim; UID EXPUNGE's own worked example shows "UID EXPUNGE
+	 * completed"; UID STORE/UID SEARCH aren't shown as literal worked
+	 * examples but follow the same "UID <cmd> completed" pattern by
+	 * direct symmetry with those two, not fabricated). Plain FETCH
+	 * doesn't need to consult this at all -- forcing MBOX_FETCH_UID into
+	 * s->fetch_attrs at dispatch time (see fetch_dispatch()) already
+	 * makes session_send_fetch_response()'s existing attrs-driven UID
+	 * printing do the right thing with no further changes there.
+	 */
+	int			 cmd_by_uid;
+
+	TAILQ_ENTRY(session)	 entry;
+};
+
+static TAILQ_HEAD(, session)	 sessions = TAILQ_HEAD_INITIALIZER(sessions);
+static uint32_t		 next_session_id = 1;
+
+/* see this file's header comment for what each of these actually is */
+static struct imsgev	 iev_auth;
+static struct imsgev	 iev_parent;
+/*
+ * Dual-stack ("listen on *") support: up to LISTENER_MAX_ADDRS bound
+ * sockets per port purpose now, one per resolved address family, instead
+ * of exactly one -- see imapd.h's LISTENER_MAX_ADDRS comment for why
+ * OpenBSD needs two separate sockets rather than one dual-mapped one.
+ * n_cleartext_fd/n_tls_fd (set once, at the end of the boot-time drain
+ * loop below) say how many of each array's slots are actually live.
+ */
+static int		 cleartext_fd[LISTENER_MAX_ADDRS] = { -1, -1 };
+static int		 tls_fd[LISTENER_MAX_ADDRS] = { -1, -1 };
+static int		 n_cleartext_fd, n_tls_fd;
+static struct event	 ev_accept_cleartext[LISTENER_MAX_ADDRS];
+static struct event	 ev_accept_tls[LISTENER_MAX_ADDRS];
+
+/*
+ * The server-wide TLS context, built once at boot in listener_main()
+ * from the cert/key bytes parent sends over IMSG_TLS_CERT/IMSG_TLS_KEY.
+ * tls_server()+tls_configure(), sourced directly against httpd's
+ * server_tls_init() (openbsd_source/src/usr.sbin/httpd/server.c) and
+ * src/lib/libtls/tls.h. NULL if TLS setup failed (bad cert/key, etc.) --
+ * checked before every tls_accept_socket() call rather than treated as
+ * fatal, so a cert problem degrades to "no TLS" (implicit-TLS port
+ * closes, STARTTLS/AUTHENTICATE keep replying NO) instead of taking the
+ * whole daemon down; cleartext CAPABILITY/NOOP/LOGOUT/ID still work.
+ *
+ * Also rebuilt post-boot, on a live SIGHUP reload -- see listener_reload_
+ * tls() below and parent.c's sighup_handler(). tls_accept_socket()
+ * (man.openbsd.org/tls_accept_socket.3: "these functions create a new
+ * context... and return it in *cctx") gives each already-connected
+ * session's own struct tls * (s->tls_ctx), entirely independent of this
+ * server-wide pair, so swapping these two pointers here on reload has no
+ * effect on sessions already mid-handshake or established -- only new
+ * tls_accept_socket() calls from this point on see the new cert/key.
+ */
+static struct tls_config	*listener_tls_config;
+static struct tls		*listener_tls_ctx;
+
+/* Matches parent.c's send_tls_certs() read buffer size -- see that
+ * function's comment for the (flagged, unsolved) chain-size limitation
+ * this shares. Moved up from just before listener_main() (its original,
+ * boot-only location) since the SIGHUP reload staging buffers just below
+ * need these two constants as well now. */
+#define TLS_CERT_MAX	8192
+#define TLS_KEY_MAX	8192
+
+/*
+ * Staging area for a SIGHUP reload's IMSG_TLS_CERT/IMSG_TLS_KEY bytes,
+ * received post-boot on the same parent channel (iev_parent) as the
+ * ongoing IMSG_STORE_FORK/IMSG_SETUP_PEER traffic listener_dispatch_
+ * parent() already handles. Parent sends these back to back (send_tls_
+ * certs(), unchanged since boot) but each arrives as its own imsg, and
+ * nothing forces both to land in the same event-loop dispatch call --
+ * these persist across calls the same way got_cert/got_key are local to
+ * listener_main()'s one-shot boot drain loop, except here as file-scope
+ * statics since the reload path runs repeatedly, from the event loop.
+ * listener_reload_tls() is only invoked once both have arrived; then both
+ * flags reset so a later reload starts clean.
+ */
+static char	 reload_cert_buf[TLS_CERT_MAX], reload_key_buf[TLS_KEY_MAX];
+static size_t	 reload_cert_len, reload_key_len;
+static int	 reload_got_cert, reload_got_key;
+
+static void	 listener_accept(int, short, void *);
+static void	 listener_dispatch_auth(int, short, void *);
+static void	 listener_dispatch_parent(int, short, void *);
+static void	 listener_reload_tls(const char *, size_t, const char *,
+		    size_t);
+static void	 session_dispatch_client(int, short, void *);
+static void	 session_store_dispatch(int, short, void *);
+static void	 session_handle_mbox_selected(struct session *,
+		    struct imsg_mbox_selected *);
+static void	 session_handle_mbox_status_result(struct session *,
+		    struct imsg_mbox_status_result *);
+static void	 session_send_fetch_response(struct session *,
+		    struct imsg_mbox_fetch_meta *);
+static void	 session_send_store_fetch_response(struct session *,
+		    struct imsg_mbox_fetch_meta *);
+static void	 session_send_expunge_response(struct session *,
+		    struct imsg_mbox_expunged *);
+static void	 session_handle_mbox_result(struct session *,
+		    struct imsg_mbox_result *);
+static int	 session_request_expunge(struct session *, const char *,
+		    int, int, uint32_t, uint32_t, int, int);
+static int	 session_finish_append(struct session *);
+static void	 session_handle_mbox_appended(struct session *,
+		    struct imsg_mbox_appended *);
+static void	 format_internaldate(int64_t, char *, size_t);
+static int	 parse_nz_number(const char *, uint32_t *);
+static int	 parse_seq_range(const char *, uint32_t *, uint32_t *,
+		    int *, int *);
+static int	 parse_fetch_atts(char *, uint32_t *, int *, int *, char *,
+		    size_t, char *, size_t, int *, char *, size_t, int *,
+		    uint32_t *, uint32_t *, const char **);
+static int	 parse_header_fields_att(const char *, int *, char *, size_t);
+static int	 section_part_valid(const char *);
+static int	 parse_partial_suffix(const char *, int *, uint32_t *,
+		    uint32_t *);
+static int	 parse_store_flags(char *, uint32_t *, char *, size_t,
+		    const char **);
+static int	 parse_date_time(const char *, int64_t *);
+struct append_parsed;
+static int	 parse_append_args(char *, struct append_parsed *,
+		    const char **);
+static int	 parse_search_date(const char *, int64_t *);
+struct search_parse_ctx;
+static int	 parse_search_key(char **, struct search_parse_ctx *,
+		    const char **);
+static int	 parse_search_key_inner(char **, struct search_parse_ctx *,
+		    const char **);
+static int	 parse_search_key_list(char **, struct search_parse_ctx *,
+		    const char **, int);
+static int	 parse_search_return_opts(char **, uint32_t *, const char **);
+static void	 session_handle_mbox_search_match(struct session *,
+		    struct imsg_mbox_search_match *);
+static void	 session_finish_search(struct session *,
+		    struct imsg_mbox_result *);
+static void	 session_request_store(struct session *,
+		    struct imsg_auth_result *);
+static void	 session_send_greeting(struct session *);
+static void	 session_teardown(struct session *);
+static struct session	*session_find(uint32_t);
+
+/* RFC 7162 (CONDSTORE/QRESYNC) helpers, added this pass. */
+static void	 session_condstore_enable(struct session *);
+static size_t	 format_seq_list(char *, size_t, const uint32_t *, uint32_t,
+		    int *);
+static size_t	 format_range_list(char *, size_t,
+		    const struct vanished_range *, uint32_t, int *);
+static void	 session_send_qresync_fetch_response(struct session *,
+		    struct imsg_mbox_fetch_meta *);
+static void	 session_handle_select_vanished(struct session *,
+		    struct imsg_mbox_select_vanished *);
+static void	 session_handle_select_fetch(struct session *,
+		    struct imsg_mbox_fetch_meta *);
+static void	 session_handle_store_modified(struct session *,
+		    struct imsg_mbox_store_modified *);
+static void	 session_handle_idle_uid(struct session *,
+		    struct imsg_mbox_idle_uid *);
+static void	 session_handle_idle_refreshed(struct session *,
+		    struct imsg_mbox_idle_refreshed *);
+static void	 session_request_idle_refresh(struct session *);
+static void	 session_push_idle_expunges(struct session *,
+		    const uint32_t *, uint32_t, const uint32_t *, uint32_t);
+static void	 session_notify_idle_peers(struct session *);
+static int	 session_handle_idle_continuation(struct session *, char *);
+static int	 parse_select_params(char *, struct imsg_mbox_select *,
+		    struct session *, int *, const char **);
+static int	 parse_qresync_group(char *, struct imsg_mbox_select *,
+		    const char **);
+static char	*split_trailing_modifiers(char *);
+static int	 parse_fetch_modifiers(char *, struct imsg_mbox_fetch *,
+		    struct session *, int, int *, const char **);
+static int	 parse_store_modifiers(char *, struct imsg_mbox_store *,
+		    const char **);
+
+/* RFC 9051 SS6.4.9 (UID command) helpers, added this pass. */
+static int	 fetch_dispatch(struct session *, const char *, char *, int);
+static int	 store_do(struct session *, const char *, char *, int);
+static int	 search_dispatch(struct session *, const char *, char *, int);
+static int	 uid_expunge_dispatch(struct session *, const char *, char *);
+static void	 session_handle_fetch_vanished(struct session *,
+		    struct imsg_mbox_select_vanished *);
+
+/* RFC 9051 SS6.4.7/SS6.4.8 (COPY/MOVE) helpers, added this pass. */
+static int	 copy_move_dispatch(struct session *, const char *, char *,
+		    int, int);
+static int	 mailbox_name_valid(const char *);
+static int	 mailbox_name_is_inbox(const char *);
+static int	 parse_list_token(char **, char *, size_t, const char **);
+static void	 session_finish_mbox_op(struct session *,
+		    struct imsg_mbox_result *);
+static void	 session_finish_list(struct session *,
+		    struct imsg_mbox_result *);
+static void	 session_handle_mbox_list_item(struct session *,
+		    struct imsg_mbox_list_item *);
+static void	 session_handle_mbox_copy_mapping(struct session *,
+		    struct imsg_mbox_copy_mapping *);
+static void	 session_finish_copy_or_move(struct session *,
+		    struct imsg_mbox_result *);
+
+static void	 session_tls_start(struct session *);
+static void	 session_tls_handshake(int, short, void *);
+static void	 session_arm_client_read(struct session *);
+
+static void	 session_write(struct session *, const char *, size_t);
+static void	 session_reply(struct session *, const char *, const char *,
+		    const char *);
+static void	 session_untagged(struct session *, const char *);
+static int	 parse_command_line(char *, char **, char **, char **);
+static int	 session_handle_line(struct session *, char *);
+static int	 session_handle_auth_continuation(struct session *, char *);
+static int	 sasl_plain_finish(struct session *, const char *,
+		    const char *, int);
+
+static int	 cmd_capability(struct session *, const char *, char *);
+static int	 cmd_noop(struct session *, const char *, char *);
+static int	 cmd_logout(struct session *, const char *, char *);
+static int	 cmd_id(struct session *, const char *, char *);
+static int	 cmd_login(struct session *, const char *, char *);
+static int	 cmd_starttls(struct session *, const char *, char *);
+static int	 cmd_authenticate(struct session *, const char *, char *);
+
+/* command-auth (RFC 9051 SS6.3 -- valid in Authenticated or Selected
+ * state): ENABLE is real (see cmd_enable()'s comment for why that's a
+ * complete implementation, not a stub, for v1). Everything else here
+ * needs a mailbox layer that doesn't exist yet -- store.c's IMSG_MBOX_*
+ * family is entirely unimplemented (see store.c's own header comment),
+ * and its wire payload shapes aren't designed, so these all reply a
+ * generic NO via stub_not_implemented() rather than doing anything real.
+ * They're in the dispatch table anyway, correctly gated by session
+ * state, because "recognized command, can't do it right now" (NO) and
+ * "not a real IMAP command at all" (BAD, session_handle_line()'s unknown-
+ * command fallback) are different, both spec-meaningful responses -- see
+ * openimap-v1-dispatch.md's dispatch table for the same distinction. */
+static int	 stub_not_implemented(struct session *, const char *,
+		    const char *);
+static int	 cmd_enable(struct session *, const char *, char *);
+static int	 cmd_select(struct session *, const char *, char *);
+static int	 cmd_examine(struct session *, const char *, char *);
+static int	 cmd_create(struct session *, const char *, char *);
+static int	 cmd_delete(struct session *, const char *, char *);
+static int	 cmd_rename(struct session *, const char *, char *);
+static int	 cmd_subscribe(struct session *, const char *, char *);
+static int	 cmd_unsubscribe(struct session *, const char *, char *);
+static int	 cmd_list(struct session *, const char *, char *);
+static int	 cmd_lsub(struct session *, const char *, char *);
+static int	 cmd_namespace(struct session *, const char *, char *);
+static int	 cmd_status(struct session *, const char *, char *);
+static int	 cmd_append(struct session *, const char *, char *);
+static int	 cmd_idle(struct session *, const char *, char *);
+
+/* command-select (RFC 9051 SS6.4 -- valid only in Selected state).
+ * SESSION_SELECTED is currently unreachable (SELECT itself is a stub
+ * above), so none of these can be dispatched to yet in practice -- listed
+ * for the same "correctly gated, honestly stubbed" reason as command-auth
+ * above. */
+static int	 cmd_close(struct session *, const char *, char *);
+static int	 cmd_unselect(struct session *, const char *, char *);
+static int	 cmd_expunge(struct session *, const char *, char *);
+static int	 cmd_search(struct session *, const char *, char *);
+static int	 cmd_fetch(struct session *, const char *, char *);
+static int	 cmd_store_cmd(struct session *, const char *, char *);
+static int	 cmd_copy(struct session *, const char *, char *);
+static int	 cmd_move(struct session *, const char *, char *);
+static int	 cmd_uid(struct session *, const char *, char *);
+
+/*
+ * v1 CAPABILITY strings, originally quoted verbatim from openimap-v1-
+ * dispatch.md's "v1 CAPABILITY strings" section (itself sourced against
+ * RFC 9051 SS6.1.1 and the IANA IMAP Capabilities Registry). Selected via
+ * session->tls_active, which a real STARTTLS/implicit-TLS handshake now
+ * sets -- see cmd_starttls() and session_tls_handshake().
+ *
+ * CONDSTORE and QRESYNC added this pass (RFC 7162). Both listed in both
+ * strings, not gated on tls_active/auth state the way AUTH=PLAIN is --
+ * SS3.1.1/SS3.2.2 define support for each purely in terms of whether the
+ * server returns the token in CAPABILITY at all, with no mention of a TLS
+ * or authentication precondition (unlike AUTH=PLAIN, which this server
+ * deliberately withholds pre-TLS to keep LOGINDISABLED honest). Listing
+ * QRESYNC implies CONDSTORE support too (SS3.2.3), but SS3.2.2 separately
+ * "SHOULD"s advertising CONDSTORE explicitly as well for CONDSTORE-only
+ * clients, so both tokens are listed rather than relying on the implication
+ * alone.
+ *
+ * IDLE (added this pass, RFC 9051 SS6.3.13) needs no new token in either
+ * string: unlike RFC 2177 (where IDLE was an extension gated on an "IDLE"
+ * capability token), SS6.3.13 redefines IDLE natively in IMAP4rev2 with no
+ * capability-gating language at all -- it's base spec, covered by the
+ * existing "IMAP4rev2" token already in both strings.
+ */
+#define CAPABILITY_PRE_TLS	"IMAP4rev2 STARTTLS LOGINDISABLED ID CONDSTORE QRESYNC"
+#define CAPABILITY_POST_TLS	"IMAP4rev2 AUTH=PLAIN ID CONDSTORE QRESYNC"
+
+/*
+ * v1 command dispatch table. "any state" (RFC 9051 command-any, plus RFC
+ * 2971's ID, which command-any is explicitly extended to include) and
+ * "not authenticated state" (command-nonauth) are fully implemented.
+ * command-auth and command-select now have real table entries too --
+ * ENABLE is a genuine, complete implementation (see cmd_enable()); every
+ * other command-auth/command-select entry is an honest stub
+ * (stub_not_implemented()) pending store.c's still-undesigned IMSG_MBOX_*
+ * wire protocol. See session_handle_line()'s "unknown command" fallback
+ * for anything not listed here at all.
+ */
+struct imap_cmd_entry {
+	const char	*name;
+	unsigned int	 states;	/* bitmask of 1U << SESSION_* */
+	int		(*handler)(struct session *, const char *, char *);
+};
+
+#define ST_ANY \
+	((1U << SESSION_NOT_AUTH) | (1U << SESSION_AUTHENTICATING) | \
+	 (1U << SESSION_STORE_PENDING) | (1U << SESSION_AUTHENTICATED) | \
+	 (1U << SESSION_SELECTING) | (1U << SESSION_SELECTED) | \
+	 (1U << SESSION_FETCHING) | (1U << SESSION_STORING) | \
+	 (1U << SESSION_EXPUNGING) | (1U << SESSION_APPENDING) | \
+	 (1U << SESSION_SEARCHING) | (1U << SESSION_STATUSING) | \
+	 (1U << SESSION_COPYING) | (1U << SESSION_CREATING) | \
+	 (1U << SESSION_DELETING) | (1U << SESSION_RENAMING) | \
+	 (1U << SESSION_LISTING))
+#define ST_NOTAUTH	(1U << SESSION_NOT_AUTH)
+
+/*
+ * RFC 9051 SS9's `command-auth`/`command-select` ABNF productions, whose
+ * inline comments are quoted directly: command-auth is "Valid only in
+ * Authenticated or Selected state"; command-select is "Valid only when
+ * in Selected state". SESSION_AUTHENTICATING/SESSION_STORE_PENDING (the
+ * async window between a successful IMSG_AUTH_RESULT and the client's
+ * tagged OK -- see sasl_plain_finish()/session_request_store()) are
+ * deliberately excluded from ST_AUTH: the client hasn't been told
+ * AUTHENTICATE succeeded yet, so from its point of view it's still in the
+ * Not Authenticated state and shouldn't be able to pipeline a command-auth
+ * command into that window. SESSION_SELECTING (the same kind of async
+ * window, this time between IMSG_MBOX_SELECT and IMSG_MBOX_SELECTED -- see
+ * select_or_examine()/session_store_dispatch()) is excluded for the identical
+ * reason: the client hasn't been told SELECT succeeded or failed yet.
+ * SESSION_FETCHING (IMSG_MBOX_FETCH sent, awaiting the IMSG_MBOX_FETCH_META
+ * stream + terminal IMSG_MBOX_RESULT) is excluded from ST_SELECTED for a
+ * related but distinct reason: s->pending_tag and s->fetch_attrs belong to
+ * the in-flight FETCH until that terminal reply arrives, so a second
+ * command-select command dispatched into that window would clobber both
+ * before session_handle_mbox_result() gets to use them. SESSION_STORING
+ * (IMSG_MBOX_STORE sent) is excluded for the identical reason -- it shares
+ * the same s->pending_tag and the same IMSG_MBOX_FETCH_META/IMSG_MBOX_
+ * RESULT reply shape as SESSION_FETCHING (see cmd_store_cmd()'s comment).
+ * SESSION_EXPUNGING (IMSG_MBOX_EXPUNGE sent, by either EXPUNGE or CLOSE)
+ * is excluded for the same reason again -- s->pending_tag and s->close_
+ * after_expunge belong to that in-flight request until IMSG_MBOX_RESULT
+ * arrives. SESSION_APPENDING (IMSG_MBOX_APPEND sent, once the client's
+ * literal has been fully read) is excluded for the same reason -- s->
+ * pending_tag and s->append_prev_state belong to that in-flight request
+ * until IMSG_MBOX_APPENDED arrives; note this is a *different* window
+ * than s->literal_pending (the client-literal-read phase that precedes
+ * it), which is intercepted at the raw-byte level in session_dispatch_
+ * client() before command dispatch is ever reached at all, and so needs
+ * no ST_* exclusion of its own -- see that field's comment. SESSION_
+ * SEARCHING (IMSG_MBOX_SEARCH sent) is excluded for the same reason
+ * again -- s->pending_tag, s->search_return_opts, and s->search_matches
+ * (accumulating across the IMSG_MBOX_SEARCH_MATCH stream) all belong to
+ * the in-flight SEARCH until IMSG_MBOX_RESULT arrives. SESSION_STATUSING
+ * (IMSG_MBOX_STATUS sent) is excluded from ST_AUTH for the same reason
+ * again -- s->pending_tag, s->status_attrs, and s->status_prev_state
+ * belong to the in-flight STATUS until IMSG_MBOX_STATUS_RESULT arrives;
+ * note STATUS itself is dispatched *from* ST_AUTH (it's valid in either
+ * Authenticated or Selected state, RFC 9051 SS6.3.11), so this exclusion
+ * only matters for a second command arriving while one STATUS is still
+ * outstanding, exactly like SELECTING/SEARCHING/etc. above. SESSION_
+ * COPYING (IMSG_MBOX_COPY or IMSG_MBOX_MOVE sent) is excluded from
+ * ST_SELECTED for the same reason as SESSION_SEARCHING -- s->pending_tag,
+ * s->copy_src_uids/copy_dest_uids, s->cmd_is_move, and (for a MOVE)
+ * s->move_expunged all belong to the in-flight COPY/MOVE until IMSG_MBOX_
+ * RESULT arrives.
+ */
+#define ST_AUTH \
+	((1U << SESSION_AUTHENTICATED) | (1U << SESSION_SELECTED))
+#define ST_SELECTED	(1U << SESSION_SELECTED)
+
+static const struct imap_cmd_entry imap_cmds[] = {
+	{ "CAPABILITY",	ST_ANY,		cmd_capability },
+	{ "NOOP",	ST_ANY,		cmd_noop },
+	{ "LOGOUT",	ST_ANY,		cmd_logout },
+	{ "ID",		ST_ANY,		cmd_id },
+	{ "LOGIN",	ST_NOTAUTH,	cmd_login },
+	{ "STARTTLS",	ST_NOTAUTH,	cmd_starttls },
+	{ "AUTHENTICATE", ST_NOTAUTH,	cmd_authenticate },
+
+	/* command-auth, RFC 9051 SS6.3 order */
+	{ "ENABLE",	ST_AUTH,	cmd_enable },
+	{ "SELECT",	ST_AUTH,	cmd_select },
+	{ "EXAMINE",	ST_AUTH,	cmd_examine },
+	{ "CREATE",	ST_AUTH,	cmd_create },
+	{ "DELETE",	ST_AUTH,	cmd_delete },
+	{ "RENAME",	ST_AUTH,	cmd_rename },
+	{ "SUBSCRIBE",	ST_AUTH,	cmd_subscribe },
+	{ "UNSUBSCRIBE", ST_AUTH,	cmd_unsubscribe },
+	{ "LIST",	ST_AUTH,	cmd_list },
+	{ "LSUB",	ST_AUTH,	cmd_lsub },
+	{ "NAMESPACE",	ST_AUTH,	cmd_namespace },
+	{ "STATUS",	ST_AUTH,	cmd_status },
+	{ "APPEND",	ST_AUTH,	cmd_append },
+	{ "IDLE",	ST_AUTH,	cmd_idle },
+
+	/* command-select, RFC 9051 SS6.4 order */
+	{ "CLOSE",	ST_SELECTED,	cmd_close },
+	{ "UNSELECT",	ST_SELECTED,	cmd_unselect },
+	{ "EXPUNGE",	ST_SELECTED,	cmd_expunge },
+	{ "SEARCH",	ST_SELECTED,	cmd_search },
+	{ "FETCH",	ST_SELECTED,	cmd_fetch },
+	{ "STORE",	ST_SELECTED,	cmd_store_cmd },
+	{ "COPY",	ST_SELECTED,	cmd_copy },
+	{ "MOVE",	ST_SELECTED,	cmd_move },
+	{ "UID",	ST_SELECTED,	cmd_uid },
+};
+#define NUM_IMAP_CMDS	(sizeof(imap_cmds) / sizeof(imap_cmds[0]))
+
+__dead void
+listener_main(void)
+{
+	struct imsgbuf			 ibuf3;
+	struct passwd			*pw;
+	int				 peer_fd;
+	struct imsg			 imsg;
+	struct imsg_listener_init	 init;
+	ssize_t				 n;
+	char		 cert_buf[TLS_CERT_MAX], key_buf[TLS_KEY_MAX];
+	size_t		 cert_len = 0, key_len = 0;
+	int		 got_cert = 0, got_key = 0, got_init = 0;
+	int		 recv_cleartext = 0, recv_tls = 0, i;
+
+	memset(&init, 0, sizeof(init));
+
+	if (imsgbuf_init(&ibuf3, 3) == -1)
+		fatal("imsgbuf_init");
+	imsgbuf_allow_fdpass(&ibuf3);	/* this channel receives fd-passed
+					 * IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS
+					 * and IMSG_SETUP_PEER messages below
+					 * -- see imsgev.c's
+					 * imsgev_init() comment. */
+
+	/* boot-time handshake: one peer (auth), then SETUP_DONE+ack. */
+	peer_fd = setup_recv_one_peer(&ibuf3);
+	setup_recv_done_and_ack(&ibuf3);
+
+	/*
+	 * IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS (one each normally, two each
+	 * for "listen on *" -- see imapd.h's LISTENER_MAX_ADDRS comment),
+	 * IMSG_TLS_CERT, IMSG_TLS_KEY, and IMSG_LISTENER_INIT still arrive
+	 * on fd 3 from parent, sent right after the handshake above (see
+	 * parent.c's parent_main()) -- read them synchronously here, before
+	 * entering the event loop, since we need the socket fds to exist
+	 * before we can event_set() an accept handler on them. ibuf3 itself
+	 * (not a fresh imsgbuf) is later handed to imsgev_init_from_ibuf()
+	 * below -- see this file's header comment for why a second
+	 * imsgbuf_init() on fd 3 would be wrong.
+	 *
+	 * How many socket messages to expect isn't known until
+	 * IMSG_LISTENER_INIT itself arrives (it carries n_cleartext_addrs/
+	 * n_tls_addrs), so the loop below only checks recv_cleartext/
+	 * recv_tls against those counts once got_init is true -- the
+	 * short-circuit "!got_init ||" keeps the loop going regardless of
+	 * what those still-zeroed counts would otherwise say. Every message
+	 * type here can arrive in any order relative to the others (parent
+	 * fires them off back to back with no synchronization forcing a
+	 * read in between), including sockets before init.
+	 */
+	/*
+	 * imsg_get() before imsgbuf_read(), same reasoning throughout this
+	 * loop as imsgev.c's setup_recv_one_peer() header comment: the
+	 * SETUP_DONE-ack exchange immediately preceding this loop
+	 * (setup_recv_done_and_ack(), just above) can leave more than one
+	 * of these five messages already sitting fully buffered in ibuf3
+	 * -- parent fires them off back to back with no synchronization
+	 * forcing a read in between -- so checking imsg_get() first avoids
+	 * ever issuing a real blocking recvmsg() for bytes that already
+	 * arrived. Restructured from the previous "outer imsgbuf_read(),
+	 * inner drain-while-imsg_get()" shape into one flat loop that
+	 * always tries imsg_get() first, since that outer/inner split had
+	 * the exact same bug on its very first iteration.
+	 */
+	while (!got_init || !got_cert || !got_key ||
+	    recv_cleartext < init.n_cleartext_addrs ||
+	    recv_tls < init.n_tls_addrs) {
+		if ((n = imsg_get(&ibuf3, &imsg)) == -1)
+			fatal("imsg_get");
+		if (n == 0) {
+			if ((n = imsgbuf_read(&ibuf3)) == -1)
+				fatal("imsgbuf_read");
+			if (n == 0)
+				fatalx("listener: parent closed channel "
+				    "during boot");
+			continue;
+		}
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_LISTENER_SOCKET_CLEARTEXT:
+			if (recv_cleartext >= LISTENER_MAX_ADDRS)
+				fatalx("listener: too many cleartext "
+				    "listener sockets (max %d)",
+				    LISTENER_MAX_ADDRS);
+			cleartext_fd[recv_cleartext++] = imsg_get_fd(&imsg);
+			break;
+		case IMSG_LISTENER_SOCKET_TLS:
+			if (recv_tls >= LISTENER_MAX_ADDRS)
+				fatalx("listener: too many tls listener "
+				    "sockets (max %d)", LISTENER_MAX_ADDRS);
+			tls_fd[recv_tls++] = imsg_get_fd(&imsg);
+			break;
+		case IMSG_TLS_CERT:
+			cert_len = imsg_get_len(&imsg);
+			if (cert_len > sizeof(cert_buf)) {
+				log_warnx("listener: TLS cert too "
+				    "large (%zu > %zu)", cert_len,
+				    sizeof(cert_buf));
+				cert_len = 0;
+			} else if (imsg_get_data(&imsg, cert_buf,
+			    cert_len) == -1) {
+				log_warnx("bad IMSG_TLS_CERT");
+				cert_len = 0;
+			}
+			got_cert = 1;
+			break;
+		case IMSG_TLS_KEY:
+			key_len = imsg_get_len(&imsg);
+			if (key_len > sizeof(key_buf)) {
+				log_warnx("listener: TLS key too "
+				    "large (%zu > %zu)", key_len,
+				    sizeof(key_buf));
+				key_len = 0;
+			} else if (imsg_get_data(&imsg, key_buf,
+			    key_len) == -1) {
+				log_warnx("bad IMSG_TLS_KEY");
+				key_len = 0;
+			}
+			got_key = 1;
+			break;
+		case IMSG_LISTENER_INIT:
+			/* see imapd.h's imsg_listener_init comment --
+			 * n_cleartext_addrs/n_tls_addrs are load-bearing
+			 * now (this loop's own exit condition reads
+			 * them), not just a log line. The listening
+			 * sockets themselves are never rebuilt from
+			 * listen_addr here; that already happened in
+			 * parent before this process even existed. */
+			if (imsg_get_data(&imsg, &init, sizeof(init))
+			    == -1) {
+				log_warnx("bad IMSG_LISTENER_INIT");
+				break;
+			}
+			got_init = 1;
+			break;
+		default:
+			log_debug("listener boot: unhandled %d",
+			    imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	n_cleartext_fd = recv_cleartext;
+	n_tls_fd = recv_tls;
+
+	log_info("listening on %s:%u (cleartext, %d socket%s) and "
+	    "%s:%u (implicit TLS, %d socket%s)",
+	    init.listen_addr, init.port_cleartext, n_cleartext_fd,
+	    n_cleartext_fd == 1 ? "" : "s",
+	    init.listen_addr, init.port_implicit_tls, n_tls_fd,
+	    n_tls_fd == 1 ? "" : "s");
+
+	if ((pw = getpwnam("_imapd")) == NULL)
+		fatalx("getpwnam _imapd: no such user "
+		    "(expected, not yet provisioned by an install script)");
+
+	if (chroot("/var/empty") == -1)
+		fatal("chroot /var/empty");
+	if (chdir("/") == -1)
+		fatal("chdir /");
+
+	/*
+	 * listener's own privilege drop -- previously flagged as entirely
+	 * missing (this process stayed at whatever uid execvp()'d it, i.e.
+	 * root). _imapd here is an ordinary system daemon user, the
+	 * listener-role counterpart to auth.c's _imapauth (renamed from
+	 * _openimap/_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) -- unrelated to the credential-file/
+	 * mailbox-user design decision, same distinction auth.c's header
+	 * comment makes for its own daemon user.
+	 */
+	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 _imapd");
+
+	/*
+	 * Build the server-wide TLS context from the cert/key bytes read
+	 * above, sourced directly against httpd's server_tls_init()
+	 * (openbsd_source/src/usr.sbin/httpd/server.c) and src/lib/libtls/
+	 * tls.h: tls_config_new()+tls_server()+tls_config_set_keypair_mem()
+	 * +tls_configure(), then tls_config_clear_keys() and scrubbing our
+	 * own copy -- httpd does the equivalent with freezero(), we don't
+	 * have that in this sandbox's stub environment so explicit_bzero()+
+	 * the buffer simply going out of scope at function return serves
+	 * the same purpose (it's a fixed-size stack buffer, not a heap
+	 * allocation, so there's nothing to free()).
+	 *
+	 * A failure here is NOT fatal to the whole daemon -- see
+	 * listener_tls_ctx's file-scope comment for why: cleartext
+	 * CAPABILITY/NOOP/LOGOUT/ID still work without TLS, so a bad
+	 * cert/key degrades to "no TLS" rather than killing listener
+	 * entirely. tls_accept_socket() call sites below all check for
+	 * listener_tls_ctx == NULL first.
+	 *
+	 * tls_config_set_ciphers(..., "secure") is explicit here rather
+	 * than left at whatever tls_config_new()'s own unset default
+	 * happens to be -- prompted by relayd(8)/httpd(8) both explicitly
+	 * moving their own default cipher sets to "secure" this same
+	 * OpenBSD development cycle (rsadowski.de's "Dead Software
+	 * Walking" writeup on the two daemons' ongoing modernization).
+	 * "secure" is a real, documented named cipher-list value
+	 * (tls_config_set_ciphers(3): "secure (or alias default)"), so
+	 * this removes any ambiguity about which list actually applies
+	 * rather than relying on an implicit library default that could
+	 * change across libtls versions.
+	 */
+	if (cert_len == 0 || key_len == 0) {
+		log_warnx("listener: no TLS cert/key received -- TLS "
+		    "disabled for this run");
+	} else if ((listener_tls_config = tls_config_new()) == NULL) {
+		log_warnx("listener: tls_config_new failed -- TLS disabled");
+	} else if ((listener_tls_ctx = tls_server()) == NULL) {
+		log_warnx("listener: tls_server failed -- TLS disabled");
+		tls_config_free(listener_tls_config);
+		listener_tls_config = NULL;
+	} else if (tls_config_set_ciphers(listener_tls_config, "secure")
+	    != 0) {
+		log_warnx("listener: tls_config_set_ciphers: %s -- "
+		    "TLS disabled", tls_config_error(listener_tls_config));
+		tls_free(listener_tls_ctx);
+		tls_config_free(listener_tls_config);
+		listener_tls_ctx = NULL;
+		listener_tls_config = NULL;
+	} else if (tls_config_set_keypair_mem(listener_tls_config,
+	    (const uint8_t *)cert_buf, cert_len,
+	    (const uint8_t *)key_buf, key_len) != 0) {
+		log_warnx("listener: tls_config_set_keypair_mem: %s -- "
+		    "TLS disabled", tls_config_error(listener_tls_config));
+		tls_free(listener_tls_ctx);
+		tls_config_free(listener_tls_config);
+		listener_tls_ctx = NULL;
+		listener_tls_config = NULL;
+	} else if (tls_configure(listener_tls_ctx, listener_tls_config)
+	    != 0) {
+		log_warnx("listener: tls_configure: %s -- TLS disabled",
+		    tls_error(listener_tls_ctx));
+		tls_free(listener_tls_ctx);
+		tls_config_free(listener_tls_config);
+		listener_tls_ctx = NULL;
+		listener_tls_config = NULL;
+	} else {
+		tls_config_clear_keys(listener_tls_config);
+		log_info("listener: TLS configured");
+	}
+	explicit_bzero(key_buf, sizeof(key_buf));
+
+	event_init();
+
+	imsgev_init(&iev_auth, peer_fd, listener_dispatch_auth, NULL);
+
+	/*
+	 * Hands off fd 3's already-populated ibuf3 to the event loop
+	 * without a second imsgbuf_init() -- see this file's header
+	 * comment and imsgev.c's imsgev_init_from_ibuf() comment for why
+	 * that distinction is load-bearing here, not just tidiness. This
+	 * is the channel parent uses for the ongoing per-session
+	 * store-fork protocol (IMSG_STORE_FORK out, IMSG_SETUP_PEER /
+	 * IMSG_STORE_FORK-as-failure in).
+	 */
+	imsgev_init_from_ibuf(&iev_parent, &ibuf3, listener_dispatch_parent,
+	    NULL);
+
+	/* Each listening socket gets its own struct event -- reusing
+	 * an imsg channel's .ev here (an earlier draft of this function
+	 * did) would clobber that channel's own event registration, since
+	 * event_set()/event_add() on a struct event that's already
+	 * registered elsewhere silently repurposes it rather than erroring.
+	 * That now also means each *additional* dual-stack socket needs its
+	 * own struct event too, not just each port purpose -- ev_accept_
+	 * cleartext[]/ev_accept_tls[] arrays, one slot per bound fd, rather
+	 * than one struct event per array. listener_accept()'s own arg
+	 * ((void *)0 cleartext, (void *)1 tls) is unchanged: it only ever
+	 * needed to know which *port purpose* accepted the connection, not
+	 * which address family did. */
+	for (i = 0; i < n_cleartext_fd; i++) {
+		event_set(&ev_accept_cleartext[i], cleartext_fd[i],
+		    EV_READ | EV_PERSIST, listener_accept, (void *)0);
+		event_add(&ev_accept_cleartext[i], NULL);
+	}
+	for (i = 0; i < n_tls_fd; i++) {
+		event_set(&ev_accept_tls[i], tls_fd[i],
+		    EV_READ | EV_PERSIST, listener_accept, (void *)1);
+		event_add(&ev_accept_tls[i], NULL);
+	}
+
+#ifdef __OpenBSD__
+	if (pledge("stdio recvfd sendfd inet", NULL) == -1)
+		fatal("pledge");
+#endif
+
+	event_dispatch();
+	fatalx("listener: exited event loop");
+}
+
+/*
+ * Accepts a connection and allocates its struct session. On the
+ * cleartext port, wires session_dispatch_client() and sends the RFC
+ * 9051 greeting immediately. On the implicit-TLS port (RFC 8314), no
+ * protocol bytes -- not even the greeting -- may cross the wire before
+ * TLS is established, so the handshake starts immediately instead (see
+ * session_tls_start()) and the greeting is deferred until it completes
+ * (s->pending_greeting, sent from session_tls_handshake()).
+ */
+static void
+listener_accept(int fd, short event, void *arg)
+{
+	struct sockaddr_storage	 ss;
+	socklen_t		 sslen = sizeof(ss);
+	int			 client_fd;
+	struct session		*s;
+
+	(void)event;
+	if ((client_fd = accept(fd, (struct sockaddr *)&ss, &sslen)) == -1) {
+		log_warn("accept");
+		return;
+	}
+
+	s = calloc(1, sizeof(*s));
+	if (s == NULL) {
+		log_warn("calloc");
+		close(client_fd);
+		return;
+	}
+	s->id = next_session_id++;
+	s->client_fd = client_fd;
+	s->state = SESSION_NOT_AUTH;
+	s->implicit_tls = (arg != (void *)0);	/* see the two event_set()
+						 * calls in listener_main() --
+						 * (void *)1 for the port-993
+						 * listener */
+	TAILQ_INSERT_TAIL(&sessions, s, entry);
+
+	log_debug("session %u: accepted (%s)", s->id,
+	    s->implicit_tls ? "implicit TLS" : "cleartext/STARTTLS");
+
+	if (s->implicit_tls) {
+		if (listener_tls_ctx == NULL) {
+			/* TLS didn't configure successfully at boot -- see
+			 * listener_tls_ctx's file-scope comment. Nothing
+			 * correct to do on this port without it. */
+			log_warnx("session %u: implicit-TLS port, but TLS "
+			    "isn't configured -- closing", s->id);
+			session_teardown(s);
+			return;
+		}
+		s->pending_greeting = 1;
+		session_tls_start(s);
+		return;
+	}
+
+	session_arm_client_read(s);
+	session_send_greeting(s);
+}
+
+/*
+ * (Re-)registers client_ev in normal EV_READ|EV_PERSIST mode pointing at
+ * session_dispatch_client() -- the steady-state read handler once no TLS
+ * handshake is in progress (either because there's no TLS at all, or
+ * because one just completed). Guards the event_del() with client_ev_
+ * added the same way session_teardown() does, since this can be the
+ * very first registration for a session (the plaintext accept path) or
+ * a re-registration after a handshake (which repurposed client_ev to a
+ * different handler/flags -- see session_tls_start()).
+ */
+static void
+session_arm_client_read(struct session *s)
+{
+	if (s->client_ev_added)
+		event_del(&s->client_ev);
+	event_set(&s->client_ev, s->client_fd, EV_READ | EV_PERSIST,
+	    session_dispatch_client, s);
+	event_add(&s->client_ev, NULL);
+	s->client_ev_added = 1;
+}
+
+/*
+ * Starts a TLS handshake on s->client_fd: tls_accept_socket() creates
+ * the per-connection struct tls (non-blocking, doesn't itself perform
+ * handshake I/O), then client_ev is armed for EV_READ pointing at
+ * session_tls_handshake() to drive the actual handshake once the socket
+ * is readable -- sourced against httpd's server_input()/tls_accept_
+ * socket() call site and server_tls_handshake() (openbsd_source/src/
+ * usr.sbin/httpd/server.c), which arms EV_READ and waits for the first
+ * callback rather than calling tls_handshake() synchronously here.
+ * Called both from listener_accept() (implicit-TLS port, client_ev not
+ * yet registered) and cmd_starttls() (cleartext port, client_ev already
+ * in normal read-dispatch mode) -- the client_ev_added guard, same
+ * pattern as session_arm_client_read() and session_teardown(), covers
+ * both.
+ */
+static void
+session_tls_start(struct session *s)
+{
+	if (tls_accept_socket(listener_tls_ctx, &s->tls_ctx, s->client_fd)
+	    != 0) {
+		log_warnx("session %u: tls_accept_socket: %s", s->id,
+		    tls_error(listener_tls_ctx));
+		session_teardown(s);
+		return;
+	}
+
+	if (s->client_ev_added)
+		event_del(&s->client_ev);
+	event_set(&s->client_ev, s->client_fd, EV_READ, session_tls_handshake,
+	    s);
+	event_add(&s->client_ev, NULL);
+	s->client_ev_added = 1;
+}
+
+/*
+ * Drives a non-blocking TLS handshake to completion, re-arming client_ev
+ * for whichever direction tls_handshake() reports it's waiting on
+ * (TLS_WANT_POLLIN/TLS_WANT_POLLOUT can each occur regardless of which
+ * direction the *previous* call was armed for -- a TLS handshake isn't
+ * a simple read-then-write sequence). Sourced directly against httpd's
+ * server_tls_handshake(): ret == 0 is success, TLS_WANT_POLLIN/POLLOUT
+ * re-arm and wait for another callback, anything else is a hard
+ * failure. This is the one place in this codebase where blocking would
+ * be a real bug rather than just a simplification -- listener is a
+ * single event loop serving every session, so blocking here on one
+ * client's slow handshake would stall all the others; unlike
+ * session_write()'s short-message blocking-write simplification, there
+ * is no equivalent shortcut available for a multi-round-trip handshake.
+ */
+static void
+session_tls_handshake(int fd, short event, void *arg)
+{
+	struct session	*s = arg;
+	int		 ret;
+
+	(void)fd;
+	(void)event;
+
+	ret = tls_handshake(s->tls_ctx);
+	if (ret == 0) {
+		s->tls_active = 1;
+		log_debug("session %u: TLS handshake complete (%s %s)",
+		    s->id, tls_conn_version(s->tls_ctx),
+		    tls_conn_cipher(s->tls_ctx));
+		session_arm_client_read(s);
+		if (s->pending_greeting) {
+			s->pending_greeting = 0;
+			session_send_greeting(s);
+		}
+		return;
+	}
+	if (ret == TLS_WANT_POLLIN) {
+		event_del(&s->client_ev);
+		event_set(&s->client_ev, s->client_fd, EV_READ,
+		    session_tls_handshake, s);
+		event_add(&s->client_ev, NULL);
+		return;
+	}
+	if (ret == TLS_WANT_POLLOUT) {
+		event_del(&s->client_ev);
+		event_set(&s->client_ev, s->client_fd, EV_WRITE,
+		    session_tls_handshake, s);
+		event_add(&s->client_ev, NULL);
+		return;
+	}
+
+	log_warnx("session %u: tls_handshake: %s", s->id,
+	    tls_error(s->tls_ctx));
+	session_teardown(s);
+}
+
+/*
+ * "* OK IMAP4rev2 server ready" -- the exact example text from RFC 9051
+ * SS7.1.1's OK-response section, used verbatim rather than paraphrased.
+ * Goes through session_write() like every other response, so it's
+ * transparently TLS-aware for the implicit-TLS accept path (see
+ * session_tls_handshake(), which calls this once the handshake
+ * completes) without needing its own TLS-vs-plaintext branch here.
+ */
+static void
+session_send_greeting(struct session *s)
+{
+	static const char	 greeting[] = "* OK IMAP4rev2 server ready\r\n";
+
+	session_write(s, greeting, sizeof(greeting) - 1);
+}
+
+/*
+ * Reads raw bytes into s->inbuf and splits them into CRLF-terminated
+ * lines, per RFC 9051 SS2.2's "all interactions ... are in the form of
+ * lines" -- a bare LF (some clients/testing tools are sloppy about this)
+ * is deliberately NOT treated as a line ending, matching the ABNF's
+ * literal CRLF requirement rather than being lenient about it. Each
+ * complete line goes to session_handle_line() for tag/command parsing
+ * and dispatch.
+ *
+ * Correctness note: session_handle_line() can tear down (free) *s* --
+ * currently only LOGOUT does this deliberately, see cmd_logout(). Once
+ * that happens this loop MUST NOT touch s again, which is why `consumed`
+ * is computed *before* the call (pure pointer arithmetic on data already
+ * in the buffer, no s-> field mutation needed yet) and the loop returns
+ * immediately rather than falling through to the memmove/inbuflen
+ * update below. Write failures do NOT tear down the session here (see
+ * session_write()'s comment) specifically to avoid needing this same
+ * care in every command handler -- LOGOUT is the one deliberate,
+ * well-understood exception.
+ */
+static void
+session_dispatch_client(int fd, short event, void *arg)
+{
+	struct session	*s = arg;
+	ssize_t		 n;
+	char		*crlf;
+
+	(void)event;
+
+	if (s->tls_active) {
+		n = tls_read(s->tls_ctx, s->inbuf + s->inbuflen,
+		    sizeof(s->inbuf) - s->inbuflen);
+		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT) {
+			/*
+			 * tls_read() can want to WRITE (renegotiation,
+			 * session ticket rotation -- see httpd's server_
+			 * tls_readcb() for the same pattern) even though
+			 * this is a read path. Re-arm client_ev for whichever
+			 * direction it actually needs and wait for the next
+			 * callback -- unlike session_write()'s bounded
+			 * blocking poll() retry, this is already inside the
+			 * event loop, so there's no need to block at all,
+			 * just re-register and return. Not EV_PERSIST: this
+			 * is a one-shot retry registration, restored to the
+			 * normal EV_READ|EV_PERSIST steady state below once
+			 * real data (or EOF) is actually obtained.
+			 */
+			event_del(&s->client_ev);
+			event_set(&s->client_ev, s->client_fd,
+			    (n == TLS_WANT_POLLIN) ? EV_READ : EV_WRITE,
+			    session_dispatch_client, s);
+			event_add(&s->client_ev, NULL);
+			return;
+		}
+		if (n == -1) {
+			log_warnx("session %u: tls_read: %s", s->id,
+			    tls_error(s->tls_ctx));
+			session_teardown(s);
+			return;
+		}
+		/*
+		 * Got real data or EOF (n == 0, handled below) -- the event
+		 * that delivered us here might have been a temporary,
+		 * non-persistent WANT_POLLIN/WANT_POLLOUT retry registration
+		 * (above) rather than the steady-state EV_READ|EV_PERSIST
+		 * one, so restore that steady state unconditionally rather
+		 * than tracking which case we're in. Redundant but harmless
+		 * in the common case where it was already correct.
+		 */
+		session_arm_client_read(s);
+	} else {
+		n = read(fd, s->inbuf + s->inbuflen,
+		    sizeof(s->inbuf) - s->inbuflen);
+		if (n == -1) {
+			log_warn("session %u: read", s->id);
+			session_teardown(s);
+			return;
+		}
+	}
+
+	if (n == 0) {
+		log_debug("session %u: client closed connection", s->id);
+		session_teardown(s);
+		return;
+	}
+	s->inbuflen += (size_t)n;
+
+	for (;;) {
+		size_t	consumed;
+		int	alive;
+
+		/*
+		 * RFC 9051 SS4.3 literal in flight (cmd_append() found a
+		 * trailing "{n}"/"{n+}" and is waiting for its raw octets --
+		 * see s->literal_pending's comment). Checked *before* the
+		 * CRLF search below on purpose: a literal's bytes can
+		 * contain CRLF sequences of their own (SS4.3: "a sequence of
+		 * zero or more octets (including CR and LF)"), so treating
+		 * them as line-oriented input here would both misparse the
+		 * literal itself and, worse, could let embedded bytes that
+		 * happen to look like "tag SP command CRLF" get dispatched
+		 * as a bogus command mid-literal. This block runs first,
+		 * every iteration, until the full literal (and its
+		 * mandatory trailing CRLF) has been consumed -- no ST_*
+		 * dispatch-table exclusion is needed for this phase, unlike
+		 * every async-imsg-wait state, because session_handle_line()
+		 * is simply never reached while it's active.
+		 */
+		if (s->literal_pending) {
+			uint64_t	want, take;
+
+			want = s->literal_remaining;
+			take = (uint64_t)s->inbuflen < want ?
+			    (uint64_t)s->inbuflen : want;
+
+			if (take > 0) {
+				memcpy(s->literal_buf +
+				    (s->literal_len - s->literal_remaining),
+				    s->inbuf, (size_t)take);
+				s->literal_remaining -= take;
+				memmove(s->inbuf, s->inbuf + take,
+				    s->inbuflen - (size_t)take);
+				s->inbuflen -= (size_t)take;
+			}
+
+			if (s->literal_remaining > 0)
+				break;	/* need more data -- wait for the
+					 * next read */
+
+			/*
+			 * Literal body fully received. RFC 9051 SS9's
+			 * `command = tag SP ... CRLF` still requires a
+			 * trailing CRLF after the literal's raw octets --
+			 * SS4.3's `literal` production ends with the octets
+			 * themselves, not a CRLF; the CRLF belongs to the
+			 * outer `command` production, same as after any
+			 * other argument. APPEND's own grammar (SS6.3.12:
+			 * `append = ... SP literal`) puts the literal last,
+			 * so nothing else can follow it on the line -- v1
+			 * doesn't support a literal anywhere but the final
+			 * argument, a deliberate simplification flagged in
+			 * parse_append_args()'s comment.
+			 */
+			if (s->inbuflen < 2)
+				break;	/* trailing CRLF hasn't arrived yet */
+			if (s->inbuf[0] != '\r' || s->inbuf[1] != '\n') {
+				/*
+				 * Genuinely hard to resync from here -- we
+				 * have no reliable way to know where the
+				 * next real command boundary is inside
+				 * whatever the client actually sent instead
+				 * of CRLF. Same "give up rather than guess"
+				 * call session_teardown() elsewhere in this
+				 * file makes for comparably confused
+				 * protocol states, rather than risk
+				 * mis-parsing arbitrary subsequent bytes as
+				 * commands.
+				 */
+				log_warnx("session %u: expected CRLF after "
+				    "literal data, closing", s->id);
+				session_teardown(s);
+				return;
+			}
+			memmove(s->inbuf, s->inbuf + 2, s->inbuflen - 2);
+			s->inbuflen -= 2;
+
+			s->literal_pending = 0;
+			alive = session_finish_append(s);
+			if (!alive)
+				return;
+			continue;
+		}
+
+		crlf = memmem(s->inbuf, s->inbuflen, "\r\n", 2);
+		if (crlf == NULL)
+			break;
+
+		*crlf = '\0';
+		consumed = (size_t)(crlf - s->inbuf) + 2;
+
+		/*
+		 * A bare SASL continuation-response line (base64, or "*" to
+		 * cancel -- RFC 9051 SS6.2.2) is NOT a tagged command and
+		 * must not go through session_handle_line()'s tag/name/args
+		 * parser -- s->auth_cont, set by cmd_authenticate() when the
+		 * client sent "AUTHENTICATE PLAIN" with no inline initial
+		 * response, routes it to session_handle_auth_continuation()
+		 * instead. Likewise, a bare "DONE" continuation line while
+		 * idling (RFC 9051 SS6.3.13) is NOT a tagged command either
+		 * -- s->idling, set by cmd_idle(), routes it to session_
+		 * handle_idle_continuation() the same way. All three share
+		 * the same alive/torn-down (1/0) return convention this loop
+		 * already relies on.
+		 */
+		alive = s->auth_cont ?
+		    session_handle_auth_continuation(s, s->inbuf) :
+		    s->idling ?
+		    session_handle_idle_continuation(s, s->inbuf) :
+		    session_handle_line(s, s->inbuf);
+		if (alive == 0)
+			return;	/* s was torn down (LOGOUT) -- must not
+				 * touch it again, see this function's
+				 * header comment. */
+
+		/*
+		 * A handler can mutate s->inbuflen itself -- cmd_starttls()
+		 * deliberately zeroes it to discard any plaintext pipelined
+		 * past the STARTTLS line (see its comment). Clamp `consumed`
+		 * (computed above, before the handler ran) against the
+		 * *current* inbuflen so the subtraction below can't
+		 * underflow a size_t into a huge value. A no-op in the
+		 * ordinary case, since inbuflen only otherwise grows via new
+		 * reads between session_dispatch_client() invocations, never
+		 * shrinks except through this same loop.
+		 */
+		if (consumed > s->inbuflen)
+			consumed = s->inbuflen;
+
+		memmove(s->inbuf, s->inbuf + consumed, s->inbuflen - consumed);
+		s->inbuflen -= consumed;
+	}
+
+	if (s->inbuflen == sizeof(s->inbuf)) {
+		/* Buffer full with no CRLF found -- matches the spirit of
+		 * RFC 9051 SS7.1.3's own example ("* BAD Command line too
+		 * long"), though that exact string is example text, not a
+		 * normative response -- see SESSION_INBUF_MAX's comment. */
+		static const char bad[] = "* BAD command line too long\r\n";
+
+		log_warnx("session %u: command line too long, closing",
+		    s->id);
+		(void)write(s->client_fd, bad, sizeof(bad) - 1);
+		session_teardown(s);
+	}
+}
+
+/*
+ * Writes to the client socket -- plain blocking write(2) for a
+ * plaintext session, or tls_write() for a TLS one. Every response in
+ * this file (including the greeting -- see session_send_greeting())
+ * goes through this one function.
+ *
+ * Blocking is a deliberate v1 simplification: worth revisiting together
+ * (event-driven EV_WRITE + a per-session outbound queue, mirroring
+ * imsgev.c's pattern) once any response needs to be larger than one
+ * send (multi-line FETCH output, etc.) -- v1's responses are all short
+ * and fixed, so this has always been an accepted shortcut, not a claim
+ * of correctness at scale.
+ *
+ * For TLS specifically: tls_write() can return TLS_WANT_POLLIN or
+ * TLS_WANT_POLLOUT even for a plain short write (renegotiation, session
+ * ticket rotation -- see httpd's server_tls_writecb() for the same
+ * pattern in event-driven form), so this retries via poll(2) with a
+ * BOUNDED timeout rather than looping forever. Unlike session_tls_
+ * handshake() (which MUST be event-driven, since listener is one event
+ * loop serving every session and a slow multi-round-trip handshake
+ * would stall all of them), a bounded blocking retry here is a much
+ * smaller, deliberately accepted risk: it can only stall this one
+ * session's own short write, and only in the rare renegotiation case,
+ * and only for SESSION_WRITE_POLL_TIMEOUT_MS at worst -- not the
+ * unbounded, whole-daemon-hanging risk an infinite poll() timeout would
+ * be against a client that simply never reads its socket.
+ *
+ * Deliberately does NOT call session_teardown() on failure (including a
+ * poll(2) timeout) -- unlike the "line too long" path. A write failure
+ * here is logged and otherwise ignored; the read side will discover the
+ * same dead connection on its next read()/tls_read() (EOF or error) and
+ * tear down then. This avoids every command handler needing to guard
+ * against *s* being freed out from under it mid-dispatch -- LOGOUT is
+ * the one place that tears a session down deliberately, and it does so
+ * explicitly, not via a write failure. See session_dispatch_client()'s
+ * header comment for the reentrancy hazard this sidesteps.
+ */
+#define SESSION_WRITE_POLL_TIMEOUT_MS	5000
+
+static void
+session_write(struct session *s, const char *buf, size_t len)
+{
+	size_t	sent = 0;
+
+	if (!s->tls_active) {
+		/*
+		 * F12 fix: loop on partial writes and retry EINTR/EAGAIN
+		 * (poll like the TLS branch below). Previously a single
+		 * write() ignored short counts, silently truncating a
+		 * multi-write response under back-pressure.
+		 */
+		while (sent < len) {
+			ssize_t		 n;
+			struct pollfd	 pfd;
+
+			n = write(s->client_fd, buf + sent, len - sent);
+			if (n == -1) {
+				if (errno == EINTR)
+					continue;
+				if (errno == EAGAIN || errno == EWOULDBLOCK) {
+					pfd.fd = s->client_fd;
+					pfd.events = POLLOUT;
+					if (poll(&pfd, 1,
+					    SESSION_WRITE_POLL_TIMEOUT_MS) <= 0) {
+						log_warnx("session %u: write: "
+						    "timed out or poll error",
+						    s->id);
+						return;
+					}
+					continue;
+				}
+				log_warn("session %u: write", s->id);
+				return;
+			}
+			sent += (size_t)n;
+		}
+		return;
+	}
+
+	while (sent < len) {
+		ssize_t		 n;
+		struct pollfd	 pfd;
+		int		 pret;
+
+		n = tls_write(s->tls_ctx, buf + sent, len - sent);
+		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT) {
+			pfd.fd = s->client_fd;
+			pfd.events = (n == TLS_WANT_POLLIN) ? POLLIN : POLLOUT;
+			pret = poll(&pfd, 1, SESSION_WRITE_POLL_TIMEOUT_MS);
+			if (pret == -1) {
+				log_warn("session %u: poll (tls_write retry)",
+				    s->id);
+				return;
+			}
+			if (pret == 0) {
+				log_warnx("session %u: tls_write: timed out "
+				    "waiting for socket", s->id);
+				return;
+			}
+			continue;
+		}
+		if (n == -1) {
+			log_warnx("session %u: tls_write: %s", s->id,
+			    tls_error(s->tls_ctx));
+			return;
+		}
+		sent += (size_t)n;
+	}
+}
+
+static void
+session_reply(struct session *s, const char *tag, const char *status,
+    const char *text)
+{
+	char	buf[512];
+	int	len;
+
+	len = snprintf(buf, sizeof(buf), "%s %s %s\r\n", tag, status, text);
+	if (len < 0)
+		return;
+	if ((size_t)len >= sizeof(buf))
+		len = sizeof(buf) - 1;	/* truncate rather than overflow --
+					 * fine for v1's short fixed messages,
+					 * none of which approach this bound */
+	session_write(s, buf, (size_t)len);
+}
+
+static void
+session_untagged(struct session *s, const char *text)
+{
+	char	buf[512];
+	int	len;
+
+	len = snprintf(buf, sizeof(buf), "* %s\r\n", text);
+	if (len < 0)
+		return;
+	if ((size_t)len >= sizeof(buf))
+		len = sizeof(buf) - 1;
+	session_write(s, buf, (size_t)len);
+}
+
+/*
+ * RFC 7162 SS3.1: marks this session as a "CONDSTORE-aware client" --
+ * called from every CONDSTORE-enabling command this server recognizes
+ * (ENABLE CONDSTORE/QRESYNC, FETCH with the MODSEQ fetch-att or CHANGEDSINCE
+ * modifier, STORE with UNCHANGEDSINCE, SEARCH with a MODSEQ criterion).
+ * SELECT/EXAMINE with a CONDSTORE or QRESYNC select-param is deliberately
+ * NOT routed through this function -- see cmd_select()'s comment -- because
+ * that command's own response already includes HIGHESTMODSEQ as part of its
+ * normal reply sequence (session_handle_mbox_selected()), so calling this
+ * from there would emit a redundant, spec-violating second copy.
+ *
+ * Idempotent (does nothing once already enabled) and, per SS3.1's own
+ * parenthetical ("A first CONDSTORE enabling command executed in the
+ * session with a mailbox selected MUST cause the server to return
+ * HIGHESTMODSEQ... (if any is selected)"), only emits the unsolicited
+ * HIGHESTMODSEQ OK response when a mailbox is actually selected right now
+ * -- e.g. ENABLE is normally issued pre-SELECT (RFC 9051 SS6.3.1), where
+ * this is correctly a no-op beyond setting the flag. Uses s->mbox_
+ * highestmodseq (cached from the last SELECT/STORE/EXPUNGE reply) rather
+ * than asking store.c for a fresh value -- see that field's comment in
+ * struct session for why a cached value is an acceptable, deliberate
+ * choice for this specific edge case.
+ */
+static void
+session_condstore_enable(struct session *s)
+{
+	char	buf[48];
+
+	if (s->condstore_enabled)
+		return;
+	s->condstore_enabled = 1;
+
+	if (s->state == SESSION_SELECTED) {
+		snprintf(buf, sizeof(buf), "OK [HIGHESTMODSEQ %llu]",
+		    (unsigned long long)s->mbox_highestmodseq);
+		session_untagged(s, buf);
+	}
+}
+
+/*
+ * Splits one already CRLF-stripped line into tag/name/args, per RFC
+ * 9051's ABNF: `command = tag SP (command-any / ...) CRLF`. Tokens are
+ * split on single spaces; RFC 9051 SS2.2.1 says extraneous spaces are
+ * technically a client syntax error, but this parser is deliberately
+ * lenient about repeated spaces between tokens (skips them) rather than
+ * rejecting -- a simplification, not a claim of full ABNF conformance.
+ * Tag characters themselves aren't validated against the real ASTRING-
+ * CHAR class (RFC 9051 SS9, `tag = 1*<any ASTRING-CHAR except "+">`) --
+ * another deliberate simplification, flagged rather than silently
+ * assumed correct.
+ *
+ * Returns -1 if no tag could be found at all (line was empty or all
+ * spaces) -- the caller sends RFC 9051 SS7.1.3's own example response
+ * for exactly this case, untagged "* BAD Empty command line". Returns 0
+ * otherwise; *name may be NULL if a tag was found but no command name
+ * followed it (caller sends a tagged BAD).
+ */
+static int
+parse_command_line(char *line, char **tag, char **name, char **args)
+{
+	char	*p = line;
+
+	while (*p == ' ')
+		p++;
+	if (*p == '\0')
+		return (-1);
+	*tag = p;
+
+	while (*p != '\0' && *p != ' ')
+		p++;
+	if (*p == '\0') {
+		*name = NULL;
+		*args = NULL;
+		return (0);
+	}
+	*p++ = '\0';
+
+	while (*p == ' ')
+		p++;
+	if (*p == '\0') {
+		*name = NULL;
+		*args = NULL;
+		return (0);
+	}
+	*name = p;
+
+	while (*p != '\0' && *p != ' ')
+		p++;
+	if (*p == '\0') {
+		*args = NULL;
+		return (0);
+	}
+	*p++ = '\0';
+
+	while (*p == ' ')
+		p++;
+	*args = (*p != '\0') ? p : NULL;
+	return (0);
+}
+
+/*
+ * Parses and dispatches one command line. Returns 1 if the session is
+ * still alive afterward, 0 if it was torn down (LOGOUT only, in v1) --
+ * see session_dispatch_client()'s header comment for why callers must
+ * check this before touching *s* again.
+ */
+static int
+session_handle_line(struct session *s, char *line)
+{
+	char		*tag, *name, *args;
+	size_t		 i;
+
+	log_debug("session %u: <<< %s", s->id, line);
+
+	if (parse_command_line(line, &tag, &name, &args) == -1) {
+		session_reply(s, "*", "BAD", "Empty command line");
+		return (1);
+	}
+	if (name == NULL) {
+		session_reply(s, tag, "BAD", "Missing command");
+		return (1);
+	}
+
+	for (i = 0; i < NUM_IMAP_CMDS; i++) {
+		if (strcasecmp(name, imap_cmds[i].name) == 0)
+			break;
+	}
+	if (i == NUM_IMAP_CMDS) {
+		session_reply(s, tag, "BAD", "Unknown command");
+		return (1);
+	}
+	if (!(imap_cmds[i].states & (1U << s->state))) {
+		/* RFC 9051 SS3: "the server will respond with a BAD or NO
+		 * (depending upon server implementation)" for a command
+		 * received in the wrong state -- BAD chosen here. */
+		session_reply(s, tag, "BAD",
+		    "Command not permitted in this state");
+		return (1);
+	}
+	return (imap_cmds[i].handler(s, tag, args));
+}
+
+static int
+cmd_capability(struct session *s, const char *tag, char *args)
+{
+	(void)args;	/* RFC 9051: "Arguments: none" -- extra args are
+			 * silently ignored rather than rejected, a
+			 * leniency simplification like parse_command_line()'s. */
+
+	session_untagged(s, s->tls_active ?
+	    "CAPABILITY " CAPABILITY_POST_TLS : "CAPABILITY " CAPABILITY_PRE_TLS);
+	session_reply(s, tag, "OK", "CAPABILITY completed");
+	return (1);
+}
+
+static int
+cmd_noop(struct session *s, const char *tag, char *args)
+{
+	(void)args;
+	session_reply(s, tag, "OK", "NOOP completed");
+	return (1);
+}
+
+static int
+cmd_logout(struct session *s, const char *tag, char *args)
+{
+	(void)args;
+
+	/* Exact example text from RFC 9051 SS6.1.3. */
+	session_untagged(s, "BYE IMAP4rev2 Server logging out");
+	session_reply(s, tag, "OK", "LOGOUT completed");
+	session_teardown(s);
+	return (0);
+}
+
+static int
+cmd_id(struct session *s, const char *tag, char *args)
+{
+	/*
+	 * RFC 2971 SS3.1: full parsing of the client's parenthesized
+	 * field/value list isn't implemented (real IMAP list/string
+	 * argument parsing -- quoted strings, literals -- doesn't exist
+	 * anywhere in this codebase yet, and nothing here needs to look
+	 * inside the list per RFC 2971's own "MUST NOT make operational
+	 * changes based on the data" rule). Logged raw and discarded,
+	 * matching openimap-v1-dispatch.md's stated v1 behavior ("accepts
+	 * client ID params, logs them, no persistence needed for v1").
+	 * Always replies NIL, per SS3.2's "a server MAY send NIL in place
+	 * of the list" -- simplest spec-compliant response, and matches
+	 * the RFC's own minimal example (C: ID NIL / S: * ID NIL).
+	 */
+	log_debug("session %u: ID params: %s", s->id,
+	    args != NULL ? args : "(none)");
+	session_untagged(s, "ID NIL");
+	session_reply(s, tag, "OK", "ID completed");
+	return (1);
+}
+
+static int
+cmd_login(struct session *s, const char *tag, char *args)
+{
+	(void)args;
+
+	/*
+	 * Permanently disabled regardless of TLS state -- resolved design
+	 * decision, openimap-v1-dispatch.md's LOGIN row: RFC 9051 SS6.2.3
+	 * only mandates refusing LOGIN when unprotected (a floor, not a
+	 * ceiling); we rely solely on AUTHENTICATE PLAIN under TLS.
+	 */
+	session_reply(s, tag, "NO",
+	    "LOGIN disabled -- use AUTHENTICATE PLAIN under TLS");
+	return (1);
+}
+
+static int
+cmd_starttls(struct session *s, const char *tag, char *args)
+{
+	if (args != NULL) {
+		/* RFC 9051 SS6.2.1 Result: "BAD - ... arguments invalid". */
+		session_reply(s, tag, "BAD", "STARTTLS takes no arguments");
+		return (1);
+	}
+	if (s->tls_active) {
+		/* RFC 9051 SS6.2.1 Result: "BAD - STARTTLS received after a
+		 * successful TLS negotiation ...". */
+		session_reply(s, tag, "BAD", "TLS already active");
+		return (1);
+	}
+	if (listener_tls_ctx == NULL) {
+		/* RFC 9051 SS6.2.1 Result: "NO - TLS negotiation can't be
+		 * initiated, due to server configuration error" -- exactly
+		 * this codebase's situation if cert/key loading failed at
+		 * boot (see listener_tls_ctx's file-scope comment). RFC
+		 * 5530: UNAVAILABLE -- "Temporary failure because a
+		 * subsystem is down," which is exactly what a boot-time
+		 * cert/key failure leaves TLS in for this run. */
+		session_reply(s, tag, "NO",
+		    "[UNAVAILABLE] TLS negotiation unavailable");
+		return (1);
+	}
+
+	/*
+	 * RFC 9051 SS6.2.1: "A TLS negotiation begins immediately after
+	 * the CRLF at the end of the tagged OK response from the server."
+	 * -- matches the RFC's own example exactly ("C: a002 STARTTLS" /
+	 * "S: a002 OK Begin TLS negotiation now"). Must go out in
+	 * cleartext BEFORE the handshake starts.
+	 */
+	session_reply(s, tag, "OK", "Begin TLS negotiation now");
+
+	/*
+	 * RFC 9051 SS6.2.1's STARTTLS command-injection mitigation: any
+	 * plaintext bytes already sitting in our own read buffer past the
+	 * STARTTLS line itself (a pipelining client, or an attacker) are
+	 * discarded rather than reinterpreted as the start of the TLS
+	 * handshake -- the "throw it away" option the RFC explicitly
+	 * permits as an alternative to "treat as start of handshake",
+	 * chosen here for simplicity. session_dispatch_client()'s caller
+	 * loop clamps its own `consumed` calculation against the
+	 * (possibly now smaller) s->inbuflen after this handler returns,
+	 * specifically to make this safe -- see its comment.
+	 */
+	s->inbuflen = 0;
+
+	session_tls_start(s);
+	return (1);
+}
+
+/*
+ * RFC 4616 SS2: authzid/authcid/passwd are each "MUST accept up to and
+ * including 255 octets", joined by two single-octet NUL delimiters --
+ * 255*3+2 = 767, rounded up. Sourced by fetching the RFC's own text
+ * (https://www.rfc-editor.org/rfc/rfc4616.txt) this session, since it
+ * wasn't present in research/ or openbsd_source/ -- see this file's
+ * git history / session notes for that lookup.
+ */
+#define SASL_PLAIN_MAX	768
+
+/*
+ * Decodes and verifies one SASL PLAIN client message (RFC 4616 SS2:
+ * "message = [authzid] UTF8NUL authcid UTF8NUL passwd"), then sends
+ * IMSG_AUTH_REQUEST to auth.c over iev_auth. b64 is the raw base64 token
+ * from the wire -- either an inline initial response on the AUTHENTICATE
+ * line itself, or the client's line following our "+ " continuation
+ * request (RFC 9051 SS6.2.2).
+ *
+ * b64_pton() (declared in <resolv.h>; OpenBSD's __b64_pton, the same
+ * codec smtpd.c uses -- see openbsd_source/src/usr.sbin/smtpd/util.c)
+ * does the actual decode. Its full implementation (openbsd_source/src/
+ * lib/libc/net/base64.c) was read this project: it returns -1 on any
+ * character outside the base64 alphabet, on target-buffer overflow, and
+ * on misplaced/non-terminal "=" padding -- exactly RFC 9051 SS6.2.2's own
+ * required rejection condition ("if it receives an invalid base64 string
+ * ... it MUST reject the AUTHENTICATE command by sending a tagged BAD
+ * response", explicitly calling out "characters outside the base64
+ * alphabet" and non-terminal "=" as the two examples).
+ *
+ * allow_empty_equals is set only by the inline-initial-response call site
+ * (cmd_authenticate()) -- RFC 9051's initial-resp ABNF is literally
+ * "(base64 / \"=\")", a single pad character meaning "response present,
+ * but zero-length" (SS6.2.2: "To send a zero-length initial response, the
+ * client MUST send a single pad character"). That shorthand is NOT part
+ * of the plain `base64` grammar used for continuation-response lines, so
+ * session_handle_auth_continuation() passes 0 here -- a lone "=" from a
+ * continuation line falls through to b64_pton() and is correctly rejected
+ * as invalid (non-terminal, in fact orphaned) padding.
+ *
+ * Never tears down the session -- an AUTHENTICATE failure keeps the
+ * connection open for a retry, per RFC 9051 SS6.2.2's own "the client MAY
+ * try another authentication mechanism" -- so this always returns 1; the
+ * int return exists only to match session_handle_line()'s alive/torn-
+ * down calling convention that session_dispatch_client()'s loop relies
+ * on.
+ */
+static int
+sasl_plain_finish(struct session *s, const char *tag, const char *b64,
+    int allow_empty_equals)
+{
+	unsigned char		 raw[SASL_PLAIN_MAX];
+	unsigned char		*authcid, *passwd, *nul;
+	int			 rawlen;
+	size_t			 off, authcidlen, passwdlen;
+	struct imsg_auth_request req;
+
+	if (allow_empty_equals && strcmp(b64, "=") == 0) {
+		rawlen = 0;
+	} else {
+		rawlen = b64_pton(b64, raw, sizeof(raw));
+		if (rawlen < 0) {
+			session_reply(s, tag, "BAD", "invalid base64");
+			return (1);
+		}
+	}
+
+	nul = memchr(raw, '\0', (size_t)rawlen);
+	if (nul == NULL) {
+		session_reply(s, tag, "BAD", "malformed SASL PLAIN message");
+		explicit_bzero(raw, sizeof(raw));
+		return (1);
+	}
+	off = (size_t)(nul - raw) + 1;
+	authcid = raw + off;
+	nul = memchr(authcid, '\0', (size_t)rawlen - off);
+	if (nul == NULL) {
+		session_reply(s, tag, "BAD", "malformed SASL PLAIN message");
+		explicit_bzero(raw, sizeof(raw));
+		return (1);
+	}
+	authcidlen = (size_t)(nul - authcid);
+	passwd = nul + 1;
+	passwdlen = (size_t)rawlen - off - authcidlen - 1;
+
+	/*
+	 * RFC 4616 SS2: "if preparation fails or results in an empty
+	 * string, verification SHALL fail". An empty authcid or passwd is
+	 * a syntactically well-formed message that can never authenticate
+	 * -- reject it the same way (NO) as a wrong password, not as a
+	 * malformed message (BAD): nothing about the SASL PLAIN framing
+	 * itself is wrong.
+	 */
+	if (authcidlen == 0 || passwdlen == 0) {
+		session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed");
+		explicit_bzero(raw, sizeof(raw));
+		return (1);
+	}
+	/*
+	 * Not a protocol error either -- just bigger than this
+	 * implementation's fixed-size struct imsg_auth_request fields
+	 * (imapd.h) support. Same generic NO as any other credential
+	 * that won't authenticate, rather than a distinct error that
+	 * would hand the client a live oracle for an implementation
+	 * limit -- same spirit as auth.c's auth_verify() comment on not
+	 * short-circuiting differently for an unknown username.
+	 */
+	if (authcidlen >= AUTH_USERNAME_MAX || passwdlen >= AUTH_PASSWORD_MAX) {
+		session_reply(s, tag, "NO", "[AUTHENTICATIONFAILED] authentication failed");
+		explicit_bzero(raw, sizeof(raw));
+		return (1);
+	}
+
+	memset(&req, 0, sizeof(req));
+	req.session_id = s->id;
+	memcpy(req.username, authcid, authcidlen);
+	memcpy(req.password, passwd, passwdlen);
+	explicit_bzero(raw, sizeof(raw));
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->state = SESSION_AUTHENTICATING;
+
+	if (imsg_compose(&iev_auth.ibuf, IMSG_AUTH_REQUEST, 0, 0, -1,
+	    &req, sizeof(req)) == -1)
+		log_warn("session %u: imsg_compose IMSG_AUTH_REQUEST", s->id);
+	imsgev_add(&iev_auth);
+	/*
+	 * imsg_compose() copies req into its own queue immediately -- safe
+	 * to scrub our own stack copy right after, same reasoning as
+	 * parent.c's send_tls_certs() scrubbing its cert/key stack buffers
+	 * right after the matching imsg_compose() calls.
+	 */
+	explicit_bzero(&req, sizeof(req));
+
+	return (1);
+}
+
+/*
+ * Handles one line received while s->auth_cont is set -- the client's
+ * response to our "+ " continuation request after a bare "AUTHENTICATE
+ * PLAIN" (see cmd_authenticate()). Always clears auth_cont first: whether
+ * this line succeeds, fails, or cancels, the *next* line is back to being
+ * an ordinary tagged command either way.
+ */
+static int
+session_handle_auth_continuation(struct session *s, char *line)
+{
+	s->auth_cont = 0;
+
+	/* RFC 9051 SS6.2.2: "If the client wishes to cancel an
+	 * authentication exchange, it issues a line consisting of a
+	 * single '*'. If the server receives such a response ... it MUST
+	 * reject the AUTHENTICATE command by sending a tagged BAD
+	 * response." */
+	if (strcmp(line, "*") == 0) {
+		session_reply(s, s->pending_tag, "BAD",
+		    "AUTHENTICATE cancelled");
+		return (1);
+	}
+
+	return sasl_plain_finish(s, s->pending_tag, line, 0);
+}
+
+/*
+ * Handles one line received while s->idling is set -- the client's
+ * response to our "+ idling" continuation after IDLE (RFC 9051 SS6.3.13;
+ * see cmd_idle()). Always clears idling first, same "next line is back to
+ * an ordinary tagged command either way" reasoning as session_handle_auth_
+ * continuation() above.
+ *
+ * RFC 9051 SS6.3.13: "The IDLE command is terminated by the receipt of a
+ * 'DONE' continuation from the client... The client MUST NOT send a
+ * command while the server is waiting for the DONE, since the server will
+ * not be able to distinguish a command from a continuation." Matched
+ * case-insensitively -- same leniency this codebase applies to every
+ * other IMAP keyword (strcasecmp throughout), and RFC 2177's original
+ * IDLE spec (SS3, worked example) shows literal "DONE" but neither RFC
+ * spells out case-sensitivity for it specifically. A client that sends
+ * anything else here has violated the MUST NOT above; there's no
+ * principled way to recover (we don't know what they meant), so the IDLE
+ * simply fails with BAD rather than silently accepting or hanging.
+ */
+static int
+session_handle_idle_continuation(struct session *s, char *line)
+{
+	s->idling = 0;
+
+	if (strcasecmp(line, "DONE") != 0) {
+		session_reply(s, s->pending_tag, "BAD",
+		    "expected DONE");
+		return (1);
+	}
+
+	session_reply(s, s->pending_tag, "OK", "IDLE terminated");
+	return (1);
+}
+
+static int
+cmd_authenticate(struct session *s, const char *tag, char *args)
+{
+	char	*mech, *p, *initial;
+
+	if (args == NULL) {
+		/* RFC 9051 SS6.2.2 Result: "BAD - ... arguments invalid". */
+		session_reply(s, tag, "BAD", "Missing SASL mechanism name");
+		return (1);
+	}
+	mech = args;
+	for (p = mech; *p != '\0' && *p != ' '; p++)
+		continue;
+	if (*p == '\0') {
+		initial = NULL;
+	} else {
+		*p++ = '\0';
+		while (*p == ' ')
+			p++;
+		initial = (*p != '\0') ? p : NULL;
+	}
+
+	/*
+	 * openimap-privsep-design.md / RFC 9051 SS6.2.2's quoted "MUST
+	 * implement a configuration in which it does NOT permit any
+	 * plaintext password mechanisms, unless the STARTTLS command has
+	 * been negotiated..." -- s->tls_active reflects a real TLS
+	 * handshake (STARTTLS and implicit-TLS both work as of the TLS-
+	 * wiring pass, see session_tls_handshake()).
+	 */
+	if (!s->tls_active) {
+		/* RFC 5530: PRIVACYREQUIRED -- "If TLS is not in use, the
+		 * client could try STARTTLS ... and then repeat the
+		 * operation," exactly this situation. */
+		session_reply(s, tag, "NO",
+		    "[PRIVACYREQUIRED] plaintext authentication requires TLS");
+		return (1);
+	}
+
+	/* v1 only implements PLAIN -- CAPABILITY_POST_TLS only ever
+	 * advertises AUTH=PLAIN, so anything else is a client asking for
+	 * something we never claimed to support. */
+	if (strcasecmp(mech, "PLAIN") != 0) {
+		session_reply(s, tag, "NO",
+		    "authentication mechanism not available");
+		return (1);
+	}
+
+	if (initial != NULL) {
+		/* RFC 9051 SS6.2.2 "initial response" (SASL SS4): the
+		 * mechanism name was followed by a token on the same line --
+		 * either a base64 blob, or "=" for a present-but-zero-length
+		 * response (initial-resp = base64 / "=") -- so the exchange
+		 * finishes in one round trip, no continuation request
+		 * needed. sasl_plain_finish() itself saves the tag into
+		 * s->pending_tag once it knows the exchange is actually going
+		 * async (IMSG_AUTH_REQUEST sent) -- no need to duplicate
+		 * that here. */
+		return sasl_plain_finish(s, tag, initial, 1);
+	}
+
+	/* No initial response: RFC 9051 SS6.2.2's continue-req -- "+ SP
+	 * [text] CRLF" (resp-text's text is optionally empty per the
+	 * errata-updated ABNF, RFC 9051 SS8 erratum note 23) -- prompts the
+	 * client for its base64 response on the next line. auth_cont routes
+	 * that next raw line to session_handle_auth_continuation() instead
+	 * of the ordinary tagged-command dispatcher. */
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->auth_cont = 1;
+	session_write(s, "+ \r\n", 4);
+	return (1);
+}
+
+/*
+ * Shared reply for every command-auth/command-select entry that's
+ * recognized (correctly gated by session state, so this is never
+ * confused with session_handle_line()'s "Unknown command" BAD) but can't
+ * actually run yet -- store.c's IMSG_MBOX_* family (imapd.h) has no
+ * designed wire payloads, so there's nothing for these handlers to send.
+ * NO (not BAD) is the correct RFC 9051 result category here: the command
+ * itself is syntactically fine, the server is just failing to perform it
+ * -- the same distinction cmd_starttls() already draws when TLS isn't
+ * configured. cmdname is only used for the log line, kept separate from
+ * the client-facing reply text (which stays generic) the same way
+ * cmd_login()'s and cmd_starttls()'s replies do.
+ */
+static int
+stub_not_implemented(struct session *s, const char *tag, const char *cmdname)
+{
+	log_debug("session %u: %s not implemented (store.c's IMSG_MBOX_* "
+	    "wire protocol isn't designed yet)", s->id, cmdname);
+	session_reply(s, tag, "NO", "not implemented");
+	return (1);
+}
+
+/*
+ * RFC 9051 SS6.3.1: `enable = "ENABLE" 1*(SP capability)` -- at least one
+ * capability argument is mandatory (Result: "BAD - No arguments, or
+ * syntax error in an argument"). For each argument, "If the argument is
+ * not an extension known to the server, the server MUST ignore the
+ * argument", and the untagged ENABLED response is sent unconditionally,
+ * "even if no extensions were enabled" -- listing only what THIS command
+ * actually newly enabled (SS6.3.1's own multi-ENABLE example: a second
+ * ENABLE "should not" re-list an already-enabled extension).
+ *
+ * RFC 7162 addition this pass: CONDSTORE and QRESYNC are now real,
+ * recognized ENABLE arguments (matched case-insensitively, per SS6.3.1's
+ * own worked example using "ENABLE CONDSTORE"). Enabling QRESYNC also
+ * enables CONDSTORE (RFC 7162 SS3.2.3: "the presence of the 'QRESYNC'
+ * capability implies support for the CONDSTORE IMAP extension"), and -- a
+ * deliberate implementation choice, matching how several real QRESYNC
+ * servers behave, though the RFC doesn't explicitly mandate the ENABLED
+ * echo specifically -- "ENABLE QRESYNC" alone reports both "QRESYNC" and
+ * "CONDSTORE" as newly enabled, not just QRESYNC, since CONDSTORE really
+ * is being enabled as a side effect of this exact command. Goes through
+ * session_condstore_enable() so the "CONDSTORE enabling command issued
+ * while a mailbox is already selected" unsolicited-HIGHESTMODSEQ case
+ * (SS3.1) is handled uniformly with every other enabling command -- even
+ * though RFC 9051 SS6.3.1 says ENABLE is only valid pre-SELECT and clients
+ * "MUST NOT issue ENABLE" after selecting a mailbox, the same section adds
+ * "server implementations don't have to check" for that, so this server
+ * doesn't reject a late ENABLE outright; handling it correctly rather than
+ * either rejecting or silently misbehaving is the safer choice.
+ */
+static int
+cmd_enable(struct session *s, const char *tag, char *args)
+{
+	char		 buf[64];
+	char		*tok, *save;
+	int		 newly_condstore = 0, newly_qresync = 0;
+
+	if (args == NULL) {
+		session_reply(s, tag, "BAD",
+		    "ENABLE requires at least one capability argument");
+		return (1);
+	}
+
+	for (tok = strtok_r(args, " ", &save); tok != NULL;
+	    tok = strtok_r(NULL, " ", &save)) {
+		if (strcasecmp(tok, "QRESYNC") == 0) {
+			if (!s->qresync_enabled) {
+				s->qresync_enabled = 1;
+				newly_qresync = 1;
+				if (!s->condstore_enabled)
+					newly_condstore = 1;
+			}
+		} else if (strcasecmp(tok, "CONDSTORE") == 0) {
+			if (!s->condstore_enabled)
+				newly_condstore = 1;
+		}
+		/* Anything else: not an extension this server advertises at
+		 * all -- SS6.3.1 says ignore it, so no else-branch needed. */
+	}
+
+	if (newly_condstore || newly_qresync)
+		session_condstore_enable(s);
+
+	buf[0] = '\0';
+	if (newly_qresync)
+		strlcat(buf, "QRESYNC", sizeof(buf));
+	if (newly_condstore) {
+		if (buf[0] != '\0')
+			strlcat(buf, " ", sizeof(buf));
+		strlcat(buf, "CONDSTORE", sizeof(buf));
+	}
+
+	if (buf[0] != '\0') {
+		char	untagged[80];
+
+		snprintf(untagged, sizeof(untagged), "ENABLED %s", buf);
+		session_untagged(s, untagged);
+	} else
+		session_untagged(s, "ENABLED");
+
+	session_reply(s, tag, "OK", "ENABLE completed");
+	return (1);
+}
+
+/*
+ * Parses the interior of QRESYNC's own parenthesized argument list (RFC
+ * 7162 SS3.2.5): `uidvalidity SP mod-sequence-value [SP known-uids [SP
+ * seq-match-data]]`. inner is already NUL-terminated at its closing paren
+ * by the caller (parse_select_params()) and modified in place.
+ *
+ * known-uids gets this codebase's usual single-range restriction (no
+ * comma-separated sequence-set, matching FETCH/STORE/SEARCH's UID-range
+ * scope elsewhere) and explicitly rejects "*", which SS3.2.5.1's own
+ * grammar comment forbids here ("Sequence of UIDs; '*' is not allowed").
+ * seq-match-data is parsed only enough to confirm balanced parentheses and
+ * skipped -- its content is never sent to store.c at all (see imapd.h's
+ * imsg_mbox_select comment for why: this implementation's chosen minimal
+ * QRESYNC state model never uses it to narrow anything, which RFC 7162
+ * SS5.2 explicitly sanctions as compliant, just less bandwidth-optimal).
+ */
+static int
+parse_qresync_group(char *inner, struct imsg_mbox_select *req,
+    const char **errmsg)
+{
+	char		*p, *tok;
+	uint32_t	 uidvalidity;
+	uint64_t	 modseq;
+	char		*ep;
+
+	p = inner;
+	while (*p == ' ')
+		p++;
+	tok = p;
+	while (*p != '\0' && *p != ' ')
+		p++;
+	if (*p != '\0') {
+		*p = '\0';
+		p++;
+	}
+	if (parse_nz_number(tok, &uidvalidity) == -1) {
+		*errmsg = "invalid QRESYNC uidvalidity";
+		return (-1);
+	}
+
+	while (*p == ' ')
+		p++;
+	tok = p;
+	while (*p != '\0' && *p != ' ')
+		p++;
+	if (*p != '\0') {
+		*p = '\0';
+		p++;
+	}
+	if (*tok == '\0') {
+		*errmsg = "QRESYNC requires uidvalidity and mod-sequence";
+		return (-1);
+	}
+	errno = 0;
+	modseq = strtoull(tok, &ep, 10);
+	if (*ep != '\0' || errno != 0) {
+		*errmsg = "invalid QRESYNC mod-sequence";
+		return (-1);
+	}
+
+	req->qresync_uidvalidity = uidvalidity;
+	req->qresync_modseq = modseq;
+	req->qresync_has_uids = 0;
+
+	while (*p == ' ')
+		p++;
+	if (*p == '\0')
+		return (0);
+
+	if (*p == '(') {
+		*errmsg = "QRESYNC seq-match-data requires known-uids first";
+		return (-1);
+	}
+
+	tok = p;
+	while (*p != '\0' && *p != ' ')
+		p++;
+	if (*p != '\0') {
+		*p = '\0';
+		p++;
+	}
+	if (strchr(tok, ',') != NULL) {
+		*errmsg = "comma-separated known-uids not supported in v1";
+		return (-1);
+	}
+	{
+		uint32_t	lo, hi;
+		int		lo_star, hi_star;
+
+		if (parse_seq_range(tok, &lo, &hi, &lo_star, &hi_star) == -1 ||
+		    lo_star || hi_star) {
+			*errmsg = "invalid known-uids -- '*' is not allowed "
+			    "here (RFC 7162 SS3.2.5.1)";
+			return (-1);
+		}
+		req->qresync_has_uids = 1;
+		req->qresync_uid_lo = lo;
+		req->qresync_uid_hi = hi;
+	}
+
+	while (*p == ' ')
+		p++;
+	if (*p == '\0')
+		return (0);
+
+	if (*p != '(') {
+		*errmsg = "expected seq-match-data";
+		return (-1);
+	}
+	{
+		char	*end = p;
+		int	 depth = 0;
+
+		for (;;) {
+			if (*end == '(')
+				depth++;
+			else if (*end == ')') {
+				depth--;
+				if (depth == 0)
+					break;
+			} else if (*end == '\0') {
+				*errmsg = "unterminated seq-match-data";
+				return (-1);
+			}
+			end++;
+		}
+		p = end + 1;
+	}
+
+	while (*p == ' ')
+		p++;
+	if (*p != '\0') {
+		*errmsg = "unexpected data after QRESYNC arguments";
+		return (-1);
+	}
+
+	return (0);
+}
+
+/*
+ * Parses the interior of SELECT/EXAMINE's select-params list (RFC 4466's
+ * generic syntax, extended by RFC 7162 SS3.1.8/SS3.2.5 with the CONDSTORE
+ * and QRESYNC select-params): zero or more space-separated params, where
+ * CONDSTORE is a bare token and QRESYNC is a token followed by its own
+ * parenthesized argument group (parsed above). p is already NUL-terminated
+ * at the outer list's closing paren by the caller (cmd_select()) and
+ * modified in place.
+ *
+ * Sets req->qresync (and fills the rest of req's qresync_* fields via
+ * parse_qresync_group()) and *want_condstore -- the latter is a plain
+ * out-parameter rather than something written straight to s->condstore_
+ * enabled, since cmd_select() itself decides exactly when to flip that (see
+ * its own comment on why this doesn't go through session_condstore_
+ * enable()).
+ *
+ * A QRESYNC select-param requires "ENABLE QRESYNC" to have already
+ * succeeded this connection (RFC 7162 SS3.2.5: tagged BAD otherwise) --
+ * checked here against s->qresync_enabled, since by the time this SELECT's
+ * response could otherwise be built it would be too late to reject it
+ * cleanly.
+ */
+static int
+parse_select_params(char *p, struct imsg_mbox_select *req, struct session *s,
+    int *want_condstore, const char **errmsg)
+{
+	*want_condstore = 0;
+
+	while (*p != '\0') {
+		while (*p == ' ')
+			p++;
+		if (*p == '\0')
+			break;
+
+		if (strncasecmp(p, "CONDSTORE", 9) == 0 &&
+		    (p[9] == '\0' || p[9] == ' ')) {
+			*want_condstore = 1;
+			p += 9;
+			continue;
+		}
+
+		if (strncasecmp(p, "QRESYNC", 7) == 0 &&
+		    (p[7] == '\0' || p[7] == ' ')) {
+			char	*q = p + 7;
+			char	*end;
+			int	 depth;
+
+			while (*q == ' ')
+				q++;
+			if (*q != '(') {
+				*errmsg = "QRESYNC requires a parenthesized "
+				    "argument list";
+				return (-1);
+			}
+			if (!s->qresync_enabled) {
+				*errmsg = "QRESYNC select-param requires "
+				    "ENABLE QRESYNC first (RFC 7162 SS3.2.5)";
+				return (-1);
+			}
+
+			depth = 0;
+			end = q;
+			for (;;) {
+				if (*end == '(')
+					depth++;
+				else if (*end == ')') {
+					depth--;
+					if (depth == 0)
+						break;
+				} else if (*end == '\0') {
+					*errmsg = "unterminated QRESYNC "
+					    "argument list";
+					return (-1);
+				}
+				end++;
+			}
+			*end = '\0';
+
+			if (parse_qresync_group(q + 1, req, errmsg) == -1)
+				return (-1);
+
+			req->qresync = 1;
+			*want_condstore = 1;	/* SS3.2.3: QRESYNC implies
+						 * CONDSTORE */
+			p = end + 1;
+			continue;
+		}
+
+		*errmsg = "unrecognized SELECT parameter";
+		return (-1);
+	}
+
+	return (0);
+}
+
+/*
+ * RFC 9051 SS6.3.2: `select = "SELECT" SP mailbox`, extended by RFC 4466's
+ * generic select-param syntax and RFC 7162's CONDSTORE/QRESYNC select-
+ * params (SS3.1.8/SS3.2.5): `select = "SELECT" SP mailbox [SP "(" select-
+ * param *(SP select-param) ")"]`. args is whatever parse_command_line()
+ * left after the command name -- a bare atom (e.g. "INBOX", or, as of RFC
+ * 9051 SS6.3.4-SS6.3.6's flat multi-mailbox support, any other valid
+ * mailbox name too -- see handle_mbox_select()'s comment in store.c) in
+ * every real-world case, but RFC 9051's `mailbox` production also allows
+ * a quoted string or a literal. Only the quoted-
+ * string case is handled here (strip a single matching pair of double
+ * quotes, no backslash-escape decoding) -- a deliberate simplification in
+ * the same spirit as parse_command_line()'s own "not full ABNF conformance"
+ * comment, not an oversight; IMAP literals ({n}-prefixed octet counts)
+ * aren't supported anywhere in this codebase yet (see SESSION_INBUF_MAX's
+ * comment).
+ *
+ * The mailbox token's own boundary is found first (respecting a leading
+ * quote, so a quoted mailbox name doesn't get accidentally split at an
+ * internal space), *then* the existing quote-stripping logic runs on just
+ * that substring, unchanged from before this pass -- anything left over is
+ * handed to parse_select_params().
+ *
+ * Shared by cmd_select() (readonly=0) and cmd_examine() (readonly=1): RFC
+ * 9051 SS6.3.3 says "The EXAMINE command is identical to SELECT and returns
+ * the same output" -- the only difference is that the selected mailbox
+ * ends up marked read-only, which this function threads through as
+ * req.readonly (to store.c, which SS6.3.2's own struct comment already
+ * notes doesn't need to do anything different with it) and s->mbox_readonly
+ * (to this session, consulted by session_handle_mbox_selected() for the
+ * READ-ONLY/READ-WRITE response code and PERMANENTFLAGS list, and by every
+ * command that can mutate the selected mailbox's permanent state). RFC 7162
+ * SS3.1.8/SS3.2.5 extend both SELECT and EXAMINE with the identical
+ * select-param syntax, so CONDSTORE/QRESYNC support doesn't need to
+ * special-case either command.
+ */
+static int
+select_or_examine(struct session *s, const char *tag, char *args, int readonly)
+{
+	struct imsg_mbox_select	 req;
+	char				*mailbox_tok, *params, *p;
+	size_t				 len;
+	int				 want_condstore = 0;
+	const char			*cmdname = readonly ? "EXAMINE" : "SELECT";
+
+	if (args == NULL) {
+		char	text[40];
+
+		snprintf(text, sizeof(text), "%s requires a mailbox name",
+		    cmdname);
+		session_reply(s, tag, "BAD", text);
+		return (1);
+	}
+
+	p = args;
+	if (*p == '"') {
+		p++;
+		while (*p != '\0' && *p != '"')
+			p++;
+		if (*p == '"')
+			p++;
+	} else {
+		while (*p != '\0' && *p != ' ')
+			p++;
+	}
+	mailbox_tok = args;
+	if (*p == ' ') {
+		*p = '\0';
+		p++;
+		while (*p == ' ')
+			p++;
+		params = (*p != '\0') ? p : NULL;
+	} else if (*p == '\0') {
+		params = NULL;
+	} else {
+		char	text[40];
+
+		snprintf(text, sizeof(text), "malformed %s arguments", cmdname);
+		session_reply(s, tag, "BAD", text);
+		return (1);
+	}
+
+	args = mailbox_tok;
+	len = strlen(args);
+	if (len >= 2 && args[0] == '"' && args[len - 1] == '"') {
+		args[len - 1] = '\0';
+		args++;
+		len -= 2;
+	}
+	if (len == 0) {
+		session_reply(s, tag, "BAD", "empty mailbox name");
+		return (1);
+	}
+	if (len >= sizeof(req.mailbox)) {
+		session_reply(s, tag, "BAD", "mailbox name too long");
+		return (1);
+	}
+
+	if (s->store_iev == NULL) {
+		/* Shouldn't happen -- ST_AUTH only dispatches here once
+		 * SESSION_AUTHENTICATED/SELECTED, both of which require
+		 * store_iev to already be wired (see listener_dispatch_
+		 * parent()'s IMSG_SETUP_PEER case) -- but this is exactly
+		 * the kind of internal-invariant check this codebase prefers
+		 * to state explicitly rather than silently assume. */
+		log_warnx("session %u: %s with no store channel wired",
+		    s->id, cmdname);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	memset(&req, 0, sizeof(req));
+	strlcpy(req.mailbox, args, sizeof(req.mailbox));
+	req.readonly = readonly;
+
+	if (params != NULL) {
+		size_t	plen = strlen(params);
+		const char *errmsg = NULL;
+
+		if (plen < 2 || params[0] != '(' || params[plen - 1] != ')') {
+			session_reply(s, tag, "BAD",
+			    "malformed select-param list");
+			return (1);
+		}
+		params[plen - 1] = '\0';
+
+		if (parse_select_params(params + 1, &req, s, &want_condstore,
+		    &errmsg) == -1) {
+			session_reply(s, tag, "BAD", errmsg);
+			return (1);
+		}
+	}
+
+	/*
+	 * RFC 7162 SS3.1.8/SS3.2.3: a CONDSTORE or QRESYNC select-param
+	 * enables CONDSTORE (and, for QRESYNC, QRESYNC too -- though that
+	 * half only ever gets here already true, since parse_select_params()
+	 * requires it up front) for this session immediately -- not routed
+	 * through session_condstore_enable(), since that function's
+	 * unsolicited-HIGHESTMODSEQ-if-already-selected behavior would be
+	 * redundant here: this SELECT's own response (session_handle_mbox_
+	 * selected()) already includes HIGHESTMODSEQ as part of its normal
+	 * sequence once s->condstore_enabled is set, which is exactly what
+	 * this line accomplishes.
+	 */
+	if (want_condstore)
+		s->condstore_enabled = 1;
+
+	/*
+	 * RFC 9051 SS6.3.2: "The SELECT command automatically deselects any
+	 * currently selected mailbox before attempting the new selection
+	 * ... the server MUST return an untagged OK response with the
+	 * '[CLOSED]' response code when the currently selected mailbox is
+	 * closed." Known synchronously, from s->state right now (about to
+	 * be overwritten below) -- no need to wait for store's reply to
+	 * say this, unlike the rest of the required SELECT responses.
+	 */
+	if (s->state == SESSION_SELECTED)
+		session_untagged(s, "OK [CLOSED] Previous mailbox is now closed");
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->state = SESSION_SELECTING;
+	s->mbox_readonly = readonly;
+	strlcpy(s->selected_mailbox, args, sizeof(s->selected_mailbox));
+
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_SELECT, 0, 0, -1,
+	    &req, sizeof(req)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_SELECT", s->id);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+static int
+cmd_select(struct session *s, const char *tag, char *args)
+{
+	return select_or_examine(s, tag, args, 0);
+}
+
+static int
+cmd_examine(struct session *s, const char *tag, char *args)
+{
+	return select_or_examine(s, tag, args, 1);
+}
+
+/*
+ * listener.c's own copy of store.c's mailbox_name_valid()/mailbox_name_
+ * is_inbox() -- same rules (RFC 9051 SS5.1's CTL-character MAY, SS5.1.1's
+ * reserved "/" hierarchy delimiter, the length cap, and the "tmp"/"new"/
+ * "cur" reserved-name hazard documented at store.c's own copy), so a
+ * client sending an obviously-invalid CREATE/DELETE/RENAME argument gets a
+ * fast BAD/NO with zero store round trip, the same "cheap client-side
+ * check before an async round trip" precedent cmd_status()'s INBOX-only
+ * check already established. Not a substitute for store.c's own check --
+ * store.c re-validates independently once its request arrives (see that
+ * function's comment for the defense-in-depth rationale across the
+ * privsep boundary); this is purely a fast-path optimization on this
+ * side, so the two copies are kept deliberately in sync rather than
+ * shared via some new cross-file helper that would be its own scope
+ * creep.
+ */
+static int
+mailbox_name_valid(const char *name)
+{
+	size_t	i, len;
+
+	len = strlen(name);
+	if (len == 0 || len >= MBOX_NAME_MAX)
+		return (0);
+	for (i = 0; i < len; i++) {
+		unsigned char	c = (unsigned char)name[i];
+
+		if (c == '/')
+			return (0);
+		if (c < 0x20 || c == 0x7f)
+			return (0);
+	}
+
+	if (strcmp(name, "tmp") == 0 || strcmp(name, "new") == 0 ||
+	    strcmp(name, "cur") == 0)
+		return (0);
+
+	return (1);
+}
+
+static int
+mailbox_name_is_inbox(const char *name)
+{
+	return (strcasecmp(name, "INBOX") == 0);
+}
+
+/*
+ * RFC 9051 SS6.3.4 CREATE: `create = "CREATE" SP mailbox` -- a single
+ * mailbox-name argument, same token shape as LIST/STATUS's own mailbox
+ * argument, so parse_list_token() is reused here too. "It is an error to
+ * attempt to create INBOX" and "It is an error to attempt to create a
+ * mailbox with a name that refers to an extant mailbox" -- the first is a
+ * client-side check (mailbox_name_is_inbox()); the second can only be
+ * answered by store.c, which owns the filesystem, so an extant-name
+ * refusal always costs a real round trip (mkdir(2)'s own EEXIST, per
+ * handle_mbox_create()'s design -- see docs/openimap-storage-backend.md
+ * item 10).
+ *
+ * Flat v1 has no hierarchy for a trailing delimiter to declare "create
+ * children of" (SS6.3.4's "If the mailbox name is suffixed with the
+ * hierarchy separator" clause) -- mailbox_name_valid() already refuses any
+ * name containing "/" outright, which also catches a trailing one, so
+ * that clause is unreachable here rather than silently ignored partway
+ * through.
+ *
+ * Async round trip: same shape as cmd_status()'s STATUS dispatch --
+ * s->mbox_op_prev_state records whichever ST_AUTH state was current (CREATE
+ * is command-auth, RFC 9051 SS6.3, valid in Authenticated or Selected
+ * state, and never changes SELECTED-ness), s->pending_tag holds the tag,
+ * s->state transitions to SESSION_CREATING until store.c's terminal
+ * IMSG_MBOX_RESULT arrives at session_finish_mbox_op().
+ */
+static int
+cmd_create(struct session *s, const char *tag, char *args)
+{
+	struct imsg_mbox_create	 req;
+	char				 mailbox[MBOX_NAME_MAX];
+	char				*p;
+	const char			*errmsg = NULL;
+
+	if (args == NULL) {
+		session_reply(s, tag, "BAD", "CREATE requires a mailbox name");
+		return (1);
+	}
+
+	p = args;
+	if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+
+	if (mailbox_name_is_inbox(mailbox)) {
+		session_reply(s, tag, "NO", "[CANNOT] cannot create INBOX");
+		return (1);
+	}
+	if (!mailbox_name_valid(mailbox)) {
+		session_reply(s, tag, "BAD", "invalid mailbox name");
+		return (1);
+	}
+
+	if (s->store_iev == NULL) {
+		log_warnx("session %u: CREATE with no store channel wired",
+		    s->id);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	memset(&req, 0, sizeof(req));
+	strlcpy(req.mailbox, mailbox, sizeof(req.mailbox));
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->mbox_op_prev_state = s->state;
+	s->state = SESSION_CREATING;
+
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_CREATE, 0, 0, -1,
+	    &req, sizeof(req)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_CREATE", s->id);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+/*
+ * RFC 9051 SS6.3.5 DELETE: `delete = "DELETE" SP mailbox`. "It is an error
+ * to attempt to delete INBOX" and "It is an error to attempt to delete a
+ * mailbox that does not exist" -- same split as CREATE: INBOX is a client-
+ * side check, existence is store.c's (handle_mbox_delete()'s own stat(2)
+ * check -- see docs/openimap-storage-backend.md item 10). Flat v1's other
+ * DELETE sub-rules (inferior hierarchical names, the \Noselect-plus-
+ * children carve-out) are moot by construction -- no mailbox here can ever
+ * have children -- so nothing else needs checking on either side.
+ *
+ * Same async shape as cmd_create() -- see that function's comment.
+ */
+static int
+cmd_delete(struct session *s, const char *tag, char *args)
+{
+	struct imsg_mbox_delete	 req;
+	char				 mailbox[MBOX_NAME_MAX];
+	char				*p;
+	const char			*errmsg = NULL;
+
+	if (args == NULL) {
+		session_reply(s, tag, "BAD", "DELETE requires a mailbox name");
+		return (1);
+	}
+
+	p = args;
+	if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+
+	if (mailbox_name_is_inbox(mailbox)) {
+		session_reply(s, tag, "NO", "[CANNOT] cannot delete INBOX");
+		return (1);
+	}
+	if (!mailbox_name_valid(mailbox)) {
+		/* RFC 5530 NONEXISTENT: an invalid name can never have
+		 * existed, same worked-example fit STATUS's own non-INBOX
+		 * check already uses. */
+		session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
+		return (1);
+	}
+
+	if (s->store_iev == NULL) {
+		log_warnx("session %u: DELETE with no store channel wired",
+		    s->id);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	memset(&req, 0, sizeof(req));
+	strlcpy(req.mailbox, mailbox, sizeof(req.mailbox));
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->mbox_op_prev_state = s->state;
+	s->state = SESSION_DELETING;
+
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_DELETE, 0, 0, -1,
+	    &req, sizeof(req)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_DELETE", s->id);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+/*
+ * RFC 9051 SS6.3.6 RENAME: `rename = "RENAME" SP mailbox SP mailbox`
+ * (existing-name, new-name). "It is an error to attempt to rename from a
+ * mailbox name that does not exist or to a mailbox name that already
+ * exists" -- both existence checks are store.c's (handle_mbox_rename()'s
+ * own stat(2) checks). Renaming *to* INBOX is already covered by "already
+ * exists" (INBOX always exists for an authenticated session), so no
+ * separate client-side check is needed for the destination; renaming
+ * *from* INBOX is a distinct rule ("using the special name INBOX as the
+ * source... some servers disallow renaming INBOX") that v1 takes the
+ * RFC's own sanctioned refusal on -- see docs/openimap-storage-backend.md
+ * item 10 for the full citation -- checked here, client-side, same as
+ * CREATE/DELETE's own INBOX checks.
+ *
+ * Same async shape as cmd_create()/cmd_delete() -- see cmd_create()'s
+ * comment.
+ */
+static int
+cmd_rename(struct session *s, const char *tag, char *args)
+{
+	struct imsg_mbox_rename	 req;
+	char				 oldname[MBOX_NAME_MAX];
+	char				 newname[MBOX_NAME_MAX];
+	char				*p;
+	const char			*errmsg = NULL;
+
+	if (args == NULL) {
+		session_reply(s, tag, "BAD",
+		    "RENAME requires two mailbox names");
+		return (1);
+	}
+
+	p = args;
+	if (parse_list_token(&p, oldname, sizeof(oldname), &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+	if (parse_list_token(&p, newname, sizeof(newname), &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+
+	if (mailbox_name_is_inbox(oldname)) {
+		/* RFC 9051 SS6.3.6 explicitly sanctions this refusal -- see
+		 * this function's header comment. RFC 5530 has no sharper
+		 * fit than CANNOT ("The operation violates some invariant
+		 * of the server and can never succeed"). */
+		session_reply(s, tag, "NO", "[CANNOT] cannot rename INBOX");
+		return (1);
+	}
+	if (!mailbox_name_valid(oldname)) {
+		session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
+		return (1);
+	}
+	if (mailbox_name_is_inbox(newname) || !mailbox_name_valid(newname)) {
+		session_reply(s, tag, "BAD", "invalid mailbox name");
+		return (1);
+	}
+
+	if (s->store_iev == NULL) {
+		log_warnx("session %u: RENAME with no store channel wired",
+		    s->id);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	memset(&req, 0, sizeof(req));
+	strlcpy(req.oldname, oldname, sizeof(req.oldname));
+	strlcpy(req.newname, newname, sizeof(req.newname));
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	strlcpy(s->rename_oldname, oldname, sizeof(s->rename_oldname));
+	strlcpy(s->rename_newname, newname, sizeof(s->rename_newname));
+	s->mbox_op_prev_state = s->state;
+	s->state = SESSION_RENAMING;
+
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_RENAME, 0, 0, -1,
+	    &req, sizeof(req)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RENAME", s->id);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+static int
+cmd_subscribe(struct session *s, const char *tag, char *args)
+{
+	(void)args;
+	return stub_not_implemented(s, tag, "SUBSCRIBE");
+}
+
+static int
+cmd_unsubscribe(struct session *s, const char *tag, char *args)
+{
+	(void)args;
+	return stub_not_implemented(s, tag, "UNSUBSCRIBE");
+}
+
+/*
+ * RFC 9051 SS6.3.9's wildcard matching ("*" matches zero or more
+ * characters including a hierarchy delimiter; "%" is the same but does
+ * NOT match a delimiter), restricted to what it actually needs to do in
+ * v1's flat, non-nested namespace: no delimiter can ever occur within any
+ * candidate name (mailbox_name_valid() refuses "/" outright for every
+ * real mailbox, and the fixed literal "INBOX" obviously has none either),
+ * so the one behavioral difference between "*" and "%" never has anything
+ * to bite on -- both are treated identically here as "match zero or more
+ * of anything." This is a real simplification specific to v1's flat
+ * namespace, not a general-purpose IMAP wildcard matcher; it would need
+ * to actually distinguish the two if this server ever grew a real nested
+ * hierarchy. SS6.3.9's further note that a trailing "%" also causes
+ * intermediate, not-yet-selectable levels of hierarchy to be returned
+ * with \Noselect doesn't apply here either, for the same reason: v1 has
+ * no intermediate levels for a trailing "%" to ever expose.
+ *
+ * ci (case-insensitive) is the caller's choice, not baked in: RFC 9051
+ * SS5.1's case-insensitivity is scoped to the single reserved name INBOX
+ * ("The case-insensitive mailbox name INBOX..."), not to mailbox names in
+ * general -- real, named mailboxes (RFC 9051 SS6.3.4-SS6.3.6) are
+ * case-sensitive on this server, matching store.c's own strcmp() (not
+ * strcasecmp()) for every name but INBOX throughout mailbox_name_valid()/
+ * handle_mbox_create()/handle_mbox_delete()/handle_mbox_rename(). Callers
+ * pass ci=1 only when matching against the literal "INBOX".
+ *
+ * Iterative two-pointer wildcard match with a single remembered backtrack
+ * point -- the standard technique behind glob(3)-family matchers, bounded
+ * at O(n*m) time in the worst case rather than the exponential blowup a
+ * naive recursive backtracking matcher (what this function used to be)
+ * hits on adversarial input.
+ *
+ * Found during manual security review: the previous implementation tried
+ * every possible split point for a wildcard via plain recursion, with no
+ * memoization. An alternating pattern like "a%a%a%...%a" matched against a
+ * same-shaped, non-fully-matching name is the textbook case for
+ * catastrophic backtracking in that style of matcher -- each wildcard's
+ * "try matching here, then here, then here..." loop calls back into a
+ * function that repeats the same search over the remaining wildcards,
+ * multiplying out exponentially. Both operands here are attacker-reachable
+ * by an authenticated user (a mailbox name via CREATE, up to MBOX_NAME_MAX
+ * bytes; the LIST pattern itself, up to 2*MBOX_NAME_MAX bytes as `canon`),
+ * and listener.c runs one event loop across every session, so a single
+ * pathological LIST could have stalled the process for every connected
+ * session, not just the one that sent it -- not a memory-safety bug, but a
+ * real CPU-exhaustion DoS.
+ *
+ * This rewrite keeps this function's own established, deliberate
+ * simplifications unchanged: "*" and "%" are still treated identically
+ * (see the header comment above -- v1's flat namespace makes the RFC's
+ * delimiter-crossing distinction between them moot), and ci is still the
+ * caller's choice, not baked in. Only the matching algorithm itself
+ * changed, not what it matches.
+ *
+ * Standard algorithm: walk pat and name together; on a literal mismatch,
+ * if a wildcard was seen earlier, retry from just after that wildcard
+ * with one additional character of name absorbed by it (star_s tracks how
+ * much the most recent wildcard has already absorbed) rather than
+ * recursing into a fresh search. Because star_s only ever advances
+ * forward, the whole scan is bounded by name's length times the number of
+ * wildcards in pat, not exponential in either.
+ */
+static int
+list_pattern_match(const char *pat, const char *name, int ci)
+{
+	const char	*p = pat;
+	const char	*s = name;
+	const char	*star_p = NULL;	/* pat position just past the most
+					 * recently seen wildcard run */
+	const char	*star_s = NULL;	/* name position that wildcard has
+					 * absorbed through so far */
+
+	/*
+	 * Loop is driven by "any name bytes left to consume", exactly like
+	 * the reference iterative algorithm (LeetCode-style "wildcard
+	 * matching", translated to pointers) -- not an unconditional loop
+	 * with internal breaks. That distinction matters: an earlier draft
+	 * of this fix used a for(;;) with breaks and left star_s un-updated
+	 * on an ordinary character match, so a stale star_s could still
+	 * read non-'\0' after p and s both legitimately reached the end,
+	 * triggering a spurious extra backtrack that broke any pattern with
+	 * a literal after a wildcard (e.g. "A*Z" against "AhelloZ") --
+	 * caught by this fix's own standalone regression test before ever
+	 * reaching store.c/listener.c, not by inspection.
+	 */
+	while (*s != '\0') {
+		if (*p == '*' || *p == '%') {
+			while (*p == '*' || *p == '%')
+				p++;
+			star_p = p;
+			star_s = s;
+		} else if (*p != '\0' && (ci ?
+		    toupper((unsigned char)*p) == toupper((unsigned char)*s) :
+		    *p == *s)) {
+			p++;
+			s++;
+		} else if (star_p != NULL) {
+			star_s++;
+			s = star_s;
+			p = star_p;
+		} else {
+			return (0);
+		}
+	}
+
+	while (*p == '*' || *p == '%')
+		p++;
+	return (*p == '\0');
+}
+
+/*
+ * Pulls one `list-mailbox`-shaped token (RFC 9051 SS9: `list-mailbox =
+ * 1*list-char / string`) off *pp, advancing *pp past it -- used for both
+ * of LIST's basic-syntax positional arguments (reference name, mailbox
+ * pattern). The quoted-string case strips one matching pair of DQUOTEs
+ * with no backslash-escape decoding, same deliberate simplification
+ * cmd_select()'s own mailbox-argument comment already documents; the
+ * unquoted case is simply "everything up to the next space", which is a
+ * superset of "1*list-char" (ATOM-CHAR / list-wildcards / resp-specials)
+ * that doesn't bother validating individual characters -- consistent
+ * with this file's general practice of not fully policing atom-syntax
+ * conformance. The `string` grammar alternative also allows an IMAP
+ * literal ("{n}"-prefixed) reference/pattern; not supported here, same
+ * gap cmd_select()'s mailbox argument already has.
+ *
+ * An empty quoted string ("") is a legal zero-length token and is
+ * accepted here -- both LIST's basic-syntax empty-mailbox special case
+ * and an empty reference argument (RFC 9051: "Clients SHOULD use the
+ * empty reference argument") depend on that.
+ */
+static int
+parse_list_token(char **pp, char *out, size_t outsize, const char **errmsg)
+{
+	char	*p = *pp;
+
+	while (*p == ' ')
+		p++;
+
+	if (*p == '\0') {
+		*errmsg = "LIST requires two arguments";
+		return (-1);
+	}
+
+	if (*p == '"') {
+		char	*start = p + 1;
+		char	*end = strchr(start, '"');
+		size_t	 len;
+
+		if (end == NULL) {
+			*errmsg = "unterminated quoted string";
+			return (-1);
+		}
+		len = (size_t)(end - start);
+		if (len >= outsize) {
+			*errmsg = "argument too long";
+			return (-1);
+		}
+		memcpy(out, start, len);
+		out[len] = '\0';
+		p = end + 1;
+	} else {
+		char	*start = p;
+		size_t	 len;
+
+		while (*p != '\0' && *p != ' ')
+			p++;
+		len = (size_t)(p - start);
+		if (len >= outsize) {
+			*errmsg = "argument too long";
+			return (-1);
+		}
+		memcpy(out, start, len);
+		out[len] = '\0';
+	}
+
+	*pp = p;
+	return (0);
+}
+
+/*
+ * RFC 9051 SS6.3.9: basic syntax only -- `list = "LIST" SP mailbox SP
+ * mbox-or-pat` where mbox-or-pat here is a single list-mailbox, not the
+ * parenthesized `patterns` alternative, and with no list-select-opts or
+ * list-return-opts. Extended syntax (detected per SS6.3.9's own three
+ * conditions: select-opts as the first word, a parenthesized pattern
+ * list as the second word, or more than two parameters) is recognized
+ * but rejected with NO, not BAD -- openimap-v1-dispatch.md's own scope
+ * note for LIST is "v1 should support at minimum the basic form
+ * cleanly", so this is a deliberate, flagged scope cut, not a syntax
+ * error.
+ *
+ * INBOX itself is answered synchronously, with no store round trip --
+ * it always exists unconditionally for any authenticated session (there's
+ * no CREATE that could make its existence conditional), so matching it is
+ * pure string/wildcard matching against the fixed name "INBOX", nothing
+ * store.c needs to be asked about. Real, named mailboxes (RFC 9051
+ * SS6.3.4-SS6.3.6 multi-mailbox support, docs/openimap-storage-backend.md
+ * item 10) are a different story -- only store.c can enumerate what
+ * actually exists on disk in this session's own maildir root, so those go
+ * through a real IMSG_MBOX_LIST/SESSION_LISTING round trip (see this
+ * function's tail and session_finish_list()/session_handle_mbox_list_
+ * item()) once the INBOX-only synchronous check above is done.
+ *
+ * LSUB (RFC 3501 -- dropped from base IMAP4rev2, but still sent by real
+ * clients for back-compat, e.g. Apple Mail's very first mailbox-listing
+ * round trip): real bug caught testing against Apple Mail live on
+ * premio -- LSUB wasn't in the dispatch table at all, so it fell through
+ * to session_handle_line()'s generic "Unknown command" BAD, which (paired
+ * with the FETCH gap fixed just below) left Mail with literally nothing
+ * to render, hence a blank Inbox. v1 has no subscription list to
+ * maintain (no UNSUBSCRIBE, no per-mailbox subscribed bit stored
+ * anywhere) and only one mailbox that could ever be subscribed to, so
+ * "LSUB matched X" and "LIST matched X, and X happens to always be
+ * subscribed" are indistinguishable in this server -- is_lsub only
+ * changes the response keyword (LSUB vs LIST) and the completion text,
+ * not the matching logic itself.
+ */
+static int
+list_dispatch(struct session *s, const char *tag, char *args, int is_lsub)
+{
+	char		 reference[MBOX_NAME_MAX];
+	char		 pattern[MBOX_NAME_MAX];
+	char		 canon[2 * MBOX_NAME_MAX];
+	char		*p;
+	const char	*errmsg;
+	const char	*cmdname = is_lsub ? "LSUB" : "LIST";
+	const char	*kw = is_lsub ? "LSUB" : "LIST";
+	char		 text[64];
+
+	if (args == NULL) {
+		snprintf(text, sizeof(text), "%s requires two arguments",
+		    cmdname);
+		session_reply(s, tag, "BAD", text);
+		return (1);
+	}
+
+	p = args;
+	while (*p == ' ')
+		p++;
+
+	if (*p == '(') {
+		/* SS6.3.9 condition 1: "the first word after the command
+		 * name begins with a parenthesis" -- list-select-opts. */
+		snprintf(text, sizeof(text),
+		    "extended %s selection options not supported", cmdname);
+		session_reply(s, tag, "NO", text);
+		return (1);
+	}
+
+	if (parse_list_token(&p, reference, sizeof(reference), &errmsg) ==
+	    -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+
+	while (*p == ' ')
+		p++;
+
+	if (*p == '(') {
+		/* SS6.3.9 condition 2: "the second word after the command
+		 * name begins with a parenthesis" -- the parenthesized
+		 * `patterns` form of mbox-or-pat, not a single
+		 * list-mailbox. */
+		snprintf(text, sizeof(text),
+		    "extended %s mailbox-pattern lists not supported",
+		    cmdname);
+		session_reply(s, tag, "NO", text);
+		return (1);
+	}
+
+	if (parse_list_token(&p, pattern, sizeof(pattern), &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+
+	while (*p == ' ')
+		p++;
+	if (*p != '\0') {
+		/* SS6.3.9 condition 3: "the LIST command has more than 2
+		 * parameters" -- trailing list-return-opts. */
+		snprintf(text, sizeof(text),
+		    "extended %s return options not supported", cmdname);
+		session_reply(s, tag, "NO", text);
+		return (1);
+	}
+
+	/*
+	 * RFC 9051 SS6.3.9: "In the basic syntax only, an empty ('' string)
+	 * mailbox name argument is a special request to return the
+	 * hierarchy delimiter and the root name of the name given in the
+	 * reference... The value returned as the root MAY be the empty
+	 * string if the reference is non-rooted or is an empty string."
+	 * v1 has no rooting/hierarchy concept at all to resolve a non-empty
+	 * reference against -- there is still no real folder tree to root
+	 * anything in, only INBOX -- so taking the RFC's own "MAY be empty"
+	 * allowance and always returning an empty root, regardless of the
+	 * reference argument's contents, is a real simplification but a
+	 * spec-permitted one, not a violation. "/" is the same real
+	 * hierarchy delimiter SELECT's own untagged LIST response and
+	 * cmd_namespace()'s NAMESPACE response both use -- a resolved
+	 * project decision as of a later pass (openimap-storage-backend.md,
+	 * "Open items carried from this session" #9), not a borrowed
+	 * placeholder anymore.
+	 */
+	if (pattern[0] == '\0') {
+		snprintf(text, sizeof(text), "%s (\\Noselect) \"/\" \"\"", kw);
+		session_untagged(s, text);
+		snprintf(text, sizeof(text), "%s completed", cmdname);
+		session_reply(s, tag, "OK", text);
+		return (1);
+	}
+
+	/*
+	 * Canonical LIST pattern: reference concatenated with the mailbox
+	 * pattern. RFC 9051 SS6.3.9: "If a server implementation has no
+	 * concept of break out characters, the canonical form is normally
+	 * the reference name appended with the mailbox name" -- squarely
+	 * true here: v1 has no "current working directory"/break-out-
+	 * character concept at all (a single flat INBOX-only namespace), so
+	 * plain concatenation is the correct reading of that sentence for
+	 * this server, not just the convenient one.
+	 */
+	{
+		size_t	n;
+
+		n = strlcpy(canon, reference, sizeof(canon));
+		if (n < sizeof(canon))
+			n = strlcat(canon, pattern, sizeof(canon));
+		if (n >= sizeof(canon)) {
+			session_reply(s, tag, "BAD",
+			    "combined reference and pattern too long");
+			return (1);
+		}
+	}
+
+	/*
+	 * SS6.3.9: "Any syntactically valid pattern that is not accepted by
+	 * a server for any reason MUST be silently ignored, i.e., it
+	 * results in no LIST responses, and the LIST command still returns
+	 * a tagged OK response." Answered synchronously and immediately for
+	 * INBOX specifically -- it always exists for any authenticated
+	 * session (there's no CREATE that could make its existence
+	 * conditional), so matching it needs no store round trip, same
+	 * "nothing store.c needs to be asked about" reasoning this
+	 * function's header comment already gives.
+	 */
+	if (list_pattern_match(canon, "INBOX", 1)) {
+		/* "()" -- no attributes -- the same choice, for the same
+		 * reason, as SELECT's own untagged LIST response: INBOX has
+		 * no children and is selectable, and SS7.3.1 makes every
+		 * attribute here optional ("MAY send none of these"). */
+		snprintf(text, sizeof(text), "%s () \"/\" INBOX", kw);
+		session_untagged(s, text);
+	}
+
+	if (s->store_iev == NULL) {
+		log_warnx("session %u: %s with no store channel wired",
+		    s->id, cmdname);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	/*
+	 * RFC 9051 SS6.3.4-SS6.3.6 multi-mailbox support (docs/openimap-
+	 * storage-backend.md item 10): beyond INBOX, real mailbox names now
+	 * live on disk, and only store.c can enumerate them (this session's
+	 * own maildir root, chrooted/unveiled per-session -- listener.c has
+	 * no filesystem access of its own to do this locally the way the
+	 * INBOX-only check above still can). IMSG_MBOX_LIST takes no
+	 * request payload (handle_mbox_list() just opendir(2)s "."), so
+	 * nothing needs to be built here beyond the imsg itself -- s->list_
+	 * pattern (the same canonical reference+pattern concatenation just
+	 * used for the INBOX check above) and s->list_is_lsub are stashed
+	 * for session_handle_mbox_list_item() to test each streamed name
+	 * against as it arrives.
+	 */
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	strlcpy(s->list_pattern, canon, sizeof(s->list_pattern));
+	s->list_is_lsub = is_lsub;
+	s->mbox_op_prev_state = s->state;
+	s->state = SESSION_LISTING;
+
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_LIST, 0, 0, -1,
+	    NULL, 0) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_LIST", s->id);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+static int
+cmd_list(struct session *s, const char *tag, char *args)
+{
+	return list_dispatch(s, tag, args, 0);
+}
+
+static int
+cmd_lsub(struct session *s, const char *tag, char *args)
+{
+	return list_dispatch(s, tag, args, 1);
+}
+
+/*
+ * RFC 9051 SS6.3.10 NAMESPACE: `namespace-response = "NAMESPACE" SP
+ * namespace SP namespace SP namespace` -- Personal, then Other Users',
+ * then Shared, each either NIL or a parenthesized list of `(prefix
+ * delimiter)` pairs. Answered entirely locally, no store round trip, same
+ * "v1 is single-mailbox, nothing for store.c to be asked" precedent as
+ * cmd_list() -- NAMESPACE doesn't even take a mailbox-name argument for
+ * store.c to validate the way LIST/STATUS/SELECT do.
+ *
+ * v1's hierarchy delimiter ("/") and Personal Namespace prefix ("") are
+ * now a real, sourced design decision (openimap-storage-backend.md, "Open
+ * items carried from this session" #9) rather than the placeholder
+ * SELECT's own LIST response had been borrowing -- this is RFC 9051
+ * SS6.3.10's own Example 1, verbatim: "a server supports a single
+ * Personal Namespace. No leading prefix is used on personal mailboxes,
+ * and '/' is the hierarchy delimiter" -> `* NAMESPACE (("" "/")) NIL
+ * NIL`. No Other Users' Namespace or Shared Namespace (both NIL) -- v1 is
+ * single-user with no shared-mailbox concept at all, so there's nothing
+ * for either to expose. Arguments are ignored (RFC 9051 SS6.3.10:
+ * "Arguments: none" -- session_handle_line()'s generic parser already
+ * hands cmd_*() functions whatever trailed the command name, if
+ * anything, same as e.g. cmd_capability() does; a client sending garbage
+ * after NAMESPACE gets a clean OK rather than a pedantic BAD, consistent
+ * with this codebase's existing leniency elsewhere for command-any/
+ * command-auth commands that formally take no arguments).
+ */
+static int
+cmd_namespace(struct session *s, const char *tag, char *args)
+{
+	(void)args;
+	session_untagged(s, "NAMESPACE ((\"\" \"/\")) NIL NIL");
+	session_reply(s, tag, "OK", "NAMESPACE command completed");
+	return (1);
+}
+
+/*
+ * RFC 9051 SS6.3.11 STATUS: `status = "STATUS" SP mailbox SP "("
+ * status-att *(SP status-att) ")"`. "does not change the currently
+ * selected mailbox, nor does it affect the state of any messages" --
+ * SESSION_STATUSING is a purely transient async-wait state (see that
+ * enum value's comment); s->status_prev_state records whichever ST_AUTH
+ * state (SESSION_AUTHENTICATED or SESSION_SELECTED) was current so
+ * session_handle_mbox_status_result() can restore it, same pattern
+ * cmd_append() already established for the identical "command-auth, not
+ * command-select" situation.
+ *
+ * Mailbox-name argument reuses parse_list_token() (LIST's own token
+ * parser -- STATUS's `mailbox` production is the same ABNF shape as one
+ * of LIST's two arguments). RFC 9051 SS6.3.4-SS6.3.6 multi-mailbox support
+ * (docs/openimap-storage-backend.md item 10) means a non-INBOX name can now
+ * genuinely exist, so the only client-side check left is mailbox_name_
+ * valid() (obviously-malformed names get a fast NONEXISTENT with zero store
+ * round trip, same as CREATE/DELETE/RENAME's own fast path) -- actual
+ * existence is store.c's call now, the same "delegates the check to
+ * store.c because it needs a round trip regardless" reasoning cmd_select()
+ * already used, not the old INBOX-only shortcut this comment used to
+ * describe.
+ */
+static int
+cmd_status(struct session *s, const char *tag, char *args)
+{
+	struct imsg_mbox_status	 req;
+	char				 mailbox[MBOX_NAME_MAX];
+	char				*p, *atts, *tok, *save;
+	char				 attbuf[256];
+	const char			*errmsg = NULL;
+	uint32_t			 attrs = 0;
+	size_t				 plen;
+
+	if (args == NULL) {
+		session_reply(s, tag, "BAD",
+		    "STATUS requires a mailbox name and status-att list");
+		return (1);
+	}
+
+	p = args;
+	if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+
+	while (*p == ' ')
+		p++;
+	atts = p;
+	plen = strlen(atts);
+	if (plen < 2 || atts[0] != '(' || atts[plen - 1] != ')') {
+		session_reply(s, tag, "BAD",
+		    "STATUS requires a parenthesized status-att list");
+		return (1);
+	}
+	atts[plen - 1] = '\0';
+	atts++;
+
+	if (strlcpy(attbuf, atts, sizeof(attbuf)) >= sizeof(attbuf)) {
+		session_reply(s, tag, "BAD", "status-att list too long");
+		return (1);
+	}
+	for (tok = strtok_r(attbuf, " ", &save); tok != NULL;
+	    tok = strtok_r(NULL, " ", &save)) {
+		if (strcasecmp(tok, "MESSAGES") == 0)
+			attrs |= STATUS_ATT_MESSAGES;
+		else if (strcasecmp(tok, "UIDNEXT") == 0)
+			attrs |= STATUS_ATT_UIDNEXT;
+		else if (strcasecmp(tok, "UIDVALIDITY") == 0)
+			attrs |= STATUS_ATT_UIDVALIDITY;
+		else if (strcasecmp(tok, "UNSEEN") == 0)
+			attrs |= STATUS_ATT_UNSEEN;
+		else if (strcasecmp(tok, "DELETED") == 0)
+			attrs |= STATUS_ATT_DELETED;
+		else if (strcasecmp(tok, "SIZE") == 0)
+			attrs |= STATUS_ATT_SIZE;
+		else if (strcasecmp(tok, "HIGHESTMODSEQ") == 0)
+			attrs |= STATUS_ATT_HIGHESTMODSEQ;
+		else {
+			session_reply(s, tag, "BAD", "unknown status-att");
+			return (1);
+		}
+	}
+
+	/*
+	 * RFC 9051 SS9's `status-att-list` ABNF is `status-att *(SP
+	 * status-att)` on the request side -- at least one is required,
+	 * unlike the *response* side's `mailbox-data` production, `"STATUS"
+	 * SP mailbox SP "(" [status-att-list] ")"`, whose square brackets
+	 * explicitly allow empty parens. An empty "()" here is therefore a
+	 * client syntax error, not a legal "ask for nothing" request.
+	 */
+	if (attrs == 0) {
+		session_reply(s, tag, "BAD",
+		    "STATUS requires at least one status-att");
+		return (1);
+	}
+
+	if (!mailbox_name_is_inbox(mailbox) && !mailbox_name_valid(mailbox)) {
+		/* RFC 5530: NONEXISTENT -- its own worked example is
+		 * literally "No such mailbox". A malformed name can never
+		 * have existed, so this is answered client-side, same fast
+		 * path CREATE/DELETE/RENAME use -- an otherwise-valid name
+		 * that simply doesn't exist on disk is store.c's call now
+		 * (handle_mbox_status()'s own select_mailbox_dir() failure
+		 * path), not this one's. */
+		session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
+		return (1);
+	}
+
+	if (s->store_iev == NULL) {
+		/* Same internal-invariant check as cmd_select()/cmd_fetch()/
+		 * cmd_store_cmd() -- ST_AUTH requires store_iev to already
+		 * be wired. */
+		log_warnx("session %u: STATUS with no store channel wired",
+		    s->id);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	/*
+	 * RFC 7162 SS3.1: STATUS (HIGHESTMODSEQ) is one of the six
+	 * CONDSTORE-enabling commands. Called synchronously here, before
+	 * s->state is overwritten below, matching fetch_dispatch()/
+	 * store_do()'s own ordering -- session_condstore_enable()'s
+	 * unsolicited-HIGHESTMODSEQ-if-already-selected check depends on
+	 * s->state == SESSION_SELECTED still being whatever it was when
+	 * this command was dispatched.
+	 */
+	if (attrs & STATUS_ATT_HIGHESTMODSEQ)
+		session_condstore_enable(s);
+
+	memset(&req, 0, sizeof(req));
+	strlcpy(req.mailbox, mailbox, sizeof(req.mailbox));
+	req.attrs = attrs;
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	strlcpy(s->status_mailbox, mailbox, sizeof(s->status_mailbox));
+	s->status_attrs = attrs;
+	s->status_prev_state = s->state;
+	s->state = SESSION_STATUSING;
+
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_STATUS, 0, 0, -1,
+	    &req, sizeof(req)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_STATUS", s->id);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+/*
+ * v1's whole-message-in-one-imsg design (imapd.h's imsg_mbox_append
+ * comment) needs the announced literal size to fit inside a single imsg.
+ * Confirmed directly against the real imsg.c's imsg_create() this
+ * session: `datalen += IMSG_HEADER_SIZE; if (datalen > imsgbuf->maxsize)
+ * ... return NULL`, with maxsize set to MAX_IMSGSIZE (16384, imsg.h) by
+ * imsgev_init(). struct imsg_mbox_append's own fixed fields (mailbox
+ * MBOX_NAME_MAX=256, keywords MBOX_FLAGS_MAX=256, plus a handful of
+ * ints/int64s) come to a bit over 500 bytes; 12000 leaves comfortable
+ * headroom under that 16384 ceiling for both that and the imsg header
+ * itself, while still covering v1's expected "personal notes/drafts/
+ * small saved messages" use case. A message larger than that needs real
+ * fd-passing, not implemented this pass; rejected with a plain NO (RFC
+ * 9051 defines no response code for a size cap) rather than truncating or
+ * crashing.
+ *
+ * Now defined in imapd.h (moved there when BODY.PEEK[]/BODY.PEEK[TEXT]
+ * were added to store.c's read_message_body()): no message can legally
+ * exist on disk larger than this cap in the first place, since APPEND is
+ * v1's only way to create one, so reusing the same symbol for reading a
+ * message back out -- rather than inventing a second, independently-
+ * maintained size constant -- keeps the two enforcement points from ever
+ * silently drifting apart.
+ */
+
+/* Defined further down alongside format_internaldate(); forward-declared
+ * here so parse_date_time() (which needs the same month-name table, just
+ * in the reverse direction) can reuse it instead of duplicating it. */
+static const char *fetch_month_names[12];
+
+/*
+ * Reverse of format_internaldate(): parses an RFC 9051 SS9 `date-time`
+ * (already stripped of its surrounding DQUOTEs by the caller) --
+ * `date-day-fixed "-" date-month "-" date-year SP time SP zone`. Uses
+ * plain C89 sscanf(3), not anything OpenBSD-specific -- unlike strlcpy()
+ * etc. elsewhere in this file, this doesn't need separate verification
+ * against real OpenBSD headers. "%2d" on a space-padded single-digit day
+ * (SS9's `date-day-fixed = (SP DIGIT) / 2DIGIT`) works without special-
+ * casing: scanf's leading-whitespace skip for %d isn't counted against
+ * the field width, so " 7" and "07" both parse to day=7. "%3s" for the
+ * month name relies on month abbreviations always being exactly 3 letters
+ * (SS9's `date-month` production lists only "Jan".."Dec") to land exactly
+ * on the following "-" with no whitespace between them.
+ *
+ * Builds a struct tm from the fixed-format fields and uses timegm(3) (UTC
+ * interpretation, no DST, no local-timezone dependency -- appropriate
+ * since the fields already carry their own explicit zone offset) to get
+ * a UTC instant for the wall-clock fields alone, then subtracts the
+ * parsed zone offset to get the true UTC timestamp: a "-0800" zone means
+ * the wall-clock time is 8 hours behind UTC, so UTC = wall_time -
+ * (-8h) = wall_time + 8h, i.e. timegm(tm) - zone_offset_seconds where
+ * zone_offset_seconds is already negative for a "-" zone.
+ *
+ * Only coarse range checks (day 1-31, hour/minute/second in range, a
+ * recognized month name, a 4-digit year not before the Unix epoch) --
+ * calendar validity (e.g. "31-Feb-2026") is NOT checked; timegm(3)
+ * normalizes an out-of-range day forward rather than erroring, which is
+ * accepted here as reasonable non-error behavior rather than treated as
+ * a bug worth guarding against, consistent with this file's general
+ * "reject clearly malformed input, don't chase every edge case" style.
+ */
+static int
+parse_date_time(const char *s, int64_t *out)
+{
+	struct tm	tm;
+	char		mon[4];
+	int		day, year, hh, mm, ss, zh, zm, i;
+	char		zsign;
+	time_t		t;
+	int64_t		zoff;
+
+	memset(&tm, 0, sizeof(tm));
+
+	if (sscanf(s, "%2d-%3s-%4d %2d:%2d:%2d %c%2d%2d", &day, mon, &year,
+	    &hh, &mm, &ss, &zsign, &zh, &zm) != 9)
+		return (-1);
+
+	if (day < 1 || day > 31 || hh < 0 || hh > 23 || mm < 0 || mm > 59 ||
+	    ss < 0 || ss > 60 || year < 1970 ||
+	    (zsign != '+' && zsign != '-'))
+		return (-1);
+
+	for (i = 0; i < 12; i++) {
+		if (strcasecmp(mon, fetch_month_names[i]) == 0)
+			break;
+	}
+	if (i == 12)
+		return (-1);
+
+	tm.tm_mday = day;
+	tm.tm_mon = i;
+	tm.tm_year = year - 1900;
+	tm.tm_hour = hh;
+	tm.tm_min = mm;
+	tm.tm_sec = ss;
+
+	if ((t = timegm(&tm)) == (time_t)-1)
+		return (-1);
+
+	zoff = (int64_t)zh * 3600 + (int64_t)zm * 60;
+	if (zsign == '-')
+		zoff = -zoff;
+
+	*out = (int64_t)t - zoff;
+	return (0);
+}
+
+/* Parsed result of parse_append_args() below -- kept together as one
+ * struct, unlike parse_seq_range()'s several out-parameters, simply
+ * because there are too many fields here for that style to stay
+ * readable. */
+struct append_parsed {
+	char		mailbox[MBOX_NAME_MAX];
+	uint32_t	sysflags;
+	char		keywords[MBOX_FLAGS_MAX];
+	int		has_date;
+	int64_t		date;
+	uint64_t	litlen;
+	int		litnonsync;
+};
+
+/*
+ * RFC 9051 SS6.3.12: `append = "APPEND" SP mailbox [SP flag-list] [SP
+ * date-time] SP literal`. A fixed positional grammar (mailbox, then an
+ * optional flag-list, then an optional date-time, then a mandatory
+ * literal, in exactly that order) -- parsed here as a straightforward
+ * left-to-right scan over args (modified in place), not a generic
+ * tokenizer, since a quoted date-time string can itself contain spaces
+ * (ruling out simple whitespace-splitting the way cmd_fetch()'s sequence-
+ * set/fetch-att parsing gets away with) and a flag-list's own internal
+ * grammar is already handled by parse_store_flags() (reused here
+ * directly, since SS6.3.12's flag-list is syntactically identical to
+ * STORE's).
+ *
+ * The mailbox token accepts a bare atom or a single quoted string (no
+ * backslash-escape decoding) -- same deliberate simplification cmd_
+ * select() already documents; APPEND's grammar also allows a literal
+ * mailbox name, not supported here for the same reason cmd_select()
+ * doesn't support one either.
+ *
+ * The literal is required to be the final thing on the line -- true by
+ * construction per this grammar (nothing follows `literal` in `append`),
+ * so this isn't actually a simplification, just an explicit check that
+ * catches a malformed line (trailing garbage after the "{n}") with a
+ * clear BAD instead of silently ignoring it.
+ *
+ * Returns 0 on success, -1 (BAD) or -2 (NO) with *errmsg set on failure --
+ * same convention as parse_fetch_atts()/parse_store_flags().
+ */
+static int
+parse_append_args(char *args, struct append_parsed *out, const char **errmsg)
+{
+	char	*p = args;
+
+	memset(out, 0, sizeof(*out));
+	*errmsg = NULL;
+
+	if (p == NULL || *p == '\0') {
+		*errmsg = "APPEND requires a mailbox name";
+		return (-1);
+	}
+
+	while (*p == ' ')
+		p++;
+	if (*p == '"') {
+		char	*start = p + 1;
+		char	*end = strchr(start, '"');
+		size_t	 len;
+
+		if (end == NULL) {
+			*errmsg = "unterminated quoted mailbox name";
+			return (-1);
+		}
+		len = (size_t)(end - start);
+		if (len == 0) {
+			*errmsg = "empty mailbox name";
+			return (-1);
+		}
+		if (len >= sizeof(out->mailbox)) {
+			*errmsg = "mailbox name too long";
+			return (-1);
+		}
+		memcpy(out->mailbox, start, len);
+		out->mailbox[len] = '\0';
+		p = end + 1;
+	} else {
+		char	*start = p;
+		size_t	 len;
+
+		while (*p != '\0' && *p != ' ')
+			p++;
+		len = (size_t)(p - start);
+		if (len == 0) {
+			*errmsg = "empty mailbox name";
+			return (-1);
+		}
+		if (len >= sizeof(out->mailbox)) {
+			*errmsg = "mailbox name too long";
+			return (-1);
+		}
+		memcpy(out->mailbox, start, len);
+		out->mailbox[len] = '\0';
+	}
+
+	while (*p == ' ')
+		p++;
+	if (*p == '(') {
+		char		*start = p;
+		char		*end = strchr(p, ')');
+		char		 saved;
+		int		 rc;
+		const char	*sub_err;
+
+		if (end == NULL) {
+			*errmsg = "unterminated flag list";
+			return (-1);
+		}
+		end++;	/* include the ')' itself in the substring below */
+		saved = *end;
+		*end = '\0';
+		rc = parse_store_flags(start, &out->sysflags, out->keywords,
+		    sizeof(out->keywords), &sub_err);
+		*end = saved;
+		if (rc != 0) {
+			*errmsg = sub_err;
+			return (rc);
+		}
+		p = end;
+		while (*p == ' ')
+			p++;
+	}
+
+	if (*p == '"') {
+		char	*start = p + 1;
+		char	*end = strchr(start, '"');
+		size_t	 dlen;
+		char	 datebuf[64];
+
+		if (end == NULL) {
+			*errmsg = "unterminated date-time string";
+			return (-1);
+		}
+		dlen = (size_t)(end - start);
+		if (dlen >= sizeof(datebuf)) {
+			*errmsg = "date-time string too long";
+			return (-1);
+		}
+		memcpy(datebuf, start, dlen);
+		datebuf[dlen] = '\0';
+		if (parse_date_time(datebuf, &out->date) == -1) {
+			*errmsg = "malformed date-time string";
+			return (-1);
+		}
+		out->has_date = 1;
+		p = end + 1;
+		while (*p == ' ')
+			p++;
+	}
+
+	if (*p != '{') {
+		*errmsg = "expected a message literal";
+		return (-1);
+	}
+	{
+		char		*start = p + 1;
+		char		*end = strchr(start, '}');
+		char		*digits_end;
+		char		*digits_stop;
+		char		 digitsbuf[24];
+		size_t		 digits_len;
+		unsigned long long litlen;
+
+		if (end == NULL) {
+			*errmsg = "malformed literal announcement";
+			return (-1);
+		}
+
+		out->litnonsync = (end > start && end[-1] == '+');
+		digits_stop = out->litnonsync ? end - 1 : end;
+		digits_len = (size_t)(digits_stop - start);
+
+		if (digits_len == 0 || digits_len >= sizeof(digitsbuf)) {
+			*errmsg = "malformed literal octet count";
+			return (-1);
+		}
+		memcpy(digitsbuf, start, digits_len);
+		digitsbuf[digits_len] = '\0';
+
+		errno = 0;
+		litlen = strtoull(digitsbuf, &digits_end, 10);
+		if (*digits_end != '\0' || errno == ERANGE) {
+			*errmsg = "malformed literal octet count";
+			return (-1);
+		}
+		out->litlen = (uint64_t)litlen;
+
+		if (out->litnonsync && out->litlen > 4096) {
+			/* RFC 9051 SS4.3: "non-synchronizing literals MUST
+			 * NOT be larger than 4096 octets. Any literal larger
+			 * than 4096 bytes MUST be sent as a synchronizing
+			 * literal." A client violating this is a protocol
+			 * error, not merely an oversized message -- BAD, not
+			 * the plain-NO size-cap rejection cmd_append() does
+			 * separately for APPEND_LITERAL_MAX. */
+			*errmsg = "non-synchronizing literal exceeds RFC "
+			    "9051 SS4.3's 4096-octet limit -- use a "
+			    "synchronizing literal instead";
+			return (-1);
+		}
+
+		if (end[1] != '\0') {
+			*errmsg = "literal must be the final argument";
+			return (-1);
+		}
+	}
+
+	return (0);
+}
+
+/*
+ * RFC 9051 SS6.3.12: `append = "APPEND" SP mailbox [SP flag-list] [SP
+ * date-time] SP literal`. Parses everything up through the literal
+ * announcement via parse_append_args(), then -- if a literal was found --
+ * allocates s->literal_buf and switches the session into the client-
+ * literal-read phase (s->literal_pending; see session_dispatch_client()'s
+ * header comment on that block for why this needs no new dispatch-table
+ * state). The actual IMSG_MBOX_APPEND isn't sent from here: that happens
+ * once the full literal (plus its trailing CRLF) has actually been read,
+ * in session_finish_append().
+ */
+static int
+cmd_append(struct session *s, const char *tag, char *args)
+{
+	struct append_parsed	 parsed;
+	int			 rc;
+	const char		*errmsg;
+
+	rc = parse_append_args(args, &parsed, &errmsg);
+	if (rc == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+	if (rc == -2) {
+		session_reply(s, tag, "NO", errmsg);
+		return (1);
+	}
+
+	if (parsed.litlen > APPEND_LITERAL_MAX) {
+		/* RFC 5530: LIMIT -- "The operation ran up against an
+		 * implementation limit of some kind," precisely
+		 * APPEND_LITERAL_MAX's own situation. */
+		session_reply(s, tag, "NO",
+		    "[LIMIT] message too large for this server (v1 size "
+		    "limit -- see APPEND_LITERAL_MAX)");
+		return (1);
+	}
+
+	if (s->store_iev == NULL) {
+		/* Same internal-invariant check as cmd_select()/cmd_fetch()/
+		 * cmd_store_cmd()/session_request_expunge() -- ST_AUTH
+		 * requires store_iev to already be wired. */
+		log_warnx("session %u: APPEND with no store channel wired",
+		    s->id);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	if (parsed.litlen > 0) {
+		if ((s->literal_buf = malloc((size_t)parsed.litlen)) ==
+		    NULL) {
+			log_warn("session %u: malloc APPEND literal buffer",
+			    s->id);
+			session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+			return (1);
+		}
+	} else
+		s->literal_buf = NULL;	/* zero-length literal -- see
+					 * session_dispatch_client()'s literal
+					 * block, which handles this without
+					 * special-casing */
+
+	strlcpy(s->append_mailbox, parsed.mailbox, sizeof(s->append_mailbox));
+	s->append_sysflags = parsed.sysflags;
+	strlcpy(s->append_keywords, parsed.keywords,
+	    sizeof(s->append_keywords));
+	s->append_has_date = parsed.has_date;
+	s->append_date = parsed.date;
+	s->append_prev_state = s->state;
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->literal_len = parsed.litlen;
+	s->literal_remaining = parsed.litlen;
+	s->literal_pending = 1;
+
+	/*
+	 * RFC 9051 SS4.3: a synchronizing literal requires the server to
+	 * send a command continuation request ("+") and the client to wait
+	 * for it before sending the literal's octets; a non-synchronizing
+	 * literal (the "+" inside the braces) requires neither -- "the
+	 * server does not generate a command continuation request... and
+	 * clients are not required to wait." Sending "+ Ready for literal
+	 * data" unconditionally would still be harmless protocol-wise (a
+	 * LITERAL+ client just ignores it, since it isn't waiting for
+	 * anything), but would misleadingly claim the server is waiting
+	 * when it isn't, so it's gated on !litnonsync.
+	 */
+	if (!parsed.litnonsync)
+		session_write(s, "+ Ready for literal data\r\n", 27);
+
+	return (1);
+}
+
+/*
+ * Called once session_dispatch_client() has fully read an APPEND
+ * literal's octets plus its trailing CRLF (see that function's literal-
+ * handling block). Builds the combined header-plus-message-bytes imsg
+ * (imapd.h's imsg_mbox_append comment explains why it's one imsg, not
+ * two or an fd) and sends it, entering SESSION_APPENDING to await the
+ * single IMSG_MBOX_APPENDED reply. s->literal_buf is freed here either
+ * way -- its contents have been copied into the imsg by the time imsg_
+ * compose() returns (same copy-not-ownership semantics parent.c's send_
+ * tls_certs() comment already documents for imsg_compose() generally),
+ * so there's nothing left needing it afterward.
+ */
+static int
+session_finish_append(struct session *s)
+{
+	struct imsg_mbox_append	 req;
+	char				*combined;
+	size_t				 combined_len;
+
+	memset(&req, 0, sizeof(req));
+	strlcpy(req.mailbox, s->append_mailbox, sizeof(req.mailbox));
+	req.sysflags = s->append_sysflags;
+	strlcpy(req.keywords, s->append_keywords, sizeof(req.keywords));
+	req.has_date = s->append_has_date;
+	req.date = s->append_date;
+	req.msglen = (uint32_t)s->literal_len;
+
+	if (s->store_iev == NULL) {
+		log_warnx("session %u: APPEND with no store channel wired "
+		    "(literal already read)", s->id);
+		session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error");
+		free(s->literal_buf);
+		s->literal_buf = NULL;
+		s->state = s->append_prev_state;
+		return (1);
+	}
+
+	combined_len = sizeof(req) + (size_t)s->literal_len;
+	if ((combined = malloc(combined_len)) == NULL) {
+		log_warn("session %u: malloc APPEND imsg buffer", s->id);
+		session_reply(s, s->pending_tag, "NO", "[SERVERBUG] internal error");
+		free(s->literal_buf);
+		s->literal_buf = NULL;
+		s->state = s->append_prev_state;
+		return (1);
+	}
+	memcpy(combined, &req, sizeof(req));
+	if (s->literal_len > 0)
+		memcpy(combined + sizeof(req), s->literal_buf,
+		    (size_t)s->literal_len);
+
+	free(s->literal_buf);
+	s->literal_buf = NULL;
+
+	s->state = SESSION_APPENDING;
+
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_APPEND, 0, 0, -1,
+	    combined, combined_len) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_APPEND", s->id);
+	free(combined);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+/*
+ * Terminal reply for the APPEND round trip session_finish_append()
+ * started. Restores s->state to whatever it was before APPEND began
+ * (s->append_prev_state) -- SESSION_AUTHENTICATED or SESSION_SELECTED,
+ * since APPEND is valid, and doesn't change the selected/authenticated
+ * state, in either (RFC 9051 command-auth).
+ *
+ * SS6.3.12: "If the destination mailbox does not exist, a server MUST
+ * return an error... Unless it is certain that the destination mailbox
+ * cannot be created, the server MUST send the response code
+ * '[TRYCREATE]'" -- v1 has no CREATE (still a stub), so retrying via
+ * CREATE would never actually help, but the response code's job is to
+ * tell the client *why* the append failed (no such mailbox), not to
+ * promise CREATE will succeed, so it's sent regardless.
+ *
+ * SS6.3.12 also: "On successful completion of an APPEND, the server
+ * returns an APPENDUID response code" (SS7.1: `"APPENDUID" SP nz-number
+ * SP append-uid` -- uidvalidity then the new UID) and "If the mailbox is
+ * currently selected... the server SHOULD notify the client immediately
+ * via an untagged EXISTS response". Before RFC 9051 SS6.3.4-SS6.3.6's flat
+ * multi-mailbox support (docs/openimap-storage-backend.md item 10),
+ * append_prev_state == SESSION_SELECTED alone was a sound proxy for "the
+ * mailbox is currently selected" -- v1 had exactly one mailbox, so
+ * anything selected was necessarily APPEND's own destination. That's no
+ * longer true (a session can have "INBOX" selected while APPENDing into
+ * "Drafts"), so the check now also compares s->append_mailbox against
+ * s->selected_mailbox -- case-insensitively if both are INBOX (RFC 9051
+ * SS5.1), case-sensitively otherwise, same rule mailbox_name_is_inbox()/
+ * mailbox_name_valid() apply everywhere else a mailbox name is compared.
+ */
+static void
+session_handle_mbox_appended(struct session *s,
+    struct imsg_mbox_appended *res)
+{
+	int	appended_to_selected;
+
+	s->state = s->append_prev_state;
+
+	if (!res->ok) {
+		if (res->no_such_mailbox)
+			session_reply(s, s->pending_tag, "NO",
+			    "[TRYCREATE] no such mailbox");
+		else
+			session_reply(s, s->pending_tag, "NO",
+			    "APPEND failed");
+		return;
+	}
+
+	if (mailbox_name_is_inbox(s->append_mailbox) &&
+	    mailbox_name_is_inbox(s->selected_mailbox))
+		appended_to_selected = 1;
+	else
+		appended_to_selected =
+		    (strcmp(s->append_mailbox, s->selected_mailbox) == 0);
+
+	/*
+	 * RFC 9051 SS6.3.13 (IDLE): a successful APPEND always adds exactly
+	 * one message, so unlike EXPUNGE/CLOSE this needs no res->count-style
+	 * gate -- wake any other same-uid session idling on this mailbox so
+	 * it can push the new EXISTS. Fired regardless of whether *this*
+	 * session has that same mailbox selected (an APPEND into "Drafts"
+	 * from a session with "INBOX" selected should still wake a different
+	 * session idling on "Drafts") -- session_notify_idle_peers() itself
+	 * doesn't yet filter by which mailbox each peer actually has
+	 * selected (a pre-existing, accepted imprecision -- see that
+	 * function's own comment), so this is a strict improvement over
+	 * today regardless, not a new gap.
+	 */
+	session_notify_idle_peers(s);
+
+	if (s->append_prev_state == SESSION_SELECTED && appended_to_selected) {
+		char	buf[32];
+
+		snprintf(buf, sizeof(buf), "%u EXISTS", res->exists);
+		session_untagged(s, buf);
+	}
+
+	{
+		char	buf[96];
+
+		snprintf(buf, sizeof(buf),
+		    "[APPENDUID %u %u] APPEND completed", res->uidvalidity,
+		    res->uid);
+		session_reply(s, s->pending_tag, "OK", buf);
+	}
+}
+
+/*
+ * RFC 9051 SS6.3.13: "Arguments: none." Valid in the authenticated or
+ * selected state -- RFC 2177's own formal grammar groups idle under
+ * "command_auth ::= ... / idle ;; Valid only in Authenticated or Selected
+ * state", matching this codebase's existing ST_AUTH bitmask (AUTHENTICATED
+ * | SELECTED) exactly, so the dispatch table entry already in place from
+ * before this command had a real implementation needed no change.
+ *
+ * "The server requests a response to the IDLE command using the
+ * continuation ('+') response" -- sent synchronously here, same as
+ * cmd_authenticate()'s own bare-AUTHENTICATE-PLAIN continuation (no imsg
+ * round trip needed just to produce a continuation prompt). If a mailbox
+ * is currently selected, also kicks off a background IMSG_MBOX_IDLE_
+ * REFRESH purely to seed s->idle_known_uids -- this doesn't delay "+
+ * idling" itself; it just means the first real change-triggered refresh
+ * (session_notify_idle_peers(), triggered by some other same-uid session)
+ * has an actual baseline to diff against instead of nothing.
+ */
+static int
+cmd_idle(struct session *s, const char *tag, char *args)
+{
+	(void)args;	/* RFC 2177's grammar takes none; same leniency
+			 * toward stray trailing tokens as every other
+			 * zero-argument command in this file. */
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->idling = 1;
+	session_write(s, "+ idling\r\n", 10);
+
+	if (s->state == SESSION_SELECTED)
+		session_request_idle_refresh(s);
+
+	return (1);
+}
+
+/*
+ * RFC 9051 SS6.4.1: "Arguments: none." Extra arguments are silently
+ * ignored (like CAPABILITY's) rather than rejected -- see cmd_capability()'s
+ * comment for the same leniency reasoning.
+ *
+ * CLOSE "permanently removes all messages that have the \Deleted flag set
+ * ... and returns to the authenticated state ... No untagged EXPUNGE
+ * responses are sent" -- implemented by sending the identical IMSG_MBOX_
+ * EXPUNGE request cmd_expunge() sends, just with silent=1, via the shared
+ * session_request_expunge() helper (see its comment). The SS6.4.1 exception
+ * for a read-only (EXAMINE'd) mailbox -- "No messages are removed, and no
+ * error is given, if the mailbox is selected by EXAMINE" -- is handled
+ * inside session_request_expunge() itself now, via s->mbox_readonly: CLOSE
+ * short-circuits to a plain "OK CLOSE completed" with no store round trip
+ * at all when read-only (see that function's comment).
+ */
+static int
+cmd_close(struct session *s, const char *tag, char *args)
+{
+	(void)args;
+	return session_request_expunge(s, tag, 1, 0, 0, 0, 0, 0);
+}
+
+/*
+ * RFC 9051 SS6.4.2: "Arguments: none." UNSELECT "frees a session's
+ * resources associated with the selected mailbox and returns the server
+ * to the authenticated state... performs the same actions as CLOSE,
+ * except that no messages are permanently removed." In this codebase
+ * store.c holds no per-selection state of its own to free -- every
+ * IMSG_MBOX_* request (SELECT included) is independently self-contained,
+ * re-opening/re-locking the index as needed rather than caching anything
+ * mailbox-specific in the store child between requests -- so there is
+ * nothing to tell store about at all, and no async round trip is needed;
+ * this is the one command-select handler in this file that's purely a
+ * local state change.
+ */
+static int
+cmd_unselect(struct session *s, const char *tag, char *args)
+{
+	(void)args;
+
+	s->state = SESSION_AUTHENTICATED;
+	session_reply(s, tag, "OK", "Unselect completed");
+	return (1);
+}
+
+/*
+ * RFC 9051 SS6.4.3: "Arguments: none." Same extra-arguments leniency as
+ * CLOSE above. Sends the real (non-silent) IMSG_MBOX_EXPUNGE via the
+ * shared session_request_expunge() helper -- see that function's comment,
+ * and imapd.h's imsg_mbox_expunge comment for the full CLOSE/EXPUNGE
+ * request-sharing rationale.
+ */
+static int
+cmd_expunge(struct session *s, const char *tag, char *args)
+{
+	(void)args;
+	return session_request_expunge(s, tag, 0, 0, 0, 0, 0, 0);
+}
+
+/* RFC 9051 SS6.4.4's SEARCH result options this server understands as
+ * ESEARCH return items (SS7.3.4) -- SAVE (the "$" search result
+ * variable, SS6.4.4.1) is recognized but rejected with a flagged NO; see
+ * parse_search_return_opts()'s comment for why it's out of scope this
+ * pass. */
+#define SEARCH_RETURN_MIN	(1U << 0)
+#define SEARCH_RETURN_MAX	(1U << 1)
+#define SEARCH_RETURN_ALL	(1U << 2)
+#define SEARCH_RETURN_COUNT	(1U << 3)
+
+/*
+ * RFC 9051 SS9: `date = date-text / DQUOTE date-text DQUOTE`, `date-text
+ * = date-day "-" date-month "-" date-year`, `date-day = 1*2DIGIT` --
+ * used by SEARCH's BEFORE/ON/SINCE (internal date, no time-of-day or
+ * zone component at all, unlike APPEND's `date-time`). Deliberately a
+ * separate function from parse_date_time(): that one parses APPEND's
+ * `date-time` (space-padded 2-digit day, full time-of-day, mandatory
+ * zone offset) -- a different grammar production, not just a formatting
+ * variant of this one. Reuses fetch_month_names[] (forward-declared
+ * above, defined alongside format_internaldate() below) for the month
+ * name, same as parse_date_time() does.
+ *
+ * "Disregarding time and timezone" (SS6.4.4's own words for BEFORE/ON/
+ * SINCE) is interpreted here as a UTC calendar day: the internaldate
+ * values being compared against are themselves stored as absolute UTC
+ * epoch instants with no separate per-message zone metadata retained --
+ * parse_maildir_timestamp() reads a delivery timestamp with no zone
+ * information at all, and APPEND's own parse_date_time() folds any
+ * client-supplied zone offset into an absolute UTC instant before
+ * storing it (see that function's comment) -- so there is no remaining
+ * per-message zone left to "disregard" separately at search time. UTC
+ * midnight of the given date-text, via timegm(3), is therefore the only
+ * coherent boundary available given what's actually on disk. This is a
+ * specific, deliberate interpretation choice, not something RFC 9051
+ * spells out explicitly -- flagged here rather than presented as if it
+ * were an unambiguous reading of the spec text.
+ *
+ * Returns 0 and fills *out (Unix timestamp, UTC midnight of that date)
+ * on success, -1 on any malformed input (bad digit counts, an
+ * unrecognized month name, a day out of 1-31, a pre-1970 year, or
+ * trailing garbage after the date-text -- checked via sscanf(3)'s "%n"
+ * conversion, which records how many characters were consumed).
+ */
+static int
+parse_search_date(const char *s, int64_t *out)
+{
+	struct tm	tm;
+	char		mon[4];
+	int		day, year, i, n;
+	time_t		t;
+
+	memset(&tm, 0, sizeof(tm));
+	n = 0;
+	if (sscanf(s, "%2d-%3s-%4d%n", &day, mon, &year, &n) != 3)
+		return (-1);
+	if (s[n] != '\0')
+		return (-1);	/* trailing garbage after date-text */
+
+	if (day < 1 || day > 31 || year < 1970)
+		return (-1);
+
+	for (i = 0; i < 12; i++) {
+		if (strcasecmp(mon, fetch_month_names[i]) == 0)
+			break;
+	}
+	if (i == 12)
+		return (-1);
+
+	tm.tm_mday = day;
+	tm.tm_mon = i;
+	tm.tm_year = year - 1900;
+	/* tm_hour/tm_min/tm_sec already 0 from memset -- UTC midnight */
+
+	if ((t = timegm(&tm)) == (time_t)-1)
+		return (-1);
+
+	*out = (int64_t)t;
+	return (0);
+}
+
+/*
+ * Accumulator for parse_search_key()/parse_search_key_list()'s compiled
+ * postfix program -- see imapd.h's imsg_mbox_search comment for why
+ * the wire format is a flat struct search_node array rather than a
+ * pointer-linked tree. Kept as a small struct (not just a bare array +
+ * count passed around) purely so search_push() has one thing to take a
+ * pointer to.
+ */
+#define SEARCH_MAX_DEPTH	64	/* F7 fix: max parse_search_key() recursion
+				 * depth -- prevents a deeply-nested SEARCH
+				 * from exhausting the listener stack. */
+struct search_parse_ctx {
+	struct search_node	nodes[SEARCH_PROGRAM_MAX_NODES];
+	uint32_t		n;
+	int			depth;	/* F7: current recursion depth */
+	int			uses_modseq; /* set by parse_search_key() when
+					     * a MODSEQ search-key is pushed --
+					     * see cmd_search()'s use of it to
+					     * set s->search_used_modseq /
+					     * call session_condstore_enable(). */
+};
+
+static int
+search_push(struct search_parse_ctx *ctx, const struct search_node *node,
+    const char **errmsg)
+{
+	if (ctx->n >= SEARCH_PROGRAM_MAX_NODES) {
+		*errmsg = "search criteria too complex";
+		return (-1);
+	}
+	ctx->nodes[ctx->n++] = *node;
+	return (0);
+}
+
+/*
+ * Reads one sequence-set token (space- or ')'-delimited) starting at
+ * *pp, rejects an internal comma (v1's established single-range-only
+ * restriction -- see imapd.h's imsg_mbox_fetch comment), and parses
+ * it via the same parse_seq_range() FETCH/STORE already use. Shared by
+ * both the bare-sequence-set search key and the "UID" SP sequence-set
+ * search key below -- their token grammar and v1 restrictions are
+ * identical, only which field of struct search_node the caller stores
+ * the result into (and which op it tags the node with) differs.
+ */
+static int
+parse_search_seqset_token(char **pp, uint32_t *lo, uint32_t *hi,
+    int *lo_star, int *hi_star, const char **errmsg)
+{
+	char	*p = *pp;
+	char	*start = p;
+
+	while (*p != '\0' && *p != ' ' && *p != ')')
+		p++;
+
+	if (p == start) {
+		*errmsg = "missing sequence set";
+		return (-1);
+	}
+
+	{
+		char	tok[32];
+		size_t	len = (size_t)(p - start);
+
+		if (len >= sizeof(tok)) {
+			*errmsg = "sequence set too long";
+			return (-1);
+		}
+		memcpy(tok, start, len);
+		tok[len] = '\0';
+
+		if (strchr(tok, ',') != NULL) {
+			*errmsg = "comma-separated sequence sets not "
+			    "supported in v1 -- issue separate SEARCH "
+			    "commands";
+			return (-1);
+		}
+
+		if (parse_seq_range(tok, lo, hi, lo_star, hi_star) == -1) {
+			*errmsg = "invalid sequence set";
+			return (-1);
+		}
+	}
+
+	*pp = p;
+	return (0);
+}
+
+/*
+ * RFC 9051 SS6.4.4's `search-key` grammar (SS9's formal ABNF), recursive-
+ * descent, one search-key at a time -- pushes exactly one resulting
+ * value onto ctx's postfix program (a single leaf node for most keys, or
+ * a leaf/subexpression followed by a combinator for NOT/OR/parenthesized
+ * lists) and leaves *pp positioned just past what it consumed.
+ *
+ * v1 scope: search keys that need actual message content or headers --
+ * BCC/BODY/CC/FROM/HEADER/SENTBEFORE/SENTON/SENTSINCE/SUBJECT/TEXT/TO --
+ * are recognized by name (so a client seeing NO for one of them gets a
+ * specific, honest explanation, not a generic BAD) but rejected
+ * immediately with -2/NO, without attempting to parse or skip their
+ * operand -- safe because the whole SEARCH command is abandoned on any
+ * -2/-1 return anyway, so there's no later parse position that still
+ * needs to be correct. This is the same "recognized, can't do it right
+ * now" category FETCH's BODY[] rejection already uses, for the same
+ * underlying reason: real support needs message content access, which
+ * needs fd-passing from store, not designed yet (see cmd_fetch()'s own
+ * comment).
+ *
+ * Returns 0 on success, -1 (BAD) for malformed syntax, -2 (NO) for a
+ * recognized-but-unsupported search key -- same convention as parse_
+ * fetch_atts()/parse_store_flags()/parse_append_args().
+ */
+static int
+parse_search_key(char **pp, struct search_parse_ctx *ctx, const char **errmsg)
+{
+	int	rc;
+
+	/*
+	 * F7 fix: bound recursion depth. Every level of the SEARCH-key
+	 * grammar (parenthesized lists, NOT, OR) re-enters this function,
+	 * so a single guard here caps total recursion and prevents a
+	 * crafted deeply-nested SEARCH from exhausting the listener stack.
+	 */
+	if (++ctx->depth > SEARCH_MAX_DEPTH) {
+		ctx->depth--;
+		*errmsg = "search criteria nested too deeply";
+		return (-1);
+	}
+	rc = parse_search_key_inner(pp, ctx, errmsg);
+	ctx->depth--;
+	return (rc);
+}
+
+static int
+parse_search_key_inner(char **pp, struct search_parse_ctx *ctx,
+    const char **errmsg)
+{
+	char	*p = *pp;
+	char	 word[32];
+	size_t	 wlen;
+
+	while (*p == ' ')
+		p++;
+
+	if (*p == '\0') {
+		*errmsg = "missing search key";
+		return (-1);
+	}
+
+	if (*p == '(') {
+		int	rc;
+
+		p++;
+		rc = parse_search_key_list(&p, ctx, errmsg, 1);
+		if (rc != 0)
+			return (rc);
+		while (*p == ' ')
+			p++;
+		if (*p != ')') {
+			*errmsg = "unterminated parenthesized search key list";
+			return (-1);
+		}
+		p++;
+		*pp = p;
+		return (0);
+	}
+
+	if (isdigit((unsigned char)*p) || *p == '*') {
+		struct search_node	node;
+
+		memset(&node, 0, sizeof(node));
+		node.op = SEARCH_OP_SEQSET;
+		if (parse_search_seqset_token(&p, &node.seq_lo, &node.seq_hi,
+		    &node.lo_is_star, &node.hi_is_star, errmsg) == -1)
+			return (-1);
+		if (search_push(ctx, &node, errmsg) == -1)
+			return (-1);
+		*pp = p;
+		return (0);
+	}
+
+	{
+		char	*start = p;
+
+		while (*p != '\0' && *p != ' ' && *p != ')')
+			p++;
+		wlen = (size_t)(p - start);
+		if (wlen == 0 || wlen >= sizeof(word)) {
+			*errmsg = "unknown search key";
+			return (-1);
+		}
+		memcpy(word, start, wlen);
+		word[wlen] = '\0';
+	}
+
+	if (strcasecmp(word, "ALL") == 0) {
+		struct search_node	node;
+
+		memset(&node, 0, sizeof(node));
+		node.op = SEARCH_OP_ALL;
+		if (search_push(ctx, &node, errmsg) == -1)
+			return (-1);
+		*pp = p;
+		return (0);
+	}
+
+	{
+		static const struct {
+			const char	*name;
+			int		 op;
+		} boolkeys[] = {
+			{ "ANSWERED",	SEARCH_OP_ANSWERED },
+			{ "UNANSWERED",	SEARCH_OP_UNANSWERED },
+			{ "DELETED",	SEARCH_OP_DELETED },
+			{ "UNDELETED",	SEARCH_OP_UNDELETED },
+			{ "DRAFT",	SEARCH_OP_DRAFT },
+			{ "UNDRAFT",	SEARCH_OP_UNDRAFT },
+			{ "FLAGGED",	SEARCH_OP_FLAGGED },
+			{ "UNFLAGGED",	SEARCH_OP_UNFLAGGED },
+			{ "SEEN",	SEARCH_OP_SEEN },
+			{ "UNSEEN",	SEARCH_OP_UNSEEN },
+		};
+		size_t	i;
+
+		for (i = 0; i < sizeof(boolkeys) / sizeof(boolkeys[0]); i++) {
+			struct search_node	node;
+
+			if (strcasecmp(word, boolkeys[i].name) != 0)
+				continue;
+			memset(&node, 0, sizeof(node));
+			node.op = boolkeys[i].op;
+			if (search_push(ctx, &node, errmsg) == -1)
+				return (-1);
+			*pp = p;
+			return (0);
+		}
+	}
+
+	if (strcasecmp(word, "KEYWORD") == 0 ||
+	    strcasecmp(word, "UNKEYWORD") == 0) {
+		struct search_node	node;
+		char			*start;
+
+		memset(&node, 0, sizeof(node));
+		node.op = (strcasecmp(word, "KEYWORD") == 0) ?
+		    SEARCH_OP_KEYWORD : SEARCH_OP_UNKEYWORD;
+
+		while (*p == ' ')
+			p++;
+		start = p;
+		while (*p != '\0' && *p != ' ' && *p != ')')
+			p++;
+		if (p == start || (size_t)(p - start) >= sizeof(node.keyword)) {
+			*errmsg = "missing or too-long KEYWORD/UNKEYWORD "
+			    "argument";
+			return (-1);
+		}
+		memcpy(node.keyword, start, (size_t)(p - start));
+		node.keyword[p - start] = '\0';
+
+		if (search_push(ctx, &node, errmsg) == -1)
+			return (-1);
+		*pp = p;
+		return (0);
+	}
+
+	if (strcasecmp(word, "BEFORE") == 0 || strcasecmp(word, "ON") == 0 ||
+	    strcasecmp(word, "SINCE") == 0) {
+		struct search_node	node;
+		char			datebuf[32];
+
+		memset(&node, 0, sizeof(node));
+		if (strcasecmp(word, "BEFORE") == 0)
+			node.op = SEARCH_OP_BEFORE;
+		else if (strcasecmp(word, "ON") == 0)
+			node.op = SEARCH_OP_ON;
+		else
+			node.op = SEARCH_OP_SINCE;
+
+		while (*p == ' ')
+			p++;
+		if (*p == '"') {
+			char	*start = p + 1;
+			char	*end = strchr(start, '"');
+			size_t	 len;
+
+			if (end == NULL) {
+				*errmsg = "unterminated date string";
+				return (-1);
+			}
+			len = (size_t)(end - start);
+			if (len >= sizeof(datebuf)) {
+				*errmsg = "malformed date";
+				return (-1);
+			}
+			memcpy(datebuf, start, len);
+			datebuf[len] = '\0';
+			p = end + 1;
+		} else {
+			char	*start = p;
+			size_t	 len;
+
+			while (*p != '\0' && *p != ' ' && *p != ')')
+				p++;
+			len = (size_t)(p - start);
+			if (len == 0 || len >= sizeof(datebuf)) {
+				*errmsg = "malformed date";
+				return (-1);
+			}
+			memcpy(datebuf, start, len);
+			datebuf[len] = '\0';
+		}
+
+		if (parse_search_date(datebuf, &node.num) == -1) {
+			*errmsg = "malformed date";
+			return (-1);
+		}
+
+		if (search_push(ctx, &node, errmsg) == -1)
+			return (-1);
+		*pp = p;
+		return (0);
+	}
+
+	if (strcasecmp(word, "LARGER") == 0 ||
+	    strcasecmp(word, "SMALLER") == 0) {
+		struct search_node	node;
+		char			numbuf[24];
+		char			*start, *numend;
+		unsigned long long	 v;
+
+		memset(&node, 0, sizeof(node));
+		node.op = (strcasecmp(word, "LARGER") == 0) ?
+		    SEARCH_OP_LARGER : SEARCH_OP_SMALLER;
+
+		while (*p == ' ')
+			p++;
+		start = p;
+		while (*p != '\0' && *p != ' ' && *p != ')')
+			p++;
+		if (p == start || (size_t)(p - start) >= sizeof(numbuf)) {
+			*errmsg = "missing or malformed octet count";
+			return (-1);
+		}
+		memcpy(numbuf, start, (size_t)(p - start));
+		numbuf[p - start] = '\0';
+
+		errno = 0;
+		v = strtoull(numbuf, &numend, 10);
+		if (*numend != '\0' || errno == ERANGE) {
+			*errmsg = "malformed octet count";
+			return (-1);
+		}
+		node.num = (int64_t)v;
+
+		if (search_push(ctx, &node, errmsg) == -1)
+			return (-1);
+		*pp = p;
+		return (0);
+	}
+
+	if (strcasecmp(word, "UID") == 0) {
+		struct search_node	node;
+
+		memset(&node, 0, sizeof(node));
+		node.op = SEARCH_OP_UIDSET;
+
+		while (*p == ' ')
+			p++;
+		if (parse_search_seqset_token(&p, &node.seq_lo, &node.seq_hi,
+		    &node.lo_is_star, &node.hi_is_star, errmsg) == -1)
+			return (-1);
+
+		if (search_push(ctx, &node, errmsg) == -1)
+			return (-1);
+		*pp = p;
+		return (0);
+	}
+
+	if (strcasecmp(word, "MODSEQ") == 0) {
+		struct search_node	node;
+		char			numbuf[24];
+		char			*start, *numend;
+		unsigned long long	 v;
+
+		memset(&node, 0, sizeof(node));
+		node.op = SEARCH_OP_MODSEQ;
+
+		while (*p == ' ')
+			p++;
+
+		/* RFC 7162 SS3.1.5 ABNF: MODSEQ [SP entry-name SP entry-
+		 * type-req] SP mod-sequence-valzer. entry-name/entry-type-
+		 * req come from RFC 5464 METADATA, which this server doesn't
+		 * implement -- SS3.1.5 itself says a server that doesn't
+		 * store separate mod-sequences per metadata item "MUST
+		 * ignore <entry-name> and <entry-type-req>", so this only
+		 * needs to parse past them syntactically (see imapd.h's
+		 * SEARCH_OP_MODSEQ comment). Detected by peeking: the
+		 * mod-sequence-valzer itself is always a bare digit string,
+		 * so anything else here must be the optional entry-name. */
+		if (*p != '\0' && !isdigit((unsigned char)*p)) {
+			if (*p == '"') {
+				char	*end = strchr(p + 1, '"');
+
+				if (end == NULL) {
+					*errmsg = "unterminated MODSEQ "
+					    "entry-name";
+					return (-1);
+				}
+				p = end + 1;
+			} else {
+				while (*p != '\0' && *p != ' ' && *p != ')')
+					p++;
+			}
+			while (*p == ' ')
+				p++;
+
+			if (strncasecmp(p, "priv", 4) == 0 &&
+			    (p[4] == ' ' || p[4] == '\0' || p[4] == ')'))
+				p += 4;
+			else if (strncasecmp(p, "shared", 6) == 0 &&
+			    (p[6] == ' ' || p[6] == '\0' || p[6] == ')'))
+				p += 6;
+			else if (strncasecmp(p, "all", 3) == 0 &&
+			    (p[3] == ' ' || p[3] == '\0' || p[3] == ')'))
+				p += 3;
+			else {
+				*errmsg = "expected priv/shared/all after "
+				    "MODSEQ entry-name";
+				return (-1);
+			}
+			while (*p == ' ')
+				p++;
+		}
+
+		start = p;
+		while (*p != '\0' && *p != ' ' && *p != ')')
+			p++;
+		if (p == start || (size_t)(p - start) >= sizeof(numbuf)) {
+			*errmsg = "missing or malformed MODSEQ value";
+			return (-1);
+		}
+		memcpy(numbuf, start, (size_t)(p - start));
+		numbuf[p - start] = '\0';
+
+		errno = 0;
+		v = strtoull(numbuf, &numend, 10);
+		if (*numend != '\0' || errno == ERANGE) {
+			*errmsg = "malformed MODSEQ value";
+			return (-1);
+		}
+		node.num = (int64_t)v;
+
+		if (search_push(ctx, &node, errmsg) == -1)
+			return (-1);
+		ctx->uses_modseq = 1;
+		*pp = p;
+		return (0);
+	}
+
+	if (strcasecmp(word, "NOT") == 0) {
+		struct search_node	node;
+		int			rc;
+
+		rc = parse_search_key(&p, ctx, errmsg);
+		if (rc != 0)
+			return (rc);
+
+		memset(&node, 0, sizeof(node));
+		node.op = SEARCH_OP_NOT;
+		if (search_push(ctx, &node, errmsg) == -1)
+			return (-1);
+		*pp = p;
+		return (0);
+	}
+
+	if (strcasecmp(word, "OR") == 0) {
+		struct search_node	node;
+		int			rc;
+
+		rc = parse_search_key(&p, ctx, errmsg);
+		if (rc != 0)
+			return (rc);
+		rc = parse_search_key(&p, ctx, errmsg);
+		if (rc != 0)
+			return (rc);
+
+		memset(&node, 0, sizeof(node));
+		node.op = SEARCH_OP_OR;
+		if (search_push(ctx, &node, errmsg) == -1)
+			return (-1);
+		*pp = p;
+		return (0);
+	}
+
+	{
+		static const char *content_keys[] = {
+			"BCC", "BODY", "CC", "FROM", "HEADER", "SENTBEFORE",
+			"SENTON", "SENTSINCE", "SUBJECT", "TEXT", "TO",
+		};
+		size_t	i;
+
+		for (i = 0; i < sizeof(content_keys) / sizeof(content_keys[0]);
+		    i++) {
+			if (strcasecmp(word, content_keys[i]) == 0) {
+				*errmsg = "search keys that require message "
+				    "content/header access are not "
+				    "supported in this pass";
+				return (-2);
+			}
+		}
+	}
+
+	*errmsg = "unknown search key";
+	return (-1);
+}
+
+/*
+ * `search-key *(SP search-key)`, ANDed together left to right -- shared
+ * by the top-level search-program (in_parens == 0, stops at end of
+ * string) and a parenthesized `patterns`-style list (in_parens == 1,
+ * stops at, but does not consume, the closing ')') -- see parse_search_
+ * key()'s own "(" branch, which consumes the parens themselves.
+ */
+static int
+parse_search_key_list(char **pp, struct search_parse_ctx *ctx,
+    const char **errmsg, int in_parens)
+{
+	int	rc;
+
+	rc = parse_search_key(pp, ctx, errmsg);
+	if (rc != 0)
+		return (rc);
+
+	for (;;) {
+		char	*p = *pp;
+
+		while (*p == ' ')
+			p++;
+
+		if (in_parens && *p == ')') {
+			*pp = p;
+			return (0);
+		}
+		if (*p == '\0') {
+			if (in_parens) {
+				*errmsg = "unterminated parenthesized search "
+				    "key list";
+				return (-1);
+			}
+			*pp = p;
+			return (0);
+		}
+		if (!in_parens && *p == ')') {
+			*errmsg = "unexpected ')'";
+			return (-1);
+		}
+
+		*pp = p;
+		rc = parse_search_key(pp, ctx, errmsg);
+		if (rc != 0)
+			return (rc);
+
+		{
+			struct search_node	combine;
+
+			memset(&combine, 0, sizeof(combine));
+			combine.op = SEARCH_OP_AND;
+			if (search_push(ctx, &combine, errmsg) == -1)
+				return (-1);
+		}
+	}
+}
+
+/*
+ * RFC 9051 SS6.4.4: `search-return-opts = SP "RETURN" SP "(" [search-
+ * return-opt *(SP search-return-opt)] ")"`. *pp must already point at
+ * the opening "(" (caller peeked for it to decide whether a RETURN
+ * clause is present at all). An empty "()" is valid ABNF and leaves
+ * *opts_out at 0 -- cmd_search() treats that identically to "no RETURN
+ * clause at all" (SS6.4.4: "If no result option is specified or empty
+ * list of options is specified as '()', ALL is assumed").
+ *
+ * SAVE (SS6.4.4.1's "$" search result variable) is recognized but
+ * rejected with -2/NO: implementing it correctly means resetting the
+ * variable on SELECT/EXAMINE, adjusting it on EXPUNGE, and teaching
+ * every command that accepts a sequence-set (FETCH, STORE, COPY, MOVE,
+ * a future UID SEARCH) to also accept "$" -- real, cross-cutting design
+ * work spanning multiple already-implemented commands, not a small
+ * addition to SEARCH alone. Deferred, same category of scope cut as
+ * APPEND's size cap or LIST's extended syntax. Any other, genuinely
+ * unrecognized token (not one of MIN/MAX/ALL/COUNT/SAVE) gets -1/BAD --
+ * SS6.3.9's own words for LIST options apply equally here: "Any options
+ * not defined by extensions that the server supports MUST be rejected
+ * with a BAD response."
+ */
+static int
+parse_search_return_opts(char **pp, uint32_t *opts_out, const char **errmsg)
+{
+	char	*p = *pp;
+
+	*opts_out = 0;
+	p++;	/* skip the '(' the caller already confirmed is there */
+
+	while (*p == ' ')
+		p++;
+	if (*p == ')') {
+		*pp = p + 1;
+		return (0);
+	}
+
+	for (;;) {
+		char	*start = p;
+		char	 word[16];
+		size_t	 len;
+
+		while (*p != '\0' && *p != ' ' && *p != ')')
+			p++;
+		len = (size_t)(p - start);
+		if (len == 0 || len >= sizeof(word)) {
+			*errmsg = "malformed SEARCH RETURN option";
+			return (-1);
+		}
+		memcpy(word, start, len);
+		word[len] = '\0';
+
+		if (strcasecmp(word, "MIN") == 0)
+			*opts_out |= SEARCH_RETURN_MIN;
+		else if (strcasecmp(word, "MAX") == 0)
+			*opts_out |= SEARCH_RETURN_MAX;
+		else if (strcasecmp(word, "ALL") == 0)
+			*opts_out |= SEARCH_RETURN_ALL;
+		else if (strcasecmp(word, "COUNT") == 0)
+			*opts_out |= SEARCH_RETURN_COUNT;
+		else if (strcasecmp(word, "SAVE") == 0) {
+			*errmsg = "SEARCH RETURN (SAVE) -- the \"$\" search "
+			    "result variable -- is not supported in this "
+			    "pass";
+			return (-2);
+		} else {
+			*errmsg = "unsupported SEARCH RETURN option";
+			return (-1);
+		}
+
+		while (*p == ' ')
+			p++;
+		if (*p == ')') {
+			*pp = p + 1;
+			return (0);
+		}
+		if (*p == '\0') {
+			*errmsg = "unterminated SEARCH RETURN option list";
+			return (-1);
+		}
+	}
+}
+
+/*
+ * RFC 9051 SS6.4.4: `search = "SEARCH" [search-return-opts] SP search-
+ * program`, `search-program = ["CHARSET" SP charset SP] search-key
+ * *(SP search-key)`.
+ *
+ * v1 scope, summarized (each piece's own comment has the full reasoning):
+ * basic MIN/MAX/ALL/COUNT result options (SAVE deferred); CHARSET
+ * accepted only as US-ASCII or UTF-8 (SS6.4.4: "Servers MUST support
+ * US-ASCII and UTF-8 charsets"), anything else gets NO [BADCHARSET]; the
+ * full flag/date/size/sequence-number/UID-range/NOT/OR/parenthesized-
+ * list search-key grammar, except the content-and-header-based keys
+ * (BCC/BODY/CC/FROM/HEADER/SENTBEFORE/SENTON/SENTSINCE/SUBJECT/TEXT/TO),
+ * which need message content access this codebase doesn't have yet.
+ *
+ * RFC 9051 SS6.4.9 (UID command) is now wired up via cmd_uid()'s SEARCH
+ * branch calling search_dispatch() below with by_uid=1: "the numbers
+ * returned in an ESEARCH response for a UID SEARCH command are unique
+ * identifiers instead of message sequence numbers... the corresponding
+ * ESEARCH response MUST include the UID indicator" -- see search_
+ * dispatch()'s own comment, session_handle_mbox_search_match(), and
+ * session_finish_search() for how.
+ */
+static int
+cmd_search(struct session *s, const char *tag, char *args)
+{
+	return search_dispatch(s, tag, args, 0);
+}
+
+/*
+ * Shared body for cmd_search() (by_uid=0) and cmd_uid()'s SEARCH branch
+ * (by_uid=1). RFC 9051 SS6.4.9: "the interpretation of the [SEARCH]
+ * arguments is the same as with SEARCH" -- by_uid does NOT change how a
+ * bare sequence-set or "UID <sequence-set>" search-key is parsed or
+ * matched (those already go through SEARCH_OP_SEQSET/SEARCH_OP_UIDSET
+ * exactly as before); it only changes what value session_handle_mbox_
+ * search_match()/session_finish_search() report for each match (UID
+ * instead of sequence number) and adds the "UID" ESEARCH correlator
+ * token -- see both functions' own comments. No store.c wire-format
+ * change was needed for this at all: store.c has always sent both seqno
+ * and uid per match (see imapd.h's imsg_mbox_search_match comment).
+ */
+static int
+search_dispatch(struct session *s, const char *tag, char *args, int by_uid)
+{
+	struct search_parse_ctx	 ctx;
+	char				*p;
+	uint32_t			 return_opts = 0;
+	int				 rc;
+	const char			*errmsg;
+
+	if (args == NULL) {
+		session_reply(s, tag, "BAD", "SEARCH requires search criteria");
+		return (1);
+	}
+
+	p = args;
+	while (*p == ' ')
+		p++;
+
+	if (strncasecmp(p, "RETURN", 6) == 0 &&
+	    (p[6] == ' ' || p[6] == '\0')) {
+		p += 6;
+		while (*p == ' ')
+			p++;
+		if (*p != '(') {
+			session_reply(s, tag, "BAD",
+			    "malformed SEARCH RETURN option list");
+			return (1);
+		}
+		rc = parse_search_return_opts(&p, &return_opts, &errmsg);
+		if (rc == -1) {
+			session_reply(s, tag, "BAD", errmsg);
+			return (1);
+		}
+		if (rc == -2) {
+			session_reply(s, tag, "NO", errmsg);
+			return (1);
+		}
+		while (*p == ' ')
+			p++;
+	}
+
+	if (return_opts == 0)
+		return_opts = SEARCH_RETURN_ALL;	/* SS6.4.4's default */
+
+	if (strncasecmp(p, "CHARSET", 7) == 0 &&
+	    (p[7] == ' ' || p[7] == '\0')) {
+		char	*start;
+		char	 charset[64];
+		size_t	 len;
+
+		p += 7;
+		while (*p == ' ')
+			p++;
+		start = p;
+		while (*p != '\0' && *p != ' ')
+			p++;
+		len = (size_t)(p - start);
+		if (len == 0 || len >= sizeof(charset)) {
+			session_reply(s, tag, "BAD", "malformed CHARSET");
+			return (1);
+		}
+		memcpy(charset, start, len);
+		charset[len] = '\0';
+
+		if (strcasecmp(charset, "US-ASCII") != 0 &&
+		    strcasecmp(charset, "UTF-8") != 0) {
+			/* RFC 9051 SS9 resp-text-code: `"BADCHARSET" [SP "("
+			 * charset *(SP charset) ")"]` -- the parens are
+			 * required by the formal ABNF; SS6.4.4.4's own prose
+			 * example ("NO [BADCHARSET UTF-8] KOI8-R is not
+			 * supported") omits them, an inconsistency in the
+			 * RFC's own text between its worked example and its
+			 * Section 9 grammar -- the ABNF is followed here as
+			 * the normative definition. */
+			session_reply(s, tag, "NO",
+			    "[BADCHARSET (US-ASCII UTF-8)] unsupported "
+			    "CHARSET");
+			return (1);
+		}
+
+		while (*p == ' ')
+			p++;
+	}
+
+	memset(&ctx, 0, sizeof(ctx));
+	rc = parse_search_key_list(&p, &ctx, &errmsg, 0);
+	if (rc == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+	if (rc == -2) {
+		session_reply(s, tag, "NO", errmsg);
+		return (1);
+	}
+
+	if (ctx.n == 0) {
+		session_reply(s, tag, "BAD", "SEARCH requires search criteria");
+		return (1);
+	}
+
+	if (s->store_iev == NULL) {
+		/* Same internal-invariant check as cmd_fetch()/cmd_store_
+		 * cmd()/session_request_expunge() -- ST_SELECTED requires
+		 * store_iev to already be wired. */
+		log_warnx("session %u: %s with no store channel wired",
+		    s->id, by_uid ? "UID SEARCH" : "SEARCH");
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	/* Defensive cleanup of a previous SEARCH's leftovers, matching the
+	 * ST_SELECTED-exclusion guarantee above (SESSION_SEARCHING can't
+	 * still be in flight when a new SEARCH is dispatched) -- shouldn't
+	 * ever actually find anything here, same belt-and-suspenders
+	 * posture as other cleanup in this file. */
+	free(s->search_matches);
+	s->search_matches = NULL;
+	s->search_nmatches = 0;
+	s->search_matches_cap = 0;
+	s->search_alloc_failed = 0;
+	s->search_return_opts = return_opts;
+	s->search_used_modseq = ctx.uses_modseq;
+	s->search_max_modseq = 0;
+	s->cmd_by_uid = by_uid;
+
+	/* RFC 7162 SS3.1: "a FETCH or SEARCH command that includes the
+	 * MODSEQ message data item" is a CONDSTORE enabling command. */
+	if (ctx.uses_modseq)
+		session_condstore_enable(s);
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->state = SESSION_SEARCHING;
+
+	{
+		struct imsg_mbox_search	 req;
+		size_t			 bodylen = (size_t)ctx.n *
+		    sizeof(struct search_node);
+		char			*combined;
+
+		memset(&req, 0, sizeof(req));
+		req.nnodes = ctx.n;
+
+		if ((combined = malloc(sizeof(req) + bodylen)) == NULL) {
+			log_warn("session %u: malloc SEARCH imsg buffer",
+			    s->id);
+			session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+			s->state = SESSION_SELECTED;
+			return (1);
+		}
+		memcpy(combined, &req, sizeof(req));
+		if (bodylen > 0)
+			memcpy(combined + sizeof(req), ctx.nodes, bodylen);
+
+		if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_SEARCH, 0, 0,
+		    -1, combined, sizeof(req) + bodylen) == -1)
+			log_warn("session %u: imsg_compose IMSG_MBOX_SEARCH",
+			    s->id);
+		free(combined);
+		imsgev_add(s->store_iev);
+	}
+
+	return (1);
+}
+
+/*
+ * RFC 9051 SS9: `nz-number = digit-nz *DIGIT` -- a non-zero unsigned
+ * decimal number, the ABNF base type behind sequence numbers and UIDs.
+ * Rejects a leading zero (matches "digit-nz *DIGIT", not the more general
+ * "number"), non-digit characters, an empty string, and overflow past
+ * UINT32_MAX (see imapd.h's imsg_mbox_fetch_meta comment and RFC 9051
+ * SS2.3.1.1's own "unsigned 32-bit" language for why UINT32_MAX, not
+ * ULONG_MAX, is the ceiling here).
+ */
+static int
+parse_nz_number(const char *str, uint32_t *out)
+{
+	unsigned long	 v;
+	char		*end;
+
+	if (str == NULL || *str == '\0' || *str == '0')
+		return (-1);
+
+	errno = 0;
+	v = strtoul(str, &end, 10);
+	if (*end != '\0' || errno == ERANGE || v == 0 || v > UINT32_MAX)
+		return (-1);
+
+	*out = (uint32_t)v;
+	return (0);
+}
+
+/*
+ * RFC 9051 SS9: `sequence-set = (seq-number / seq-range) *("," sequence-
+ * set)`, `seq-range = seq-number ":" seq-number`, `seq-number = nz-number /
+ * "*"`. v1 supports exactly one seq-number or seq-range per FETCH --
+ * cmd_fetch() rejects a comma-separated sequence-set with a BAD before this
+ * function is ever called, so a comma reaching here would be a caller bug,
+ * not client input this function itself needs to detect.
+ *
+ * "*" (SS9: "the largest number in use") can't be resolved here: listener
+ * doesn't reliably know the live message count (mail can arrive between
+ * SELECT and this FETCH). lo_star and hi_star instead carry the "this side
+ * was a star" fact through IMSG_MBOX_FETCH so store.c can resolve it
+ * against its own up-to-the-moment index length -- see imapd.h's
+ * imsg_mbox_fetch comment.
+ *
+ * A bare seq-number (no ":") normalizes to lo == hi. A backwards range
+ * (e.g. "4:2") is swapped so lo <= hi, matching SS9's own "it is possible
+ * to specify a decreasing range (e.g., '4:2')" note -- store.c's
+ * handle_mbox_fetch() iterates lo..hi ascending and has no other way to
+ * handle a decreasing range. A star on one side of a range with a literal
+ * number on the other (e.g. "4:*") is left unswapped -- whether that ends
+ * up lo <= hi is something only store.c, once it resolves the star, can
+ * know.
+ */
+static int
+parse_seq_range(const char *tok, uint32_t *lo, uint32_t *hi, int *lo_star,
+    int *hi_star)
+{
+	char		 buf[32];
+	char		*colon;
+	const char	*loside, *hiside;
+
+	if (tok == NULL || *tok == '\0' || strlen(tok) >= sizeof(buf))
+		return (-1);
+	strlcpy(buf, tok, sizeof(buf));
+
+	*lo_star = *hi_star = 0;
+	*lo = *hi = 0;
+
+	if ((colon = strchr(buf, ':')) != NULL) {
+		*colon = '\0';
+		loside = buf;
+		hiside = colon + 1;
+	} else {
+		loside = buf;
+		hiside = buf;
+	}
+
+	if (strcmp(loside, "*") == 0)
+		*lo_star = 1;
+	else if (parse_nz_number(loside, lo) == -1)
+		return (-1);
+
+	if (strcmp(hiside, "*") == 0)
+		*hi_star = 1;
+	else if (parse_nz_number(hiside, hi) == -1)
+		return (-1);
+
+	if (!*lo_star && !*hi_star && *lo > *hi) {
+		uint32_t	 tmp = *lo;
+
+		*lo = *hi;
+		*hi = tmp;
+	}
+
+	return (0);
+}
+
+/*
+ * Same calling convention as strtok_r(str, " ", &savep) (pass str on the
+ * first call, NULL thereafter, using the same savep each time), but a
+ * space is not treated as a delimiter while inside an unclosed '[' or
+ * '(' -- needed because a single fetch-att can itself contain a
+ * mandatory embedded space: RFC 9051 SS9's header-list production,
+ * "(" header-fld-name *(SP header-fld-name) ")", sits inside a bracketed
+ * section-spec, e.g. the RFC's own example, "BODY[HEADER.FIELDS (DATE
+ * FROM)]" (SS6.4.5). Plain strtok_r(spec, " ", &save) -- what parse_
+ * fetch_atts() used before this pass -- would split that into three
+ * garbage tokens ("BODY[HEADER.FIELDS", "(DATE", "FROM)]"), the second
+ * and third of which match nothing and would fail the whole FETCH as
+ * BAD. This is a real, previously undiscovered bug: a comment in this
+ * file claimed the generic BODY-prefix catch-all already "recognizes and
+ * skips the whole item" for a multi-token split like that, but nothing
+ * actually reassembles tokens 2 and 3 into anything recognizable --
+ * found only because implementing HEADER.FIELDS required tracing exactly
+ * how a bracketed, space-containing fetch-att reaches parse_fetch_atts()
+ * in the first place, not by inspection of the (incorrect) comment
+ * alone. No real client request had exercised this path before now.
+ *
+ * '[' and '(' share one depth counter since section-spec's own brackets
+ * and header-list's parens are always properly nested relative to each
+ * other in this grammar -- nothing here needs to distinguish which kind
+ * of bracket is currently open, only whether depth is zero. An unclosed
+ * bracket/paren at end of string is left as trailing unbalanced depth;
+ * the caller's own field-list parsing (not this function) is what
+ * rejects that as a syntax error, same "let the specific parser catch
+ * the specific mistake" split this file already uses elsewhere (e.g.
+ * split_trailing_modifiers() vs. its own caller).
+ */
+static char *
+fetch_att_tok(char *str, char **savep)
+{
+	char	*p, *start;
+	int	 depth = 0;
+
+	p = (str != NULL) ? str : *savep;
+
+	while (*p == ' ')
+		p++;
+	if (*p == '\0') {
+		*savep = p;
+		return (NULL);
+	}
+
+	for (start = p; *p != '\0'; p++) {
+		if (*p == '[' || *p == '(')
+			depth++;
+		else if (*p == ']' || *p == ')') {
+			if (depth > 0)
+				depth--;
+		} else if (*p == ' ' && depth == 0)
+			break;
+	}
+
+	if (*p != '\0') {
+		*p = '\0';
+		p++;
+	}
+	*savep = p;
+	return (start);
+}
+
+/*
+ * Parses one "HEADER.FIELDS (name ...)" or "HEADER.FIELDS.NOT (name ...)"
+ * bracket body -- inner is everything between BODY.PEEK[...]'s brackets,
+ * e.g. "HEADER.FIELDS (DATE FROM)" (RFC 9051 SS9: section-msgtext = ... /
+ * "HEADER.FIELDS" [".NOT"] SP header-list, header-list = "(" header-fld-
+ * name *(SP header-fld-name) ")"). Only called once fetch_att_tok() (see
+ * its own comment for why a plain strtok_r() split can't handle this
+ * token's embedded space) has already isolated the whole "BODY.PEEK[...]"
+ * atom, and parse_fetch_atts() has confirmed it starts with "BODY.PEEK
+ * [HEADER.FIELDS" case-insensitively.
+ *
+ * Returns 0 and fills *not_out, fields_out (space-joined field names --
+ * see struct imsg_mbox_fetch's header_fields comment in imapd.h for
+ * why store.c gets this pre-extracted form rather than the raw bracket
+ * text) on success. Returns -1 on a syntax error (missing SP, missing/
+ * unbalanced parens, empty list, a field name containing a double quote,
+ * or a field-name list too long for HEADER_FIELDS_MAX) -- unlike an
+ * *unsupported* item, which parse_fetch_atts() silently degrades, a
+ * fetch-att that announces itself as HEADER.FIELDS but is malformed is a
+ * real client error (this implementation does support the item), so the
+ * caller sends BAD rather than silently dropping it.
+ *
+ * Field names are required to be bare atoms (no quoted-string or literal
+ * form, checked only by rejecting an embedded '"' -- RFC 5322's own
+ * field-name syntax, 1*ftext, already excludes space/colon/control
+ * characters, so a bare atom is the only shape any legitimate field name
+ * can ever take; this implementation simply doesn't bother recognizing
+ * the quoted-string astring alternative nothing real would need).
+ */
+static int
+parse_header_fields_att(const char *inner, int *not_out, char *fields_out,
+    size_t fields_outsize)
+{
+	char	 listbuf[HEADER_FIELDS_LABEL_MAX];
+	char	*p = listbuf, *listp, *end;
+	char	*name, *save;
+	int	 first = 1;
+
+	*not_out = 0;
+	fields_out[0] = '\0';
+
+	if (strlcpy(listbuf, inner, sizeof(listbuf)) >= sizeof(listbuf))
+		return (-1);
+
+	if (strncasecmp(p, "HEADER.FIELDS", 13) != 0)
+		return (-1);
+	p += 13;
+
+	if (strncasecmp(p, ".NOT", 4) == 0) {
+		*not_out = 1;
+		p += 4;
+	}
+
+	if (*p != ' ')
+		return (-1);
+	p++;
+
+	listp = p;
+	if (*listp != '(')
+		return (-1);
+	listp++;
+
+	end = strchr(listp, ')');
+	if (end == NULL || end[1] != '\0')
+		return (-1);
+	*end = '\0';
+
+	if (*listp == '\0')
+		return (-1);	/* header-list requires at least one name */
+
+	for (name = strtok_r(listp, " ", &save); name != NULL;
+	    name = strtok_r(NULL, " ", &save)) {
+		if (strchr(name, '"') != NULL)
+			return (-1);
+		if (!first &&
+		    strlcat(fields_out, " ", fields_outsize) >= fields_outsize)
+			return (-1);
+		if (strlcat(fields_out, name, fields_outsize) >= fields_outsize)
+			return (-1);
+		first = 0;
+	}
+
+	return (0);
+}
+
+/*
+ * RFC 9051 SS6.4.5.1: section-part = nz-number *("." nz-number), e.g.
+ * "3.1". Validates the grammar only -- doesn't parse it into a path
+ * array, since listener.c never needs the individual part numbers
+ * itself; it just needs to know whether the bracket body it just
+ * isolated is a legal section-part before setting MBOX_FETCH_BODY_PART
+ * and handing the verbatim string on to store.c's own parse_section_
+ * part() (store.c and listener.c are separate processes -- this
+ * validation exists so an *invalid* string is caught here and silently
+ * degraded like any other unsupported BODY.PEEK[...] shape, rather than
+ * crossing the imsg boundary and only failing there). Bounded at MIME_
+ * MAX_DEPTH components, same as store.c's own parser and for the same
+ * reason: a path deeper than that could never match anything build_
+ * bodystructure() would ever describe.
+ */
+static int
+section_part_valid(const char *s)
+{
+	int	 n = 0;
+
+	if (s == NULL || *s == '\0')
+		return (0);
+
+	while (*s != '\0') {
+		if (*s < '1' || *s > '9')	/* nz-number: digit-nz first */
+			return (0);
+		if (n >= MIME_MAX_DEPTH)
+			return (0);
+		n++;
+		while (*s >= '0' && *s <= '9')
+			s++;
+		if (*s == '\0')
+			return (1);
+		if (*s != '.')
+			return (0);
+		s++;
+		if (*s == '\0')
+			return (0);	/* trailing dot */
+	}
+	return (1);
+}
+
+/*
+ * RFC 9051 SS6.4.5's origin-octet-then-count partial-range suffix,
+ * `"<" number "." nz-number ">"` (a BODY[<section>]<<partial>> request's
+ * trailing "<start.count>", e.g. "<0.16384>"). s is whatever followed a
+ * BODY.PEEK[...] token's closing "]" -- empty (no partial range: *has_
+ * partial_out = 0, success) or exactly one "<...>" as above. count is
+ * only required to be numeric here, not strictly nz-number (a client-
+ * sent "<5.0>" is a strange request, not a malformed one --
+ * apply_partial_range() in store.c already handles a zero count
+ * gracefully, returning an empty string, same "be liberal" precedent
+ * this function's own caller uses for unsupported section shapes).
+ *
+ * Returns 0 on success (including the "no suffix at all" case), -1 if s
+ * is non-empty but doesn't match the grammar exactly.
+ */
+static int
+parse_partial_suffix(const char *s, int *has_partial_out,
+    uint32_t *start_out, uint32_t *count_out)
+{
+	const char	*p;
+	char		*end;
+	unsigned long	 start, count;
+
+	*has_partial_out = 0;
+	*start_out = 0;
+	*count_out = 0;
+
+	if (s == NULL || *s == '\0')
+		return (0);
+
+	if (s[0] != '<')
+		return (-1);
+	p = s + 1;
+
+	if (*p < '0' || *p > '9')
+		return (-1);
+	errno = 0;
+	start = strtoul(p, &end, 10);
+	if (errno != 0 || start > UINT32_MAX || *end != '.')
+		return (-1);
+	p = end + 1;
+
+	if (*p < '0' || *p > '9')
+		return (-1);
+	errno = 0;
+	count = strtoul(p, &end, 10);
+	if (errno != 0 || count > UINT32_MAX || *end != '>' || end[1] != '\0')
+		return (-1);
+
+	*has_partial_out = 1;
+	*start_out = (uint32_t)start;
+	*count_out = (uint32_t)count;
+	return (0);
+}
+
+/*
+ * RFC 9051 SS6.4.5: `fetch-att`, plus the "ALL" / "FULL" / "FAST" macros.
+ * spec is the fetch-att portion of the command line, with or without
+ * surrounding parentheses (a single un-parenthesized item, e.g. bare
+ * "FLAGS", is valid ABNF too), modified in place (strtok_r()).
+ *
+ * Returns 0 and fills *attrs_out on success; -1 (with *errmsg set) for a
+ * syntax error -- caller sends BAD.
+ *
+ * Real bug caught testing against Apple Mail live on premio: this used to
+ * return -2 (caller sends NO) the instant it saw a single recognized-but-
+ * unsupported item (BODY[...]/ENVELOPE/RFC822/etc, or the ALL/FULL macros
+ * that expand to include ENVELOPE) -- rejecting the *entire* fetch-att
+ * list, even when the same request also asked for several items this
+ * server fully supports. Apple Mail's actual first FETCH after SELECT was
+ * "FETCH 1:3 (INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[HEADER])" --
+ * four fully-supported items bundled with one unsupported one -- so the
+ * old behavior meant Mail got nothing at all for any of it, not even the
+ * flags/dates/sizes it could have had, which (paired with LSUB not being
+ * implemented at all -- see list_dispatch()'s header comment above) left
+ * it with literally no data to render, hence a blank Inbox.
+ *
+ * Now: an unsupported item is silently skipped (not added to attrs) and
+ * parsing continues, rather than aborting the whole request -- the same
+ * "be liberal about what doesn't apply, answer what you can" leniency
+ * SS6.3.1 already mandates for ENABLE's unrecognized arguments. At the
+ * time this comment was first written, ALL/FULL both degraded to the FAST
+ * set once their ENVELOPE component was dropped; ENVELOPE is real now (see
+ * MBOX_FETCH_ENVELOPE below), so ALL is a complete macro and only FULL
+ * still degrades, to everything but BODY. *degraded_out is set to 1
+ * whenever anything was silently
+ * dropped this way, purely so the caller can log it -- it doesn't change
+ * the wire response, which is now a normal OK with whatever data items
+ * *were* recognized. The -2/NO path is kept for the one case dropping
+ * still can't paper over: every requested item was unsupported (or the
+ * list was ALL/FULL alone, which -- unlike bundled with real items above
+ * -- has nothing left to fall back to without inventing data the client
+ * didn't ask for), so there would be nothing at all to answer with.
+ */
+static int
+parse_fetch_atts(char *spec, uint32_t *attrs_out, int *degraded_out,
+    int *header_fields_not_out, char *header_fields_out,
+    size_t header_fields_outsize, char *header_fields_label_out,
+    size_t header_fields_label_outsize, int *bodystructure_full_out,
+    char *section_part_out, size_t section_part_outsize,
+    int *has_partial_out, uint32_t *partial_start_out,
+    uint32_t *partial_count_out, const char **errmsg)
+{
+	char		*p, *tok, *save;
+	size_t		 len;
+	uint32_t	 attrs = 0;
+	int		 degraded = 0;
+	int		 has_partial = 0;
+	uint32_t	 partial_start = 0, partial_count = 0;
+
+	*errmsg = NULL;
+	*attrs_out = 0;
+	*degraded_out = 0;
+	*header_fields_not_out = 0;
+	header_fields_out[0] = '\0';
+	header_fields_label_out[0] = '\0';
+	*bodystructure_full_out = 0;
+	section_part_out[0] = '\0';
+	*has_partial_out = 0;
+	*partial_start_out = 0;
+	*partial_count_out = 0;
+
+	if (spec == NULL || *spec == '\0') {
+		*errmsg = "missing message data item(s)";
+		return (-1);
+	}
+
+	p = spec;
+	len = strlen(p);
+	if (len >= 2 && p[0] == '(' && p[len - 1] == ')') {
+		p[len - 1] = '\0';
+		p++;
+	}
+	if (*p == '\0') {
+		*errmsg = "empty message data item list";
+		return (-1);
+	}
+
+	for (tok = fetch_att_tok(p, &save); tok != NULL;
+	    tok = fetch_att_tok(NULL, &save)) {
+		if (strcasecmp(tok, "FAST") == 0) {
+			/* SS6.4.5: "Macro equivalent to: (FLAGS INTERNALDATE
+			 * RFC822.SIZE)" -- all three are real in v1, so FAST
+			 * is a complete, correct macro here, same as ALL and
+			 * (now that BODYSTRUCTURE is implemented too) FULL. */
+			attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
+			    MBOX_FETCH_RFC822_SIZE;
+		} else if (strcasecmp(tok, "ALL") == 0) {
+			/* SS6.4.5: "Macro equivalent to: (FLAGS INTERNALDATE
+			 * RFC822.SIZE ENVELOPE)" -- all four are real in v1 as
+			 * of the ENVELOPE pass, so ALL is now a complete,
+			 * correct macro, not a degraded one. */
+			attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
+			    MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE;
+		} else if (strcasecmp(tok, "FULL") == 0) {
+			/* SS6.4.5: "Macro equivalent to: (FLAGS INTERNALDATE
+			 * RFC822.SIZE ENVELOPE BODY)" -- BODY here is the bare,
+			 * non-extensible form (MBOX_FETCH_BODYSTRUCTURE, see
+			 * imapd.h), real as of this pass, so FULL is now a
+			 * complete, correct macro too, not a degraded one.
+			 * bodystructure_full_out stays 0 (the "BODY" label,
+			 * not "BODYSTRUCTURE") since that's literally what the
+			 * macro's own definition expands to. */
+			attrs |= MBOX_FETCH_FLAGS | MBOX_FETCH_INTERNALDATE |
+			    MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_ENVELOPE |
+			    MBOX_FETCH_BODYSTRUCTURE;
+		} else if (strcasecmp(tok, "FLAGS") == 0) {
+			attrs |= MBOX_FETCH_FLAGS;
+		} else if (strcasecmp(tok, "UID") == 0) {
+			attrs |= MBOX_FETCH_UID;
+		} else if (strcasecmp(tok, "INTERNALDATE") == 0) {
+			attrs |= MBOX_FETCH_INTERNALDATE;
+		} else if (strcasecmp(tok, "RFC822.SIZE") == 0) {
+			attrs |= MBOX_FETCH_RFC822_SIZE;
+		} else if (strcasecmp(tok, "MODSEQ") == 0) {
+			/* RFC 7162 SS3.1.4.2 fetch-mod-sequence: "MODSEQ" --
+			 * causes MODSEQ FETCH response data items, and is
+			 * itself a CONDSTORE-enabling command (SS3.1) --
+			 * cmd_fetch() checks this bit to decide whether to
+			 * call session_condstore_enable(). */
+			attrs |= MBOX_FETCH_MODSEQ;
+		} else if (strcasecmp(tok, "BODY.PEEK[HEADER]") == 0) {
+			/*
+			 * The one BODY[...] variant this pass actually
+			 * implements -- see MBOX_FETCH_BODY_HEADER's comment
+			 * in imapd.h for why it's scoped to exactly this
+			 * token (RFC 5322 header, raw and unparsed, via
+			 * store.c's read_message_header()) and not plain
+			 * BODY[HEADER] (would need to implicitly set \Seen,
+			 * not implemented), HEADER.FIELDS/.NOT, TEXT, or any
+			 * MIME-part addressing. An exact strcasecmp() match,
+			 * checked before the generic BODY-prefix catch-all
+			 * below so this one recognized case doesn't fall into
+			 * it and get marked degraded instead.
+			 */
+			attrs |= MBOX_FETCH_BODY_HEADER;
+		} else if (strncasecmp(tok, "BODY.PEEK[", strlen("BODY.PEEK[")) ==
+		    0 && strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
+		    strlen("BODY.PEEK[HEADER.FIELDS")) != 0) {
+			/*
+			 * Reached for every BODY.PEEK[...] shape except
+			 * HEADER (exact match above) and HEADER.FIELDS[.NOT]
+			 * (own prefix branch just below, excluded from this
+			 * one by the strncasecmp() != 0 above so the two
+			 * don't fight over the same token): BODY.PEEK[]
+			 * (whole message, SS6.4.5: "If BODY[] is specified
+			 * ... the FETCH is requesting the [RFC5322]
+			 * expression of the entire message"), BODY.PEEK
+			 * [TEXT] (SS6.4.5.1: "the text body of the message,
+			 * omitting the [RFC5322] header"), or BODY.PEEK
+			 * [<section-part>] (MIME part-addressed content,
+			 * e.g. "3.1" -- see MBOX_FETCH_BODY_PART's comment
+			 * in imapd.h), each optionally followed by a
+			 * <<start.count>> partial-range suffix (SS6.4.5,
+			 * e.g. "BODY.PEEK[TEXT]<0.16384>" -- the exact shape
+			 * observed from real Apple Mail traffic; see
+			 * parse_partial_suffix()'s own comment). Unified into
+			 * one branch (replacing this pass's former separate
+			 * exact-match BODY.PEEK[]/BODY.PEEK[TEXT] cases) since
+			 * all three now share the same "parse the bracket
+			 * body, then the optional trailing <...>" shape.
+			 *
+			 * Same .PEEK-only, checked-before-the-generic-catch-
+			 * all scoping as BODY.PEEK[HEADER] above, for the
+			 * same reason (plain BODY[...] implicitly sets \Seen,
+			 * not implemented for any content item).
+			 */
+			const char	*bracket_start = tok +
+			    strlen("BODY.PEEK[");
+			char		*close;
+			char		 inner[SECTION_PART_MAX];
+			const char	*suffix;
+
+			close = strchr(bracket_start, ']');
+			if (close == NULL) {
+				degraded = 1;	/* not even well-bracketed --
+						 * same lenient "unrecognized
+						 * BODY[...] shape, skip it"
+						 * handling every other
+						 * unsupported form here gets,
+						 * rather than a new BAD path */
+				continue;
+			}
+			if ((size_t)(close - bracket_start) >= sizeof(inner)) {
+				degraded = 1;
+				continue;
+			}
+			memcpy(inner, bracket_start, close - bracket_start);
+			inner[close - bracket_start] = '\0';
+			suffix = close + 1;
+
+			if (suffix[0] != '\0' &&
+			    parse_partial_suffix(suffix, &has_partial,
+			    &partial_start, &partial_count) == -1) {
+				*errmsg = "malformed <partial> range";
+				return (-1);
+			}
+
+			if (inner[0] == '\0') {
+				attrs |= MBOX_FETCH_BODY_WHOLE;
+			} else if (strcasecmp(inner, "TEXT") == 0) {
+				attrs |= MBOX_FETCH_BODY_TEXT;
+			} else if (section_part_valid(inner)) {
+				attrs |= MBOX_FETCH_BODY_PART;
+				if (strlcpy(section_part_out, inner,
+				    section_part_outsize) >=
+				    section_part_outsize) {
+					attrs &= ~MBOX_FETCH_BODY_PART;
+					degraded = 1;
+					continue;
+				}
+			} else {
+				degraded = 1;	/* recognized-shape-but-
+						 * unsupported section, e.g.
+						 * nested MESSAGE/RFC822
+						 * numbering ("2.1.TEXT") --
+						 * same silent-skip precedent
+						 * as the generic BODY-prefix
+						 * catch-all below */
+				continue;
+			}
+		} else if (strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
+		    strlen("BODY.PEEK[HEADER.FIELDS")) == 0) {
+			/*
+			 * "BODY.PEEK[HEADER.FIELDS (...)"/"BODY.PEEK[HEADER.
+			 * FIELDS.NOT (...)" -- checked by prefix (not exact
+			 * match, unlike BODY.PEEK[HEADER]/[]/[TEXT] above)
+			 * since the field-name list itself varies per
+			 * request. tok is already the whole bracketed atom
+			 * here, embedded space and all, thanks to fetch_att_
+			 * tok() -- see that function's comment for the real
+			 * bug this replaced. Strips the outer "BODY.PEEK["
+			 * and trailing "]" here (both already confirmed
+			 * present by the strncasecmp() prefix match and the
+			 * closing-bracket check below) and hands the
+			 * "HEADER.FIELDS[...] (...)" interior to parse_
+			 * header_fields_att() for the real grammar work.
+			 * A second HEADER.FIELDS-shaped item in the same
+			 * FETCH (legal per SS6.4.5, unseen from any real
+			 * client) is silently ignored once one has already
+			 * been captured -- same "first one wins, no error"
+			 * simplification as MBOX_FETCH_BODY_HEADER's
+			 * precedence over this bit, decided below.
+			 */
+			size_t	 toklen = strlen(tok);
+			char	 inner[HEADER_FIELDS_LABEL_MAX];
+
+			if (toklen < strlen("BODY.PEEK[") + 1 ||
+			    tok[toklen - 1] != ']') {
+				*errmsg = "malformed HEADER.FIELDS section";
+				return (-1);
+			}
+			if (attrs & MBOX_FETCH_HEADER_FIELDS)
+				continue; /* already captured one -- ignore
+					     any further duplicates */
+
+			if (toklen - strlen("BODY.PEEK[") - 1 >=
+			    sizeof(inner)) {
+				*errmsg = "HEADER.FIELDS section too long";
+				return (-1);
+			}
+			memcpy(inner, tok + strlen("BODY.PEEK["),
+			    toklen - strlen("BODY.PEEK[") - 1);
+			inner[toklen - strlen("BODY.PEEK[") - 1] = '\0';
+
+			if (parse_header_fields_att(inner,
+			    header_fields_not_out, header_fields_out,
+			    header_fields_outsize) == -1) {
+				*errmsg = "malformed HEADER.FIELDS section";
+				return (-1);
+			}
+			if (strlcpy(header_fields_label_out, inner,
+			    header_fields_label_outsize) >=
+			    header_fields_label_outsize) {
+				*errmsg = "HEADER.FIELDS section too long";
+				return (-1);
+			}
+			attrs |= MBOX_FETCH_HEADER_FIELDS;
+		} else if (strcasecmp(tok, "ENVELOPE") == 0) {
+			/*
+			 * RFC 9051 SS7.5.2 ENVELOPE -- see MBOX_FETCH_ENVELOPE's
+			 * comment in imapd.h for the full scoping story
+			 * (parsed RFC 5322 header fields + a deliberately
+			 * scoped-down address-list parser, no MIME awareness --
+			 * BODYSTRUCTURE, just below, is the one with that).
+			 * No .PEEK variant
+			 * and no \Seen side effect to avoid, so -- unlike the
+			 * BODY.PEEK[...] family above -- the bare token is
+			 * enough.
+			 */
+			attrs |= MBOX_FETCH_ENVELOPE;
+		} else if (strcasecmp(tok, "BODY") == 0 ||
+		    strcasecmp(tok, "BODYSTRUCTURE") == 0) {
+			/*
+			 * RFC 9051 SS9's fetch-att: `"BODY" ["STRUCTURE"]` --
+			 * bare "BODY" (no brackets) and "BODYSTRUCTURE" are
+			 * both requests for the non-extensible body structure
+			 * (see MBOX_FETCH_BODYSTRUCTURE's comment in
+			 * imapd.h for why this implementation's BODY and
+			 * BODYSTRUCTURE produce byte-identical output -- no
+			 * extension data is ever emitted). Checked by exact
+			 * match, before the generic "BODY" prefix catch-all
+			 * below, so these two recognized cases don't fall
+			 * into it and get marked degraded instead -- same
+			 * precedent as BODY.PEEK[HEADER]/[]/[TEXT] above.
+			 * bodystructure_full_out records which literal token
+			 * was used, purely so session_send_fetch_response()
+			 * can echo the same label back (SS9's grammar: `"BODY"
+			 * ["STRUCTURE"] SP body` -- the response label itself
+			 * is "BODY" or "BODYSTRUCTURE", not a separate response
+			 * name). If a client somehow requests both in the same
+			 * FETCH (legal, redundant, unseen from any real
+			 * client), whichever is parsed last simply wins the
+			 * label -- a low-stakes, purely cosmetic difference,
+			 * not worth a "first wins" guard.
+			 */
+			attrs |= MBOX_FETCH_BODYSTRUCTURE;
+			*bodystructure_full_out =
+			    (strcasecmp(tok, "BODYSTRUCTURE") == 0);
+		} else if (strncasecmp(tok, "BODY", 4) == 0 ||
+		    strcasecmp(tok, "RFC822") == 0 ||
+		    strcasecmp(tok, "RFC822.HEADER") == 0 ||
+		    strcasecmp(tok, "RFC822.TEXT") == 0) {
+			/* BODY[...]/BODY.PEEK[...] beyond the four items
+			 * already implemented above (whole message, TEXT,
+			 * HEADER, HEADER.FIELDS[.NOT]) -- specifically MIME
+			 * part-addressed content, e.g. BODY[1.2] or BODY.PEEK
+			 * [2.TEXT], which BODYSTRUCTURE (just above) only
+			 * *describes* the existence of, never returns the
+			 * content of -- plus the RFC822(.HEADER/.TEXT) content
+			 * shorthands, need actual per-part content extraction
+			 * beyond what's implemented, not designed this pass.
+			 * strncasecmp() (not strcasecmp()) for the BODY
+			 * prefix specifically catches BODY[...]/BODY.PEEK[...]
+			 * tokens too, even though strtok_r() has already
+			 * split a bracketed fetch-att with embedded spaces
+			 * (e.g. "BODY[HEADER.FIELDS (DATE FROM)]") into
+			 * multiple tokens -- the first such token alone is
+			 * enough to recognize and skip the whole item. Note
+			 * this prefix match would also catch bare "BODY"/
+			 * "BODYSTRUCTURE" if they ever reached here, but the
+			 * exact-match branch just above already claims both
+			 * first. Silently dropped now (see header comment)
+			 * rather than aborting the whole FETCH. */
+			degraded = 1;
+		} else {
+			*errmsg = "unknown message data item";
+			return (-1);
+		}
+	}
+
+	if (attrs == 0) {
+		/* Every requested item was unsupported -- nothing left to
+		 * answer with, unlike the bundled case this function now
+		 * handles gracefully. ALL/FAST/FULL are all complete macros
+		 * as of the BODYSTRUCTURE pass, so this is only reached by a
+		 * request naming exclusively still-unsupported items, e.g.
+		 * BODY[<part>]/BODY.PEEK[<part>] (MIME part-addressed
+		 * content) or RFC822/RFC822.HEADER/RFC822.TEXT alone. */
+		*errmsg = "cannot fetch that message content yet -- "
+		    "supported: FLAGS/UID/INTERNALDATE/RFC822.SIZE/MODSEQ/"
+		    "ENVELOPE/(BODY|BODYSTRUCTURE)/BODY.PEEK[...]";
+		return (-2);
+	}
+
+	/*
+	 * If a client somehow requested both plain BODY.PEEK[HEADER] and a
+	 * BODY.PEEK[HEADER.FIELDS...] variant in the same FETCH (legal per
+	 * SS6.4.5, unseen from any real client so far), HEADER wins --
+	 * there's only one pending_header_* slot on struct session, and the
+	 * whole header is a strict superset of any subset of it, same "more
+	 * general variant wins" precedent MBOX_FETCH_BODY_WHOLE already
+	 * uses over MBOX_FETCH_BODY_TEXT.
+	 */
+	if ((attrs & MBOX_FETCH_BODY_HEADER) &&
+	    (attrs & MBOX_FETCH_HEADER_FIELDS))
+		attrs &= ~MBOX_FETCH_HEADER_FIELDS;
+
+	*attrs_out = attrs;
+	*degraded_out = degraded;
+	/*
+	 * has_partial/partial_start/partial_count were accumulated into
+	 * local variables (not written straight to the out-params) by
+	 * whichever BODY.PEEK[...] branch matched above, mirroring how attrs
+	 * itself is built up in a local before this one final copy-out --
+	 * done here, not per-branch, so the "if a client requests both
+	 * BODY.PEEK[HEADER] and BODY.PEEK[HEADER.FIELDS...], HEADER wins"
+	 * resolution just above stays the single place attrs gets adjusted
+	 * after parsing, without also needing a matching adjustment to which
+	 * item's partial range should apply.
+	 */
+	*has_partial_out = has_partial;
+	*partial_start_out = partial_start;
+	*partial_count_out = partial_count;
+	return (0);
+}
+
+static const char *fetch_month_names[12] = {
+	"Jan", "Feb", "Mar", "Apr", "May", "Jun",
+	"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
+};
+
+/*
+ * RFC 9051 SS9: `date-time = DQUOTE date-day-fixed "-" date-month "-"
+ * date-year SP time SP zone DQUOTE`, `date-day-fixed = (SP DIGIT) /
+ * 2DIGIT` (space-padded, not zero-padded, below 10), `zone = ("+" / "-")
+ * 4DIGIT`. Always formats in UTC ("+0000"): ts is a bare Unix timestamp
+ * (store.c's parse_maildir_timestamp()) with no timezone information
+ * attached at all, a chroot'd store child can't be assumed to have tzdata
+ * unveiled, and "+0000" is unambiguous -- a deliberate implementation
+ * simplification, not something the RFC itself requires (it permits any
+ * valid zone offset).
+ */
+static void
+format_internaldate(int64_t ts, char *out, size_t outsize)
+{
+	struct tm	 tm;
+	time_t		 t = (time_t)ts;
+
+	if (gmtime_r(&t, &tm) == NULL) {
+		strlcpy(out, "01-Jan-1970 00:00:00 +0000", outsize);
+		return;
+	}
+
+	snprintf(out, outsize, "%2d-%s-%04d %02d:%02d:%02d +0000",
+	    tm.tm_mday, fetch_month_names[tm.tm_mon], tm.tm_year + 1900,
+	    tm.tm_hour, tm.tm_min, tm.tm_sec);
+}
+
+/*
+ * snprintf(3)-into-a-growing-buffer helper for session_send_fetch_
+ * response(): appends at buf + *len, then advances *len by however much
+ * was (or would have been) written. Guards against the exact bug flagged
+ * while this function was being designed -- naively chaining
+ * `snprintf(buf + len, sizeof(buf) - len, ...)` calls is only safe as long
+ * as len never exceeds sizeof(buf); if an earlier call were ever truncated,
+ * snprintf(3)'s return value (the length that *would* have been written)
+ * can push len past sizeof(buf), and the next call's `sizeof(buf) - len`
+ * would underflow (size_t is unsigned) into a huge value. Clamping *len to
+ * bufsize here, plus the "*len >= bufsize" early return, means every
+ * subsequent call sees a valid, non-negative remaining size instead.
+ */
+static void
+fetch_append(char *buf, size_t bufsize, size_t *len, const char *fmt, ...)
+{
+	va_list	 ap;
+	int	 n;
+
+	if (*len >= bufsize)
+		return;
+
+	va_start(ap, fmt);
+	n = vsnprintf(buf + *len, bufsize - *len, fmt, ap);
+	va_end(ap);
+
+	if (n < 0)
+		return;
+
+	*len += (size_t)n;
+	if (*len > bufsize)
+		*len = bufsize;
+}
+
+/*
+ * Formats and sends one untagged "* <seqno> FETCH (...)" response (RFC
+ * 9051 SS7.5.2) for a single IMSG_MBOX_FETCH_META reply. Only prints the
+ * data items s->fetch_attrs actually requested: store.c's handle_mbox_
+ * fetch() always populates every field of struct imsg_mbox_fetch_meta it
+ * can regardless of what was asked for (see that struct's comment in
+ * imapd.h), so this function is what actually enforces "don't show the
+ * client attributes it didn't ask for".
+ *
+ * Field order (FLAGS, UID, INTERNALDATE, RFC822.SIZE) matches the bit
+ * order of the MBOX_FETCH_* constants, not anything RFC 9051 requires --
+ * SS7.5.2 doesn't mandate a msg-att ordering.
+ *
+ * buf is sized generously (MBOX_FLAGS_MAX plus comfortable room for the
+ * other three items' text) but, like every other fixed buffer in this
+ * file, truncates rather than overflows if somehow exceeded; session_
+ * untagged()'s own 512-byte buffer is the tighter, and ultimately
+ * governing, bound in that unlikely case -- same "truncate rather than
+ * overflow" style as session_reply()/session_untagged() themselves.
+ *
+ * BODY.PEEK[HEADER]/BODY.PEEK[]/BODY.PEEK[TEXT] addition: when any of these
+ * were requested and store.c actually found something for this message
+ * (s->pending_header_found / s->pending_body_found, stashed by the IMSG_
+ * MBOX_FETCH_HEADER / IMSG_MBOX_FETCH_BODY cases just before this IMSG_
+ * MBOX_FETCH_META arrived -- see struct session's comment), the response
+ * can't be built as one NUL-terminated string handed to session_untagged()
+ * the way every other item above is: RFC 9051 SS9's literal syntax puts a
+ * "{n}\r\n" marker followed by exactly n raw octets (which may contain any
+ * byte except NUL -- store.c's read_message_header()/read_message_body()
+ * already reject content containing one) directly in the middle of the
+ * response, with the closing ")" continuing right after those n octets,
+ * not on a fresh "line" in the usual CRLF-delimited sense. A client can
+ * legally request both BODY.PEEK[HEADER] and one of BODY.PEEK[]/BODY.PEEK
+ * [TEXT] in the same FETCH (SS6.4.5 doesn't forbid combining section
+ * specs), so this function has to be able to splice in zero, one, or two
+ * such literal blocks -- not just the single hardcoded case the header-
+ * only version of this function had. Each literal block gets its own
+ * "flush buf so far as one raw write, then write the raw payload bytes"
+ * cycle; buf/len are reused (reset) between blocks. Header is always
+ * spliced in before body when both are present, matching MBOX_FETCH_*
+ * bit order.
+ *
+ * ENVELOPE addition: s->pending_envelope_buf holds build_envelope()'s
+ * *already-formatted* "(...)" text (see struct imsg_mbox_fetch_envelope's
+ * comment in imapd.h), not raw message bytes needing a `{n}` literal
+ * wrapper -- but it still can't be handed to fetch_append() into buf[768]
+ * above, since a formatted envelope can be far larger than that (up to
+ * ENVELOPE_MAX, 8192 bytes) and fetch_append() truncates rather than
+ * overflows. So ENVELOPE reuses the same "flush buf, then a second raw
+ * session_write() for the oversized part" mechanism the header/body
+ * literals use, just without a `{n}\r\n` marker in front of it -- it's
+ * written as "ENVELOPE " followed directly by the pre-formatted text, no
+ * literal syntax involved at all. This also means an ENVELOPE-only FETCH
+ * (no BODY.PEEK[...] items at all) now takes this branch too, where
+ * before this addition only BODY.PEEK[...] requests ever did -- see
+ * have_envelope below.
+ *
+ * BODYSTRUCTURE addition: same "already-formatted text, no literal, flush
+ * buf first" shape as ENVELOPE, just written as "BODY " or "BODYSTRUCTURE "
+ * (s->pending_bodystructure_label -- see struct session's comment on that
+ * field for why the label has to echo whichever bare token the client
+ * used) followed by build_bodystructure()'s text.
+ */
+static void
+session_send_fetch_response(struct session *s,
+    struct imsg_mbox_fetch_meta *meta)
+{
+	char	 buf[768];
+	char	 date[40];
+	size_t	 len = 0;
+	int	 need_sp = 0;
+	int	 have_header = (s->fetch_attrs & (MBOX_FETCH_BODY_HEADER |
+	    MBOX_FETCH_HEADER_FIELDS)) && s->pending_header_found;
+	int	 have_body = (s->fetch_attrs &
+	    (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
+	    MBOX_FETCH_BODY_PART)) && s->pending_body_found;
+	int	 have_envelope = (s->fetch_attrs & MBOX_FETCH_ENVELOPE) &&
+	    s->pending_envelope_found;
+	int	 have_bodystructure = (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE) &&
+	    s->pending_bodystructure_found;
+
+	fetch_append(buf, sizeof(buf), &len, "%u FETCH (", meta->seqno);
+
+	if (s->fetch_attrs & MBOX_FETCH_FLAGS) {
+		fetch_append(buf, sizeof(buf), &len, "FLAGS (%s)", meta->flags);
+		need_sp = 1;
+	}
+	if (s->fetch_attrs & MBOX_FETCH_UID) {
+		fetch_append(buf, sizeof(buf), &len, "%sUID %u",
+		    need_sp ? " " : "", meta->uid);
+		need_sp = 1;
+	}
+	if (s->fetch_attrs & MBOX_FETCH_INTERNALDATE) {
+		format_internaldate(meta->internaldate, date, sizeof(date));
+		fetch_append(buf, sizeof(buf), &len, "%sINTERNALDATE \"%s\"",
+		    need_sp ? " " : "", date);
+		need_sp = 1;
+	}
+	if (s->fetch_attrs & MBOX_FETCH_RFC822_SIZE) {
+		fetch_append(buf, sizeof(buf), &len, "%sRFC822.SIZE %llu",
+		    need_sp ? " " : "", (unsigned long long)meta->size);
+		need_sp = 1;
+	}
+	if (s->fetch_attrs & MBOX_FETCH_MODSEQ) {
+		/* RFC 7162 SS3.1.4.2 fetch-mod-resp: "MODSEQ" SP "("
+		 * permsg-modsequence ")". */
+		fetch_append(buf, sizeof(buf), &len, "%sMODSEQ (%llu)",
+		    need_sp ? " " : "", (unsigned long long)meta->modseq);
+		need_sp = 1;
+	}
+
+	if (have_header || have_body || have_envelope || have_bodystructure) {
+		session_write(s, "* ", 2);
+		session_write(s, buf, len);
+
+		if (have_envelope) {
+			len = 0;
+			fetch_append(buf, sizeof(buf), &len, "%sENVELOPE ",
+			    need_sp ? " " : "");
+			session_write(s, buf, len);
+			if (s->pending_envelope_len > 0)
+				session_write(s, s->pending_envelope_buf,
+				    s->pending_envelope_len);
+			need_sp = 1;
+		}
+		if (have_bodystructure) {
+			len = 0;
+			fetch_append(buf, sizeof(buf), &len, "%s%s ",
+			    need_sp ? " " : "", s->pending_bodystructure_label);
+			session_write(s, buf, len);
+			if (s->pending_bodystructure_len > 0)
+				session_write(s, s->pending_bodystructure_buf,
+				    s->pending_bodystructure_len);
+			need_sp = 1;
+		}
+		if (have_header) {
+			len = 0;
+			fetch_append(buf, sizeof(buf), &len,
+			    "%sBODY[%s] {%u}\r\n", need_sp ? " " : "",
+			    s->pending_header_label, s->pending_header_len);
+			session_write(s, buf, len);
+			if (s->pending_header_len > 0)
+				session_write(s, s->pending_header_buf,
+				    s->pending_header_len);
+			need_sp = 1;
+		}
+		if (have_body) {
+			/*
+			 * RFC 9051 SS6.4.5: "The origin octet facility MUST
+			 * NOT be used by a server in a FETCH response unless
+			 * the client specifically requested it" -- and even
+			 * then, only the *origin* (pending_body_partial_
+			 * origin, the requested start octet) is echoed, never
+			 * the count store.c actually returned. pending_body_
+			 * label already holds the right text regardless of
+			 * which of WHOLE/TEXT/PART was selected -- see that
+			 * field's own comment.
+			 */
+			len = 0;
+			if (s->pending_body_has_partial)
+				fetch_append(buf, sizeof(buf), &len,
+				    "%sBODY[%s]<%u> {%u}\r\n",
+				    need_sp ? " " : "", s->pending_body_label,
+				    s->pending_body_partial_origin,
+				    s->pending_body_len);
+			else
+				fetch_append(buf, sizeof(buf), &len,
+				    "%sBODY[%s] {%u}\r\n", need_sp ? " " : "",
+				    s->pending_body_label, s->pending_body_len);
+			session_write(s, buf, len);
+			if (s->pending_body_len > 0)
+				session_write(s, s->pending_body_buf,
+				    s->pending_body_len);
+		}
+		session_write(s, ")\r\n", 3);
+	} else {
+		fetch_append(buf, sizeof(buf), &len, ")");
+
+		if (len >= sizeof(buf))
+			log_warnx("session %u: FETCH response for seq %u "
+			    "truncated", s->id, meta->seqno);
+
+		session_untagged(s, buf);
+	}
+
+	/*
+	 * Reset pending_header_* and pending_body_* regardless of whether
+	 * this particular message had anything to give (have_header/have_body
+	 * false just means store.c found nothing -- pending_*_found itself
+	 * still needs resetting so it doesn't leak into the next message's
+	 * response, same reasoning the header-only version of this function
+	 * already documented).
+	 */
+	if (have_header) {
+		free(s->pending_header_buf);
+		s->pending_header_buf = NULL;
+		s->pending_header_len = 0;
+	}
+	if (s->fetch_attrs & (MBOX_FETCH_BODY_HEADER | MBOX_FETCH_HEADER_FIELDS))
+		s->pending_header_found = 0;
+
+	if (have_body) {
+		free(s->pending_body_buf);
+		s->pending_body_buf = NULL;
+		s->pending_body_len = 0;
+	}
+	if (s->fetch_attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
+	    MBOX_FETCH_BODY_PART))
+		s->pending_body_found = 0;
+
+	if (have_envelope) {
+		free(s->pending_envelope_buf);
+		s->pending_envelope_buf = NULL;
+		s->pending_envelope_len = 0;
+	}
+	if (s->fetch_attrs & MBOX_FETCH_ENVELOPE)
+		s->pending_envelope_found = 0;
+
+	if (have_bodystructure) {
+		free(s->pending_bodystructure_buf);
+		s->pending_bodystructure_buf = NULL;
+		s->pending_bodystructure_len = 0;
+	}
+	if (s->fetch_attrs & MBOX_FETCH_BODYSTRUCTURE)
+		s->pending_bodystructure_found = 0;
+}
+
+/*
+ * STORE's untagged FETCH response (RFC 9051 SS6.4.6's own example: "* 2
+ * FETCH (FLAGS (\Deleted \Seen))") shows FLAGS, regardless of what the
+ * original STORE actually changed -- unlike FETCH's own response, there's
+ * no s->fetch_attrs-style bitmask to consult here because STORE only ever
+ * has one client-requested data item to report. meta->flags is store.c's
+ * handle_mbox_store()-computed post-STORE value, not the client-supplied
+ * delta.
+ *
+ * RFC 7162 addition this pass: also shows MODSEQ whenever this session is
+ * CONDSTORE-aware (s->condstore_enabled), independent of whether *this*
+ * particular STORE used UNCHANGEDSINCE -- SS3.1.3's own Examples 3-10 all
+ * show MODSEQ in the STORE echo purely because CONDSTORE was already
+ * enabled for the session, not because each individual STORE requested it
+ * (STORE has no fetch-att list to request it with in the first place).
+ * meta->modseq is always populated by store.c regardless (see imapd.h's
+ * imsg_mbox_fetch_meta comment), so this is purely a "whether to print it"
+ * decision, same split every other field in this struct already uses.
+ */
+static void
+session_send_store_fetch_response(struct session *s,
+    struct imsg_mbox_fetch_meta *meta)
+{
+	char	buf[MBOX_FLAGS_MAX + 96];
+	size_t	len;
+
+	/*
+	 * RFC 9051 SS6.4.9 addition: a UID STORE's echo must include UID too
+	 * (see struct session's cmd_by_uid comment) -- inserted right after
+	 * FLAGS, matching SS6.4.9's own UID FETCH example's FLAGS-then-UID
+	 * order ("FLAGS (\Seen) UID 4827313"); element order within a
+	 * msg-att's parenthesized list isn't itself semantically significant
+	 * (RFC 9051 SS9's msg-att grammar is an unordered set of
+	 * alternatives), so there's no conflict with RFC 7162 SS3.1.3's own
+	 * example showing UID before MODSEQ in a *different* (FLAGS-omitted)
+	 * echo shape.
+	 */
+	len = (size_t)snprintf(buf, sizeof(buf), "%u FETCH (FLAGS (%s)",
+	    meta->seqno, meta->flags);
+	if (s->cmd_by_uid && len < sizeof(buf))
+		len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+		    " UID %u", meta->uid);
+	if (s->condstore_enabled && len < sizeof(buf))
+		len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+		    " MODSEQ (%llu)", (unsigned long long)meta->modseq);
+	if (len < sizeof(buf))
+		snprintf(buf + len, sizeof(buf) - len, ")");
+
+	session_untagged(s, buf);
+}
+
+/*
+ * Splits a trailing RFC 4466 modifier list -- "[SP '(' modifier *(SP
+ * modifier) ')']" -- off of spec (a fetch-att or store-att-flags spec that
+ * may be followed by one), used by both cmd_fetch() (modifiers trail the
+ * fetch-att list) and cmd_store_cmd() (modifiers instead lead, before
+ * store-att-flags -- see that function's own comment on why it calls this
+ * on a different substring). spec is modified in place: NUL-terminated
+ * right after its own portion, with the returned pointer (or NULL, if
+ * nothing trails) pointing at the still-parenthesized modifier text for
+ * the caller's own parser to strip and tokenize.
+ *
+ * Depth-counts through spec's own parens (rather than assuming it's never
+ * parenthesized) so this works whether spec itself is a bare token
+ * ("FLAGS") or a parenthesized list ("(FLAGS UID)") -- v1 doesn't need to
+ * handle nested parens *within* spec (no fetch-att or flag needs them),
+ * but counting depth anyway costs nothing and avoids assuming that stays
+ * true forever.
+ */
+static char *
+split_trailing_modifiers(char *spec)
+{
+	char	*p = spec;
+
+	if (*p == '(') {
+		int	depth = 0;
+
+		for (;;) {
+			if (*p == '(')
+				depth++;
+			else if (*p == ')') {
+				depth--;
+				if (depth == 0) {
+					p++;
+					break;
+				}
+			} else if (*p == '\0')
+				return (NULL);	/* unterminated -- let the
+						 * caller's own parser produce
+						 * the BAD for this */
+			p++;
+		}
+	} else {
+		while (*p != '\0' && *p != ' ')
+			p++;
+	}
+
+	if (*p == '\0')
+		return (NULL);
+	*p++ = '\0';
+	while (*p == ' ')
+		p++;
+	if (*p == '\0')
+		return (NULL);
+	return (p);
+}
+
+/*
+ * Parses a FETCH command's trailing fetch-modifier list (RFC 4466's generic
+ * syntax, extended by RFC 7162 SS3.1.4.1/SS3.2.6). modspec is the still-
+ * parenthesized text split off by split_trailing_modifiers() above,
+ * modified in place.
+ *
+ * CHANGEDSINCE <mod-sequence-value> is the only fetch-modifier this server
+ * implements; it implicitly sets MBOX_FETCH_MODSEQ (SS3.1.4.1: "implicitly
+ * adds the MODSEQ FETCH message data item"). VANISHED (RFC 7162 SS3.2.6) is
+ * only legal on UID FETCH (by_uid) and only once this session has "ENABLE
+ * QRESYNC"'d -- both a tagged BAD otherwise, matching parse_select_params()'s
+ * existing pattern for QRESYNC select-params' own "requires ENABLE QRESYNC
+ * first" check. VANISHED's *other* restriction -- "MUST only be specified
+ * together with the CHANGEDSINCE UID FETCH modifier" -- can't be checked
+ * here: CHANGEDSINCE might appear later in the same modifier list (order
+ * isn't fixed), so fetch_dispatch() checks that combination itself, once
+ * the whole list has been parsed; *want_vanished just carries "VANISHED was
+ * present, syntax was fine" out to it. Anything else is an unrecognized
+ * modifier -- BAD, since v1 defines no other fetch-modifier at all for this
+ * server to legitimately ignore the way ENABLE ignores unknown capabilities.
+ */
+static int
+parse_fetch_modifiers(char *modspec, struct imsg_mbox_fetch *req,
+    struct session *s, int by_uid, int *want_vanished, const char **errmsg)
+{
+	char	*p, *tok, *save;
+	size_t	 len;
+
+	*errmsg = NULL;
+	*want_vanished = 0;
+	len = strlen(modspec);
+	if (len < 2 || modspec[0] != '(' || modspec[len - 1] != ')') {
+		*errmsg = "malformed fetch-modifier list";
+		return (-1);
+	}
+	modspec[len - 1] = '\0';
+	p = modspec + 1;
+
+	for (tok = strtok_r(p, " ", &save); tok != NULL;
+	    tok = strtok_r(NULL, " ", &save)) {
+		if (strcasecmp(tok, "CHANGEDSINCE") == 0) {
+			char	*valtok = strtok_r(NULL, " ", &save);
+			char	*ep;
+
+			if (valtok == NULL) {
+				*errmsg = "CHANGEDSINCE requires a "
+				    "mod-sequence value";
+				return (-1);
+			}
+			errno = 0;
+			req->changedsince = strtoull(valtok, &ep, 10);
+			if (*ep != '\0' || errno != 0) {
+				*errmsg = "invalid CHANGEDSINCE mod-sequence";
+				return (-1);
+			}
+			req->has_changedsince = 1;
+			req->attrs |= MBOX_FETCH_MODSEQ;
+		} else if (strcasecmp(tok, "VANISHED") == 0) {
+			if (!by_uid) {
+				/* RFC 7162 SS3.2.6: "the VANISHED UID FETCH
+				 * modifier is NOT allowed with a FETCH
+				 * command. The server MUST return a tagged
+				 * BAD response..." */
+				*errmsg = "VANISHED is only valid as a UID "
+				    "FETCH modifier (RFC 7162 SS3.2.6)";
+				return (-1);
+			}
+			if (!s->qresync_enabled) {
+				*errmsg = "VANISHED requires ENABLE QRESYNC "
+				    "first (RFC 7162 SS3.2.6)";
+				return (-1);
+			}
+			*want_vanished = 1;
+		} else {
+			*errmsg = "unrecognized fetch modifier";
+			return (-1);
+		}
+	}
+
+	return (0);
+}
+
+/*
+ * RFC 9051 SS6.4.5: `fetch = "FETCH" SP sequence-set SP ("ALL" / "FULL" /
+ * "FAST" / fetch-att / "(" fetch-att *(SP fetch-att) ")")`, extended by RFC
+ * 4466/RFC 7162 with an optional trailing fetch-modifier list: `[SP "("
+ * fetch-modifier *(SP fetch-modifier) ")"]`.
+ *
+ * v1 scope: message METADATA (FLAGS, UID, INTERNALDATE, RFC822.SIZE,
+ * MODSEQ), plus, across six real-client/hand-built-testing passes,
+ * BODY.PEEK[HEADER], BODY.PEEK[]/BODY.PEEK[TEXT], BODY.PEEK[HEADER.FIELDS
+ * (...)]/BODY.PEEK[HEADER.FIELDS.NOT (...)] (see imapd.h's MBOX_FETCH_
+ * BODY_* / MBOX_FETCH_HEADER_FIELDS comments), ENVELOPE (see MBOX_FETCH_
+ * ENVELOPE's comment) -- the parsed, structured RFC 5322 header summary
+ * most clients need for a message list view -- and BODY/BODYSTRUCTURE (see
+ * MBOX_FETCH_BODYSTRUCTURE's comment), full recursive MIME structure
+ * parsing bounded by MIME_MAX_DEPTH/MIME_MAX_PARTS, without RFC 9051's
+ * optional extension data. Everything else content-related -- plain
+ * BODY[HEADER]/BODY[]/BODY[TEXT]/BODY[HEADER.FIELDS...] without .PEEK
+ * (would implicitly set \Seen, a real design decision about flag-mutation-
+ * during-FETCH not taken any of these passes), and BODY[<part>]/BODY.PEEK
+ * [<part>] (MIME part-*addressed content*, i.e. actually returning one
+ * specific part's bytes -- distinct from BODYSTRUCTURE, which only
+ * describes the part tree's existence) -- remains a deliberate, flagged
+ * scope cut, not full FETCH. parse_fetch_atts() silently drops each of
+ * those (see its own header comment) rather than rejecting the whole
+ * request.
+ *
+ * Also v1-scoped: exactly one sequence-set range or number per command,
+ * never a comma-separated list -- rejected below with BAD rather than
+ * silently fetching only the first sub-range (avoids needing a multi-range
+ * queue in struct session, whose pending_tag/fetch_attrs fields already
+ * only track a single in-flight async operation at a time).
+ */
+static int
+cmd_fetch(struct session *s, const char *tag, char *args)
+{
+	return fetch_dispatch(s, tag, args, 0);
+}
+
+/*
+ * Shared body for cmd_fetch() (by_uid=0) and cmd_uid()'s FETCH branch
+ * (by_uid=1) -- see cmd_fetch()'s own comment for the grammar/scope this
+ * parses. RFC 9051 SS6.4.9 additions this pass, all gated on by_uid:
+ *
+ * - The sequence-set argument is resolved as UIDs, not sequence numbers
+ *   (req.by_uid, threaded to store.c -- see handle_mbox_fetch()'s comment
+ *   in store.c for the actual resolution).
+ * - MBOX_FETCH_UID is forced into attrs regardless of what the client's
+ *   fetch-att list asked for (SS6.4.9: "server implementations MUST
+ *   implicitly include the UID message data item as part of any FETCH
+ *   response caused by a UID command") -- session_send_fetch_response()
+ *   needs no changes at all for this, since it already prints UID whenever
+ *   that bit is set.
+ * - VANISHED is legal as a fetch-modifier (RFC 7162 SS3.2.6), checked by
+ *   parse_fetch_modifiers() itself for the by_uid/ENABLE QRESYNC
+ *   restrictions; the remaining restriction -- VANISHED requires
+ *   CHANGEDSINCE also be present -- is checked here, after the full
+ *   modifier list has been parsed (order-independent).
+ * - s->cmd_by_uid is set unconditionally (to by_uid itself) before
+ *   dispatch, same as store_do()/search_dispatch()/session_request_
+ *   expunge() -- see that field's own comment in struct session. FETCH's
+ *   own response formatting doesn't consult it (see above), but it's set
+ *   anyway for consistency and because a later command in the same
+ *   session must never see a stale value from this one.
+ */
+static int
+fetch_dispatch(struct session *s, const char *tag, char *args, int by_uid)
+{
+	struct imsg_mbox_fetch	 req;
+	char			*seqtok, *attspec, *modspec;
+	uint32_t		 lo, hi, attrs;
+	int			 lo_star, hi_star, rc, want_vanished = 0, degraded;
+	int			 header_fields_not = 0;
+	char			 header_fields[HEADER_FIELDS_MAX];
+	char			 header_fields_label[HEADER_FIELDS_LABEL_MAX];
+	int			 bodystructure_full = 0;
+	char			 section_part[SECTION_PART_MAX];
+	int			 has_partial = 0;
+	uint32_t		 partial_start = 0, partial_count = 0;
+	const char		*errmsg;
+	const char		*cmdname = by_uid ? "UID FETCH" : "FETCH";
+
+	if (args == NULL) {
+		session_reply(s, tag, "BAD",
+		    "FETCH requires a sequence set and message data item(s)");
+		return (1);
+	}
+
+	seqtok = args;
+	while (*args != '\0' && *args != ' ')
+		args++;
+	if (*args == '\0') {
+		session_reply(s, tag, "BAD",
+		    "FETCH requires message data item(s)");
+		return (1);
+	}
+	*args++ = '\0';
+	while (*args == ' ')
+		args++;
+	attspec = args;
+
+	if (strchr(seqtok, ',') != NULL) {
+		session_reply(s, tag, "BAD",
+		    "comma-separated sequence sets not supported in v1 -- "
+		    "issue separate FETCH commands");
+		return (1);
+	}
+
+	if (parse_seq_range(seqtok, &lo, &hi, &lo_star, &hi_star) == -1) {
+		session_reply(s, tag, "BAD", "invalid sequence set");
+		return (1);
+	}
+
+	modspec = split_trailing_modifiers(attspec);
+
+	rc = parse_fetch_atts(attspec, &attrs, &degraded, &header_fields_not,
+	    header_fields, sizeof(header_fields), header_fields_label,
+	    sizeof(header_fields_label), &bodystructure_full, section_part,
+	    sizeof(section_part), &has_partial, &partial_start,
+	    &partial_count, &errmsg);
+	if (rc == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+	if (rc == -2) {
+		session_reply(s, tag, "NO", errmsg);
+		return (1);
+	}
+	if (degraded)
+		log_debug("session %u: %s: one or more unsupported message "
+		    "data items silently dropped (plain BODY[...]/BODY[<part>]"
+		    "/BODY.PEEK[<part>] with MESSAGE/RFC822|GLOBAL or MULTIPART"
+		    " nested numbering/RFC822[.HEADER/.TEXT]) -- answering "
+		    "with whatever was recognized", s->id, cmdname);
+
+	memset(&req, 0, sizeof(req));
+	req.attrs = attrs;
+	req.by_uid = by_uid;
+	req.header_fields_not = header_fields_not;
+	strlcpy(req.header_fields, header_fields, sizeof(req.header_fields));
+
+	/*
+	 * has_partial/partial_start/partial_count apply uniformly to
+	 * whichever of WHOLE/TEXT/PART attrs ends up selecting (see store.c's
+	 * handle_mbox_fetch() comment) -- copied through to store.c
+	 * unconditionally here rather than gated on a specific bit, since
+	 * store.c is the one place that already knows the final WHOLE > TEXT
+	 * > PART precedence and applies the range to whichever it picks.
+	 * section_part is similarly always copied through; store.c only
+	 * consults it when MBOX_FETCH_BODY_PART actually wins.
+	 */
+	strlcpy(req.section_part, section_part, sizeof(req.section_part));
+	req.has_partial = has_partial;
+	req.partial_start = partial_start;
+	req.partial_count = partial_count;
+
+	/*
+	 * The verbatim client-typed label ("HEADER" for plain BODY.PEEK
+	 * [HEADER], or e.g. "HEADER.FIELDS (DATE FROM)" for the fields
+	 * variant) never crosses the imsg boundary to store.c -- it's
+	 * purely a listener.c-side echo concern, stashed on the session now
+	 * so session_send_fetch_response() can use it once the matching
+	 * IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_FETCH_META pair arrives per
+	 * message. See struct session's pending_header_label comment.
+	 */
+	if (attrs & MBOX_FETCH_BODY_HEADER)
+		strlcpy(s->pending_header_label, "HEADER",
+		    sizeof(s->pending_header_label));
+	else if (attrs & MBOX_FETCH_HEADER_FIELDS)
+		strlcpy(s->pending_header_label, header_fields_label,
+		    sizeof(s->pending_header_label));
+
+	/*
+	 * Same idea as pending_header_label just above, for BODY.PEEK[]/
+	 * BODY.PEEK[TEXT]/BODY.PEEK[<section-part>] -- WHOLE > TEXT > PART
+	 * precedence matches store.c's handle_mbox_fetch() exactly (see that
+	 * function's comment), since both sides have to agree on which one a
+	 * client that somehow requested more than one of the three actually
+	 * gets. pending_body_has_partial/pending_body_partial_origin are only
+	 * set when the request has_partial applies to *this* winning variant
+	 * -- store.c already ties has_partial/partial_start/partial_count to
+	 * whichever of WHOLE/TEXT/PART it picks with the same precedence, so
+	 * there's nothing further to disambiguate here.
+	 */
+	if (attrs & MBOX_FETCH_BODY_WHOLE)
+		s->pending_body_label[0] = '\0';
+	else if (attrs & MBOX_FETCH_BODY_TEXT)
+		strlcpy(s->pending_body_label, "TEXT",
+		    sizeof(s->pending_body_label));
+	else if (attrs & MBOX_FETCH_BODY_PART)
+		strlcpy(s->pending_body_label, section_part,
+		    sizeof(s->pending_body_label));
+	s->pending_body_has_partial = has_partial;
+	s->pending_body_partial_origin = partial_start;
+
+	/*
+	 * Same idea as pending_header_label just above, for BODYSTRUCTURE:
+	 * RFC 9051 SS9's `"BODY" ["STRUCTURE"] SP body` means the response
+	 * label itself has to echo whichever bare token the client used --
+	 * see struct session's pending_bodystructure_label comment.
+	 */
+	if (attrs & MBOX_FETCH_BODYSTRUCTURE)
+		strlcpy(s->pending_bodystructure_label,
+		    bodystructure_full ? "BODYSTRUCTURE" : "BODY",
+		    sizeof(s->pending_bodystructure_label));
+
+	if (modspec != NULL) {
+		if (parse_fetch_modifiers(modspec, &req, s, by_uid,
+		    &want_vanished, &errmsg) == -1) {
+			session_reply(s, tag, "BAD", errmsg);
+			return (1);
+		}
+	}
+
+	if (want_vanished && !req.has_changedsince) {
+		/* RFC 7162 SS3.2.6: "The VANISHED UID FETCH modifier MUST
+		 * only be specified together with the CHANGEDSINCE UID
+		 * FETCH modifier... the server MUST respond with a tagged
+		 * BAD response." */
+		session_reply(s, tag, "BAD",
+		    "VANISHED requires CHANGEDSINCE also be specified "
+		    "(RFC 7162 SS3.2.6)");
+		return (1);
+	}
+	req.want_vanished = want_vanished;
+
+	if (by_uid)
+		req.attrs |= MBOX_FETCH_UID;
+
+	if (s->store_iev == NULL) {
+		/* Same internal-invariant check as cmd_select() -- ST_SELECTED
+		 * requires store_iev to already be wired. */
+		log_warnx("session %u: %s with no store channel wired",
+		    s->id, cmdname);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	req.seq_lo = lo;
+	req.seq_hi = hi;
+	req.lo_is_star = lo_star;
+	req.hi_is_star = hi_star;
+
+	/* RFC 7162 SS3.1: the MODSEQ fetch-att and CHANGEDSINCE modifier are
+	 * both CONDSTORE-enabling commands. */
+	if (req.attrs & MBOX_FETCH_MODSEQ)
+		session_condstore_enable(s);
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->fetch_attrs = req.attrs;
+	s->cmd_by_uid = by_uid;
+	s->state = SESSION_FETCHING;
+
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_FETCH, 0, 0, -1,
+	    &req, sizeof(req)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_FETCH", s->id);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+/*
+ * RFC 9051 SS6.4.6: `store-att-flags = (["+" / "-"] "FLAGS" [".SILENT"]) SP
+ * (flag-list / (flag *(SP flag)))`, `flag = "\Answered" / "\Flagged" /
+ * "\Deleted" / "\Seen" / "\Draft" / flag-keyword / flag-extension ; Does
+ * not include "\Recent"`, `flag-keyword = "$MDNSent" / "$Forwarded" /
+ * "$Junk" / "$NotJunk" / "$Phishing" / atom`, `flag-extension = "\" atom`.
+ *
+ * modetok is the "(["+"/"-"] "FLAGS" [".SILENT"])" token (e.g. "+FLAGS",
+ * "FLAGS.SILENT", "-FLAGS"); flagspec is everything after it, with or
+ * without surrounding parentheses, modified in place (strtok_r()) --
+ * same shape parse_fetch_atts() already accepts for fetch-att lists.
+ *
+ * *sysflags_out collects the five supported system flags as a MBOX_FLAG_*
+ * bitmask. Any other "\"-prefixed token is either "\Recent" (explicitly
+ * excluded from the `flag` production itself -- not a client error
+ * exactly, but not something STORE can ever legitimately be asked to set)
+ * or a flag-extension this server doesn't define (SS2.3.2: system flags
+ * are "predefined in this specification" -- there is no mechanism here
+ * for a client to invent a new one), so both are rejected the same way
+ * FETCH rejects BODY[...]: -2/NO, "recognized syntax, not something this
+ * server supports" rather than a syntax error. A bare (non-"\") token is
+ * a keyword and is appended to keywords_out as-is, EXCEPT that a keyword
+ * containing ':' or ',' is also rejected with -2/NO: both characters are
+ * valid in IMAP's `atom` grammar, but the index format
+ * (openimap-storage-backend.md) uses them as its own field/record
+ * delimiters with no escaping mechanism, so storing such a keyword
+ * verbatim would corrupt the index -- a v1 storage-format limitation,
+ * not an IMAP protocol restriction, and treated as "unsupported" rather
+ * than "invalid" for exactly that reason.
+ *
+ * System-flag name matching is case-insensitive (`strcasecmp()`) as a
+ * deliberate implementation choice for interoperability -- RFC 9051's own
+ * ABNF for `flag` doesn't state a case-sensitivity rule one way or the
+ * other, so this isn't a sourced requirement, just this server being
+ * lenient the same way it already is about repeated spaces in
+ * parse_command_line(). mode/silent themselves are parsed separately by
+ * the caller (the store-att-flags prefix, e.g. "+FLAGS.SILENT") -- this
+ * function only ever sees the flag-list half.
+ */
+static int
+parse_store_flags(char *flagspec, uint32_t *sysflags_out, char *keywords_out,
+    size_t keywords_out_size, const char **errmsg)
+{
+	char		*p, *tok, *save;
+	size_t		 len;
+	uint32_t	 sysflags = 0;
+	int		 first = 1;
+
+	*errmsg = NULL;
+	*sysflags_out = 0;
+	keywords_out[0] = '\0';
+
+	if (flagspec == NULL || *flagspec == '\0') {
+		*errmsg = "missing flag list";
+		return (-1);
+	}
+
+	p = flagspec;
+	len = strlen(p);
+	if (len >= 2 && p[0] == '(' && p[len - 1] == ')') {
+		p[len - 1] = '\0';
+		p++;
+	}
+	/* An empty flag-list, "()" or "", is valid ABNF-wise (flag-list =
+	 * "(" [flag *(SP flag)] ")") but pointless for STORE -- SET with no
+	 * flags would clear everything, which is a real (if unusual)
+	 * request, so this is only rejected when the caller can't tell SET
+	 * from ADD/REMOVE apart at this layer; see cmd_store_cmd(), which
+	 * allows an empty list only for a bare "FLAGS"/"FLAGS.SILENT". */
+	if (*p == '\0')
+		return (0);
+
+	for (tok = strtok_r(p, " ", &save); tok != NULL;
+	    tok = strtok_r(NULL, " ", &save)) {
+		if (tok[0] == '\\') {
+			if (strcasecmp(tok, "\\Answered") == 0)
+				sysflags |= MBOX_FLAG_ANSWERED;
+			else if (strcasecmp(tok, "\\Flagged") == 0)
+				sysflags |= MBOX_FLAG_FLAGGED;
+			else if (strcasecmp(tok, "\\Deleted") == 0)
+				sysflags |= MBOX_FLAG_DELETED;
+			else if (strcasecmp(tok, "\\Seen") == 0)
+				sysflags |= MBOX_FLAG_SEEN;
+			else if (strcasecmp(tok, "\\Draft") == 0)
+				sysflags |= MBOX_FLAG_DRAFT;
+			else if (strcasecmp(tok, "\\Recent") == 0) {
+				*errmsg = "\\Recent cannot be set -- RFC 9051 "
+				    "deprecates it and excludes it from the "
+				    "flag grammar entirely";
+				return (-2);
+			} else {
+				*errmsg = "unsupported system flag -- v1 only "
+				    "supports \\Answered/\\Flagged/\\Deleted/"
+				    "\\Seen/\\Draft";
+				return (-2);
+			}
+			continue;
+		}
+
+		if (strchr(tok, ':') != NULL || strchr(tok, ',') != NULL) {
+			*errmsg = "keyword contains ':' or ',' -- not "
+			    "representable in this server's index format";
+			return (-2);
+		}
+
+		if (!first)
+			strlcat(keywords_out, ",", keywords_out_size);
+		strlcat(keywords_out, tok, keywords_out_size);
+		first = 0;
+	}
+
+	*sysflags_out = sysflags;
+	return (0);
+}
+
+/*
+ * Parses a STORE command's leading store-modifier list (RFC 4466's generic
+ * syntax, extended by RFC 7162 SS3.1.3). modspec is the still-parenthesized
+ * text cmd_store_cmd() split off before store-att-flags, modified in place
+ * -- see that function's comment for why store-modifiers lead rather than
+ * trail here, unlike FETCH's fetch-modifiers.
+ *
+ * UNCHANGEDSINCE <mod-sequence-valzer> is the only store-modifier this
+ * server implements. Unlike parse_fetch_modifiers()'s CHANGEDSINCE, the
+ * value here is explicitly allowed to be 0 (SS3.1.3 Example 8: "Use of
+ * UNCHANGEDSINCE with a modification sequence of 0 always fails if the
+ * metadata item exists" -- a real, distinct case from "not specified",
+ * hence has_unchangedsince rather than testing unchangedsince != 0).
+ */
+static int
+parse_store_modifiers(char *modspec, struct imsg_mbox_store *req,
+    const char **errmsg)
+{
+	char	*p, *tok, *save;
+	size_t	 len;
+
+	*errmsg = NULL;
+	len = strlen(modspec);
+	if (len < 2 || modspec[0] != '(' || modspec[len - 1] != ')') {
+		*errmsg = "malformed store-modifier list";
+		return (-1);
+	}
+	modspec[len - 1] = '\0';
+	p = modspec + 1;
+
+	for (tok = strtok_r(p, " ", &save); tok != NULL;
+	    tok = strtok_r(NULL, " ", &save)) {
+		if (strcasecmp(tok, "UNCHANGEDSINCE") == 0) {
+			char	*valtok = strtok_r(NULL, " ", &save);
+			char	*ep;
+
+			if (valtok == NULL) {
+				*errmsg = "UNCHANGEDSINCE requires a "
+				    "mod-sequence value";
+				return (-1);
+			}
+			errno = 0;
+			req->unchangedsince = strtoull(valtok, &ep, 10);
+			if (*ep != '\0' || errno != 0) {
+				*errmsg = "invalid UNCHANGEDSINCE mod-sequence";
+				return (-1);
+			}
+			req->has_unchangedsince = 1;
+		} else {
+			*errmsg = "unrecognized store modifier";
+			return (-1);
+		}
+	}
+
+	return (0);
+}
+
+/* Named cmd_store_cmd(), not cmd_store(), to avoid reading as though it
+ * belongs to -- or calls into -- the STORE *role* (store.c, s->store_iev,
+ * session_store_dispatch() etc. elsewhere in this file): same command
+ * name, unrelated concept.
+ *
+ * RFC 9051 SS6.4.6: `store = "STORE" SP sequence-set SP store-att-flags`,
+ * extended by RFC 4466/RFC 7162 with an optional store-modifier list
+ * between the sequence-set and store-att-flags: `"STORE" SP sequence-set
+ * [SP "(" store-modifier *(SP store-modifier) ")"] SP store-att-flags` --
+ * store-modifiers *lead*, unlike FETCH's fetch-modifiers, which trail (see
+ * cmd_fetch()'s comment and RFC 7162 SS3.1.3's own examples, e.g. "STORE *
+ * (UNCHANGEDSINCE 12121230045) +FLAGS.SILENT (...)"), so this function
+ * peeks for a leading "(" right after the sequence-set rather than reusing
+ * split_trailing_modifiers().
+ *
+ * v1 scope matches FETCH's: exactly one sequence-set range or number,
+ * never a comma-separated list, for the same struct-session-only-tracks-
+ * one-async-operation reason cmd_fetch()'s comment explains. Replies
+ * reuse IMSG_MBOX_FETCH_META/IMSG_MBOX_RESULT (see imapd.h's imsg_
+ * mbox_store comment) since RFC 9051 SS6.4.6 itself says STORE's only
+ * response is "untagged responses: FETCH" -- the exact same wire shape
+ * FETCH already produces, so store.c and this function are what decide
+ * it's a STORE in flight (s->state == SESSION_STORING), not a different
+ * imsg type.
+ */
+static int
+cmd_store_cmd(struct session *s, const char *tag, char *args)
+{
+	return store_do(s, tag, args, 0);
+}
+
+/*
+ * Shared body for cmd_store_cmd() (by_uid=0) and cmd_uid()'s STORE branch
+ * (by_uid=1) -- see cmd_store_cmd()'s own comment for the grammar/scope
+ * this parses. RFC 9051 SS6.4.9 addition this pass: req.by_uid threads the
+ * UID-vs-sequence-number resolution to store.c (see handle_mbox_store()'s
+ * comment there); s->cmd_by_uid is set unconditionally before dispatch so
+ * session_send_store_fetch_response() knows to include UID in the STORE
+ * echo (SS6.4.9's "MUST implicitly include the UID message data item...
+ * primarily applies to the UID FETCH and UID STORE commands").
+ */
+static int
+store_do(struct session *s, const char *tag, char *args, int by_uid)
+{
+	struct imsg_mbox_store	 req;
+	char			*seqtok, *modetok, *flagspec, *modspec = NULL;
+	uint32_t		 lo, hi, sysflags;
+	int			 lo_star, hi_star, mode, silent, rc;
+	char			 keywords[MBOX_FLAGS_MAX];
+	const char		*errmsg;
+	const char		*cmdname = by_uid ? "UID STORE" : "STORE";
+
+	if (args == NULL) {
+		session_reply(s, tag, "BAD",
+		    "STORE requires a sequence set and store-att-flags");
+		return (1);
+	}
+
+	seqtok = args;
+	while (*args != '\0' && *args != ' ')
+		args++;
+	if (*args == '\0') {
+		session_reply(s, tag, "BAD", "STORE requires store-att-flags");
+		return (1);
+	}
+	*args++ = '\0';
+	while (*args == ' ')
+		args++;
+
+	if (*args == '(') {
+		char	*p = args;
+		int	 depth = 0;
+
+		for (;;) {
+			if (*p == '(')
+				depth++;
+			else if (*p == ')') {
+				depth--;
+				if (depth == 0)
+					break;
+			} else if (*p == '\0') {
+				session_reply(s, tag, "BAD",
+				    "unterminated store-modifier list");
+				return (1);
+			}
+			p++;
+		}
+		/* p points at the matching ')' for modspec (== args) */
+		modspec = args;
+		p++;	/* just past ')' */
+		if (*p == '\0') {
+			session_reply(s, tag, "BAD",
+			    "STORE requires store-att-flags");
+			return (1);
+		}
+		if (*p != ' ') {
+			session_reply(s, tag, "BAD",
+			    "expected a space after store-modifier list");
+			return (1);
+		}
+		*p = '\0';	/* terminate modspec right after ')' */
+		p++;
+		while (*p == ' ')
+			p++;
+		args = p;
+	}
+
+	modetok = args;
+	while (*args != '\0' && *args != ' ')
+		args++;
+	if (*args == '\0') {
+		session_reply(s, tag, "BAD", "STORE requires a flag list");
+		return (1);
+	}
+	*args++ = '\0';
+	while (*args == ' ')
+		args++;
+	flagspec = args;
+
+	if (strchr(seqtok, ',') != NULL) {
+		session_reply(s, tag, "BAD",
+		    "comma-separated sequence sets not supported in v1 -- "
+		    "issue separate STORE commands");
+		return (1);
+	}
+	if (parse_seq_range(seqtok, &lo, &hi, &lo_star, &hi_star) == -1) {
+		session_reply(s, tag, "BAD", "invalid sequence set");
+		return (1);
+	}
+
+	memset(&req, 0, sizeof(req));
+	if (modspec != NULL) {
+		if (parse_store_modifiers(modspec, &req, &errmsg) == -1) {
+			session_reply(s, tag, "BAD", errmsg);
+			return (1);
+		}
+	}
+
+	if (modetok[0] == '+') {
+		mode = MBOX_STORE_ADD;
+		modetok++;
+	} else if (modetok[0] == '-') {
+		mode = MBOX_STORE_REMOVE;
+		modetok++;
+	} else
+		mode = MBOX_STORE_SET;
+
+	if (strcasecmp(modetok, "FLAGS") == 0)
+		silent = 0;
+	else if (strcasecmp(modetok, "FLAGS.SILENT") == 0)
+		silent = 1;
+	else {
+		session_reply(s, tag, "BAD",
+		    "expected FLAGS, FLAGS.SILENT, +FLAGS, +FLAGS.SILENT, "
+		    "-FLAGS, or -FLAGS.SILENT");
+		return (1);
+	}
+
+	rc = parse_store_flags(flagspec, &sysflags, keywords, sizeof(keywords),
+	    &errmsg);
+	if (rc == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+	if (rc == -2) {
+		session_reply(s, tag, "NO", errmsg);
+		return (1);
+	}
+
+	if (s->mbox_readonly) {
+		/*
+		 * RFC 9051 SS6.3.3: "No changes to the permanent state of
+		 * the mailbox, including per-user state, are permitted" for
+		 * an EXAMINE'd mailbox -- STORE's entire purpose is exactly
+		 * that kind of change. RFC 5530 CANNOT, same reasoning as
+		 * session_request_expunge()'s identical check.
+		 */
+		session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
+		    "(selected via EXAMINE)");
+		return (1);
+	}
+
+	if (s->store_iev == NULL) {
+		/* Same internal-invariant check as cmd_select()/cmd_fetch()
+		 * -- ST_SELECTED requires store_iev to already be wired. */
+		log_warnx("session %u: %s with no store channel wired",
+		    s->id, cmdname);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	/* req was already memset(3)'d and populated with has_unchangedsince/
+	 * unchangedsince (if modspec was present) earlier -- not re-zeroed
+	 * here, or that would silently wipe those two fields back out. */
+	req.seq_lo = lo;
+	req.seq_hi = hi;
+	req.lo_is_star = lo_star;
+	req.hi_is_star = hi_star;
+	req.mode = mode;
+	req.silent = silent;
+	req.sysflags = sysflags;
+	req.by_uid = by_uid;
+	strlcpy(req.keywords, keywords, sizeof(req.keywords));
+
+	/* RFC 7162 SS3.1: UNCHANGEDSINCE is a CONDSTORE-enabling command. */
+	if (req.has_unchangedsince)
+		session_condstore_enable(s);
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->cmd_by_uid = by_uid;
+	s->state = SESSION_STORING;
+
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_STORE, 0, 0, -1,
+	    &req, sizeof(req)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_STORE", s->id);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+/*
+ * RFC 9051 SS6.4.7 COPY / SS6.4.8 MOVE: `copy = "COPY" SP sequence-set SP
+ * mailbox`, `move = "MOVE" SP sequence-set SP mailbox` -- identical
+ * argument grammar, differing only in which IMSG_MBOX_* this sends and
+ * (per is_move) which store.c handler that becomes. cmd_copy()/cmd_move()
+ * are thin by_uid=0 wrappers around this, and cmd_uid() calls it again
+ * with by_uid=1 for UID COPY/UID MOVE -- the same "_dispatch() shared body,
+ * thin cmd_*() wrappers" pattern fetch_dispatch()/store_do()/search_
+ * dispatch() already established for RFC 9051 SS6.4.9's UID command.
+ *
+ * Mailbox-name argument reuses parse_list_token() (LIST/STATUS's own
+ * token parser). Fast client-side validation reuses mailbox_name_is_
+ * inbox()/mailbox_name_valid() -- CREATE/DELETE/RENAME's own precedent
+ * (RFC 9051 SS6.3.4-SS6.3.6 flat multi-mailbox support, docs/openimap-
+ * storage-backend.md item 10's own follow-up), not cmd_list()/cmd_
+ * status()'s "only INBOX can ever match" precedent this function used
+ * before that support existed: destname is now genuinely any real,
+ * existing mailbox, not just INBOX, so a syntactically valid name gets a
+ * real store round trip (IMSG_MBOX_COPY/IMSG_MBOX_MOVE now carry it, see
+ * imapd.h's imsg_mbox_copy comment) rather than being answered locally.
+ *
+ * A syntactically invalid name (BAD, no round trip) is a different
+ * failure from "syntactically fine but doesn't exist" -- the latter is
+ * exactly SS6.4.7's TRYCREATE case: "If the destination mailbox does not
+ * exist, a server MUST return an error... Unless it is certain that the
+ * destination mailbox can not be created, the server MUST send the
+ * response code '[TRYCREATE]'". store.c's handle_mbox_copy()/handle_mbox_
+ * move() report that case back via struct imsg_mbox_result's no_such_
+ * mailbox field (mirroring imsg_mbox_appended's own field for APPEND);
+ * session_finish_copy_or_move() sends TRYCREATE only then, a plain NO
+ * "failed" for any other failure.
+ */
+static int
+copy_move_dispatch(struct session *s, const char *tag, char *args,
+    int by_uid, int is_move)
+{
+	struct imsg_mbox_copy	 req;
+	char			 mailbox[MBOX_NAME_MAX];
+	char			*seqtok, *p;
+	uint32_t		 lo, hi;
+	int			 lo_star, hi_star;
+	const char		*errmsg = NULL;
+	const char		*cmdname = is_move ?
+	    (by_uid ? "UID MOVE" : "MOVE") : (by_uid ? "UID COPY" : "COPY");
+
+	if (args == NULL) {
+		char	text[64];
+
+		snprintf(text, sizeof(text),
+		    "%s requires a sequence set and mailbox name", cmdname);
+		session_reply(s, tag, "BAD", text);
+		return (1);
+	}
+
+	seqtok = args;
+	p = args;
+	while (*p != '\0' && *p != ' ')
+		p++;
+	if (*p == '\0') {
+		char	text[48];
+
+		snprintf(text, sizeof(text), "%s requires a mailbox name",
+		    cmdname);
+		session_reply(s, tag, "BAD", text);
+		return (1);
+	}
+	*p++ = '\0';
+	while (*p == ' ')
+		p++;
+
+	if (strchr(seqtok, ',') != NULL) {
+		session_reply(s, tag, "BAD",
+		    "comma-separated sequence sets not supported in v1 -- "
+		    "issue separate COPY/MOVE commands");
+		return (1);
+	}
+	if (parse_seq_range(seqtok, &lo, &hi, &lo_star, &hi_star) == -1) {
+		session_reply(s, tag, "BAD", "invalid sequence set");
+		return (1);
+	}
+
+	if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
+		return (1);
+	}
+	if (*p != '\0') {
+		session_reply(s, tag, "BAD", "trailing garbage after "
+		    "mailbox name");
+		return (1);
+	}
+
+	if (!mailbox_name_is_inbox(mailbox) && !mailbox_name_valid(mailbox)) {
+		session_reply(s, tag, "BAD", "invalid mailbox name");
+		return (1);
+	}
+
+	if (is_move && s->mbox_readonly) {
+		/*
+		 * MOVE permanently removes the copied messages from the
+		 * source (selected) mailbox -- RFC 9051 SS6.4.8 describes it
+		 * as "COPY, followed by removal of the copied messages",
+		 * exactly the kind of permanent-state change SS6.3.3
+		 * prohibits for an EXAMINE'd mailbox. COPY, by contrast,
+		 * never touches the source mailbox's own state, so it's
+		 * unaffected by mbox_readonly -- only checked for is_move.
+		 */
+		session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
+		    "(selected via EXAMINE)");
+		return (1);
+	}
+
+	if (s->store_iev == NULL) {
+		/* Same internal-invariant check as cmd_select()/cmd_fetch()/
+		 * cmd_store_cmd() -- ST_SELECTED requires store_iev to
+		 * already be wired. */
+		log_warnx("session %u: %s with no store channel wired",
+		    s->id, cmdname);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	memset(&req, 0, sizeof(req));
+	req.by_uid = by_uid;
+	req.seq_lo = lo;
+	req.seq_hi = hi;
+	req.lo_is_star = lo_star;
+	req.hi_is_star = hi_star;
+	strlcpy(req.destname, mailbox, sizeof(req.destname));
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->cmd_by_uid = by_uid;
+	s->cmd_is_move = is_move;
+	s->state = SESSION_COPYING;
+
+	if (imsg_compose(&s->store_iev->ibuf,
+	    is_move ? IMSG_MBOX_MOVE : IMSG_MBOX_COPY, 0, 0, -1, &req,
+	    sizeof(req)) == -1)
+		log_warn("session %u: imsg_compose %s", s->id, cmdname);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+static int
+cmd_copy(struct session *s, const char *tag, char *args)
+{
+	return copy_move_dispatch(s, tag, args, 0, 0);
+}
+
+static int
+cmd_move(struct session *s, const char *tag, char *args)
+{
+	return copy_move_dispatch(s, tag, args, 0, 1);
+}
+
+/*
+ * RFC 9051 SS6.4.9: `uid = "UID" SP (copy / move / fetch / search /
+ * store)`, extended by RFC 4315/UIDPLUS's UID EXPUNGE (folded into base
+ * IMAP4rev2 -- confirmed by grepping this server's own copy of RFC 9051
+ * SS6.4.9, which documents UID EXPUNGE directly, no separate extension
+ * capability needed). All six sub-commands are real as of this pass --
+ * FETCH/STORE/SEARCH/EXPUNGE (fetch_dispatch()/store_do()/search_
+ * dispatch()/uid_expunge_dispatch()) from an earlier pass, and (this
+ * pass) COPY/MOVE too, via the identical copy_move_dispatch() the base
+ * (non-UID) COPY/MOVE commands themselves use, just called here with
+ * by_uid=1 -- same "_dispatch() shared body, by_uid parameter" pattern
+ * as the other four.
+ *
+ * Sub-command name and its own arguments are split the same way parse_
+ * command_line() splits tag/command-name/args at the top level (first
+ * space-delimited token, case-insensitive, rest is that sub-command's own
+ * argument string, NULL if nothing follows) -- UID's sub-command isn't
+ * itself a "tag", so it doesn't reuse that function, but the token-
+ * splitting logic is the same shape.
+ */
+static int
+cmd_uid(struct session *s, const char *tag, char *args)
+{
+	char	*sub, *subargs;
+
+	if (args == NULL) {
+		session_reply(s, tag, "BAD", "UID requires a sub-command");
+		return (1);
+	}
+
+	sub = args;
+	while (*args != '\0' && *args != ' ')
+		args++;
+	if (*args == ' ') {
+		*args++ = '\0';
+		while (*args == ' ')
+			args++;
+		subargs = (*args != '\0') ? args : NULL;
+	} else
+		subargs = NULL;
+
+	if (strcasecmp(sub, "FETCH") == 0)
+		return fetch_dispatch(s, tag, subargs, 1);
+	if (strcasecmp(sub, "STORE") == 0)
+		return store_do(s, tag, subargs, 1);
+	if (strcasecmp(sub, "SEARCH") == 0)
+		return search_dispatch(s, tag, subargs, 1);
+	if (strcasecmp(sub, "EXPUNGE") == 0)
+		return uid_expunge_dispatch(s, tag, subargs);
+	if (strcasecmp(sub, "COPY") == 0)
+		return copy_move_dispatch(s, tag, subargs, 1, 0);
+	if (strcasecmp(sub, "MOVE") == 0)
+		return copy_move_dispatch(s, tag, subargs, 1, 1);
+
+	session_reply(s, tag, "BAD",
+	    "UID sub-command must be COPY, FETCH, MOVE, SEARCH, or STORE");
+	return (1);
+}
+
+/*
+ * The AUTH channel: only IMSG_AUTH_RESULT arrives here (wired once at
+ * boot via peer_fd). On success, kicks off the per-session store spawn.
+ *
+ * Real bug caught writing SELECT's async round trip: imsgev_init() (see
+ * imsgev.c) registers every channel it wraps as plain EV_READ, NOT
+ * EV_READ|EV_PERSIST -- confirmed against the real event.h this pass
+ * ("The additional flag EV_PERSIST makes an event_add() persistent until
+ * event_del() has been called", openbsd_source/src/lib/libevent/event.h),
+ * meaning a one-shot registration fires exactly once and then goes
+ * inactive until something calls event_add() (imsgev_add(), here) again.
+ * This function never did that for iev_auth itself -- session_request_
+ * store()'s imsgev_add() call re-arms iev_parent, a different channel,
+ * on the one path that reaches it. The result: the SECOND
+ * IMSG_AUTH_RESULT ever received on this channel, across the whole
+ * daemon's lifetime (not just per-session -- iev_auth is one shared
+ * channel), would never be seen -- every AUTHENTICATE after the very
+ * first one handled process-wide would silently hang forever. auth.c's
+ * own auth_dispatch() happens to avoid this by always calling
+ * imsgev_add(iev) after composing its reply, which incidentally re-arms
+ * its own read side every time; this function had no equivalent. Fixed
+ * by unconditionally re-arming at the end below, the same fix applied to
+ * listener_dispatch_parent() and parent.c's store_child_dispatch(), the
+ * other two channels with the identical gap.
+ */
+static void
+listener_dispatch_auth(int fd, short event, void *arg)
+{
+	struct imsgev	*iev = arg;
+	struct imsg	 imsg;
+	ssize_t		 n;
+
+	/*
+	 * EV_WRITE: real bug caught on first real-hardware run -- see
+	 * auth.c's auth_dispatch() header comment for the full citation
+	 * (imsg_init(3)'s own EXAMPLES section) and the live symptoms this
+	 * exact gap produced here specifically: IMSG_AUTH_REQUEST queued by
+	 * cmd_authenticate() via imsg_compose() never actually left this
+	 * process, and this function's own unconditional imsgev_add() at
+	 * the bottom kept re-arming EV_WRITE for a write that never
+	 * happened -- a busy loop, confirmed via `ps` showing 25+ minutes
+	 * of CPU time on an otherwise-idle listener process.
+	 */
+	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("auth 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_RESULT: {
+			struct imsg_auth_result	 res;
+			struct session		*s;
+
+			if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+				log_warnx("bad IMSG_AUTH_RESULT");
+				break;
+			}
+			if ((s = session_find(res.session_id)) == NULL) {
+				log_debug("IMSG_AUTH_RESULT for unknown "
+				    "session %u", res.session_id);
+				break;
+			}
+			if (!res.ok) {
+				s->state = SESSION_NOT_AUTH;
+				session_reply(s, s->pending_tag, "NO",
+				    "[AUTHENTICATIONFAILED] authentication failed");
+				break;
+			}
+			session_request_store(s, &res);
+			break;
+		}
+		default:
+			log_debug("listener_dispatch_auth: unhandled %d",
+			    imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	imsgev_add(iev);	/* re-arm -- see this function's header comment */
+	(void)fd;
+}
+
+/*
+ * Rebuilds listener_tls_ctx/listener_tls_config from freshly-received
+ * cert/key bytes, for a live SIGHUP reload -- called from listener_
+ * dispatch_parent()'s IMSG_TLS_CERT/IMSG_TLS_KEY cases below once both
+ * halves of one reload have arrived. Deliberately a separate function
+ * from listener_main()'s boot-time TLS build rather than a shared helper,
+ * even though the tls_config_new()+tls_server()+tls_config_set_ciphers()+
+ * tls_config_set_keypair_mem()+tls_configure() chain is identical (same
+ * sourcing as listener_main()'s own copy, against httpd's server_tls_
+ * init()): boot failure degrades to "no TLS, cleartext still works" (see
+ * listener_main()), but a reload failure must NEVER tear down an already-
+ * working TLS context just because, say, the operator's cron-driven cert
+ * renewal wrote a momentarily-truncated file. Every failure path here
+ * frees only the half-built new pair and returns with listener_tls_ctx/
+ * listener_tls_config completely untouched; a fully-built replacement
+ * pair is only ever swapped in after every step succeeds, and the OLD
+ * pair is freed only once the swap itself is done -- see listener_tls_
+ * ctx's file-scope comment for why the swap itself is safe with respect
+ * to sessions already connected.
+ */
+static void
+listener_reload_tls(const char *cert_buf, size_t cert_len,
+    const char *key_buf, size_t key_len)
+{
+	struct tls		*new_ctx;
+	struct tls_config	*new_config;
+	struct tls		*old_ctx;
+	struct tls_config	*old_config;
+
+	if (cert_len == 0 || key_len == 0) {
+		log_warnx("listener: SIGHUP reload: empty cert or key -- "
+		    "keeping previous TLS configuration");
+		return;
+	}
+
+	if ((new_config = tls_config_new()) == NULL) {
+		log_warnx("listener: SIGHUP reload: tls_config_new failed -- "
+		    "keeping previous TLS configuration");
+		return;
+	}
+	if ((new_ctx = tls_server()) == NULL) {
+		log_warnx("listener: SIGHUP reload: tls_server failed -- "
+		    "keeping previous TLS configuration");
+		tls_config_free(new_config);
+		return;
+	}
+	if (tls_config_set_ciphers(new_config, "secure") != 0) {
+		log_warnx("listener: SIGHUP reload: tls_config_set_ciphers: "
+		    "%s -- keeping previous TLS configuration",
+		    tls_config_error(new_config));
+		tls_free(new_ctx);
+		tls_config_free(new_config);
+		return;
+	}
+	if (tls_config_set_keypair_mem(new_config, (const uint8_t *)cert_buf,
+	    cert_len, (const uint8_t *)key_buf, key_len) != 0) {
+		log_warnx("listener: SIGHUP reload: tls_config_set_keypair_"
+		    "mem: %s -- keeping previous TLS configuration",
+		    tls_config_error(new_config));
+		tls_free(new_ctx);
+		tls_config_free(new_config);
+		return;
+	}
+	if (tls_configure(new_ctx, new_config) != 0) {
+		log_warnx("listener: SIGHUP reload: tls_configure: %s -- "
+		    "keeping previous TLS configuration", tls_error(new_ctx));
+		tls_free(new_ctx);
+		tls_config_free(new_config);
+		return;
+	}
+	tls_config_clear_keys(new_config);
+
+	old_ctx = listener_tls_ctx;
+	old_config = listener_tls_config;
+	listener_tls_ctx = new_ctx;
+	listener_tls_config = new_config;
+	if (old_ctx != NULL)
+		tls_free(old_ctx);
+	if (old_config != NULL)
+		tls_config_free(old_config);
+
+	log_info("listener: SIGHUP reload: TLS configuration reloaded");
+}
+
+/*
+ * The PARENT channel (this process's fd 3, kept alive for its whole
+ * lifetime -- see this file's header comment): IMSG_STORE_FORK arrives
+ * here as parent's *failure* reply to a store-spawn request (see
+ * parent.c's parent_handle_store_fork()/store_child_teardown() "fail:"
+ * paths, which reuse this type for that -- a *successful* peer wire-up
+ * arrives as IMSG_SETUP_PEER instead). IMSG_SETUP_PEER now carries the
+ * requesting session's id in the imsg header's "id" field (see parent.c's
+ * setup_peer_send()), read back here via imsg_get_id() to find the right
+ * session -- this is the fix for the session-demux gap flagged in an
+ * earlier pass.
+ *
+ * IMSG_TLS_CERT/IMSG_TLS_KEY arrive here too now, post-boot, whenever
+ * parent's sighup_handler() re-pushes freshly-read cert/key bytes (see
+ * that function's header comment in parent.c) -- reusing the exact same
+ * two imsg types the boot-time drain loop in listener_main() consumes
+ * once, synchronously, before the event loop even starts. Each is staged
+ * into reload_cert_buf/reload_key_buf independently (they can arrive in
+ * either order, and not necessarily in the same dispatch call -- see
+ * those statics' own comment), and listener_reload_tls() only runs once
+ * both reload_got_cert and reload_got_key are set, after which both flags
+ * reset for the next reload.
+ *
+ * Re-arms iev (imsgev_add()) at the end, same fix and same reason as
+ * listener_dispatch_auth()'s header comment: imsgev_init() registers
+ * plain EV_READ, not EV_PERSIST, and nothing here previously re-armed
+ * this channel's OWN read side after servicing it -- the second
+ * IMSG_STORE_FORK-failure or IMSG_SETUP_PEER, across the daemon's whole
+ * lifetime, would otherwise never have been seen.
+ */
+static void
+listener_dispatch_parent(int fd, short event, void *arg)
+{
+	struct imsgev	*iev = arg;
+	struct imsg	 imsg;
+	ssize_t		 n;
+
+	/* EV_WRITE: same real bug, same fix -- see listener_dispatch_auth()'s
+	 * header comment and auth.c's auth_dispatch() for the full citation. */
+	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("parent 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_STORE_FORK: {
+			struct imsg_store_fork	 fail;
+			struct session		*s;
+
+			if (imsg_get_data(&imsg, &fail, sizeof(fail)) == -1) {
+				log_warnx("bad IMSG_STORE_FORK reply");
+				break;
+			}
+			if ((s = session_find(fail.session_id)) != NULL) {
+				s->state = SESSION_NOT_AUTH;
+				session_reply(s, s->pending_tag, "NO",
+				    "authentication succeeded but mailbox "
+				    "store unavailable");
+			}
+			break;
+		}
+		case IMSG_SETUP_PEER: {
+			uint32_t		 sess_id = imsg_get_id(&imsg);
+			int			 store_fd = imsg_get_fd(&imsg);
+			struct session		*s;
+
+			if (store_fd == -1) {
+				log_warnx("IMSG_SETUP_PEER (store) carried "
+				    "no fd");
+				break;
+			}
+			if ((s = session_find(sess_id)) == NULL) {
+				log_warnx("IMSG_SETUP_PEER (store) for "
+				    "unknown session %u", sess_id);
+				close(store_fd);
+				break;
+			}
+			s->store_iev = calloc(1, sizeof(*s->store_iev));
+			if (s->store_iev == NULL) {
+				log_warn("calloc");
+				close(store_fd);
+				break;
+			}
+			imsgev_init(s->store_iev, store_fd,
+			    session_store_dispatch, s);
+			s->state = SESSION_AUTHENTICATED;
+			log_debug("session %u: store peer wired", sess_id);
+			/* Exact example text from RFC 9051 SS6.2.2's PLAIN
+			 * example ("S: A03 OK Success (tls protection)") --
+			 * accurate here too, since AUTHENTICATE PLAIN is only
+			 * ever reachable once s->tls_active is set (see
+			 * cmd_authenticate()'s TLS gate). */
+			session_reply(s, s->pending_tag, "OK",
+			    "Success (tls protection)");
+			break;
+		}
+		case IMSG_TLS_CERT: {
+			size_t	 len = imsg_get_len(&imsg);
+
+			if (len > sizeof(reload_cert_buf)) {
+				log_warnx("listener: SIGHUP reload: TLS cert "
+				    "too large (%zu > %zu)", len,
+				    sizeof(reload_cert_buf));
+				len = 0;
+			} else if (imsg_get_data(&imsg, reload_cert_buf, len)
+			    == -1) {
+				log_warnx("bad IMSG_TLS_CERT (reload)");
+				len = 0;
+			}
+			reload_cert_len = len;
+			reload_got_cert = 1;
+			if (reload_got_cert && reload_got_key) {
+				listener_reload_tls(reload_cert_buf,
+				    reload_cert_len, reload_key_buf,
+				    reload_key_len);
+				explicit_bzero(reload_key_buf,
+				    sizeof(reload_key_buf));
+				reload_got_cert = reload_got_key = 0;
+			}
+			break;
+		}
+		case IMSG_TLS_KEY: {
+			size_t	 len = imsg_get_len(&imsg);
+
+			if (len > sizeof(reload_key_buf)) {
+				log_warnx("listener: SIGHUP reload: TLS key "
+				    "too large (%zu > %zu)", len,
+				    sizeof(reload_key_buf));
+				len = 0;
+			} else if (imsg_get_data(&imsg, reload_key_buf, len)
+			    == -1) {
+				log_warnx("bad IMSG_TLS_KEY (reload)");
+				len = 0;
+			}
+			reload_key_len = len;
+			reload_got_key = 1;
+			if (reload_got_cert && reload_got_key) {
+				listener_reload_tls(reload_cert_buf,
+				    reload_cert_len, reload_key_buf,
+				    reload_key_len);
+				explicit_bzero(reload_key_buf,
+				    sizeof(reload_key_buf));
+				reload_got_cert = reload_got_key = 0;
+			}
+			break;
+		}
+		default:
+			log_debug("listener_dispatch_parent: unhandled %d",
+			    imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	imsgev_add(iev);	/* re-arm -- see this function's header comment */
+	(void)fd;
+}
+
+/*
+ * The per-session STORE channel, wired once listener_dispatch_parent()'s
+ * IMSG_SETUP_PEER case sets s->store_iev. IMSG_MBOX_SELECTED (see
+ * cmd_select() for the request side, store.c's handle_mbox_select() for
+ * what actually produces this reply) was the first IMSG_MBOX_* reply this
+ * handled for real; IMSG_MBOX_FETCH_META (zero or more, streamed) and the
+ * terminal IMSG_MBOX_RESULT (see cmd_fetch()/store.c's handle_mbox_fetch())
+ * are the second pair. Everything else in the family still has no request
+ * path at all (store.c's own TODO), so nothing else can arrive here yet.
+ *
+ * A lost store channel (read failure, or a clean close -- the child
+ * process exiting or crashing) tears down the whole client session rather
+ * than trying to degrade back to an unselected/unauthenticated state:
+ * v1's simplification, not a claim that a client-visible reconnect-
+ * without-losing-the-TCP-connection story wouldn't be better eventually.
+ *
+ * Re-arms s->store_iev (imsgev_add()) at the end -- see listener_dispatch_
+ * auth()'s header comment for the underlying EV_PERSIST gap this fixes;
+ * this channel has the exact same shape (imsgev_init(), no re-arm on the
+ * read side), and without this a second SELECT or FETCH on the same
+ * session would send its IMSG_MBOX_* request to store just fine but never
+ * see the reply.
+ */
+static void
+session_store_dispatch(int fd, short event, void *arg)
+{
+	struct session	*s = arg;
+	struct imsg	 imsg;
+	ssize_t		 n;
+
+	/*
+	 * EV_WRITE: same real bug as listener_dispatch_auth()/auth.c's
+	 * auth_dispatch() (see those header comments for the full
+	 * citation) -- every IMSG_MBOX_* request this session ever sends
+	 * (SELECT, FETCH, STORE, ...) is queued via imsg_compose() and
+	 * needs an actual imsgbuf_write() once the fd is writable, which
+	 * nothing here ever did before this fix.
+	 */
+	if (event & EV_WRITE) {
+		if (imsgbuf_write(&s->store_iev->ibuf) == -1) {
+			log_warnx("session %u: imsgbuf_write (store)", s->id);
+			session_teardown(s);
+			return;
+		}
+	}
+
+	if (event & EV_READ) {
+		if ((n = imsgbuf_read(&s->store_iev->ibuf)) == -1) {
+			log_warnx("session %u: imsgbuf_read (store)", s->id);
+			session_teardown(s);
+			return;
+		}
+		if (n == 0) {
+			log_warnx("session %u: store closed channel", s->id);
+			session_teardown(s);
+			return;
+		}
+	}
+
+	for (;;) {
+		if ((n = imsg_get(&s->store_iev->ibuf, &imsg)) == -1) {
+			log_warnx("session %u: imsg_get (store)", s->id);
+			session_teardown(s);
+			return;
+		}
+		if (n == 0)
+			break;
+
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_MBOX_SELECTED: {
+			struct imsg_mbox_selected	 res;
+
+			if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+				log_warnx("bad IMSG_MBOX_SELECTED");
+				break;
+			}
+			session_handle_mbox_selected(s, &res);
+			break;
+		}
+		case IMSG_MBOX_STATUS_RESULT: {
+			struct imsg_mbox_status_result	 res;
+
+			if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+				log_warnx("bad IMSG_MBOX_STATUS_RESULT");
+				break;
+			}
+			session_handle_mbox_status_result(s, &res);
+			break;
+		}
+		case IMSG_MBOX_FETCH_HEADER: {
+			struct imsg_mbox_fetch_header	 hdr;
+			size_t				 hdrlen;
+
+			/*
+			 * Same fixed-header-plus-variable-trailing-bytes
+			 * technique as IMSG_MBOX_APPEND's read side in
+			 * store.c's handle_mbox_append() case (imsg_get_buf()
+			 * for the fixed part, then imsg_get_len()/
+			 * imsg_get_buf() for whatever remains) -- just read
+			 * here instead of there, since this message travels
+			 * store -> listener rather than the other way.
+			 */
+			if (imsg_get_buf(&imsg, &hdr, sizeof(hdr)) == -1) {
+				log_warnx("bad IMSG_MBOX_FETCH_HEADER (header)");
+				break;
+			}
+			free(s->pending_header_buf);
+			s->pending_header_buf = NULL;
+			s->pending_header_len = 0;
+			s->pending_header_found = 0;
+
+			hdrlen = imsg_get_len(&imsg);
+			if (!hdr.found)
+				break;	/* store.c found nothing for this
+					 * message -- nothing more to read. */
+			if (hdrlen != hdr.hdrlen) {
+				log_warnx("session %u: IMSG_MBOX_FETCH_HEADER "
+				    "length mismatch (header says %u, imsg "
+				    "has %zu)", s->id, hdr.hdrlen, hdrlen);
+				break;
+			}
+			if (hdrlen == 0) {
+				/* found but empty is a legitimate, if odd,
+				 * message (a header-less file) --
+				 * pending_header_found alone is enough for
+				 * session_send_fetch_response() to emit a
+				 * zero-length BODY[HEADER] {0} literal. */
+				s->pending_header_found = 1;
+				break;
+			}
+			if ((s->pending_header_buf = malloc(hdrlen)) == NULL) {
+				log_warn("session %u: malloc pending header "
+				    "buffer", s->id);
+				break;
+			}
+			if (imsg_get_buf(&imsg, s->pending_header_buf, hdrlen)
+			    == -1) {
+				log_warnx("bad IMSG_MBOX_FETCH_HEADER (body)");
+				free(s->pending_header_buf);
+				s->pending_header_buf = NULL;
+				break;
+			}
+			s->pending_header_len = (uint32_t)hdrlen;
+			s->pending_header_found = 1;
+			break;
+		}
+		case IMSG_MBOX_FETCH_BODY: {
+			struct imsg_mbox_fetch_body	 bodyhdr;
+			size_t				 bodylen;
+
+			/* Same technique as IMSG_MBOX_FETCH_HEADER just
+			 * above -- see that case's comment. */
+			if (imsg_get_buf(&imsg, &bodyhdr, sizeof(bodyhdr)) ==
+			    -1) {
+				log_warnx("bad IMSG_MBOX_FETCH_BODY (header)");
+				break;
+			}
+			free(s->pending_body_buf);
+			s->pending_body_buf = NULL;
+			s->pending_body_len = 0;
+			s->pending_body_found = 0;
+			/* pending_body_label/has_partial/partial_origin are
+			 * NOT reset here -- like pending_header_label, they're
+			 * set once in fetch_dispatch() for the whole FETCH,
+			 * not per message; bodyhdr.is_text (struct imsg_mbox_
+			 * fetch_body) is redundant with what fetch_dispatch()
+			 * already knows it asked for and is intentionally
+			 * unused here. */
+
+			bodylen = imsg_get_len(&imsg);
+			if (!bodyhdr.found)
+				break;	/* store.c found nothing for this
+					 * message -- nothing more to read. */
+			if (bodylen != bodyhdr.bodylen) {
+				log_warnx("session %u: IMSG_MBOX_FETCH_BODY "
+				    "length mismatch (header says %u, imsg "
+				    "has %zu)", s->id, bodyhdr.bodylen,
+				    bodylen);
+				break;
+			}
+			if (bodylen == 0) {
+				/* found but empty is legitimate (a
+				 * zero-length message, or BODY.PEEK[TEXT] on
+				 * a message that's all header) --
+				 * pending_body_found alone is enough for
+				 * session_send_fetch_response() to emit a
+				 * zero-length literal. */
+				s->pending_body_found = 1;
+				break;
+			}
+			if ((s->pending_body_buf = malloc(bodylen)) == NULL) {
+				log_warn("session %u: malloc pending body "
+				    "buffer", s->id);
+				break;
+			}
+			if (imsg_get_buf(&imsg, s->pending_body_buf, bodylen)
+			    == -1) {
+				log_warnx("bad IMSG_MBOX_FETCH_BODY (body)");
+				free(s->pending_body_buf);
+				s->pending_body_buf = NULL;
+				break;
+			}
+			s->pending_body_len = (uint32_t)bodylen;
+			s->pending_body_found = 1;
+			break;
+		}
+		case IMSG_MBOX_FETCH_ENVELOPE: {
+			struct imsg_mbox_fetch_envelope	 envhdr;
+			size_t					 envlen;
+
+			/* Same technique as IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_
+			 * FETCH_BODY above -- see IMSG_MBOX_FETCH_HEADER's
+			 * comment. */
+			if (imsg_get_buf(&imsg, &envhdr, sizeof(envhdr)) ==
+			    -1) {
+				log_warnx("bad IMSG_MBOX_FETCH_ENVELOPE (header)");
+				break;
+			}
+			free(s->pending_envelope_buf);
+			s->pending_envelope_buf = NULL;
+			s->pending_envelope_len = 0;
+			s->pending_envelope_found = 0;
+
+			envlen = imsg_get_len(&imsg);
+			if (!envhdr.found)
+				break;	/* store.c couldn't build an envelope
+					 * for this message -- nothing more to
+					 * read. */
+			if (envlen != envhdr.envlen) {
+				log_warnx("session %u: IMSG_MBOX_FETCH_ENVELOPE "
+				    "length mismatch (header says %u, imsg "
+				    "has %zu)", s->id, envhdr.envlen, envlen);
+				break;
+			}
+			if (envlen == 0) {
+				/* build_envelope() always emits at least
+				 * "(NIL NIL NIL NIL NIL NIL NIL NIL NIL NIL)"
+				 * -- a zero-length result should never
+				 * actually happen, but handled the same
+				 * defensive way as the header/body cases
+				 * above rather than assumed impossible. */
+				s->pending_envelope_found = 1;
+				break;
+			}
+			if ((s->pending_envelope_buf = malloc(envlen)) == NULL) {
+				log_warn("session %u: malloc pending envelope "
+				    "buffer", s->id);
+				break;
+			}
+			if (imsg_get_buf(&imsg, s->pending_envelope_buf, envlen)
+			    == -1) {
+				log_warnx("bad IMSG_MBOX_FETCH_ENVELOPE (body)");
+				free(s->pending_envelope_buf);
+				s->pending_envelope_buf = NULL;
+				break;
+			}
+			s->pending_envelope_len = (uint32_t)envlen;
+			s->pending_envelope_found = 1;
+			break;
+		}
+		case IMSG_MBOX_FETCH_BODYSTRUCTURE: {
+			struct imsg_mbox_fetch_bodystructure	 bshdr;
+			size_t					 bslen;
+
+			/* Same technique as IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_
+			 * FETCH_ENVELOPE above -- see IMSG_MBOX_FETCH_HEADER's
+			 * comment. */
+			if (imsg_get_buf(&imsg, &bshdr, sizeof(bshdr)) ==
+			    -1) {
+				log_warnx("bad IMSG_MBOX_FETCH_BODYSTRUCTURE "
+				    "(header)");
+				break;
+			}
+			free(s->pending_bodystructure_buf);
+			s->pending_bodystructure_buf = NULL;
+			s->pending_bodystructure_len = 0;
+			s->pending_bodystructure_found = 0;
+
+			bslen = imsg_get_len(&imsg);
+			if (!bshdr.found)
+				break;	/* store.c couldn't build a
+					 * BODYSTRUCTURE for this message --
+					 * nothing more to read. */
+			if (bslen != bshdr.bslen) {
+				log_warnx("session %u: "
+				    "IMSG_MBOX_FETCH_BODYSTRUCTURE length "
+				    "mismatch (header says %u, imsg has %zu)",
+				    s->id, bshdr.bslen, bslen);
+				break;
+			}
+			if (bslen == 0) {
+				/* build_bodystructure() always emits at
+				 * least a minimal single-part structure --
+				 * zero-length should never actually happen,
+				 * handled defensively anyway, same as the
+				 * header/body/envelope cases above. */
+				s->pending_bodystructure_found = 1;
+				break;
+			}
+			if ((s->pending_bodystructure_buf = malloc(bslen)) ==
+			    NULL) {
+				log_warn("session %u: malloc pending "
+				    "bodystructure buffer", s->id);
+				break;
+			}
+			if (imsg_get_buf(&imsg, s->pending_bodystructure_buf,
+			    bslen) == -1) {
+				log_warnx("bad IMSG_MBOX_FETCH_BODYSTRUCTURE "
+				    "(body)");
+				free(s->pending_bodystructure_buf);
+				s->pending_bodystructure_buf = NULL;
+				break;
+			}
+			s->pending_bodystructure_len = (uint32_t)bslen;
+			s->pending_bodystructure_found = 1;
+			break;
+		}
+		case IMSG_MBOX_FETCH_META: {
+			struct imsg_mbox_fetch_meta	 meta;
+
+			if (imsg_get_data(&imsg, &meta, sizeof(meta)) == -1) {
+				log_warnx("bad IMSG_MBOX_FETCH_META");
+				break;
+			}
+			/* Shared reply type for FETCH, STORE, and (RFC 7162
+			 * addition this pass) a QRESYNC SELECT resync (see
+			 * imapd.h's imsg_mbox_store/imsg_mbox_selected
+			 * comments) -- s->state says which request is
+			 * actually in flight right now, and therefore which
+			 * formatter applies. The SELECTING case is buffered
+			 * (session_handle_select_fetch()), not formatted
+			 * immediately, per RFC 7162 SS3.2.6's VANISHED-before-
+			 * FETCH ordering requirement -- see that function's
+			 * comment. */
+			if (s->state == SESSION_SELECTING)
+				session_handle_select_fetch(s, &meta);
+			else if (s->state == SESSION_STORING)
+				session_send_store_fetch_response(s, &meta);
+			else
+				session_send_fetch_response(s, &meta);
+			break;
+		}
+		case IMSG_MBOX_EXPUNGED: {
+			struct imsg_mbox_expunged	 exp;
+
+			if (imsg_get_data(&imsg, &exp, sizeof(exp)) == -1) {
+				log_warnx("bad IMSG_MBOX_EXPUNGED");
+				break;
+			}
+			/*
+			 * A real EXPUNGE/CLOSE writes each one to the client
+			 * immediately, as it always has. During a MOVE
+			 * (s->state == SESSION_COPYING), handle_mbox_move()
+			 * sends this same message type for each moved
+			 * message's old UID/seqno -- but SS6.4.8 requires
+			 * COPYUID to precede any EXPUNGE/VANISHED for the
+			 * same operation, so these get buffered instead and
+			 * flushed (after COPYUID) only once IMSG_MBOX_RESULT
+			 * arrives -- see s->move_expunged's comment and
+			 * session_finish_copy_or_move().
+			 */
+			if (s->state == SESSION_COPYING) {
+				if (s->move_expunged_n == s->move_expunged_cap) {
+					uint32_t	 newcap =
+					    s->move_expunged_cap ?
+					    s->move_expunged_cap * 2 : 16;
+					struct imsg_mbox_expunged *enew =
+					    reallocarray(s->move_expunged,
+					    newcap, sizeof(*enew));
+
+					if (enew == NULL) {
+						log_warn("session %u: "
+						    "realloc move_expunged",
+						    s->id);
+						break;
+					}
+					s->move_expunged = enew;
+					s->move_expunged_cap = newcap;
+				}
+				s->move_expunged[s->move_expunged_n++] = exp;
+			} else
+				session_send_expunge_response(s, &exp);
+			break;
+		}
+		case IMSG_MBOX_COPY_MAPPING: {
+			struct imsg_mbox_copy_mapping	 m;
+
+			if (imsg_get_data(&imsg, &m, sizeof(m)) == -1) {
+				log_warnx("bad IMSG_MBOX_COPY_MAPPING");
+				break;
+			}
+			session_handle_mbox_copy_mapping(s, &m);
+			break;
+		}
+		case IMSG_MBOX_SEARCH_MATCH: {
+			struct imsg_mbox_search_match	 m;
+
+			if (imsg_get_data(&imsg, &m, sizeof(m)) == -1) {
+				log_warnx("bad IMSG_MBOX_SEARCH_MATCH");
+				break;
+			}
+			session_handle_mbox_search_match(s, &m);
+			break;
+		}
+		case IMSG_MBOX_SELECT_VANISHED: {
+			struct imsg_mbox_select_vanished	 v;
+
+			if (imsg_get_data(&imsg, &v, sizeof(v)) == -1) {
+				log_warnx("bad IMSG_MBOX_SELECT_VANISHED");
+				break;
+			}
+			/* Despite the name, also reused for RFC 7162 SS3.2.6's
+			 * VANISHED UID FETCH modifier this pass -- see
+			 * imapd.h's imsg_mbox_select_vanished comment.
+			 * SESSION_SELECTING buffers (ordering against EXISTS/
+			 * the resync FETCH data); SESSION_FETCHING writes
+			 * immediately, since a plain UID FETCH has only the
+			 * "VANISHED before any FETCH response" ordering
+			 * requirement, already satisfied by store.c's own
+			 * send order (see handle_mbox_fetch()'s comment). */
+			if (s->state == SESSION_SELECTING)
+				session_handle_select_vanished(s, &v);
+			else
+				session_handle_fetch_vanished(s, &v);
+			break;
+		}
+		case IMSG_MBOX_STORE_MODIFIED: {
+			struct imsg_mbox_store_modified	 m;
+
+			if (imsg_get_data(&imsg, &m, sizeof(m)) == -1) {
+				log_warnx("bad IMSG_MBOX_STORE_MODIFIED");
+				break;
+			}
+			session_handle_store_modified(s, &m);
+			break;
+		}
+		case IMSG_MBOX_IDLE_UID: {
+			struct imsg_mbox_idle_uid	 item;
+
+			if (imsg_get_data(&imsg, &item, sizeof(item)) == -1) {
+				log_warnx("bad IMSG_MBOX_IDLE_UID");
+				break;
+			}
+			session_handle_idle_uid(s, &item);
+			break;
+		}
+		case IMSG_MBOX_IDLE_REFRESHED: {
+			struct imsg_mbox_idle_refreshed	 res;
+
+			if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+				log_warnx("bad IMSG_MBOX_IDLE_REFRESHED");
+				break;
+			}
+			session_handle_idle_refreshed(s, &res);
+			break;
+		}
+		case IMSG_MBOX_RESULT: {
+			struct imsg_mbox_result	 res;
+
+			if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+				log_warnx("bad IMSG_MBOX_RESULT");
+				break;
+			}
+			session_handle_mbox_result(s, &res);
+			break;
+		}
+		case IMSG_MBOX_LIST_ITEM: {
+			struct imsg_mbox_list_item	 item;
+
+			if (imsg_get_data(&imsg, &item, sizeof(item)) == -1) {
+				log_warnx("bad IMSG_MBOX_LIST_ITEM");
+				break;
+			}
+			session_handle_mbox_list_item(s, &item);
+			break;
+		}
+		case IMSG_MBOX_APPENDED: {
+			struct imsg_mbox_appended	 res;
+
+			if (imsg_get_data(&imsg, &res, sizeof(res)) == -1) {
+				log_warnx("bad IMSG_MBOX_APPENDED");
+				break;
+			}
+			session_handle_mbox_appended(s, &res);
+			break;
+		}
+		default:
+			log_debug("session %u: store channel: unhandled %d",
+			    s->id, imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	imsgev_add(s->store_iev);	/* re-arm -- see this function's
+					 * header comment */
+	(void)fd;
+}
+
+/*
+ * Finishes the SELECT round trip cmd_select() started: builds and sends
+ * the RFC 9051 SS6.3.2-required response sequence (or the NO failure
+ * case) using s->pending_tag, and transitions session state.
+ *
+ * RFC 7162 additions this pass: s->mbox_highestmodseq is cached from
+ * res->highestmodseq unconditionally (store.c always populates it -- see
+ * imapd.h's imsg_mbox_selected comment), for session_condstore_
+ * enable()'s later use regardless of whether *this* session is CONDSTORE-
+ * aware yet. If it is (s->condstore_enabled, set synchronously by
+ * cmd_select() when it saw a CONDSTORE or QRESYNC select-param -- see that
+ * function's comment for why it doesn't go through session_condstore_
+ * enable() itself), an OK [HIGHESTMODSEQ] response is included -- NOMODSEQ
+ * is never emitted, since v1's only mailbox always supports persistent
+ * mod-sequence storage (see struct mbox_index's comment in store.c).
+ * Finally, any accumulated QRESYNC resync data (s->vanished_ranges/s->
+ * qresync_fetches, populated by session_handle_select_vanished()/session_
+ * handle_select_fetch() while this SELECT was in flight) is flushed in the
+ * RFC-required order: VANISHED (EARLIER) first, then the resync FETCH
+ * responses, then the tagged OK -- see those two fields' comments in
+ * struct session for why this can't be streamed live instead.
+ */
+static void
+session_handle_mbox_selected(struct session *s, struct imsg_mbox_selected *res)
+{
+	char	buf[128];
+
+	if (!res->ok) {
+		/* RFC 9051 SS6.3.2 Result: "NO - select failure, now in
+		 * authenticated state: no such mailbox, can't access
+		 * mailbox". v1's only failure mode is "not INBOX" (see
+		 * handle_mbox_select() in store.c), which maps directly to
+		 * "no such mailbox" -- RFC 5530 NONEXISTENT's own worked
+		 * example is literally that same text. */
+		s->state = SESSION_AUTHENTICATED;
+		session_reply(s, s->pending_tag, "NO",
+		    "[NONEXISTENT] no such mailbox");
+		return;
+	}
+
+	s->state = SESSION_SELECTED;
+	s->mbox_highestmodseq = res->highestmodseq;
+
+	/*
+	 * Order matches RFC 9051 SS6.3.2's own worked example (EXISTS,
+	 * OK[UIDVALIDITY], OK[UIDNEXT], FLAGS, OK[PERMANENTFLAGS], LIST),
+	 * though that section explicitly says response order isn't
+	 * significant.
+	 */
+	snprintf(buf, sizeof(buf), "%u EXISTS", res->exists);
+	session_untagged(s, buf);
+
+	snprintf(buf, sizeof(buf), "OK [UIDVALIDITY %u] UIDs valid",
+	    res->uidvalidity);
+	session_untagged(s, buf);
+
+	snprintf(buf, sizeof(buf), "OK [UIDNEXT %u] Predicted next UID",
+	    res->uidnext);
+	session_untagged(s, buf);
+
+	if (s->condstore_enabled) {
+		snprintf(buf, sizeof(buf), "OK [HIGHESTMODSEQ %llu]",
+		    (unsigned long long)res->highestmodseq);
+		session_untagged(s, buf);
+	}
+
+	/*
+	 * Standard flags, RFC 9051 SS6.3.2's own example list verbatim --
+	 * matches the five spec-defined maildir flag-suffix letters
+	 * (D/R/S/T/F) openimap-storage-backend.md sourced against
+	 * Courier's maildir(5). FLAGS itself is unaffected by read-only:
+	 * it just reports what flags exist in the mailbox, not what this
+	 * session may change.
+	 *
+	 * PERMANENTFLAGS differs for EXAMINE: SS6.3.3's own worked example
+	 * shows "OK [PERMANENTFLAGS ()] No permanent flags permitted" for
+	 * an EXAMINE'd mailbox, matching that section's "No changes to the
+	 * permanent state of the mailbox... are permitted" text. For a
+	 * plain SELECT, PERMANENTFLAGS adds "\*" per SS6.3.2's own example:
+	 * the index format supports arbitrary per-message keywords, so the
+	 * client is allowed to create new ones.
+	 */
+	session_untagged(s,
+	    "FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)");
+	if (s->mbox_readonly)
+		session_untagged(s,
+		    "OK [PERMANENTFLAGS ()] No permanent flags permitted");
+	else
+		session_untagged(s,
+		    "OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen "
+		    "\\Draft \\*)] System flags and keywords allowed");
+
+	/*
+	 * RFC 9051 SS6.3.2: "The server MUST return a LIST response with
+	 * the mailbox name." "/" as the hierarchy delimiter -- a resolved
+	 * project decision as of a later pass (openimap-storage-backend.md,
+	 * "Open items carried from this session" #9; see also cmd_
+	 * namespace()'s NAMESPACE response, which reports the identical
+	 * delimiter). "()" -- no attributes -- since no mailbox here can
+	 * ever have children (v1 is flat) and this LIST response only fires
+	 * once SELECT/EXAMINE has already succeeded, i.e. the mailbox is
+	 * selectable by construction.
+	 *
+	 * Real-hardware bug found testing RFC 9051 SS6.3.4-SS6.3.6 multi-
+	 * mailbox support (docs/openimap-storage-backend.md item 10) on
+	 * premio: this used to be the fixed literal "INBOX" unconditionally,
+	 * correct back when INBOX was the only mailbox that could ever be
+	 * selected. s->selected_mailbox (set by select_or_examine() at
+	 * dispatch time -- see that field's own comment) now names whatever
+	 * was actually just selected; quoted, not bare, since an arbitrary
+	 * flat mailbox name can contain a space (same reasoning as session_
+	 * handle_mbox_status_result()'s identical choice) -- INBOX itself is
+	 * still safe unquoted, but there's no need for a special case since
+	 * a quoted "INBOX" is exactly as valid IMAP as a bare one.
+	 */
+	{
+		char	listbuf[MBOX_NAME_MAX + 32];
+
+		snprintf(listbuf, sizeof(listbuf), "LIST () \"/\" \"%s\"",
+		    s->selected_mailbox);
+		session_untagged(s, listbuf);
+	}
+
+	if (s->vanished_nranges > 0) {
+		char	vbuf[8192];
+		char	full[8192 + 32];
+		int	truncated;
+		size_t	vlen;
+		int	flen;
+
+		/*
+		 * session_untagged()'s own 512-byte buffer is too small for
+		 * a potentially large VANISHED list -- same bypass session_
+		 * finish_search() already uses for ESEARCH, and for the same
+		 * reason (truncating range data would be materially wrong,
+		 * not cosmetic). Formats "* VANISHED (EARLIER) <ranges>\r\n"
+		 * directly, not through the small buf[] above.
+		 */
+		vlen = format_range_list(vbuf, sizeof(vbuf),
+		    s->vanished_ranges, s->vanished_nranges, &truncated);
+		if (truncated)
+			log_warnx("session %u: VANISHED (EARLIER) list "
+			    "truncated at %zu bytes", s->id, vlen);
+
+		flen = snprintf(full, sizeof(full), "* VANISHED (EARLIER) %s\r\n",
+		    vbuf);
+		if (flen > 0)
+			session_write(s, full, (size_t)flen >= sizeof(full) ?
+			    sizeof(full) - 1 : (size_t)flen);
+	}
+	free(s->vanished_ranges);
+	s->vanished_ranges = NULL;
+	s->vanished_nranges = 0;
+	s->vanished_cap = 0;
+
+	{
+		uint32_t	i;
+
+		for (i = 0; i < s->qresync_nfetches; i++)
+			session_send_qresync_fetch_response(s,
+			    &s->qresync_fetches[i]);
+	}
+	free(s->qresync_fetches);
+	s->qresync_fetches = NULL;
+	s->qresync_nfetches = 0;
+	s->qresync_fetches_cap = 0;
+
+	/*
+	 * RFC 9051 SS6.3.2: "the server SHOULD prefix the text of the
+	 * tagged OK response with the '[READ-WRITE]' response code" for
+	 * SELECT. SS6.3.3: the tagged OK for EXAMINE "MUST begin with the
+	 * '[READ-ONLY]' response code" -- s->mbox_readonly is a reliable
+	 * proxy for "this was EXAMINE, not SELECT" since v1 has no other
+	 * way for a mailbox to end up read-only (see struct session's
+	 * mbox_readonly comment).
+	 */
+	if (s->mbox_readonly)
+		session_reply(s, s->pending_tag, "OK",
+		    "[READ-ONLY] EXAMINE completed");
+	else
+		session_reply(s, s->pending_tag, "OK",
+		    "[READ-WRITE] SELECT completed");
+}
+
+/*
+ * Finishes the STATUS round trip cmd_status() started: formats the
+ * untagged "* STATUS mailbox (...)" response using s->status_attrs to
+ * decide which of store.c's always-populated fields to include, restores
+ * s->state to s->status_prev_state (STATUS never changes SELECTED-ness --
+ * RFC 9051 SS6.3.11), and sends the tagged completion.
+ *
+ * Attribute order in the response is a fixed canonical order (MESSAGES,
+ * UIDNEXT, UIDVALIDITY, UNSEEN, DELETED, SIZE, HIGHESTMODSEQ), not the
+ * client's own request order -- directly sourced from RFC 9051 SS6.3.11's
+ * own worked example: "C: A042 STATUS blurdybloop (UIDNEXT MESSAGES)"
+ * answered "S: * STATUS blurdybloop (MESSAGES 231 UIDNEXT 44292)" -- the
+ * server reordered UIDNEXT/MESSAGES from the client's own request order,
+ * so there's no conformance reason to preserve request order here either.
+ */
+static void
+session_handle_mbox_status_result(struct session *s,
+    struct imsg_mbox_status_result *res)
+{
+	char	buf[MBOX_NAME_MAX + 256];	/* F2 fix: was buf[256], too small
+					 * for a near-maximal mailbox name plus
+					 * the STATUS attrs. */
+	size_t	len;
+	int	n, first = 1;
+
+	s->state = s->status_prev_state;
+
+	if (!res->ok) {
+		/* cmd_status() already ruled out a malformed name -- what's
+		 * left is either a syntactically valid name that doesn't
+		 * exist on disk (handle_mbox_status()'s select_mailbox_dir()
+		 * failing, RFC 9051 SS6.3.4-SS6.3.6 multi-mailbox support) or
+		 * store.c's own open/flock/index_load failing the same way
+		 * handle_mbox_select() can. v1 has no way to tell those two
+		 * apart from here (struct imsg_mbox_status_result carries no
+		 * distinguishing detail, unlike IMSG_MBOX_APPENDED's no_such_
+		 * mailbox flag) -- NONEXISTENT is still the more informative
+		 * answer in the common case, so it's used for both, same
+		 * "one plausible RFC 5530 code covers the real failure modes"
+		 * judgment call session_handle_mbox_selected() already makes
+		 * for SELECT's identical ambiguity. */
+		session_reply(s, s->pending_tag, "NO",
+		    "[NONEXISTENT] no such mailbox");
+		return;
+	}
+
+	/*
+	 * Quoted, not bare -- unlike the fixed literal "INBOX" this response
+	 * used to always echo, an arbitrary flat mailbox name can contain a
+	 * space (mailbox_name_valid() only refuses "/", CTL bytes, and the
+	 * three reserved maildir-internal names), which would otherwise
+	 * desynchronize a naive client's own response parser. No backslash-
+	 * escaping of an embedded '"' or '\' within the name itself -- same
+	 * "not full ABNF/quoted-string conformance" simplification parse_
+	 * list_token()'s own comment already documents for the parsing
+	 * direction; a name containing either is an accepted, pre-existing
+	 * gap, not new here.
+	 */
+	len = (size_t)snprintf(buf, sizeof(buf), "STATUS \"%s\" (",
+	    s->status_mailbox);
+
+#define STATUS_APPEND(fmt, val) do {					\
+	if (len < sizeof(buf)) {	/* F2 fix: never index past buf */	\
+		n = snprintf(buf + len, sizeof(buf) - len, "%s" fmt,	\
+		    first ? "" : " ", (val));				\
+		if (n > 0 && (size_t)n < sizeof(buf) - len)		\
+			len += (size_t)n;				\
+		first = 0;						\
+	}								\
+} while (0)
+
+	if (s->status_attrs & STATUS_ATT_MESSAGES)
+		STATUS_APPEND("MESSAGES %u", res->messages);
+	if (s->status_attrs & STATUS_ATT_UIDNEXT)
+		STATUS_APPEND("UIDNEXT %u", res->uidnext);
+	if (s->status_attrs & STATUS_ATT_UIDVALIDITY)
+		STATUS_APPEND("UIDVALIDITY %u", res->uidvalidity);
+	if (s->status_attrs & STATUS_ATT_UNSEEN)
+		STATUS_APPEND("UNSEEN %u", res->unseen);
+	if (s->status_attrs & STATUS_ATT_DELETED)
+		STATUS_APPEND("DELETED %u", res->deleted);
+	if (s->status_attrs & STATUS_ATT_SIZE)
+		STATUS_APPEND("SIZE %llu", (unsigned long long)res->size);
+	if (s->status_attrs & STATUS_ATT_HIGHESTMODSEQ)
+		STATUS_APPEND("HIGHESTMODSEQ %llu",
+		    (unsigned long long)res->highestmodseq);
+
+#undef STATUS_APPEND
+
+	if (len < sizeof(buf) - 1) {
+		buf[len++] = ')';
+		buf[len] = '\0';
+	}
+
+	session_untagged(s, buf);
+	session_reply(s, s->pending_tag, "OK", "STATUS completed");
+}
+
+/*
+ * Terminal reply shared by CREATE/DELETE/RENAME (cmd_create()/cmd_delete()/
+ * cmd_rename()): all three send exactly one struct imsg_mbox_result back,
+ * with only "ok" meaningful (see imapd.h's imsg_mbox_create/imsg_mbox_
+ * delete/imsg_mbox_rename comments) -- "count" is always 0, unlike LIST
+ * there's no per-item stream preceding this terminal reply. Which of the
+ * three commands was actually in flight is read from s->state before it's
+ * overwritten, purely to pick the right command name for the tagged
+ * completion/failure text -- same "read s->state before restoring it"
+ * pattern session_handle_mbox_result() itself already uses for STORING/
+ * EXPUNGING/FETCHING. s->mbox_op_prev_state (whichever ST_AUTH state was
+ * current before the round trip -- all three are command-auth and none
+ * changes SELECTED-ness, RFC 9051 SS6.3.4-SS6.3.6) is restored
+ * unconditionally, success or failure, same as s->status_prev_state's own
+ * restore in session_handle_mbox_status_result() above.
+ */
+static void
+session_finish_mbox_op(struct session *s, struct imsg_mbox_result *res)
+{
+	const char	*cmdname;
+	char		 text[64];
+
+	if (s->state == SESSION_CREATING)
+		cmdname = "CREATE";
+	else if (s->state == SESSION_DELETING)
+		cmdname = "DELETE";
+	else
+		cmdname = "RENAME";
+
+	s->state = s->mbox_op_prev_state;
+
+	if (!res->ok) {
+		/* store.c's own failure modes: CREATE's mkdir(2)/
+		 * ensure_maildir_dirs() failing for a reason other than the
+		 * name already existing (already ruled out client-side by
+		 * mailbox_name_is_inbox()/mailbox_name_valid(), and handled
+		 * as its own EEXIST case -- see handle_mbox_create()), or
+		 * DELETE/RENAME losing a race against the name's own
+		 * existence between listener.c's client-side check and
+		 * store.c's own stat(2) -- no RFC 5530 code fits either
+		 * cleanly, so a plain NO is the honest answer, same as
+		 * session_handle_mbox_result()'s own FETCH/STORE/EXPUNGE
+		 * failure path. */
+		snprintf(text, sizeof(text), "%s failed", cmdname);
+		session_reply(s, s->pending_tag, "NO", text);
+		return;
+	}
+
+	/*
+	 * Real-hardware bug found testing RENAME on premio: if this session
+	 * had the just-renamed mailbox itself SELECTed, s->selected_mailbox
+	 * (consulted by session_handle_mbox_appended()'s own-mailbox check
+	 * and echoed in SELECT's/EXAMINE's own untagged LIST response) still
+	 * held the old name -- store.c's handle_mbox_rename() already makes
+	 * its own cwd/current_mailbox_dir follow the rename (see that
+	 * function's comment), so listener.c needs the identical fix on its
+	 * side of the same problem, or the two would silently disagree about
+	 * what this session actually has selected. strcmp(cmdname, "RENAME")
+	 * rather than checking s->state directly since that's already been
+	 * overwritten (restored to s->mbox_op_prev_state) a few lines above.
+	 */
+	if (strcmp(cmdname, "RENAME") == 0 &&
+	    strcmp(s->selected_mailbox, s->rename_oldname) == 0)
+		strlcpy(s->selected_mailbox, s->rename_newname,
+		    sizeof(s->selected_mailbox));
+
+	snprintf(text, sizeof(text), "%s completed", cmdname);
+	session_reply(s, s->pending_tag, "OK", text);
+}
+
+/*
+ * One item of the IMSG_MBOX_LIST_ITEM stream store.c's handle_mbox_list()
+ * sends during a SESSION_LISTING round trip (list_dispatch()'s own tail,
+ * once its synchronous empty-pattern/INBOX special cases are past) --
+ * tested immediately against s->list_pattern (the same canonical
+ * reference-plus-pattern concatenation list_dispatch() already used for
+ * the synchronous INBOX check) via list_pattern_match(), case-sensitively
+ * (ci=0 -- see that function's own comment on why INBOX alone gets ci=1),
+ * and turned into its own untagged LIST/LSUB response immediately if it
+ * matches. Streamed straight through rather than buffered first the way
+ * s->search_matches accumulates SEARCH's results -- there's no sorting,
+ * deduplication, or MIN/MAX/COUNT-style aggregation LIST needs to do
+ * across the whole result set, so nothing is gained by waiting.
+ */
+static void
+session_handle_mbox_list_item(struct session *s,
+    struct imsg_mbox_list_item *item)
+{
+	char		 buf[(2 * MBOX_NAME_MAX) + 32];
+	const char	*kw = s->list_is_lsub ? "LSUB" : "LIST";
+
+	if (!list_pattern_match(s->list_pattern, item->mailbox, 0))
+		return;
+
+	/* Quoted, not bare -- same "an arbitrary flat mailbox name can
+	 * contain a space" reasoning as session_handle_mbox_status_
+	 * result()'s identical choice; the fixed literal "INBOX" is safe
+	 * unquoted (see list_dispatch()'s own untagged response for it) but
+	 * a real, named mailbox isn't. "()" -- no attributes -- same
+	 * SS7.3.1 "MAY send none of these" choice used throughout. */
+	snprintf(buf, sizeof(buf), "%s () \"/\" \"%s\"", kw, item->mailbox);
+	session_untagged(s, buf);
+}
+
+/*
+ * Terminal reply for the SESSION_LISTING round trip list_dispatch()
+ * started: store.c's handle_mbox_list() streams zero or more IMSG_MBOX_
+ * LIST_ITEM messages (each already turned into its own untagged response,
+ * if it matched, by session_handle_mbox_list_item() above) followed by
+ * exactly one terminal IMSG_MBOX_RESULT. res->count (how many items were
+ * streamed, matched or not) isn't needed here -- only res->ok, and even
+ * that can only be 0 from a real I/O error opening this session's own
+ * maildir root (handle_mbox_list()'s own comment), not from anything
+ * pattern- or mailbox-name-related; SS6.3.9's "silently ignore" rule for
+ * an unmatched pattern is already satisfied by simply not having sent an
+ * untagged response for it, same as the synchronous INBOX case just above
+ * in list_dispatch(). Restores s->mbox_op_prev_state the same way
+ * session_finish_mbox_op() does (LIST/LSUB are command-auth, RFC 9051
+ * SS6.3.9, and neither changes SELECTED-ness).
+ */
+static void
+session_finish_list(struct session *s, struct imsg_mbox_result *res)
+{
+	const char	*cmdname = s->list_is_lsub ? "LSUB" : "LIST";
+	char		 text[32];
+
+	s->state = s->mbox_op_prev_state;
+
+	if (!res->ok) {
+		snprintf(text, sizeof(text), "%s failed", cmdname);
+		session_reply(s, s->pending_tag, "NO", text);
+		return;
+	}
+
+	snprintf(text, sizeof(text), "%s completed", cmdname);
+	session_reply(s, s->pending_tag, "OK", text);
+}
+
+/*
+ * EXPUNGE's untagged response (RFC 9051 SS7.5.1): "* <seqno> EXPUNGE",
+ * `seqno` already computed by store.c's handle_mbox_expunge() per
+ * SS7.5.1's "immediately decremented" rule -- this function has nothing
+ * to compute, only to format. Never called for CLOSE's silent=1 request:
+ * store.c simply never sends IMSG_MBOX_EXPUNGED when req->silent is set
+ * (RFC 9051 SS6.4.1: "No untagged EXPUNGE responses are sent"), so there's
+ * no silent-suppression logic needed here either.
+ *
+ * RFC 7162 SS3.2.10.2 addition: once this session has "ENABLE QRESYNC"'d
+ * (s->qresync_enabled), the server "MUST use the VANISHED response without
+ * the EARLIER tag instead of the EXPUNGE response... for the duration of
+ * the connection" -- UID-based (exp->uid, populated by store.c this pass;
+ * see imapd.h's imsg_mbox_expunged comment), one per message, matching
+ * this function's existing one-response-per-removed-message shape (RFC
+ * 7162's own examples combine several UIDs into a single VANISHED line,
+ * but nothing requires it -- see SS3.2.10's "if necessary, two VANISHED
+ * responses are sent" for confirmation that multiple VANISHED lines per
+ * operation are legal; combining these would need the same "buffer until
+ * the terminal reply" treatment the QRESYNC SELECT resync path uses,
+ * which isn't necessary here since EXPUNGE has no VANISHED-before-FETCH
+ * ordering constraint to satisfy -- SS3.2.10.2's ordering rule is specific
+ * to VANISHED (EARLIER), not this plain form).
+ */
+static void
+session_send_expunge_response(struct session *s,
+    struct imsg_mbox_expunged *exp)
+{
+	char	buf[32];
+
+	if (s->qresync_enabled)
+		snprintf(buf, sizeof(buf), "VANISHED %u", exp->uid);
+	else
+		snprintf(buf, sizeof(buf), "%u EXPUNGE", exp->seqno);
+	session_untagged(s, buf);
+}
+
+/*
+ * RFC 7162 SS3.2.6's VANISHED UID FETCH modifier: formats one range from
+ * store.c's IMSG_MBOX_SELECT_VANISHED (see that struct's now-broadened
+ * comment in imapd.h) as a "* VANISHED (EARLIER) ..." untagged
+ * response, written immediately rather than buffered -- unlike the QRESYNC
+ * SELECT resync case (session_handle_select_vanished()), a UID FETCH has
+ * no other untagged response that needs to interleave in a specific
+ * relative order with this one; the only ordering requirement (VANISHED
+ * before any FETCH response for the same command) is already guaranteed
+ * by handle_mbox_fetch() sending all of its VANISHED ranges, in full,
+ * before it sends any IMSG_MBOX_FETCH_META.
+ */
+static void
+session_handle_fetch_vanished(struct session *s,
+    struct imsg_mbox_select_vanished *v)
+{
+	char	buf[64];
+
+	if (v->uid_lo == v->uid_hi)
+		snprintf(buf, sizeof(buf), "VANISHED (EARLIER) %u", v->uid_lo);
+	else
+		snprintf(buf, sizeof(buf), "VANISHED (EARLIER) %u:%u",
+		    v->uid_lo, v->uid_hi);
+	session_untagged(s, buf);
+}
+
+/*
+ * Shared by cmd_expunge(), cmd_close(), and (this pass) cmd_uid()'s
+ * EXPUNGE branch: all three send the identical IMSG_MBOX_EXPUNGE request
+ * shape, transition to SESSION_EXPUNGING, and save the tag -- see
+ * imapd.h's imsg_mbox_expunge comment for why CLOSE reuses EXPUNGE's
+ * request/reply pair wholesale rather than getting its own. is_close is
+ * stashed in s->close_after_expunge so session_handle_mbox_result() knows,
+ * once the terminal IMSG_MBOX_RESULT arrives, which tagged completion text
+ * to send and which state to return to.
+ *
+ * RFC 9051 SS6.4.9 addition (UID EXPUNGE): by_uid/uid_lo/uid_hi/lo_star/
+ * hi_star carry UID EXPUNGE's required UID-set argument through to
+ * store.c (see imapd.h's imsg_mbox_expunge comment) -- meaningless (and
+ * always zeroed by cmd_close()/cmd_expunge()) when by_uid is 0, since
+ * plain EXPUNGE takes no arguments and CLOSE has no UID-restricted form at
+ * all (never called with is_close=1 and by_uid=1 together). Untagged
+ * EXPUNGE/VANISHED responses stay seqno/UID exactly as store.c's silent
+ * flag and s->qresync_enabled already decide (RFC 9051 SS6.4.9: "The
+ * number after the '*' in an untagged FETCH or EXPUNGE response is always
+ * a message sequence number... even for a UID command response") -- only
+ * the tagged completion text changes based on by_uid, via s->cmd_by_uid
+ * and session_handle_mbox_result()'s cmdname.
+ */
+static int
+session_request_expunge(struct session *s, const char *tag, int is_close,
+    int by_uid, uint32_t uid_lo, uint32_t uid_hi, int lo_star, int hi_star)
+{
+	struct imsg_mbox_expunge	 req;
+	const char			*cmdname = is_close ? "CLOSE" :
+	    (by_uid ? "UID EXPUNGE" : "EXPUNGE");
+
+	if (s->mbox_readonly) {
+		if (is_close) {
+			/*
+			 * RFC 9051 SS6.4.1: "No messages are removed, and no
+			 * error is given, if the mailbox is selected by
+			 * EXAMINE." Skip the store round trip entirely --
+			 * nothing to expunge, s->mbox_highestmodseq is still
+			 * correct since nothing changed, and CLOSE already
+			 * sends no untagged EXPUNGE responses even in the
+			 * read-write case, so there's nothing else this reply
+			 * needs to do besides deselect and reply OK.
+			 */
+			s->state = SESSION_AUTHENTICATED;
+			session_reply(s, tag, "OK", "CLOSE completed");
+			return (1);
+		}
+		/*
+		 * Unlike CLOSE, plain/UID EXPUNGE has no RFC 9051 text
+		 * excusing it on a read-only mailbox -- SS6.3.3's "No changes
+		 * to the permanent state of the mailbox... are permitted"
+		 * applies directly, since EXPUNGE permanently removes
+		 * messages. RFC 5530 CANNOT ("The operation violates some
+		 * invariant of the server and can never succeed") fits: this
+		 * particular session can never expunge while its selection
+		 * stays read-only, no retry will help.
+		 */
+		session_reply(s, tag, "NO", "[CANNOT] Mailbox is read-only "
+		    "(selected via EXAMINE)");
+		return (1);
+	}
+
+	if (s->store_iev == NULL) {
+		/* Same internal-invariant check as cmd_select()/cmd_fetch()/
+		 * cmd_store_cmd() -- ST_SELECTED requires store_iev to
+		 * already be wired. */
+		log_warnx("session %u: %s with no store channel wired",
+		    s->id, cmdname);
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
+
+	memset(&req, 0, sizeof(req));
+	req.silent = is_close;
+	req.by_uid = by_uid;
+	req.seq_lo = uid_lo;
+	req.seq_hi = uid_hi;
+	req.lo_is_star = lo_star;
+	req.hi_is_star = hi_star;
+
+	strlcpy(s->pending_tag, tag, sizeof(s->pending_tag));
+	s->close_after_expunge = is_close;
+	s->cmd_by_uid = by_uid;
+	s->state = SESSION_EXPUNGING;
+
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_EXPUNGE, 0, 0, -1,
+	    &req, sizeof(req)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_EXPUNGE", s->id);
+	imsgev_add(s->store_iev);
+
+	return (1);
+}
+
+/*
+ * cmd_uid()'s EXPUNGE branch: RFC 9051 SS6.4.9's second UID command form,
+ * "UID EXPUNGE <sequence set>" (the sequence set is a required argument
+ * here, unlike plain EXPUNGE, which takes none at all -- see imapd.h's
+ * imsg_mbox_expunge comment). Same v1 single-range restriction FETCH/
+ * STORE/SEARCH already have (parse_seq_range() rejects a comma internally
+ * anyway, but the explicit check here gives a clearer BAD reason, matching
+ * those other three call sites' own style). Never reachable with is_close
+ * set -- there is no "UID CLOSE" -- so this always calls session_request_
+ * expunge() with is_close=0.
+ */
+static int
+uid_expunge_dispatch(struct session *s, const char *tag, char *args)
+{
+	uint32_t	lo, hi;
+	int		lo_star, hi_star;
+
+	if (args == NULL) {
+		session_reply(s, tag, "BAD",
+		    "UID EXPUNGE requires a sequence set of UIDs");
+		return (1);
+	}
+	if (strchr(args, ',') != NULL) {
+		session_reply(s, tag, "BAD",
+		    "comma-separated sequence sets not supported in v1 -- "
+		    "issue separate UID EXPUNGE commands");
+		return (1);
+	}
+	if (parse_seq_range(args, &lo, &hi, &lo_star, &hi_star) == -1) {
+		session_reply(s, tag, "BAD", "invalid sequence set");
+		return (1);
+	}
+
+	return session_request_expunge(s, tag, 0, 1, lo, hi, lo_star, hi_star);
+}
+
+/*
+ * Appends one IMSG_MBOX_SEARCH_MATCH's sequence number to s->search_
+ * matches, growing the array as needed (doubling, starting at 32 --
+ * generous for a v1-scale personal mailbox without over-allocating for
+ * the common small-result-set case). store.c streams these in ascending
+ * index order (see handle_mbox_search()'s comment); since the index
+ * itself is UID-ordered and sequence number is just live position within
+ * it, seqno and uid are *both* ascending across the stream, so the array
+ * stays sorted and duplicate-free by construction regardless of which of
+ * the two values gets pushed below -- session_finish_search() relies on
+ * that for both its range-compaction (format_seq_list()) and its MIN/MAX
+ * shortcuts (first/last element).
+ *
+ * A realloc(3) failure here sets s->search_alloc_failed instead of
+ * silently dropping the match: an ESEARCH response that's missing
+ * matches the server actually found would be *wrong*, not just
+ * incomplete, and this codebase's established posture (EXPUNGE's
+ * "conservatively kept rather than dropped," APPEND's "reject cleanly
+ * rather than silently truncate") is to never hand a client data it
+ * can't stand behind. session_finish_search() checks the flag and sends
+ * a NO instead of a wrong ESEARCH if it's set. m->uid is now used (RFC
+ * 9051 SS6.4.9's UID SEARCH command wrapper, wired up this pass via
+ * search_dispatch()'s by_uid) -- see s->cmd_by_uid's comment in struct
+ * session for which of seqno/uid gets pushed.
+ */
+static void
+session_handle_mbox_search_match(struct session *s,
+    struct imsg_mbox_search_match *m)
+{
+	if (s->search_alloc_failed)
+		return;
+
+	if (s->search_nmatches == s->search_matches_cap) {
+		uint32_t	 newcap = s->search_matches_cap ?
+		    s->search_matches_cap * 2 : 32;
+		uint32_t	*n = reallocarray(s->search_matches, newcap,
+		    sizeof(uint32_t));
+
+		if (n == NULL) {
+			log_warn("session %u: realloc SEARCH match array",
+			    s->id);
+			s->search_alloc_failed = 1;
+			return;
+		}
+		s->search_matches = n;
+		s->search_matches_cap = newcap;
+	}
+
+	/* RFC 9051 SS6.4.9: UID SEARCH reports UIDs instead of sequence
+	 * numbers in its ESEARCH data -- see s->cmd_by_uid's comment. */
+	s->search_matches[s->search_nmatches++] = s->cmd_by_uid ?
+	    m->uid : m->seqno;
+
+	/* RFC 7162 SS3.1.6: track the running max modseq across all matches
+	 * -- store.c always populates m->modseq (see imapd.h's struct
+	 * imsg_mbox_search_match comment), so this costs nothing even when
+	 * the search program didn't use MODSEQ; session_finish_search()
+	 * only actually prints it when s->search_used_modseq is set. */
+	if (m->modseq > s->search_max_modseq)
+		s->search_max_modseq = m->modseq;
+}
+
+/*
+ * Appends one IMSG_MBOX_COPY_MAPPING's src_uid/dest_uid pair to s->
+ * copy_src_uids/copy_dest_uids, growing both arrays together (same
+ * doubling-from-16 growth as s->move_expunged, generous for v1-scale
+ * COPY/MOVE ranges without over-allocating the common single-message
+ * case). store.c streams these in ascending order (both handle_mbox_
+ * copy() and handle_mbox_move() walk the index forward), so both arrays
+ * stay sorted by construction -- session_finish_copy_or_move() relies on
+ * that for format_seq_list()'s range-compaction, same precondition
+ * session_handle_mbox_search_match()'s own comment already documents for
+ * s->search_matches.
+ *
+ * A realloc(3) failure here sets s->copy_alloc_failed instead of silently
+ * dropping the mapping -- same "never hand a client data it can't stand
+ * behind" reasoning as s->search_alloc_failed; session_finish_copy_or_
+ * move() checks the flag and sends a NO instead of a wrong/incomplete
+ * COPYUID if it's set.
+ */
+static void
+session_handle_mbox_copy_mapping(struct session *s,
+    struct imsg_mbox_copy_mapping *m)
+{
+	if (s->copy_alloc_failed)
+		return;
+
+	if (s->copy_n == s->copy_cap) {
+		uint32_t	 newcap = s->copy_cap ? s->copy_cap * 2 : 16;
+		uint32_t	*newsrc = reallocarray(s->copy_src_uids, newcap,
+		    sizeof(uint32_t));
+		uint32_t	*newdest;
+
+		if (newsrc == NULL) {
+			log_warn("session %u: realloc COPY src array", s->id);
+			s->copy_alloc_failed = 1;
+			return;
+		}
+		s->copy_src_uids = newsrc;
+
+		newdest = reallocarray(s->copy_dest_uids, newcap,
+		    sizeof(uint32_t));
+		if (newdest == NULL) {
+			log_warn("session %u: realloc COPY dest array", s->id);
+			s->copy_alloc_failed = 1;
+			return;
+		}
+		s->copy_dest_uids = newdest;
+		s->copy_cap = newcap;
+	}
+
+	s->copy_src_uids[s->copy_n] = m->src_uid;
+	s->copy_dest_uids[s->copy_n] = m->dest_uid;
+	s->copy_n++;
+}
+
+/*
+ * Appends one IMSG_MBOX_SELECT_VANISHED range to s->vanished_ranges --
+ * same growable-array shape as session_handle_mbox_search_match() above,
+ * without an alloc-failure flag: unlike a wrong/incomplete SEARCH result
+ * (which this codebase treats as a hard failure -- see session_handle_
+ * mbox_search_match()'s comment), a dropped VANISHED range only means the
+ * client doesn't find out about some already-vanished messages this one
+ * time and will simply learn about them on its next resync -- a real
+ * degradation, but not a correctness violation the way handing back a
+ * SEARCH result for a query that was never actually evaluated would be.
+ * Logged either way.
+ */
+static void
+session_handle_select_vanished(struct session *s,
+    struct imsg_mbox_select_vanished *v)
+{
+	if (s->vanished_nranges == s->vanished_cap) {
+		uint32_t		 newcap = s->vanished_cap ?
+		    s->vanished_cap * 2 : 16;
+		struct vanished_range	*n = reallocarray(s->vanished_ranges,
+		    newcap, sizeof(*n));
+
+		if (n == NULL) {
+			log_warn("session %u: realloc VANISHED range array",
+			    s->id);
+			return;
+		}
+		s->vanished_ranges = n;
+		s->vanished_cap = newcap;
+	}
+
+	s->vanished_ranges[s->vanished_nranges].lo = v->uid_lo;
+	s->vanished_ranges[s->vanished_nranges].hi = v->uid_hi;
+	s->vanished_nranges++;
+}
+
+/*
+ * Appends one IMSG_MBOX_FETCH_META (from a QRESYNC SELECT resync -- see
+ * session_store_dispatch()'s IMSG_MBOX_FETCH_META case, which routes here
+ * specifically when s->state == SESSION_SELECTING) to s->qresync_fetches.
+ * Same "held back until the terminal reply" reason as vanished_ranges --
+ * RFC 7162 SS3.2.6's VANISHED-before-FETCH ordering requirement.
+ */
+static void
+session_handle_select_fetch(struct session *s, struct imsg_mbox_fetch_meta *m)
+{
+	if (s->qresync_nfetches == s->qresync_fetches_cap) {
+		uint32_t			 newcap = s->qresync_fetches_cap ?
+		    s->qresync_fetches_cap * 2 : 16;
+		struct imsg_mbox_fetch_meta	*n = reallocarray(
+		    s->qresync_fetches, newcap, sizeof(*n));
+
+		if (n == NULL) {
+			log_warn("session %u: realloc QRESYNC fetch array",
+			    s->id);
+			return;
+		}
+		s->qresync_fetches = n;
+		s->qresync_fetches_cap = newcap;
+	}
+
+	s->qresync_fetches[s->qresync_nfetches++] = *m;
+}
+
+/*
+ * Appends one IMSG_MBOX_IDLE_UID (RFC 9051 SS6.3.13) to s->idle_incoming_
+ * uids -- accumulated during an in-flight refresh, held back until
+ * session_handle_idle_refreshed() has the complete list to diff against
+ * s->idle_known_uids. Same realloc-doubling shape and same "log and drop
+ * this one item on allocation failure" leniency as session_handle_select_
+ * vanished()/session_handle_select_fetch() above -- for the same reason:
+ * losing track of one UID here just means this round's diff might miss
+ * reporting it, not a crash or a wrong tagged response.
+ */
+static void
+session_handle_idle_uid(struct session *s, struct imsg_mbox_idle_uid *item)
+{
+	if (s->idle_incoming_n == s->idle_incoming_cap) {
+		uint32_t	 newcap = s->idle_incoming_cap ?
+		    s->idle_incoming_cap * 2 : 16;
+		uint32_t	*n = reallocarray(s->idle_incoming_uids,
+		    newcap, sizeof(*n));
+
+		if (n == NULL) {
+			log_warn("session %u: realloc IDLE UID array", s->id);
+			return;
+		}
+		s->idle_incoming_uids = n;
+		s->idle_incoming_cap = newcap;
+	}
+
+	s->idle_incoming_uids[s->idle_incoming_n++] = item->uid;
+}
+
+/*
+ * Pushes "* N EXPUNGE" for every UID present in old (length oldn) but
+ * absent from cur (length curn), in ascending old-list-position order.
+ * RFC 9051 SS7.5.1: "the server MUST immediately decrement" the sequence
+ * number of every subsequent message -- so a removed message's reported
+ * seqno is exactly its position among messages processed so far (both
+ * already-reported-removed and still-present), which is why seqno only
+ * advances on a *present* match below, never on a removed one. Matches
+ * store.c's own handle_mbox_expunge() comment on the identical rule
+ * ("that message's current sequence number minus one"), and produces the
+ * exact same output as SS6.4.3's own worked example (removing original
+ * positions 3, 4, 7, 11 from an 11-message mailbox reports 3, 3, 5, 8) --
+ * reimplemented here as a plain array diff, rather than reusing that
+ * function directly, since this session's own store child isn't the one
+ * that removed anything; some *other* session's mutation is what
+ * triggered this refresh (session_notify_idle_peers()), so all this
+ * session has to go on is two UID snapshots, not a live index to mutate.
+ *
+ * O(oldn * curn) -- fine at the mailbox sizes this project targets (see
+ * openimap-privsep-design.md's "personal use" scope), same non-goal this
+ * codebase states elsewhere about large-scale performance.
+ */
+static void
+session_push_idle_expunges(struct session *s, const uint32_t *old,
+    uint32_t oldn, const uint32_t *cur, uint32_t curn)
+{
+	uint32_t	i, j, seqno;
+	char		buf[32];
+
+	seqno = 1;
+	for (i = 0; i < oldn; i++) {
+		int	present = 0;
+
+		for (j = 0; j < curn; j++) {
+			if (cur[j] == old[i]) {
+				present = 1;
+				break;
+			}
+		}
+		if (present) {
+			seqno++;
+			continue;
+		}
+		snprintf(buf, sizeof(buf), "%u EXPUNGE", seqno);
+		session_untagged(s, buf);
+	}
+}
+
+/*
+ * Terminal reply to an IMSG_MBOX_IDLE_REFRESH round trip (RFC 9051
+ * SS6.3.13) -- either the baseline one cmd_idle() triggers right after
+ * sending "+ idling" (s->idle_baseline_valid still 0: nothing to diff
+ * against yet, this call's only job is to adopt the freshly streamed list
+ * as the baseline), or a change-triggered one from session_notify_idle_
+ * peers() (baseline already valid: diff old vs. new, push EXPUNGE/EXISTS
+ * for whatever actually changed).
+ *
+ * res->ok == 0 (index open/lock/load failure in store.c, same failure
+ * modes handle_mbox_select() itself can hit) just drops this round on the
+ * floor -- s->idle_known_uids is left exactly as it was, so a later
+ * successful refresh can still diff correctly against whatever the last
+ * good baseline was; the client just doesn't find out about this
+ * particular change until then.
+ *
+ * Pushing anything is gated on s->idling && s->state == SESSION_SELECTED:
+ * this round trip is asynchronous, so by the time it lands the client may
+ * already have sent DONE (s->idling cleared) or issued a new SELECT
+ * (s->state changed) -- in either case, sending untagged EXPUNGE/EXISTS
+ * now would be at best surprising and at worst interleaved with whatever
+ * that new command's own response is building. The cache still gets
+ * updated either way; only the pushing is skipped.
+ */
+static void
+session_handle_idle_refreshed(struct session *s,
+    struct imsg_mbox_idle_refreshed *res)
+{
+	uint32_t	*newlist = s->idle_incoming_uids;
+	uint32_t	 newn = s->idle_incoming_n;
+
+	s->idle_incoming_uids = NULL;
+	s->idle_incoming_n = 0;
+	s->idle_incoming_cap = 0;
+
+	s->idle_refresh_pending = 0;
+
+	if (!res->ok) {
+		log_warnx("session %u: IDLE refresh failed, keeping last "
+		    "known state", s->id);
+		free(newlist);
+		goto maybe_again;
+	}
+
+	if (!s->idle_baseline_valid) {
+		free(s->idle_known_uids);
+		s->idle_known_uids = newlist;
+		s->idle_known_nuids = newn;
+		s->idle_known_cap = newn;
+		s->idle_baseline_valid = 1;
+		goto maybe_again;
+	}
+
+	if (s->idling && s->state == SESSION_SELECTED) {
+		session_push_idle_expunges(s, s->idle_known_uids,
+		    s->idle_known_nuids, newlist, newn);
+		if (newn != s->idle_known_nuids) {
+			char	buf[32];
+
+			snprintf(buf, sizeof(buf), "%u EXISTS", newn);
+			session_untagged(s, buf);
+		}
+	}
+
+	free(s->idle_known_uids);
+	s->idle_known_uids = newlist;
+	s->idle_known_nuids = newn;
+	s->idle_known_cap = newn;
+
+maybe_again:
+	if (s->idle_refresh_again) {
+		s->idle_refresh_again = 0;
+		session_request_idle_refresh(s);
+	}
+}
+
+/*
+ * Sends IMSG_MBOX_IDLE_REFRESH on s's own store_iev -- either cmd_idle()
+ * seeding this session's own baseline right after "+ idling", or session_
+ * notify_idle_peers() asking a *sibling* session (same uid, currently
+ * idling) to recheck after this session's own successful mutation.
+ * Coalesces: if a refresh is already in flight for s, this just remembers
+ * to run another one immediately after it completes (s->idle_refresh_
+ * again) rather than starting a second overlapping request on the same
+ * store_iev channel.
+ */
+static void
+session_request_idle_refresh(struct session *s)
+{
+	if (s->idle_refresh_pending) {
+		s->idle_refresh_again = 1;
+		return;
+	}
+	if (s->store_iev == NULL)
+		return;		/* no store child wired -- nothing to ask */
+
+	s->idle_refresh_pending = 1;
+	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_IDLE_REFRESH, 0, 0,
+	    -1, NULL, 0) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_IDLE_REFRESH",
+		    s->id);
+	imsgev_add(s->store_iev);
+}
+
+/*
+ * Called after a successful EXPUNGE/UID EXPUNGE/CLOSE-with-real-removal,
+ * APPEND, or MOVE/UID MOVE -- the mutations that can change which UIDs
+ * exist (a plain STORE, by contrast, only changes flags, which this v1
+ * scope doesn't push during IDLE at all -- see imapd.h's imsg_mbox_
+ * idle_uid comment). Scans every other live session belonging to the
+ * *same* uid (v1 has no shared mailboxes, so cross-user notification never
+ * applies -- see struct session's uid comment) that's currently idling
+ * with a mailbox selected, and asks each one to refresh. s itself (the
+ * session that just mutated something) is skipped -- it already knows
+ * about its own change through its own command's ordinary response.
+ *
+ * Doesn't check *which* mailbox each peer has selected against which
+ * mailbox actually changed -- harmless imprecision, not a correctness
+ * bug: a peer with a different mailbox selected still only gets asked to
+ * refresh (IMSG_MBOX_IDLE_REFRESH), and that refresh re-diffs the peer's
+ * own store child's own current_mailbox_dir against its own cached UID
+ * snapshot (handle_mbox_idle_refresh() in store.c), so a mismatched wakeup
+ * finds nothing changed and pushes nothing -- at most a wasted round trip,
+ * never wrong data. Before RFC 9051 SS6.3.4-SS6.3.6's flat multi-mailbox
+ * support (docs/openimap-storage-backend.md item 10) this distinction
+ * didn't exist at all (every SELECTED session necessarily had the same,
+ * only mailbox selected); tightening this to only wake peers actually
+ * selected on the mailbox that changed is a real, flagged follow-up, not
+ * done here.
+ */
+static void
+session_notify_idle_peers(struct session *s)
+{
+	struct session	*other;
+
+	TAILQ_FOREACH(other, &sessions, entry) {
+		if (other == s)
+			continue;
+		if (other->uid != s->uid)
+			continue;
+		if (!other->idling || other->state != SESSION_SELECTED)
+			continue;
+		session_request_idle_refresh(other);
+	}
+}
+
+/*
+ * Formats one QRESYNC-resync FETCH response: always UID + FLAGS + MODSEQ,
+ * matching RFC 7162 SS3.2.5.1's own worked examples exactly (e.g. "* 49
+ * FETCH (UID 117 FLAGS (\Seen \Answered) MODSEQ (90060115194045001))") --
+ * unlike session_send_fetch_response(), there's no s->fetch_attrs to
+ * consult here, since this isn't a client-requested attribute list; the
+ * RFC itself fixes what a resync FETCH response contains.
+ */
+static void
+session_send_qresync_fetch_response(struct session *s,
+    struct imsg_mbox_fetch_meta *meta)
+{
+	char	buf[MBOX_FLAGS_MAX + 96];
+
+	snprintf(buf, sizeof(buf), "%u FETCH (UID %u FLAGS (%s) MODSEQ (%llu))",
+	    meta->seqno, meta->uid, meta->flags,
+	    (unsigned long long)meta->modseq);
+	session_untagged(s, buf);
+}
+
+/*
+ * Appends one IMSG_MBOX_STORE_MODIFIED (seqno of a message that failed a
+ * STORE's UNCHANGEDSINCE test) to s->store_modified -- same growable-array
+ * shape as the two accumulators above, for session_handle_mbox_result()
+ * to range-compact into the tagged response's MODIFIED response code once
+ * the terminal IMSG_MBOX_RESULT arrives.
+ */
+static void
+session_handle_store_modified(struct session *s,
+    struct imsg_mbox_store_modified *m)
+{
+	if (s->store_modified_n == s->store_modified_cap) {
+		uint32_t	 newcap = s->store_modified_cap ?
+		    s->store_modified_cap * 2 : 16;
+		uint32_t	*n = reallocarray(s->store_modified, newcap,
+		    sizeof(*n));
+
+		if (n == NULL) {
+			log_warn("session %u: realloc STORE MODIFIED array",
+			    s->id);
+			return;
+		}
+		s->store_modified = n;
+		s->store_modified_cap = newcap;
+	}
+
+	/* RFC 7162 SS3.1.3: MODIFIED lists UIDs for UID STORE, sequence
+	 * numbers for plain STORE -- see s->cmd_by_uid's comment in struct
+	 * session. */
+	s->store_modified[s->store_modified_n++] =
+	    s->cmd_by_uid ? m->uid : m->seqno;
+}
+
+/*
+ * Appends nums[i] as either a bare number or (for a run of consecutive
+ * values) a "lo:hi" range to buf, comma-separating entries -- RFC 9051
+ * SS7.3.4's own ESEARCH examples use exactly this compacted form (e.g.
+ * "ALL 2,10:11"). nums must already be sorted ascending with no
+ * duplicates (true by construction -- see session_handle_mbox_search_
+ * match()'s comment).
+ *
+ * Stops cleanly at a token boundary (never writes a partial number or a
+ * dangling comma) if the next range wouldn't fit in the remaining space,
+ * and reports that via *truncated -- see session_finish_search()'s
+ * comment for why a v1-scale response is expected to always fit, and
+ * what happens on the rare occasion it doesn't.
+ */
+static size_t
+format_seq_list(char *buf, size_t bufsize, const uint32_t *nums, uint32_t n,
+    int *truncated)
+{
+	size_t		written = 0;
+	uint32_t	i = 0;
+	int		first = 1;
+
+	*truncated = 0;
+	buf[0] = '\0';
+
+	while (i < n) {
+		uint32_t	start = nums[i];
+		uint32_t	end = start;
+		uint32_t	j = i + 1;
+		char		tok[24];
+		int		toklen;
+
+		while (j < n && nums[j] == end + 1) {
+			end = nums[j];
+			j++;
+		}
+
+		if (start == end)
+			toklen = snprintf(tok, sizeof(tok), "%u", start);
+		else
+			toklen = snprintf(tok, sizeof(tok), "%u:%u", start,
+			    end);
+		if (toklen < 0)
+			break;
+
+		if (written + (size_t)toklen + (first ? 0 : 1) >= bufsize) {
+			*truncated = 1;
+			break;
+		}
+
+		if (!first)
+			buf[written++] = ',';
+		memcpy(buf + written, tok, (size_t)toklen);
+		written += (size_t)toklen;
+		buf[written] = '\0';
+		first = 0;
+		i = j;
+	}
+
+	return (written);
+}
+
+/*
+ * RFC 7162 counterpart to format_seq_list() above, for VANISHED (EARLIER)'s
+ * known-uids list: ranges is already pre-compacted (store.c's qresync_send_
+ * resync() computes minimal maximal ranges directly, see imapd.h's
+ * imsg_mbox_select_vanished comment), so this just joins ranges[i].lo:
+ * ranges[i].hi (or a bare number when lo == hi) with commas -- no run-
+ * detection needed, unlike format_seq_list()'s individual-number input.
+ * Same truncate-cleanly-at-a-token-boundary contract and *truncated
+ * signal as format_seq_list().
+ */
+static size_t
+format_range_list(char *buf, size_t bufsize, const struct vanished_range *ranges,
+    uint32_t n, int *truncated)
+{
+	size_t		written = 0;
+	uint32_t	i;
+	int		first = 1;
+
+	*truncated = 0;
+	buf[0] = '\0';
+
+	for (i = 0; i < n; i++) {
+		char	tok[24];
+		int	toklen;
+
+		if (ranges[i].lo == ranges[i].hi)
+			toklen = snprintf(tok, sizeof(tok), "%u", ranges[i].lo);
+		else
+			toklen = snprintf(tok, sizeof(tok), "%u:%u",
+			    ranges[i].lo, ranges[i].hi);
+		if (toklen < 0)
+			break;
+
+		if (written + (size_t)toklen + (first ? 0 : 1) >= bufsize) {
+			*truncated = 1;
+			break;
+		}
+
+		if (!first)
+			buf[written++] = ',';
+		memcpy(buf + written, tok, (size_t)toklen);
+		written += (size_t)toklen;
+		buf[written] = '\0';
+		first = 0;
+	}
+
+	return (written);
+}
+
+/*
+ * Terminal reply for the SEARCH round trip cmd_search() started --
+ * called from session_handle_mbox_result() when s->state is SESSION_
+ * SEARCHING, before that function's generic FETCH/STORE/EXPUNGE/CLOSE
+ * handling (SEARCH's reply shape, an ESEARCH response built from the
+ * accumulated match list rather than a fixed "<cmdname> completed/
+ * failed" text, is different enough to warrant its own function rather
+ * than another branch bolted onto the generic one).
+ *
+ * RFC 9051 SS6.4.4/SS7.3.4's MIN/MAX/ALL/COUNT semantics, resolved
+ * against that section's own worked examples (not just the prose, which
+ * reads ambiguously on its own -- see the specific examples cited
+ * below): COUNT is included, unconditionally on match count (even
+ * "COUNT 0"), if and only if it was requested (RETURN (MIN) UNSEEN's
+ * own example response, "MIN 4", has no COUNT at all, showing COUNT is
+ * NOT sent just because it's a valid default -- only when actually
+ * asked for); MIN/MAX/ALL are each included only if requested AND there
+ * is at least one match (RETURN (MIN) UNSEEN's response also confirms
+ * MIN is omitted, not sent as 0, when unrequested attributes are absent,
+ * and SEARCH's own no-RETURN-clause example, "SEARCH TEXT ...", with no
+ * matches produces a bare "* ESEARCH (TAG "...")" with nothing after
+ * the correlator at all -- the default-ALL implied option, with zero
+ * matches, is simply omitted). The correlator ("(TAG "<tag>")") is
+ * always sent, since every ESEARCH this server produces is a direct
+ * response to a specific tagged command, never spontaneous.
+ *
+ * No "UID" indicator: see imapd.h's imsg_mbox_search comment on why
+ * ESEARCH data is always in sequence-number space this pass.
+ *
+ * Built via session_write() directly with a generously-sized local
+ * buffer (8192 bytes, matching SESSION_INBUF_MAX's own "generous"
+ * precedent), bypassing session_untagged()'s ordinary 512-byte cap --
+ * that cap is fine for this file's other short, fixed-shape status
+ * text, but truncating ESEARCH's ALL data at 512 bytes would silently
+ * hand the client a materially wrong (incomplete) result, not just a
+ * cosmetically shortened one. format_seq_list() itself never writes a
+ * partial token even within this larger buffer; on the rare v1-scale
+ * mailbox where even 8192 bytes isn't enough for a highly non-contiguous
+ * match set, the response is truncated at a clean token boundary and a
+ * warning is logged server-side -- a known, flagged v1-scale limitation
+ * (RFC 9051 has no mechanism to signal a partial ALL list to the
+ * client), not a silent-corruption risk, since COUNT (when requested) is
+ * always computed from the true, untruncated match count regardless.
+ */
+static void
+session_finish_search(struct session *s, struct imsg_mbox_result *res)
+{
+	char	buf[8192];
+	size_t	len;
+	int	truncated = 0;
+
+	s->state = SESSION_SELECTED;
+
+	log_debug("session %u: SEARCH done, ok=%d, %u match(es)", s->id,
+	    res->ok, res->count);
+
+	if (!res->ok || s->search_alloc_failed) {
+		session_reply(s, s->pending_tag, "NO",
+		    s->cmd_by_uid ? "UID SEARCH failed" : "SEARCH failed");
+		goto cleanup;
+	}
+
+	len = (size_t)snprintf(buf, sizeof(buf), "* ESEARCH (TAG \"%s\")",
+	    s->pending_tag);
+
+	/* RFC 9051 SS9 esearch-response ABNF: `"ESEARCH" [search-correlator]
+	 * [SP "UID"] *(SP search-return-data)` -- the "UID" indicator (RFC
+	 * 9051 SS6.4.9: "the corresponding ESEARCH response MUST include the
+	 * UID indicator") comes right after the correlator, before any of
+	 * MIN/MAX/ALL/COUNT/MODSEQ. Matches SS6.4.9's own worked examples
+	 * exactly: `* ESEARCH (TAG "A285") UID MIN 7 MAX 3800` and
+	 * `* ESEARCH (TAG "A301") UID ALL 17,900,901`. */
+	if (s->cmd_by_uid && len < sizeof(buf))
+		len += (size_t)snprintf(buf + len, sizeof(buf) - len, " UID");
+
+	if ((s->search_return_opts & SEARCH_RETURN_MIN) &&
+	    s->search_nmatches > 0 && len < sizeof(buf))
+		len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+		    " MIN %u", s->search_matches[0]);
+
+	if ((s->search_return_opts & SEARCH_RETURN_MAX) &&
+	    s->search_nmatches > 0 && len < sizeof(buf))
+		len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+		    " MAX %u", s->search_matches[s->search_nmatches - 1]);
+
+	if ((s->search_return_opts & SEARCH_RETURN_ALL) &&
+	    s->search_nmatches > 0 && len < sizeof(buf)) {
+		size_t	listlen;
+
+		len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+		    " ALL ");
+		if (len < sizeof(buf)) {
+			listlen = format_seq_list(buf + len,
+			    sizeof(buf) - len, s->search_matches,
+			    s->search_nmatches, &truncated);
+			len += listlen;
+			if (truncated)
+				log_warnx("session %u: SEARCH ALL response "
+				    "truncated at %zu bytes (%u total "
+				    "matches) -- known v1-scale limitation, "
+				    "see session_finish_search()'s comment",
+				    s->id, sizeof(buf), s->search_nmatches);
+		}
+	}
+
+	if ((s->search_return_opts & SEARCH_RETURN_COUNT) && len < sizeof(buf))
+		len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+		    " COUNT %u", s->search_nmatches);
+
+	/* RFC 7162 SS3.1.10 (Example 19): a MODSEQ SEARCH criterion with a
+	 * non-empty result gets "MODSEQ n" appended to the ESEARCH response,
+	 * using the highest mod-sequence among the returned messages --
+	 * this codebase always answers SEARCH in ESEARCH form (see the RETURN
+	 * default set above), so SS3.1.10's ESEARCH case is the only one
+	 * that applies here; SS3.1.6's plain "* SEARCH ... (MODSEQ n)" form
+	 * never arises. */
+	if (s->search_used_modseq && s->search_nmatches > 0 &&
+	    len < sizeof(buf))
+		len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+		    " MODSEQ %llu",
+		    (unsigned long long)s->search_max_modseq);
+
+	if (len >= sizeof(buf))
+		len = sizeof(buf) - 1;	/* truncate rather than overflow --
+					 * see this function's header comment;
+					 * only reachable if even the fixed
+					 * correlator/MIN/MAX/COUNT portions
+					 * somehow overflowed, which a v1-scale
+					 * tag length never should */
+
+	buf[len++] = '\r';
+	buf[len++] = '\n';
+	session_write(s, buf, len);
+
+	/* RFC 9051 SS6.4.9's own worked example shows "UID FETCH completed"
+	 * verbatim; UID EXPUNGE's own worked example shows "UID EXPUNGE
+	 * completed". "UID SEARCH completed" isn't shown as a literal
+	 * example in the RFC text, but follows the same "UID <cmd>
+	 * completed" pattern by direct symmetry with those two -- not
+	 * fabricated, just not independently quoted. */
+	session_reply(s, s->pending_tag, "OK",
+	    s->cmd_by_uid ? "UID SEARCH completed" : "SEARCH completed");
+
+cleanup:
+	free(s->search_matches);
+	s->search_matches = NULL;
+	s->search_nmatches = 0;
+	s->search_matches_cap = 0;
+	s->search_alloc_failed = 0;
+	s->search_used_modseq = 0;
+	s->search_max_modseq = 0;
+}
+
+/*
+ * Finishes the COPY/MOVE round trip copy_move_dispatch() started. Peeled
+ * off session_handle_mbox_result() the same way SEARCH is, since COPY/
+ * MOVE's reply shape (a COPYUID response code, optionally an untagged OK
+ * plus buffered EXPUNGE/VANISHED for a MOVE) doesn't fit the generic
+ * "<cmdname> completed/failed" text FETCH/STORE/EXPUNGE/CLOSE share.
+ *
+ * Response placement differs between the two, per RFC 9051 SS6.4.7/
+ * SS6.4.8/SS7.1: COPY's COPYUID goes in its own TAGGED OK ("This response
+ * code is returned in a tagged OK response to the COPY or UID COPY
+ * command"); MOVE's goes in an UNTAGGED OK sent before any EXPUNGE/
+ * VANISHED ("Servers are also REQUIRED to send the COPYUID response code
+ * in an untagged OK before sending EXPUNGE"), followed by every buffered
+ * s->move_expunged entry (in original order -- see that field's own
+ * comment for why these were buffered instead of written live), then a
+ * plain tagged OK with no COPYUID in it.
+ *
+ * If zero messages matched, RFC 9051 SS6.4.7's own worked example shows
+ * a plain "OK No matching messages, so nothing copied" with no COPYUID
+ * at all -- COPYUID's uid-set ABNF has no legal empty-set spelling, so
+ * omitting the bracket code entirely (rather than emitting "[COPYUID v
+ * ]" with nothing after it) is the only spec-consistent choice; this
+ * function applies the same omission to a zero-match MOVE by the same
+ * reasoning, with no RFC worked example to cite for that half but no
+ * more of a legal spelling for an empty MOVE either.
+ */
+static void
+session_finish_copy_or_move(struct session *s, struct imsg_mbox_result *res)
+{
+	const char	*cmdname = s->cmd_is_move ?
+	    (s->cmd_by_uid ? "UID MOVE" : "MOVE") :
+	    (s->cmd_by_uid ? "UID COPY" : "COPY");
+	uint32_t	 i;
+
+	s->state = SESSION_SELECTED;
+
+	if (!res->ok || s->copy_alloc_failed) {
+		char	text[48];
+
+		/*
+		 * RFC 9051 SS6.4.7/SS6.4.8: destname resolved to a
+		 * syntactically valid name (BAD would already have been
+		 * sent otherwise, before any store round trip) but store.c
+		 * couldn't find it on disk -- TRYCREATE, not a plain
+		 * failure, same distinction imsg_mbox_appended's own
+		 * no_such_mailbox field already makes for APPEND.
+		 */
+		if (!s->copy_alloc_failed && res->no_such_mailbox)
+			snprintf(text, sizeof(text), "[TRYCREATE] no such "
+			    "mailbox");
+		else
+			snprintf(text, sizeof(text), "%s failed", cmdname);
+		session_reply(s, s->pending_tag, "NO", text);
+		goto cleanup;
+	}
+
+	/* RFC 7162 SS3.1.2.1: cache the mailbox's post-operation
+	 * HIGHESTMODSEQ, same as session_handle_mbox_result() already does
+	 * for STORE/EXPUNGE -- COPY/MOVE both mutate the mailbox (COPY
+	 * always appends; MOVE both appends and removes) and so both change
+	 * it too, even though neither is itself one of RFC 7162 SS3.1's six
+	 * CONDSTORE-enabling commands (no [HIGHESTMODSEQ] is emitted on
+	 * COPY/MOVE's own tagged/untagged OK anywhere below -- this is
+	 * purely for session_condstore_enable()'s later cached-value use if
+	 * CONDSTORE gets enabled afterward). */
+	s->mbox_highestmodseq = res->highestmodseq;
+
+	/*
+	 * RFC 9051 SS6.3.13 (IDLE): both COPY and MOVE mutate a mailbox
+	 * some *other* same-uid session could have selected and be idling
+	 * on -- COPY always appends at the destination; MOVE does that too
+	 * and additionally removes s->move_expunged_n message(s) from the
+	 * source. Before cross-mailbox COPY/MOVE existed (RFC 9051
+	 * SS6.3.4-SS6.3.6, docs/openimap-storage-backend.md item 10's own
+	 * follow-up) the destination was always the same mailbox already
+	 * selected by *this* session, so a peer noticing a COPY's own
+	 * appended messages could only ever be a peer with that same
+	 * mailbox open -- still worth waking, but this call was
+	 * (incorrectly) gated to MOVE only until now, which meant a peer
+	 * idling on that shared mailbox never got woken by a plain COPY
+	 * into it. Now that the destination can be genuinely different
+	 * from anything *this* session has open, notifying unconditionally
+	 * is the only way a peer with the *destination* mailbox selected
+	 * ever finds out. session_notify_idle_peers()'s own comment
+	 * already documents why not filtering by which mailbox each peer
+	 * has selected is harmless imprecision, not a correctness bug --
+	 * that reasoning covers this unconditional call the same way.
+	 * Gated on copy_n > 0 since the zero-match branch below sends no
+	 * COPYUID/EXPUNGE at all, i.e. neither copied nor moved anything.
+	 */
+	if (s->copy_n > 0)
+		session_notify_idle_peers(s);
+
+	if (s->copy_n > 0) {
+		char	srcbuf[2048], destbuf[2048];
+		char	text[4096 + 64];
+		int	truncated;
+
+		format_seq_list(srcbuf, sizeof(srcbuf), s->copy_src_uids,
+		    s->copy_n, &truncated);
+		if (truncated)
+			log_warnx("session %u: COPYUID source list truncated",
+			    s->id);
+		format_seq_list(destbuf, sizeof(destbuf), s->copy_dest_uids,
+		    s->copy_n, &truncated);
+		if (truncated)
+			log_warnx("session %u: COPYUID dest list truncated",
+			    s->id);
+
+		if (s->cmd_is_move) {
+			snprintf(text, sizeof(text), "OK [COPYUID %u %s %s]",
+			    res->uidvalidity, srcbuf, destbuf);
+			session_untagged(s, text);
+
+			for (i = 0; i < s->move_expunged_n; i++)
+				session_send_expunge_response(s,
+				    &s->move_expunged[i]);
+
+			session_reply(s, s->pending_tag, "OK", "Done");
+		} else {
+			snprintf(text, sizeof(text),
+			    "[COPYUID %u %s %s] %s completed",
+			    res->uidvalidity, srcbuf, destbuf, cmdname);
+			session_reply(s, s->pending_tag, "OK", text);
+		}
+	} else {
+		/* RFC 9051 SS6.4.7's own worked example, verbatim: "S: A005
+		 * OK No matching messages, so nothing copied" -- see this
+		 * function's header comment for why MOVE gets the identical
+		 * text in the zero-match case. */
+		session_reply(s, s->pending_tag, "OK",
+		    "No matching messages, so nothing copied");
+	}
+
+cleanup:
+	free(s->copy_src_uids);
+	s->copy_src_uids = NULL;
+	free(s->copy_dest_uids);
+	s->copy_dest_uids = NULL;
+	s->copy_n = 0;
+	s->copy_cap = 0;
+	s->copy_alloc_failed = 0;
+	free(s->move_expunged);
+	s->move_expunged = NULL;
+	s->move_expunged_n = 0;
+	s->move_expunged_cap = 0;
+	s->cmd_is_move = 0;
+}
+
+/*
+ * Terminal reply for the FETCH, STORE, EXPUNGE/CLOSE, SEARCH, COPY, MOVE,
+ * CREATE, DELETE, or RENAME round trip cmd_fetch()/cmd_store_cmd()/
+ * cmd_expunge()/cmd_close()/cmd_search()/cmd_copy()/cmd_move()/cmd_create()/
+ * cmd_delete()/cmd_rename() started: store.c sends exactly one IMSG_MBOX_
+ * RESULT after the last (zero or more) per-message reply for a given
+ * request. SEARCH, COPY/MOVE, and CREATE/DELETE/RENAME are all peeled off
+ * first, into session_finish_search()/session_finish_copy_or_move()/
+ * session_finish_mbox_op() respectively, since none of their reply shapes
+ * fit the generic "<cmdname> completed/failed" text the remaining four
+ * share. Which of those four this was is read from s->state (and, for
+ * EXPUNGE vs. CLOSE specifically, s->close_after_expunge) before either is
+ * overwritten -- purely to pick the right tagged completion text and the
+ * right next state; all four otherwise share this handler completely. See
+ * the ST_SELECTED/ST_AUTH comments above the dispatch table for why
+ * SESSION_FETCHING/SESSION_STORING/SESSION_EXPUNGING/SESSION_SEARCHING/
+ * SESSION_COPYING/SESSION_CREATING/SESSION_DELETING/SESSION_RENAMING all
+ * have to stay excluded from ST_SELECTED (or ST_AUTH, for the last three)
+ * until exactly this point.
+ */
+static void
+session_handle_mbox_result(struct session *s, struct imsg_mbox_result *res)
+{
+	int		 was_close = 0, was_storing = 0, was_expunging = 0;
+	const char	*cmdname;
+
+	if (s->state == SESSION_SEARCHING) {
+		session_finish_search(s, res);
+		return;
+	}
+	if (s->state == SESSION_COPYING) {
+		session_finish_copy_or_move(s, res);
+		return;
+	}
+	if (s->state == SESSION_CREATING || s->state == SESSION_DELETING ||
+	    s->state == SESSION_RENAMING) {
+		/* RFC 9051 SS6.3.4-SS6.3.6 (docs/openimap-storage-backend.md
+		 * item 10) -- same "peel off before the generic FETCH/
+		 * STORE/EXPUNGE shape below" pattern as SESSION_SEARCHING/
+		 * SESSION_COPYING just above, since CREATE/DELETE/RENAME's
+		 * reply text ("CREATE completed"/"failed", etc.) doesn't fit
+		 * this function's cmdname table either. */
+		session_finish_mbox_op(s, res);
+		return;
+	}
+	if (s->state == SESSION_LISTING) {
+		/* RFC 9051 SS6.3.9 -- same peel-off reasoning as SESSION_
+		 * CREATING/DELETING/RENAMING just above; LIST/LSUB's
+		 * "completed"/"failed" text is keyed off s->list_is_lsub,
+		 * not this function's by-state cmdname table. */
+		session_finish_list(s, res);
+		return;
+	}
+
+	/*
+	 * RFC 9051 SS6.4.9 addition: every one of these becomes "UID <CMD>"
+	 * when s->cmd_by_uid is set (see that field's own comment in struct
+	 * session for the sourcing on "UID <cmd> completed"/"failed" text) --
+	 * CLOSE is the one exception, since there is no "UID CLOSE" (UID
+	 * EXPUNGE's is_close is always 0, enforced by uid_expunge_dispatch()
+	 * only ever calling session_request_expunge() that way).
+	 */
+	if (s->state == SESSION_STORING) {
+		cmdname = s->cmd_by_uid ? "UID STORE" : "STORE";
+		was_storing = 1;
+	} else if (s->state == SESSION_EXPUNGING) {
+		was_close = s->close_after_expunge;
+		cmdname = was_close ? "CLOSE" :
+		    (s->cmd_by_uid ? "UID EXPUNGE" : "EXPUNGE");
+		was_expunging = 1;
+	} else
+		cmdname = s->cmd_by_uid ? "UID FETCH" : "FETCH";
+
+	log_debug("session %u: %s done, ok=%d, %u response(s) sent",
+	    s->id, cmdname, res->ok, res->count);
+
+	/*
+	 * RFC 7162: cache the mailbox's post-operation HIGHESTMODSEQ for
+	 * session_condstore_enable()'s later use -- only STORE/EXPUNGE (not
+	 * a plain FETCH, which leaves res->highestmodseq at its zeroed
+	 * default; see imapd.h's imsg_mbox_result comment) ever change
+	 * it, so a plain FETCH mustn't blindly overwrite the cached value
+	 * with that default 0.
+	 */
+	if (was_storing || was_expunging)
+		s->mbox_highestmodseq = res->highestmodseq;
+
+	/*
+	 * RFC 9051 SS6.4.1: CLOSE "returns to the authenticated state from
+	 * the selected state" -- unlike FETCH/STORE/a real EXPUNGE, which
+	 * all stay in Selected.
+	 */
+	s->state = was_close ? SESSION_AUTHENTICATED : SESSION_SELECTED;
+
+	if (!res->ok) {
+		/* v1's only failure mode is an index I/O error (see
+		 * store.c's handle_mbox_fetch()/handle_mbox_store()/
+		 * handle_mbox_expunge() ok = 0 paths) -- RFC 9051 has no
+		 * specific response code for this, so a plain NO is the
+		 * honest answer. */
+		char	text[32];
+
+		free(s->store_modified);
+		s->store_modified = NULL;
+		s->store_modified_n = 0;
+		s->store_modified_cap = 0;
+
+		snprintf(text, sizeof(text), "%s failed", cmdname);
+		session_reply(s, s->pending_tag, "NO", text);
+		return;
+	}
+
+	/*
+	 * RFC 7162 SS3.1.3: a STORE that used UNCHANGEDSINCE and had at
+	 * least one message fail the conditional test gets the MODIFIED
+	 * response code on its tagged OK -- v1 never has a reason to use
+	 * the tagged NO form instead (SS3.1.3's Example 11 mixes an
+	 * UNCHANGEDSINCE failure with expunged messages to get that; v1's
+	 * STORE simply never visits an expunged message's index line at
+	 * all -- see handle_mbox_store()'s lo/hi bound against the current
+	 * idx.nlines -- so that combination can't arise here).
+	 */
+	if (was_storing && s->store_modified_n > 0) {
+		char	rbuf[2048];
+		char	text[2048 + 32];
+		int	truncated;
+
+		format_seq_list(rbuf, sizeof(rbuf), s->store_modified,
+		    s->store_modified_n, &truncated);
+		if (truncated)
+			log_warnx("session %u: MODIFIED list truncated",
+			    s->id);
+		snprintf(text, sizeof(text), "[MODIFIED %s] Conditional "
+		    "%s failed for some messages", rbuf, cmdname);
+		session_reply(s, s->pending_tag, "OK", text);
+
+		free(s->store_modified);
+		s->store_modified = NULL;
+		s->store_modified_n = 0;
+		s->store_modified_cap = 0;
+		return;
+	}
+	free(s->store_modified);
+	s->store_modified = NULL;
+	s->store_modified_n = 0;
+	s->store_modified_cap = 0;
+
+	/*
+	 * RFC 9051 SS6.3.13 (IDLE): a successful EXPUNGE/UID EXPUNGE, or a
+	 * CLOSE that silently expunged \Deleted messages, may have changed
+	 * what messages exist -- wake any other same-uid session currently
+	 * idling on this mailbox so it can push EXISTS/EXPUNGE. Fired
+	 * unconditionally on was_expunging rather than gated on res->count,
+	 * since (per the HIGHESTMODSEQ comment just below) store.c only
+	 * reports a nonzero count for a real, non-silent EXPUNGE -- CLOSE's
+	 * count is always 0 even when it removed messages, so count can't be
+	 * used to detect that case. An extra, harmless peer refresh on a
+	 * no-op EXPUNGE/CLOSE is the tradeoff.
+	 */
+	if (was_expunging)
+		session_notify_idle_peers(s);
+
+	/*
+	 * RFC 7162 SS3.2.7: a real EXPUNGE (not CLOSE -- SS3.2.8 explicitly
+	 * forbids it there, "as this might cause loss of synchronization on
+	 * the client") that removed at least one message includes
+	 * HIGHESTMODSEQ in its tagged OK once this session is CONDSTORE-
+	 * aware. res->count is exact here: store.c only composes
+	 * IMSG_MBOX_EXPUNGED (which is what increments it) when !silent,
+	 * i.e. for a real EXPUNGE, never for CLOSE.
+	 */
+	if (was_expunging && !was_close && s->condstore_enabled &&
+	    res->count > 0) {
+		char	text[64];
+
+		snprintf(text, sizeof(text), "[HIGHESTMODSEQ %llu] %s "
+		    "completed", (unsigned long long)res->highestmodseq,
+		    cmdname);
+		session_reply(s, s->pending_tag, "OK", text);
+		return;
+	}
+
+	{
+		char	text[32];
+
+		snprintf(text, sizeof(text), "%s completed", cmdname);
+		session_reply(s, s->pending_tag, "OK", text);
+	}
+}
+
+static void
+session_request_store(struct session *s, struct imsg_auth_result *res)
+{
+	struct imsg_store_fork	 req;
+
+	req.session_id = s->id;
+	req.uid = res->uid;
+	req.gid = res->gid;
+	/*
+	 * Also kept on struct session itself, not just forwarded in this
+	 * request -- session_notify_idle_peers() (RFC 9051 SS6.3.13) needs
+	 * to find this session's *other* concurrent sessions later, and
+	 * this is the only point in the whole connection lifecycle where
+	 * the authenticated uid is available at all (auth.c doesn't share
+	 * it any other way, and store.c never sends it back).
+	 */
+	s->uid = res->uid;
+	/*
+	 * res->maildir was resolved by auth.c straight from the credential
+	 * file's fifth field and was, until this pass, silently dropped
+	 * here -- imapd.h's imsg_store_fork comment has the full "this
+	 * was always meant to carry it" citation. Without this, store.c has
+	 * no way to know which subdirectory of the shared spool_root is
+	 * this session's own mailbox.
+	 */
+	strlcpy(req.maildir, res->maildir, sizeof(req.maildir));
+
+	s->state = SESSION_STORE_PENDING;
+
+	if (imsg_compose(&iev_parent.ibuf, IMSG_STORE_FORK, 0, 0, -1,
+	    &req, sizeof(req)) == -1)
+		log_warn("imsg_compose IMSG_STORE_FORK");
+	imsgev_add(&iev_parent);
+}
+
+static struct session *
+session_find(uint32_t id)
+{
+	struct session *s;
+
+	TAILQ_FOREACH(s, &sessions, entry) {
+		if (s->id == id)
+			return (s);
+	}
+	return (NULL);
+}
+
+/*
+ * Tears down a session fully: notifies its store child (if wired) with
+ * IMSG_STORE_SHUTDOWN, closes both fds, unregisters both events, removes
+ * from the session table, frees. Closes a small gap surfaced by actually
+ * writing this: store.c has handled IMSG_STORE_SHUTDOWN since it was
+ * first written ("arrives directly from listener, no round-trip through
+ * parent" -- see its header comment), but nothing anywhere ever sent
+ * one. Best-effort on the imsg send -- if the store child is already
+ * gone or its channel is backed up, log and move on rather than block
+ * client teardown on it.
+ */
+static void
+session_teardown(struct session *s)
+{
+	if (s->store_iev != NULL) {
+		if (imsg_compose(&s->store_iev->ibuf, IMSG_STORE_SHUTDOWN,
+		    0, 0, -1, NULL, 0) == -1)
+			log_warn("session %u: imsg_compose "
+			    "IMSG_STORE_SHUTDOWN", s->id);
+		else if (imsgbuf_flush(&s->store_iev->ibuf) == -1)
+			log_warn("session %u: imsgbuf_flush "
+			    "IMSG_STORE_SHUTDOWN", s->id);
+		event_del(&s->store_iev->ev);
+		close(s->store_iev->ibuf.fd);
+		free(s->store_iev);
+	}
+
+	if (s->client_ev_added)
+		event_del(&s->client_ev);
+
+	if (s->tls_ctx != NULL) {
+		int	ret = tls_close(s->tls_ctx);
+
+		/*
+		 * tls_close() closes the underlying fd itself -- confirmed
+		 * by reading tls_close()'s real implementation
+		 * (openbsd_source/src/lib/libtls/tls.c) rather than
+		 * assuming; it's a real, easy-to-get-wrong fact (plenty of
+		 * TLS libraries in other languages do NOT close the
+		 * wrapped fd for you). The one case it does NOT close the
+		 * fd is when the close_notify shutdown itself needs another
+		 * network round trip (TLS_WANT_POLLIN/POLLOUT) -- not
+		 * something a forced teardown should wait for, so we close
+		 * the fd ourselves in exactly that case. Any other return
+		 * (0 success, or a hard -1 from a failed shutdown(2)/
+		 * close(2) syscall) means tls_close() already reached its
+		 * own close(2) call, so closing again here would double-
+		 * close the fd.
+		 */
+		if (ret == TLS_WANT_POLLIN || ret == TLS_WANT_POLLOUT)
+			close(s->client_fd);
+		else if (ret != 0)
+			log_warnx("session %u: tls_close: %s", s->id,
+			    tls_error(s->tls_ctx));
+		tls_free(s->tls_ctx);
+	} else {
+		close(s->client_fd);
+	}
+
+	free(s->literal_buf);	/* NULL-safe if no APPEND literal was ever
+				 * in flight when this session was torn
+				 * down */
+	free(s->search_matches); /* NULL-safe if no SEARCH was ever in
+				 * flight when this session was torn down */
+	free(s->vanished_ranges); /* NULL-safe -- RFC 7162 QRESYNC resync
+				 * accumulator, see struct session's comment */
+	free(s->qresync_fetches); /* NULL-safe, same reason */
+	free(s->store_modified); /* NULL-safe -- RFC 7162 STORE UNCHANGEDSINCE
+				 * accumulator, see struct session's comment */
+	free(s->pending_header_buf); /* NULL-safe -- see struct session's
+				 * comment; store.c's ordering contract means
+				 * this is normally already NULL by the time a
+				 * FETCH finishes, but a mid-stream teardown
+				 * (client disconnects between the header and
+				 * meta imsgs) could leave it set */
+	free(s->pending_body_buf); /* NULL-safe, same reasoning as
+				 * pending_header_buf just above */
+	free(s->pending_envelope_buf); /* NULL-safe, same reasoning as
+				 * pending_header_buf just above */
+	free(s->pending_bodystructure_buf); /* NULL-safe, same reasoning as
+				 * pending_header_buf just above */
+
+	TAILQ_REMOVE(&sessions, s, entry);
+	log_debug("session %u: closed", s->id);
+	free(s);
+}
blob - /dev/null
blob + 891297a134890265490234e6143f2866aaba7dc8 (mode 644)
--- /dev/null
+++ src/log.c
@@ -0,0 +1,192 @@
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * 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.
+ */
+
+#include <sys/types.h>
+
+#include <errno.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <syslog.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "log.h"
+
+static int	 log_foreground = 1;
+static int	 log_verbose = 0;
+static char	 log_procname[32] = "imapd";
+
+void
+log_init(int foreground, int verbose)
+{
+	log_foreground = foreground;
+	log_verbose = verbose;
+
+	if (!log_foreground)
+		openlog(log_procname, LOG_PID | LOG_NDELAY, LOG_DAEMON);
+
+	tzset();
+}
+
+void
+log_procinit(const char *name)
+{
+	if (name != NULL)
+		(void)strlcpy(log_procname, name, sizeof(log_procname));
+}
+
+void
+log_setverbose(int v)
+{
+	log_verbose = v;
+}
+
+int
+log_getverbose(void)
+{
+	return (log_verbose);
+}
+
+void
+vlog(int pri, const char *fmt, va_list ap)
+{
+	char	*nfmt;
+	int	 saved_errno = errno;
+
+	if (log_foreground) {
+		if (asprintf(&nfmt, "%s: %s\n", log_procname, fmt) == -1) {
+			vfprintf(stderr, fmt, ap);
+			fprintf(stderr, "\n");
+		} else {
+			vfprintf(stderr, nfmt, ap);
+			free(nfmt);
+		}
+		fflush(stderr);
+	} else
+		vsyslog(pri, fmt, ap);
+
+	errno = saved_errno;
+}
+
+void
+logit(int pri, const char *fmt, ...)
+{
+	va_list	 ap;
+
+	va_start(ap, fmt);
+	vlog(pri, fmt, ap);
+	va_end(ap);
+}
+
+void
+log_warn(const char *emsg, ...)
+{
+	char	*nfmt;
+	va_list	 ap;
+
+	/* best-effort in appending strerror(errno) after the format */
+	if (emsg == NULL)
+		logit(LOG_ERR, "%s", strerror(errno));
+	else {
+		if (asprintf(&nfmt, "%s: %s", emsg, strerror(errno)) == -1) {
+			va_start(ap, emsg);
+			vlog(LOG_ERR, emsg, ap);
+			va_end(ap);
+			return;
+		}
+		va_start(ap, emsg);
+		vlog(LOG_ERR, nfmt, ap);
+		va_end(ap);
+		free(nfmt);
+	}
+}
+
+void
+log_warnx(const char *emsg, ...)
+{
+	va_list	 ap;
+
+	va_start(ap, emsg);
+	vlog(LOG_ERR, emsg, ap);
+	va_end(ap);
+}
+
+void
+log_info(const char *emsg, ...)
+{
+	va_list	 ap;
+
+	va_start(ap, emsg);
+	vlog(LOG_INFO, emsg, ap);
+	va_end(ap);
+}
+
+void
+log_debug(const char *emsg, ...)
+{
+	va_list	 ap;
+
+	if (log_verbose == 0)
+		return;
+
+	va_start(ap, emsg);
+	vlog(LOG_DEBUG, emsg, ap);
+	va_end(ap);
+}
+
+static void
+vfatalc(int code, const char *emsg, va_list ap)
+{
+	static char	 s[BUFSIZ];
+	const char	*sep;
+
+	if (emsg != NULL) {
+		(void)vsnprintf(s, sizeof(s), emsg, ap);
+		sep = ": ";
+	} else {
+		s[0] = '\0';
+		sep = "";
+	}
+
+	if (code != 0)
+		logit(LOG_CRIT, "fatal: %s%s%s", s, sep, strerror(code));
+	else
+		logit(LOG_CRIT, "fatal: %s", s);
+}
+
+__dead void
+fatal(const char *emsg, ...)
+{
+	va_list	 ap;
+
+	va_start(ap, emsg);
+	vfatalc(errno, emsg, ap);
+	va_end(ap);
+	exit(1);
+}
+
+__dead void
+fatalx(const char *emsg, ...)
+{
+	va_list	 ap;
+
+	va_start(ap, emsg);
+	vfatalc(0, emsg, ap);
+	va_end(ap);
+	exit(1);
+}
blob - /dev/null
blob + 7b785502874e0472f92af2fc91ab23b0ad5088de (mode 644)
--- /dev/null
+++ src/log.h
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * 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.
+ */
+
+/*
+ * Minimal logging, matching the call signatures actually observed in the
+ * uploaded smtpd.c this session (fatal("pledge"), fatalx("chdir"),
+ * log_debug("setup_done: %s[%d] done", p->name, p->pid),
+ * log_procinit(proc_title(smtpd_process))) -- those call sites are real,
+ * quoted material; this implementation of them is this project's own, not
+ * copied from smtpd's actual log.c (not among the uploaded files).
+ */
+
+#ifndef OPENIMAP_LOG_H
+#define OPENIMAP_LOG_H
+
+#include <sys/cdefs.h>
+#include <stdarg.h>
+
+void		 log_init(int, int);
+void		 log_procinit(const char *);
+void		 log_setverbose(int);
+int		 log_getverbose(void);
+
+void		 log_warn(const char *, ...)
+		    __attribute__((__format__ (printf, 1, 2)));
+void		 log_warnx(const char *, ...)
+		    __attribute__((__format__ (printf, 1, 2)));
+void		 log_info(const char *, ...)
+		    __attribute__((__format__ (printf, 1, 2)));
+void		 log_debug(const char *, ...)
+		    __attribute__((__format__ (printf, 1, 2)));
+void		 logit(int, const char *, ...)
+		    __attribute__((__format__ (printf, 2, 3)));
+void		 vlog(int, const char *, va_list);
+__dead void	 fatal(const char *, ...)
+		    __attribute__((__format__ (printf, 1, 2)));
+__dead void	 fatalx(const char *, ...)
+		    __attribute__((__format__ (printf, 1, 2)));
+
+#endif /* OPENIMAP_LOG_H */
blob - /dev/null
blob + c881c86004f3b0309b567ed7aa864a6e6c5ca0bd (mode 644)
--- /dev/null
+++ src/main.c
@@ -0,0 +1,200 @@
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * 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.
+ */
+
+/*
+ * imapd(8) entry point: argv parsing and the role dispatch described in
+ * openimap-privsep-design.md's "fork+exec re-invocation" section.
+ *
+ * Invoked two different ways:
+ *
+ *   - No "-x" flag: this is the original, root-owned invocation (from
+ *     rc.d or a shell at boot). Falls through to parent_main(), which
+ *     does the actual fork()+dup2(sp[0], 3)+closefrom(4)+execvp() dance
+ *     for "listener" and "auth" per that document -- parent_main() is
+ *     handed argc/argv so it can re-exec children with the same argv[0]
+ *     plus "-x <role>" appended, exactly as documented.
+ *
+ *   - "-x <role>": this is a re-exec'd child. Its control channel to
+ *     parent is already sitting on fd 3 (parent dup2'd it there before
+ *     exec). We skip straight to that role's _main(), which begins by
+ *     reading fd 3 in a setup_proc()-style loop -- see listener.c/auth.c/
+ *     store.c.
+ */
+
+#include <sys/types.h>
+
+#include <err.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+
+__dead static void	 usage(void);
+
+static const struct {
+	const char		*name;
+	enum openimap_proc_type	 type;
+} proc_table[] = {
+	{ "listener",	PROC_LISTENER },
+	{ "auth",	PROC_AUTH },
+	{ "store",	PROC_STORE },
+};
+
+const char *
+log_procname(enum openimap_proc_type type)
+{
+	switch (type) {
+	case PROC_PARENT:
+		return "parent";
+	case PROC_LISTENER:
+		return "listener";
+	case PROC_AUTH:
+		return "auth";
+	case PROC_STORE:
+		return "store";
+	default:
+		return "?";
+	}
+}
+
+__dead static void
+usage(void)
+{
+	fprintf(stderr,
+	    "usage: %s [-dVv] [-D macro=value] [-f config] [-x role]\n",
+	    getprogname());
+	exit(1);
+}
+
+int
+main(int argc, char *argv[])
+{
+	int			 ch;
+	int			 debug = 0, verbose = 0;
+	const char		*conffile = "/etc/imapd.conf";
+	const char		*rolearg = NULL;
+	enum openimap_proc_type  role = PROC_PARENT;
+	size_t			 i;
+	struct openimap_config	 conf;
+
+	memset(&conf, 0, sizeof(conf));
+
+	while ((ch = getopt(argc, argv, "D:df:Vvx:")) != -1) {
+		switch (ch) {
+		case 'V':
+			/*
+			 * Print version and exit immediately -- before
+			 * log_init()/config_load()/any role dispatch, so
+			 * this has zero side effects (no syslog handle
+			 * opened, no config file touched, no privsep
+			 * children spawned). This is deliberate: it's the
+			 * RELINK smoke-test command in ../Makefile
+			 * (RELINK= "./${PROG} -V > /dev/null"), which bsd.
+			 * prog.mk runs against a freshly-relinked binary
+			 * before installing it -- it has to be safe to run
+			 * as any user, with no config file present, with no
+			 * chroot/privsep setup done, and it has to actually
+			 * exercise "the binary starts and runs its own
+			 * code" rather than just being a static string
+			 * embedded in the executable.
+			 */
+			printf("%s %s\n", getprogname(), IMAPD_VERSION);
+			return (0);
+		case 'D':
+			/*
+			 * "-D name=value" config-file macro definitions,
+			 * matching ripd(8)/smtpd(8)'s own "-D" convention --
+			 * applied to parse.y's symbol table immediately
+			 * (cmdline_symset()), not deferred, so they're
+			 * already in place by the time config_load() below
+			 * calls yyparse(). Only meaningful for role ==
+			 * PROC_PARENT (only parent ever parses the config
+			 * file), but harmless to accept unconditionally
+			 * before the role is known -- getopt(3) runs before
+			 * the role switch either way.
+			 */
+			if (cmdline_symset(optarg) == -1)
+				fatalx("could not parse macro definition %s",
+				    optarg);
+			break;
+		case 'd':
+			debug = 1;
+			break;
+		case 'f':
+			conffile = optarg;
+			break;
+		case 'v':
+			verbose = 1;
+			break;
+		case 'x':
+			rolearg = optarg;
+			break;
+		default:
+			usage();
+		}
+	}
+
+	if (rolearg != NULL) {
+		for (i = 0; i < sizeof(proc_table) / sizeof(proc_table[0]);
+		    i++) {
+			if (strcmp(rolearg, proc_table[i].name) == 0) {
+				role = proc_table[i].type;
+				break;
+			}
+		}
+		if (i == sizeof(proc_table) / sizeof(proc_table[0]))
+			usage();
+	}
+
+	log_init(debug, verbose);
+	log_procinit(log_procname(role));
+
+	/*
+	 * Only parent reads imapd.conf. Re-exec'd children (role !=
+	 * PROC_PARENT) get their slice of it over fd 3 from parent instead
+	 * -- IMSG_LISTENER_INIT, IMSG_AUTH_INIT, IMSG_STORE_INIT -- not
+	 * from this "conf" local, which is why listener_main()/auth_main()/
+	 * store_main() don't take it as a parameter at all. This used to be
+	 * a flagged gap (an earlier draft passed this same zeroed,
+	 * never-populated "conf" through to every role, which was silently
+	 * wrong for auth specifically: auth_main() derived its chroot
+	 * directory from conf->cred_file, which was always an empty string
+	 * for a re-exec'd child). Fixed by removing the parameter entirely
+	 * rather than leaving an unused one that looks like it should work.
+	 */
+	switch (role) {
+	case PROC_PARENT:
+		/* parent alone reads and validates the real config file. */
+		if (config_load(conffile, &conf) == -1)
+			fatalx("config_load: %s", conffile);
+		parent_main(conffile, argc, argv, &conf);
+		/* NOTREACHED */
+	case PROC_LISTENER:
+		listener_main();
+		/* NOTREACHED */
+	case PROC_AUTH:
+		auth_main();
+		/* NOTREACHED */
+	case PROC_STORE:
+		store_main();
+		/* NOTREACHED */
+	}
+
+	fatalx("unhandled role %d", role);
+}
blob - /dev/null
blob + 0b114fd2ed455f752f3bfed30d1c2dadb7a15023 (mode 644)
--- /dev/null
+++ src/parent.c
@@ -0,0 +1,1346 @@
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * 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.
+ */
+
+/*
+ * parent.c -- privileged supervisor. Implements the mechanisms decided in
+ * openimap-privsep-design.md:
+ *
+ *   - fork()+dup2(sp[0], 3)+closefrom(4)+execvp() re-exec for listener and
+ *     auth at boot ("Peer-wiring handshake" section).
+ *   - the SETUP_PEER/SETUP_DONE handshake, sourced against smtpd.c's
+ *     setup_peers()/setup_done()/setup_proc(), wiring listener<->auth at
+ *     boot.
+ *   - per-session store spawn ("Per-session store spawn & peer-wiring"),
+ *     including the deliberate departures from forkmda(): re-exec instead
+ *     of plain fork, IMSG_STORE_INIT carrying runtime uid/gid, and
+ *     session-scoped (not daemon-fatal) handling of a handshake timeout --
+ *     sourced against setup_done()'s real fatal()-on-timeout behavior,
+ *     which this file deliberately does NOT copy, for the reasons that
+ *     section spells out.
+ *
+ * TODO, flagged rather than silently stubbed: TLS cert loading reads the
+ * files but does no validation. Does not block the process-management
+ * mechanics this file exists to get right. (config_load() itself used to
+ * be flagged here too -- it's a real yacc-based imapd.conf parser now,
+ * living in parse.y, not this file; see that file's header comment.)
+ *
+ * API NAMES: checked against the real src/imsg.h ($OpenBSD: imsg.h,v
+ * 1.24 2025/06/05, Claudio Jeker et al.) this session. Every imsg/imsgbuf
+ * call used in this skeleton -- imsgbuf_init(), imsgbuf_read(),
+ * imsgbuf_flush(), imsgbuf_set_maxsize(), imsgbuf_queuelen(), imsg_get(),
+ * imsg_get_type(), imsg_get_data(), imsg_get_fd(), imsg_compose(),
+ * imsg_free() -- matches the real header's signature and calling
+ * convention exactly. imsg_get_ibuf() was guessed with the wrong
+ * signature (real: `int imsg_get_ibuf(struct imsg *, struct ibuf *)`,
+ * not what earlier comments assumed) but is never actually called
+ * anywhere in this codebase, only referenced in stale comments -- no
+ * runtime bug resulted. One real gap the real header surfaced:
+ * imsgbuf_allow_fdpass(struct imsgbuf *) exists and was NOT being called
+ * anywhere, despite every role either sending or receiving fd-passed
+ * messages (listening sockets, SETUP_PEER peer fds). Added below and in
+ * imsgev.c/listener.c/auth.c/store.c wherever a struct imsgbuf is
+ * imsgbuf_init()'d. Same verification applies in listener.c/auth.c/
+ * store.c/imsgev.c.
+ */
+
+#include <sys/types.h>
+#include <sys/queue.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/wait.h>
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <event.h>
+#include <fcntl.h>
+#include <imsg.h>
+#include <limits.h>
+#include <netinet/in.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+
+#define STORE_SETUP_TIMEOUT_SEC	10	/* matches smtpd's setup_done()
+					 * precedent of 10000ms -- see the
+					 * design doc's sourcing note on why
+					 * OpenIMAPD keeps the value but not
+					 * smtpd's fatal()-on-timeout
+					 * behavior. */
+
+#define STORE_CHILD_MAX		64	/* F5 fix: cap concurrent per-session
+					 * store children so a fork flood cannot
+					 * exhaust PIDs/fds/memory in the root
+					 * parent. */
+
+struct child {
+	pid_t				 pid;
+	enum openimap_proc_type	 type;
+	struct imsgev			 iev;
+	TAILQ_ENTRY(child)		 entry;
+};
+
+/*
+ * A per-session store child, from fork through teardown. "pending" is
+ * true from fork until its IMSG_SETUP_DONE ack arrives (or the handshake
+ * times out); timeout_ev is only armed while pending. Deliberately ONE
+ * struct for both states rather than two (an earlier draft of this file
+ * had a separate store_pending vs. store_child pair and "promoted"
+ * between them by copying struct imsgev -- unsafe, since struct imsgev
+ * embeds a struct event that's already registered with libevent at its
+ * original address by the time of that copy; event(3) requires the
+ * struct not move while registered. One struct, allocated once, closes
+ * that bug rather than papering over it.
+ */
+struct store_child {
+	uint32_t			 session_id;
+	pid_t				 pid;
+	struct imsgev			 iev;	/* parent<->this store child */
+	int				 pending;
+	struct event			 timeout_ev;
+	struct imsgev			*listener_iev; /* who to notify on
+							* failure -- see
+							* store_child_fail() */
+	TAILQ_ENTRY(store_child)	 entry;
+};
+
+static TAILQ_HEAD(, child)		 children =
+				    TAILQ_HEAD_INITIALIZER(children);
+static TAILQ_HEAD(, store_child)	 store_children =
+				    TAILQ_HEAD_INITIALIZER(store_children);
+
+static struct imsgev	*iev_listener;
+static struct imsgev	*iev_auth;
+static struct openimap_config *gconf;
+static char		 progpath[PATH_MAX];
+static char		**saved_argv;
+static const char	*conf_path;	/* set once in parent_main(), read by
+					 * sighup_handler() to re-run
+					 * config_load() against the same
+					 * file main() originally loaded --
+					 * see imapd.h's parent_main() comment */
+
+static struct event	 ev_sighup, ev_sigterm, ev_sigchld;
+
+static pid_t	 fork_child(enum openimap_proc_type, struct imsgev **,
+		    void (*)(int, short, void *));
+static void	 setup_peer_send(struct imsgev *, struct imsgev *, uint32_t);
+static void	 setup_done_send(struct imsgev *);
+static void	 parent_dispatch_child(int, short, void *);
+static void	 parent_handle_store_fork(uint32_t, uid_t, gid_t, const char *,
+		    struct imsgev *);
+static void	 store_child_dispatch(int, short, void *);
+static void	 store_child_timeout(int, short, void *);
+static void	 store_child_teardown(struct store_child *, int);
+static void	 store_child_fail(struct store_child *);
+static int	 bind_one(int, const struct sockaddr *, socklen_t, uint16_t);
+static int	 bind_listen_socket(const char *, uint16_t,
+		    int[LISTENER_MAX_ADDRS]);
+static void	 send_listener_sockets(struct imsgev *, int *, int, int *,
+		    int);
+static void	 send_tls_certs(struct imsgev *, struct openimap_config *);
+static void	 send_listener_init(struct imsgev *, struct openimap_config *,
+		    int, int);
+static void	 send_auth_init(struct imsgev *, struct openimap_config *);
+static void	 sighup_handler(int, short, void *);
+static void	 sigterm_handler(int, short, void *);
+static void	 sigchld_handler(int, short, void *);
+static void	 reap_child(pid_t, int);
+
+/*
+ * config_load() used to live here as a stub that filled in v1 defaults and
+ * ignored its "path" argument entirely. The real implementation -- a full
+ * imapd.conf grammar, yacc-based, matching the pattern used throughout
+ * the OpenBSD base system's own daemons -- now lives in parse.y, per the
+ * project's own "follow the OpenSMTPD/httpd/ntpd pattern" design
+ * philosophy. See parse.y's header comment for the grammar's sourcing and
+ * scope; imapd.h still carries config_load()'s prototype unchanged, so
+ * this file's only callers (main.c) needed no changes at all.
+ */
+
+__dead void
+parent_main(const char *conffile, int argc, char *argv[],
+    struct openimap_config *conf)
+{
+	int	 cleartext_fds[LISTENER_MAX_ADDRS], tls_fds[LISTENER_MAX_ADDRS];
+	int	 n_cleartext, n_tls, i;
+
+	(void)argc;	/* saved_argv (argv itself) is what fork_child()/
+			 * parent_handle_store_fork() actually walk when
+			 * building a child's re-exec argv -- see both for
+			 * why: they scan for a NUL terminator via
+			 * saved_argv[i] != NULL rather than using argc. */
+
+	if (geteuid() != 0)
+		fatalx("parent must start as root");
+
+	gconf = conf;
+	conf_path = conffile;
+	saved_argv = argv;
+	if (realpath(argv[0], progpath) == NULL)
+		fatal("realpath");
+
+	/* bind(2) on <1024 needs root -- done here, before any privdrop. */
+	n_cleartext = bind_listen_socket(conf->listen_addr,
+	    conf->port_cleartext, cleartext_fds);
+	n_tls = bind_listen_socket(conf->listen_addr,
+	    conf->port_implicit_tls, tls_fds);
+
+	event_init();
+
+	/*
+	 * Boot-time children: listener and auth only. store is NOT started
+	 * here -- see the "Per-session store spawn" section of
+	 * openimap-privsep-design.md. This corrects an earlier draft of
+	 * that document, which originally showed store as a fourth
+	 * boot-time process.
+	 */
+	fork_child(PROC_LISTENER, &iev_listener, parent_dispatch_child);
+	fork_child(PROC_AUTH, &iev_auth, parent_dispatch_child);
+
+	/*
+	 * IMSG_AUTH_INIT before the peer handshake, not after (unlike
+	 * listener's INIT below) -- auth_main() derives its chroot
+	 * directory from cred_file, so it needs this message before it can
+	 * even chroot(), let alone finish the handshake. Mirrors
+	 * IMSG_STORE_INIT's precedent of arriving before anything else on
+	 * that child's fd 3. auth.c reads this first, matching the order
+	 * sent here.
+	 */
+	send_auth_init(iev_auth, conf);
+
+	/* IMSG_SETUP_PEER / IMSG_SETUP_DONE, sourced against smtpd.c's
+	 * setup_peers()/setup_done() -- see design doc. Only listener<->auth
+	 * is wired at boot. */
+	setup_peer_send(iev_listener, iev_auth, 0);
+	setup_done_send(iev_listener);
+	setup_done_send(iev_auth);
+
+	/*
+	 * send_listener_sockets() fd-passes cleartext_fds[]/tls_fds[] to
+	 * listener via imsg_compose() (SCM_RIGHTS over the AF_UNIX
+	 * socketpair, not a local dup(2)) -- our own copies are closed right
+	 * after, listener now owns the only live references.
+	 * IMSG_LISTENER_INIT rides along with these post-handshake boot
+	 * extras, carrying n_cleartext/n_tls so listener's synchronous
+	 * drain loop knows how many IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS
+	 * messages to expect (1 each normally, 2 each for "listen on *") --
+	 * that loop tolerates any arrival order among these messages (see
+	 * listener.c), so sending init first here is for readability, not
+	 * correctness.
+	 */
+	send_listener_init(iev_listener, conf, n_cleartext, n_tls);
+	send_listener_sockets(iev_listener, cleartext_fds, n_cleartext,
+	    tls_fds, n_tls);
+	send_tls_certs(iev_listener, conf);
+	for (i = 0; i < n_cleartext; i++)
+		close(cleartext_fds[i]);
+	for (i = 0; i < n_tls; i++)
+		close(tls_fds[i]);
+
+	signal_set(&ev_sighup, SIGHUP, sighup_handler, NULL);
+	signal_set(&ev_sigterm, SIGTERM, sigterm_handler, NULL);
+	signal_set(&ev_sigchld, SIGCHLD, sigchld_handler, NULL);
+	signal_add(&ev_sighup, NULL);
+	signal_add(&ev_sigterm, NULL);
+	signal_add(&ev_sigchld, NULL);
+	signal(SIGPIPE, SIG_IGN);
+
+	/*
+	 * pledge, resolved in openimap-privsep-design.md: proc/exec/sendfd
+	 * stay for the process lifetime (not narrowed post-boot) because
+	 * store is fork-per-session -- parent forks continuously, not just
+	 * at startup. See that document's item 5 for why an earlier draft
+	 * of this decision was wrong.
+	 */
+#ifdef __OpenBSD__
+	if (pledge("stdio rpath inet proc exec sendfd", NULL) == -1)
+		fatal("pledge");
+#endif
+
+	event_dispatch();
+	fatalx("parent: exited event loop");
+}
+
+/*
+ * Re-exec mechanism, sourced against smtpd.c's start_child(): parent
+ * creates a socketpair, fork()s, the child dup2()s its end onto fd 3,
+ * closefrom(4)s, and execvp()s argv[0] plus "-x <role>". Returns the
+ * child's pid; *ievp is initialized in place on a freshly allocated
+ * struct child, never copied afterward -- see the struct store_child
+ * comment above for why that matters (a struct imsgev must not move once
+ * imsgev_init() has registered its struct event with libevent).
+ *
+ * Only used for the two boot-time children (listener, auth). Per-session
+ * store children go through parent_handle_store_fork() below instead,
+ * which duplicates a little of this function's fork/exec body rather
+ * than threading an opaque "child type" through one shared path -- boot
+ * children and per-session store children have different enough
+ * lifecycles (fixed pair-wiring vs. a dynamic session-keyed table with a
+ * timeout) that forcing one function to cover both got harder to read
+ * than two similar ones.
+ */
+static pid_t
+fork_child(enum openimap_proc_type type, struct imsgev **ievp,
+    void (*handler)(int, short, void *))
+{
+	int		 sp[2];
+	pid_t		 pid;
+	char		*nargv[16];
+	int		 i, n;
+	struct child	*c;
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, sp) == -1)
+		fatal("socketpair");
+
+	if ((pid = fork()) == -1)
+		fatal("fork");
+
+	if (pid == 0) {
+		/* child */
+		close(sp[0]);
+		if (dup2(sp[1], 3) == -1)
+			_exit(1);
+		closefrom(4);
+
+		n = 0;
+		nargv[n++] = progpath;
+		nargv[n++] = "-x";
+		nargv[n++] = (char *)log_procname(type);
+		for (i = 1; saved_argv[i] != NULL && n < 13; i++) {
+			/* skip a pre-existing -x <role> from our own argv,
+			 * everything else (e.g. -d/-v) passes through */
+			if (strcmp(saved_argv[i], "-x") == 0) {
+				i++;
+				continue;
+			}
+			nargv[n++] = saved_argv[i];
+		}
+		nargv[n] = NULL;
+
+		execvp(nargv[0], nargv);
+		_exit(1);
+	}
+
+	/* parent */
+	close(sp[1]);
+
+	c = calloc(1, sizeof(*c));
+	if (c == NULL)
+		fatal("calloc");
+	c->pid = pid;
+	c->type = type;
+	/*
+	 * Real bug caught via gdb backtrace + core dump on premio (first
+	 * real-hardware run to ever exercise this path -- parent_dispatch_
+	 * child() is only invoked post-boot, and nothing sent listener/auth
+	 * anything post-boot until this session's first successful
+	 * AUTHENTICATE triggered the first-ever IMSG_STORE_FORK): passing
+	 * "c" here instead of NULL meant iev->data (and therefore the arg
+	 * libevent hands back to parent_dispatch_child()) was the enclosing
+	 * struct child*, not &c->iev. Every dispatch handler in this
+	 * codebase -- parent_dispatch_child() included, unlike
+	 * store_child_dispatch(), which deliberately takes a struct
+	 * store_child* and is the one caller that correctly passes its
+	 * enclosing struct -- does `struct imsgev *iev = arg;` and expects
+	 * arg to already be &iev. With arg == c (8 bytes before &c->iev on
+	 * this build), iev->ibuf's fields were read shifted by the width of
+	 * struct child's pid+type header, producing exactly the implausible
+	 * fd/pointer values the core dump showed (confirmed by comparing
+	 * the crashing arg against a live `print iev_listener` in the same
+	 * gdb session: arg was iev_listener - 8, i.e. c itself). NULL here
+	 * takes imsgev_init()'s own fallback (iev->data = data != NULL ?
+	 * data : iev) to self-reference &c->iev instead -- the same pattern
+	 * every other imsgev_init() call site in the tree already uses
+	 * (store.c's, listener.c's x3, auth.c's).
+	 */
+	imsgev_init(&c->iev, sp[0], handler, NULL);
+	TAILQ_INSERT_TAIL(&children, c, entry);
+
+	*ievp = &c->iev;
+	return (pid);
+}
+
+/*
+ * IMSG_SETUP_PEER: parent opens a fresh socketpair and fd-passes one end
+ * to each of "a" and "b" over their existing fd-3 channels. Sourced
+ * against smtpd.c's setup_peers() (imsg_compose(..., IMSG_SETUP_PEER,
+ * b->proc, b->pid, sp[0], ...)) -- that quoted call shape is exactly
+ * where "id" slots into imsg_compose()'s second argument the same way
+ * "b->proc" does there; this project's "id" is a uint32_t session_id
+ * instead of smtpd's proc type, but the mechanism (the imsg header's
+ * generic 32-bit id field, read back via imsg_get_id()) is the same one.
+ *
+ * "id" is 0 for the one boot-time call (listener<->auth, no session
+ * concept yet) and the session_id for every per-session store call --
+ * see parent_handle_store_fork() below. This is the fix for the gap
+ * flagged in an earlier pass: listener previously had no way to tell
+ * which in-flight store handshake a given IMSG_SETUP_PEER belonged to,
+ * since this always sent peerid/id 0. listener.c's IMSG_SETUP_PEER
+ * handler now reads it back with imsg_get_id() to find the right
+ * session.
+ */
+static void
+setup_peer_send(struct imsgev *a, struct imsgev *b, uint32_t id)
+{
+	int sp[2];
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, sp) == -1)
+		fatal("socketpair");
+
+	if (imsg_compose(&a->ibuf, IMSG_SETUP_PEER, id, 0, sp[0], NULL, 0)
+	    == -1)
+		fatal("imsg_compose IMSG_SETUP_PEER (a)");
+	if (imsg_compose(&b->ibuf, IMSG_SETUP_PEER, id, 0, sp[1], NULL, 0)
+	    == -1)
+		fatal("imsg_compose IMSG_SETUP_PEER (b)");
+
+	if (imsgbuf_flush(&a->ibuf) == -1)
+		fatal("imsgbuf_flush");
+	if (imsgbuf_flush(&b->ibuf) == -1)
+		fatal("imsgbuf_flush");
+}
+
+/*
+ * IMSG_SETUP_DONE: tell a child no more IMSG_SETUP_PEER messages are
+ * coming, then block for its ack. Sourced against smtpd.c's setup_done()
+ * -- including its 10000ms blocking wait -- but see the design doc for
+ * why this file does NOT copy setup_done()'s fatal()-on-timeout for the
+ * *per-session* store handshake (parent_handle_store_setup_done() below
+ * uses an event-driven timeout instead, precisely because that handshake
+ * happens continuously at runtime, not once at boot like this one).
+ * This blocking version is only used for the boot-time listener/auth
+ * wiring, where blocking the whole daemon during startup is fine -- there
+ * is no other in-flight work yet to protect.
+ */
+static void
+setup_done_send(struct imsgev *iev)
+{
+	struct imsg	 imsg;
+	ssize_t		 n;
+
+	if (imsg_compose(&iev->ibuf, IMSG_SETUP_DONE, 0, 0, -1, NULL, 0) == -1)
+		fatal("imsg_compose IMSG_SETUP_DONE");
+	if (imsgbuf_flush(&iev->ibuf) == -1)
+		fatal("imsgbuf_flush");
+
+	/*
+	 * imsg_get() before imsgbuf_read(): a real deadlock, caught via
+	 * ktrace(1) on the first real-OpenBSD run, not this sandbox --
+	 * see imsgev.c's setup_recv_one_peer()/setup_recv_done_and_ack()
+	 * header comments for the full citation. setup_peer_send() and
+	 * this function's own IMSG_SETUP_DONE send happen back to back
+	 * on the same fd with no wait in between, so the kernel can (and,
+	 * on the live hang, did) coalesce both into one readable chunk on
+	 * the child's end -- meaning the child's ack can arrive already
+	 * sitting in *our own* ibuf here, buffered from this same
+	 * function's earlier reads in the multi-child boot sequence.
+	 * Blindly calling imsgbuf_read() first would issue a real blocking
+	 * syscall waiting for bytes that were never coming.
+	 */
+	for (;;) {
+		if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsg_get");
+		if (n != 0)
+			break;
+		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
+			fatal("imsgbuf_read");
+		if (n == 0)
+			fatalx("setup_done_send: child closed channel");
+	}
+
+	if (imsg_get_type(&imsg) != IMSG_SETUP_DONE)
+		fatalx("setup_done_send: expected IMSG_SETUP_DONE");
+	imsg_free(&imsg);
+}
+
+/* dispatch for the boot-time listener/auth channels once we're in the
+ * main event loop. TODO: real handling of IMSG_AUTH_* passthrough isn't
+ * needed here at all -- listener and auth talk to each other directly
+ * over the peer channel wired above, not through parent. The only
+ * message parent expects on these channels post-boot is
+ * IMSG_STORE_FORK, from listener. */
+static void
+parent_dispatch_child(int fd, short event, void *arg)
+{
+	struct imsgev	*iev = arg;
+	struct imsg	 imsg;
+	ssize_t		 n;
+
+	/*
+	 * EV_WRITE: real bug caught on first real-hardware run -- see
+	 * auth.c's auth_dispatch() header comment for the full citation
+	 * against imsg_init(3)'s own EXAMPLES section. imsg_compose() only
+	 * queues; imsgbuf_write() is what actually puts bytes on the wire,
+	 * and nothing here ever called it.
+	 */
+	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) {
+			/* child's end closed -- reaped via SIGCHLD */
+			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_STORE_FORK: {
+			struct imsg_store_fork	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_STORE_FORK");
+				break;
+			}
+			parent_handle_store_fork(req.session_id, req.uid,
+			    req.gid, req.maildir, iev);
+			break;
+		}
+		default:
+			log_debug("parent_dispatch_child: unhandled %d",
+			    imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	/*
+	 * Real bug caught on first real-hardware run, same shape and same
+	 * fix as auth.c's auth_dispatch() and store.c's store_dispatch()
+	 * (see auth_dispatch()'s header comment for the full citation
+	 * against imsg_init(3)): nothing re-armed this channel when the
+	 * for loop above found nothing to process, which is exactly what
+	 * happens on a pure EV_WRITE firing now that EV_WRITE is actually
+	 * handled (just above). Unconditional call closes that gap.
+	 */
+	imsgev_add(iev);
+	(void)fd;
+}
+
+/*
+ * F1 helper: reject a maildir that could escape the intended spool
+ * subtree. Rejects NULL/empty, absolute paths, and any path containing
+ * a "." or ".." component. Used before spawning a store child because
+ * the maildir is relayed through the untrusted, network-facing listener.
+ */
+static int
+maildir_path_is_safe(const char *p)
+{
+	const char	*seg = p;
+
+	if (p == NULL || p[0] == '\0' || p[0] == '/')
+		return (0);
+	for (;;) {
+		const char	*slash = strchr(seg, '/');
+		size_t		 len = slash ? (size_t)(slash - seg) : strlen(seg);
+
+		if (len == 1 && seg[0] == '.')
+			return (0);
+		if (len == 2 && seg[0] == '.' && seg[1] == '.')
+			return (0);
+		if (slash == NULL)
+			break;
+		seg = slash + 1;
+	}
+	return (1);
+}
+
+/*
+ * Per-session store spawn, triggered by listener's IMSG_STORE_FORK.
+ * Mirrors the boot-time re-exec + SETUP_PEER mechanism above, with the
+ * departures documented in openimap-privsep-design.md: re-exec (not
+ * forkmda()'s plain fork), an IMSG_STORE_INIT carrying this session's
+ * uid/gid before the child privilege-drops, and an event-driven timeout
+ * instead of setup_done()'s blocking-and-fatal() behavior.
+ */
+static void
+parent_handle_store_fork(uint32_t session_id, uid_t uid, gid_t gid,
+    const char *maildir, struct imsgev *listener_iev)
+{
+	struct store_child	*sc;
+	int			 pair[2];
+	pid_t			 pid;
+	char			*nargv[16];
+	int			 i, n;
+	struct timeval		 tv;
+	struct imsg_store_init	 init_payload;
+	struct imsg_store_fork	 fail_payload;
+	struct store_child	*it;
+	unsigned int		 nchildren = 0;
+
+	/*
+	 * F1 fix (defense in depth): uid/gid/maildir arrived relayed
+	 * through the untrusted, network-facing listener. Never grant a
+	 * store child uid/gid 0, and never accept an unsafe maildir. The
+	 * complete fix is for the trusted auth process to vend these to the
+	 * parent directly, keyed by session_id; this closes the escalation
+	 * in the meantime.
+	 */
+	if (uid == 0 || gid == 0) {
+		log_warnx("refusing IMSG_STORE_FORK: privileged uid=%u gid=%u "
+		    "for session %u", (unsigned)uid, (unsigned)gid,
+		    session_id);
+		goto fail;
+	}
+	if (!maildir_path_is_safe(maildir)) {
+		log_warnx("refusing IMSG_STORE_FORK: unsafe maildir for "
+		    "session %u", session_id);
+		goto fail;
+	}
+
+	/* F5 fix: cap concurrent store children. */
+	TAILQ_FOREACH(it, &store_children, entry)
+		nchildren++;
+	if (nchildren >= STORE_CHILD_MAX) {
+		log_warnx("refusing IMSG_STORE_FORK: %u store children active "
+		    "(session %u)", nchildren, session_id);
+		goto fail;
+	}
+
+	if (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, pair) == -1) {
+		log_warn("socketpair");
+		goto fail;
+	}
+
+	if ((pid = fork()) == -1) {
+		log_warn("fork");
+		close(pair[0]);
+		close(pair[1]);
+		goto fail;
+	}
+
+	if (pid == 0) {
+		close(pair[0]);
+		if (dup2(pair[1], 3) == -1)
+			_exit(1);
+		closefrom(4);
+
+		n = 0;
+		nargv[n++] = progpath;
+		nargv[n++] = "-x";
+		nargv[n++] = "store";
+		for (i = 1; saved_argv[i] != NULL && n < 13; i++) {
+			if (strcmp(saved_argv[i], "-x") == 0) {
+				i++;
+				continue;
+			}
+			nargv[n++] = saved_argv[i];
+		}
+		nargv[n] = NULL;
+
+		execvp(nargv[0], nargv);
+		_exit(1);
+	}
+
+	close(pair[1]);
+
+	/*
+	 * Allocated once, in place -- sc->iev is never copied afterward.
+	 * See the struct store_child comment above for why that matters.
+	 */
+	sc = calloc(1, sizeof(*sc));
+	if (sc == NULL)
+		fatal("calloc");
+	sc->session_id = session_id;
+	sc->pid = pid;
+	sc->pending = 1;
+	sc->listener_iev = listener_iev;
+	imsgev_init(&sc->iev, pair[0], store_child_dispatch, sc);
+
+	/* IMSG_STORE_INIT: the one message a per-session store child needs
+	 * that a boot-time child doesn't -- its privilege target is a
+	 * runtime value, not fixed by the role flag. See the design doc's
+	 * "Departure #2". */
+	init_payload.session_id = session_id;
+	init_payload.uid = uid;
+	init_payload.gid = gid;
+	(void)strlcpy(init_payload.spool_root, gconf->spool_root,
+	    sizeof(init_payload.spool_root));
+	/* maildir came from listener's IMSG_STORE_FORK, which got it from
+	 * auth's IMSG_AUTH_RESULT, which resolved it from the credential
+	 * file's fifth field -- see imapd.h's imsg_store_fork comment.
+	 * Passed through verbatim; parent itself never interprets it. */
+	(void)strlcpy(init_payload.maildir, maildir,
+	    sizeof(init_payload.maildir));
+	init_payload.bodystructure_read_max = gconf->bodystructure_read_max;
+	if (imsg_compose(&sc->iev.ibuf, IMSG_STORE_INIT, 0, 0, -1,
+	    &init_payload, sizeof(init_payload)) == -1) {
+		log_warn("imsg_compose IMSG_STORE_INIT");
+		goto fail_kill;
+	}
+
+	/* session_id threaded through as the imsg "id" field -- see
+	 * setup_peer_send()'s header comment -- so listener.c's
+	 * IMSG_SETUP_PEER handler can tell which in-flight store handshake
+	 * this new peer fd belongs to. */
+	setup_peer_send(&sc->iev, listener_iev, session_id);
+
+	if (imsg_compose(&sc->iev.ibuf, IMSG_SETUP_DONE, 0, 0, -1, NULL, 0)
+	    == -1) {
+		log_warn("imsg_compose IMSG_SETUP_DONE");
+		goto fail_kill;
+	}
+	imsgev_add(&sc->iev);
+
+	evtimer_set(&sc->timeout_ev, store_child_timeout, sc);
+	tv.tv_sec = STORE_SETUP_TIMEOUT_SEC;
+	tv.tv_usec = 0;
+	evtimer_add(&sc->timeout_ev, &tv);
+
+	TAILQ_INSERT_TAIL(&store_children, sc, entry);
+	return;
+
+fail_kill:
+	kill(pid, SIGKILL);
+	event_del(&sc->iev.ev);
+	close(sc->iev.ibuf.fd);	/* F11 fix: match store_child_teardown();
+				 * without this each setup failure leaks the
+				 * socketpair fd in the root parent. */
+	free(sc);
+fail:
+	/* Per the design doc's resolution: fail only this one session, not
+	 * the daemon. Reusing IMSG_STORE_FORK as the failure-reply type
+	 * (listener never otherwise receives that type from parent), with
+	 * uid/gid zeroed -- listener must key off "this came from parent"
+	 * plus session_id to recognize a failure reply, not the uid/gid
+	 * fields, which are meaningless here. memset() first now that the
+	 * struct also carries maildir -- otherwise that field would send
+	 * whatever garbage happened to be on the stack rather than an
+	 * empty string; harmless over a local imsg channel either way, but
+	 * sloppy, and listener.c doesn't read it on this path regardless. */
+	memset(&fail_payload, 0, sizeof(fail_payload));
+	fail_payload.session_id = session_id;
+	if (imsg_compose(&listener_iev->ibuf, IMSG_STORE_FORK, 0, 0, -1,
+	    &fail_payload, sizeof(fail_payload)) == -1)
+		log_warn("imsg_compose IMSG_STORE_FORK (failure reply)");
+	imsgev_add(listener_iev);
+}
+
+/*
+ * Real bug caught while wiring listener.c's SELECT round trip (same root
+ * cause, three places -- see listener_dispatch_auth()'s header comment
+ * there for the full citation against the real event.h): imsgev_init()
+ * registers plain EV_READ, not EV_PERSIST, and this function never
+ * re-armed sc->iev's own read side after servicing it. Not actively
+ * harmful yet (store.c never sends parent anything after its boot-time
+ * IMSG_SETUP_DONE), but it's the identical latent gap, so fixed the same
+ * way: unconditional imsgev_add() at the end, reached only when sc wasn't
+ * just torn down by store_child_fail() (both call sites return
+ * immediately after calling it).
+ */
+static void
+store_child_dispatch(int fd, short event, void *arg)
+{
+	struct store_child	*sc = arg;
+	struct imsg		 imsg;
+	ssize_t			 n;
+
+	/*
+	 * EV_WRITE: same real bug as this file's parent_dispatch_child() --
+	 * see auth.c's auth_dispatch() header comment for the full citation
+	 * against imsg_init(3). imsg_compose() only queues; without an
+	 * actual imsgbuf_write() on a writable fd, nothing this process
+	 * sends a store child (IMSG_STORE_INIT, IMSG_SETUP_PEER) would ever
+	 * really leave the socket.
+	 */
+	if (event & EV_WRITE) {
+		if (imsgbuf_write(&sc->iev.ibuf) == -1) {
+			store_child_fail(sc);
+			return;
+		}
+	}
+
+	if (event & EV_READ) {
+		if ((n = imsgbuf_read(&sc->iev.ibuf)) == -1 || n == 0) {
+			store_child_fail(sc);
+			return;
+		}
+	}
+
+	for (;;) {
+		if ((n = imsg_get(&sc->iev.ibuf, &imsg)) == -1) {
+			store_child_fail(sc);
+			return;
+		}
+		if (n == 0)
+			break;
+
+		if (sc->pending && imsg_get_type(&imsg) == IMSG_SETUP_DONE) {
+			evtimer_del(&sc->timeout_ev);
+			sc->pending = 0;
+			imsg_free(&imsg);
+			continue;
+		}
+
+		log_debug("store_child_dispatch: unhandled %d (session %u)",
+		    imsg_get_type(&imsg), sc->session_id);
+		imsg_free(&imsg);
+	}
+	imsgev_add(&sc->iev);	/* re-arm -- see this function's header comment */
+	(void)fd;
+}
+
+static void
+store_child_timeout(int fd, short event, void *arg)
+{
+	struct store_child *sc = arg;
+
+	(void)fd;
+	(void)event;
+	log_warnx("session %u: store setup timed out after %ds",
+	    sc->session_id, STORE_SETUP_TIMEOUT_SEC);
+	store_child_fail(sc);
+}
+
+/*
+ * Shared teardown for a store_child, whether it's still alive (needs
+ * killing) or was reaped by SIGCHLD already (do not kill(2) a pid that's
+ * already exited -- harmless on most systems but sloppy, and this project
+ * would rather be explicit). If it never finished setup (sc->pending),
+ * tells listener_iev the fork failed, reusing IMSG_STORE_FORK as the
+ * failure-reply type -- see parent_handle_store_fork()'s "fail:" path for
+ * the matching send on earlier-failure cases (socketpair/fork failure,
+ * before a store_child even exists) that never reach this function.
+ */
+static void
+store_child_teardown(struct store_child *sc, int already_dead)
+{
+	if (sc->pending) {
+		struct imsg_store_fork	 fail_payload;
+
+		evtimer_del(&sc->timeout_ev);
+		memset(&fail_payload, 0, sizeof(fail_payload));
+		fail_payload.session_id = sc->session_id;
+		if (imsg_compose(&sc->listener_iev->ibuf, IMSG_STORE_FORK,
+		    0, 0, -1, &fail_payload, sizeof(fail_payload)) == -1)
+			log_warn("imsg_compose IMSG_STORE_FORK (failure reply)");
+		imsgev_add(sc->listener_iev);
+	}
+	if (!already_dead)
+		kill(sc->pid, SIGKILL);
+	event_del(&sc->iev.ev);
+	close(sc->iev.ibuf.fd);
+	TAILQ_REMOVE(&store_children, sc, entry);
+	free(sc);
+}
+
+static void
+store_child_fail(struct store_child *sc)
+{
+	store_child_teardown(sc, 0);
+}
+
+/*
+ * Binds, sets SO_REUSEADDR, and listen(2)s one socket of the given family
+ * against sa/salen -- the common tail end of every case in
+ * bind_listen_socket() below, factored out rather than repeated three
+ * times. listen(2)'s backlog of 16 is unchanged from before this pass;
+ * see README.skeleton's own note on that being a known, minor,
+ * accepted v1 limitation, not something this pass touches.
+ */
+static int
+bind_one(int family, const struct sockaddr *sa, socklen_t salen,
+    uint16_t port)
+{
+	int	fd, val;
+
+	if ((fd = socket(family, SOCK_STREAM, 0)) == -1)
+		fatal("socket");
+
+	val = 1;
+	if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &val, sizeof(val)) == -1)
+		fatal("setsockopt");
+
+	if (bind(fd, sa, salen) == -1)
+		fatal("bind port %u", port);
+	if (listen(fd, 16) == -1)
+		fatal("listen");
+
+	return (fd);
+}
+
+/*
+ * Resolves and binds "addr" for "port", filling fds[] and returning how
+ * many it filled (1, or LISTENER_MAX_ADDRS for "*" -- see that macro's
+ * comment in imapd.h for the full "why two sockets, not one dual-mapped
+ * one" sourcing). "addr" is deliberately restricted to "*" or a literal
+ * IPv4/IPv6 address -- no getaddrinfo(3) hostname resolution -- per this
+ * pass's scoped decision: a personal single-box mail server has no real
+ * need for "listen on" to carry a DNS dependency, and parse.y's own
+ * grammar action rejects anything else before config_load() ever returns,
+ * so reaching the fatalx() below at runtime would mean parse.y's check and
+ * this one have drifted out of sync with each other, not a bad config
+ * file reaching this far.
+ */
+static int
+bind_listen_socket(const char *addr, uint16_t port, int fds[LISTENER_MAX_ADDRS])
+{
+	struct sockaddr_in	 sin;
+	struct sockaddr_in6	 sin6;
+	struct in_addr		 ina;
+	struct in6_addr		 ina6;
+	int			 n = 0;
+
+	if (strcmp(addr, "*") == 0) {
+		memset(&sin, 0, sizeof(sin));
+		sin.sin_family = AF_INET;
+		sin.sin_addr.s_addr = INADDR_ANY;
+		sin.sin_port = htons(port);
+		fds[n++] = bind_one(AF_INET, (struct sockaddr *)&sin,
+		    sizeof(sin), port);
+
+		memset(&sin6, 0, sizeof(sin6));
+		sin6.sin6_family = AF_INET6;
+		sin6.sin6_addr = in6addr_any;
+		sin6.sin6_port = htons(port);
+		fds[n++] = bind_one(AF_INET6, (struct sockaddr *)&sin6,
+		    sizeof(sin6), port);
+		return (n);
+	}
+
+	memset(&ina, 0, sizeof(ina));
+	if (inet_pton(AF_INET, addr, &ina) == 1) {
+		memset(&sin, 0, sizeof(sin));
+		sin.sin_family = AF_INET;
+		sin.sin_addr = ina;
+		sin.sin_port = htons(port);
+		fds[n++] = bind_one(AF_INET, (struct sockaddr *)&sin,
+		    sizeof(sin), port);
+		return (n);
+	}
+
+	memset(&ina6, 0, sizeof(ina6));
+	if (inet_pton(AF_INET6, addr, &ina6) == 1) {
+		memset(&sin6, 0, sizeof(sin6));
+		sin6.sin6_family = AF_INET6;
+		sin6.sin6_addr = ina6;
+		sin6.sin6_port = htons(port);
+		fds[n++] = bind_one(AF_INET6, (struct sockaddr *)&sin6,
+		    sizeof(sin6), port);
+		return (n);
+	}
+
+	fatalx("bind_listen_socket: \"%s\" is not \"*\" or a literal "
+	    "IPv4/IPv6 address", addr);
+}
+
+static void
+send_listener_sockets(struct imsgev *iev, int cleartext_fds[],
+    int n_cleartext, int tls_fds[], int n_tls)
+{
+	int	i;
+
+	for (i = 0; i < n_cleartext; i++) {
+		if (imsg_compose(&iev->ibuf, IMSG_LISTENER_SOCKET_CLEARTEXT,
+		    0, 0, cleartext_fds[i], NULL, 0) == -1)
+			fatal("imsg_compose IMSG_LISTENER_SOCKET_CLEARTEXT");
+	}
+	for (i = 0; i < n_tls; i++) {
+		if (imsg_compose(&iev->ibuf, IMSG_LISTENER_SOCKET_TLS, 0, 0,
+		    tls_fds[i], NULL, 0) == -1)
+			fatal("imsg_compose IMSG_LISTENER_SOCKET_TLS");
+	}
+	if (imsgbuf_flush(&iev->ibuf) == -1)
+		fatal("imsgbuf_flush");
+}
+
+/*
+ * Reads and sends both the TLS certificate AND the private key, as two
+ * separate IMSG_TLS_CERT / IMSG_TLS_KEY messages. Real gap fixed this
+ * pass: this function used to be named send_tls_cert() (singular) and
+ * only ever read+sent conf->tls_cert_file -- listener.c's TLS server
+ * context needs both (tls_config_set_keypair_mem(), sourced against
+ * httpd's server_tls_init() -- see listener.c), so without the key, real
+ * TLS could never have worked no matter what listener.c did with the
+ * cert alone.
+ *
+ * Two more real bugs fixed this pass, both found testing against smtpd(8)
+ * on the same box (premio) rather than guessed at:
+ *
+ *   1. The private key was never permission-checked. Found because
+ *      /etc/ssl/private/openimap.key sat at mode 0644 (world-readable)
+ *      this whole time -- this daemon never noticed or complained, while
+ *      smtpd's load_pki_keys() refused to even start with the same file
+ *      ("insecure permissions: must be at most rwxr-----"). Added the
+ *      same check smtpd's ssl_load_key() does (openbsd_source/src/
+ *      usr.sbin/smtpd/ssl.c:132-145): reject unless owned by uid 0 and
+ *      mode is no more permissive than rwxr----- (0740). The cert file
+ *      gets no such check -- it's public by definition, same as smtpd
+ *      only permission-checks the key, never the cert.
+ *
+ *   2. Both the old fopen()-failure paths and (without care) a new
+ *      permission-check failure would have `return`ed early, sending
+ *      neither IMSG_TLS_CERT nor IMSG_TLS_KEY for that failed file. But
+ *      listener.c's boot loop (its "while (!got_sockets || !got_cert ||
+ *      !got_key || !got_init)" drain, see listener_main()) blocks until
+ *      it receives exactly one of each message -- parent_main() never
+ *      retries after send_tls_certs() returns, so a missing/unreadable/
+ *      insecurely-permissioned cert or key used to hang listener at boot
+ *      forever instead of degrading to "no TLS", which is what listener's
+ *      own cert_len==0/key_len==0 handling (line ~1164) is designed to
+ *      do. Fixed by always sending both messages, with a zero-length
+ *      payload standing in for "unusable" on any failure, so listener's
+ *      existing graceful-degrade path is what actually runs.
+ *
+ * TODO: reads cert/key files but does no further validation (expiry,
+ * matching pubkey, etc.) -- libtls's own loading functions should
+ * probably do this work instead of hand-rolled file reads. TODO: each
+ * read is capped at sizeof(buf) (8192) and sent as a single imsg, itself
+ * capped at MAX_IMSGSIZE (16384) -- fine for a single leaf cert/key pair
+ * (typically 1-2KB each PEM-encoded), but there's no chunking for a
+ * certificate chain that exceeded either limit. Flagged, not solved --
+ * v1 doesn't need chain support per the design docs' current scope.
+ */
+static void
+send_tls_certs(struct imsgev *iev, struct openimap_config *conf)
+{
+	FILE		*fp;
+	char		 buf[8192];
+	size_t		 n;
+	struct stat	 st;
+
+	/* Cert is public -- existence/readability is all that matters. */
+	n = 0;
+	if ((fp = fopen(conf->tls_cert_file, "r")) == NULL) {
+		log_warn("fopen %s", conf->tls_cert_file);
+	} else {
+		n = fread(buf, 1, sizeof(buf), fp);
+		fclose(fp);
+	}
+
+	if (imsg_compose(&iev->ibuf, IMSG_TLS_CERT, 0, 0, -1, buf, n) == -1)
+		fatal("imsg_compose IMSG_TLS_CERT");
+	if (imsgbuf_flush(&iev->ibuf) == -1)
+		fatal("imsgbuf_flush");
+
+	/*
+	 * Key is private material: same ownership/mode check smtpd applies
+	 * (see this function's header comment), and n stays 0 -- sending an
+	 * empty IMSG_TLS_KEY -- on any failure rather than returning early.
+	 */
+	n = 0;
+	if ((fp = fopen(conf->tls_key_file, "r")) == NULL) {
+		log_warn("fopen %s", conf->tls_key_file);
+	} else if (fstat(fileno(fp), &st) == -1) {
+		log_warn("fstat %s", conf->tls_key_file);
+		fclose(fp);
+	} else if (st.st_uid != 0) {
+		log_warnx("%s: not owned by uid 0 -- refusing to load "
+		    "(TLS will be disabled)", conf->tls_key_file);
+		fclose(fp);
+	} else if (st.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO) & ~0740) {
+		log_warnx("%s: insecure permissions -- must be at most "
+		    "rwxr----- (TLS will be disabled)", conf->tls_key_file);
+		fclose(fp);
+	} else {
+		n = fread(buf, 1, sizeof(buf), fp);
+		fclose(fp);
+	}
+
+	/*
+	 * imsg_compose() copies buf's contents into the imsgbuf's own
+	 * internal queue immediately (see imsg.h -- it takes a `const
+	 * void *`/length pair, not ownership of buf itself), so it's safe
+	 * to scrub our own stack copy of the key material right after this
+	 * call returns, before imsgbuf_flush() actually writes the queued
+	 * bytes to the socket.
+	 */
+	if (imsg_compose(&iev->ibuf, IMSG_TLS_KEY, 0, 0, -1, buf, n) == -1) {
+		explicit_bzero(buf, sizeof(buf));
+		fatal("imsg_compose IMSG_TLS_KEY");
+	}
+	explicit_bzero(buf, sizeof(buf));
+	if (imsgbuf_flush(&iev->ibuf) == -1)
+		fatal("imsgbuf_flush");
+}
+
+/*
+ * IMSG_LISTENER_INIT: listener's slice of config. Used for a startup log
+ * line as before, but n_cleartext_addrs/n_tls_addrs are load-bearing now
+ * -- listener's boot-time drain loop needs them to know how many
+ * IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS messages to wait for (see that
+ * struct's comment in imapd.h). The listening sockets themselves still
+ * always arrive as already-bound fds via send_listener_sockets(), never
+ * reconstructed from listen_addr here.
+ */
+static void
+send_listener_init(struct imsgev *iev, struct openimap_config *conf,
+    int n_cleartext, int n_tls)
+{
+	struct imsg_listener_init	 init;
+
+	memset(&init, 0, sizeof(init));
+	(void)strlcpy(init.listen_addr, conf->listen_addr,
+	    sizeof(init.listen_addr));
+	init.port_cleartext = conf->port_cleartext;
+	init.port_implicit_tls = conf->port_implicit_tls;
+	init.n_cleartext_addrs = (uint8_t)n_cleartext;
+	init.n_tls_addrs = (uint8_t)n_tls;
+
+	if (imsg_compose(&iev->ibuf, IMSG_LISTENER_INIT, 0, 0, -1,
+	    &init, sizeof(init)) == -1)
+		fatal("imsg_compose IMSG_LISTENER_INIT");
+	if (imsgbuf_flush(&iev->ibuf) == -1)
+		fatal("imsgbuf_flush");
+}
+
+/*
+ * IMSG_AUTH_INIT: auth's slice of config -- just cred_file, the one
+ * field auth_main() actually needs (to derive its chroot directory and,
+ * post-chroot, the unveil() path). Not optional polish -- see this
+ * struct's comment in imapd.h for the bug this closes.
+ */
+static void
+send_auth_init(struct imsgev *iev, struct openimap_config *conf)
+{
+	struct imsg_auth_init	 init;
+
+	memset(&init, 0, sizeof(init));
+	(void)strlcpy(init.cred_file, conf->cred_file, sizeof(init.cred_file));
+
+	if (imsg_compose(&iev->ibuf, IMSG_AUTH_INIT, 0, 0, -1,
+	    &init, sizeof(init)) == -1)
+		fatal("imsg_compose IMSG_AUTH_INIT");
+	if (imsgbuf_flush(&iev->ibuf) == -1)
+		fatal("imsgbuf_flush");
+}
+
+/*
+ * SIGHUP: re-read imapd.conf and adopt what's safely reloadable, matching
+ * httpd(8)'s own documented behavior (man.openbsd.org/httpd.8: "httpd
+ * rereads its configuration file when it receives SIGHUP") -- the model
+ * this project follows throughout for "listen on" syntax, so it's the
+ * natural precedent for reload semantics too. Unlike httpd, there's no
+ * SIGUSR1/log-file-reopen counterpart here: this daemon logs to syslog(3)
+ * only (see log.c), same as smtpd(8) (man.openbsd.org/smtpd.8: no log-file
+ * FILES entry at all, just stderr under -d or syslogd(8) otherwise) --
+ * httpd's SIGUSR1 exists specifically to reopen its access.log/error.log
+ * after newsyslog(8) rotation, which doesn't apply here.
+ *
+ * Not everything in imapd.conf can be safely swapped into a *running*
+ * daemon:
+ *
+ *   - listen_addr/port_cleartext/port_implicit_tls: the listening sockets
+ *     are already bound and fd-passed to listener at boot (bind_listen_
+ *     socket() above, called once from parent_main() before the privilege
+ *     drop). Changing these live would mean rebinding new sockets and
+ *     redoing the whole IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS fd-passing
+ *     handshake listener's boot-time drain loop expects exactly once --
+ *     not attempted here. A real change to these three fields requires a
+ *     restart, same as smtpd/httpd expect for their own listen directives.
+ *
+ *   - cred_file: auth_main() chroot(2)s into this file's dirname once, at
+ *     boot (see auth.c), before ever reading IMSG_AUTH_REQUEST. There is no
+ *     way to hand auth a new cred_file living outside that chroot without
+ *     re-chrooting, which is impossible for an already-running process.
+ *     Same "requires a restart" treatment.
+ *
+ * Both of the above are detected and logged as a warning -- config_load()
+ * itself always succeeds against a file that merely changed one of these,
+ * so silently ignoring the new values (rather than pretending they took
+ * effect) is what keeps the running daemon's actual behavior truthful.
+ *
+ * spool_root and bodystructure_read_max are trivially safe: both are read
+ * fresh out of *gconf by parent_handle_store_fork() every time a session
+ * authenticates and a new store child is spawned (see that function's
+ * imsg_store_init population above) -- nothing caches a copy anywhere else,
+ * so updating gconf's copy here is the entire fix.
+ *
+ * TLS cert/key are re-read and re-pushed to listener unconditionally,
+ * whether or not the *path* strings changed -- the ordinary reason to
+ * SIGHUP a running mail server at all is that acme-client(1) (or a cron
+ * job wrapping it) just renewed the certificate at the same path, not that
+ * the path moved. listener.c's new IMSG_TLS_CERT/IMSG_TLS_KEY handling in
+ * listener_dispatch_parent() (added this same pass) rebuilds its TLS
+ * context from the fresh bytes and only swaps it in on complete success --
+ * see listener_reload_tls()'s header comment for why a reload failure must
+ * never tear down an already-working TLS context. send_tls_certs() already
+ * degrades gracefully to a zero-length payload on any read/permission
+ * failure (see that function's own header comment), which listener's
+ * reload path treats the same way: keep serving with whatever TLS
+ * configuration already worked.
+ */
+static void
+sighup_handler(int fd, short event, void *arg)
+{
+	struct openimap_config	 newconf;
+
+	(void)fd; (void)event; (void)arg;
+
+	log_info("SIGHUP: reloading %s", conf_path);
+
+	memset(&newconf, 0, sizeof(newconf));
+	if (config_load(conf_path, &newconf) == -1) {
+		log_warnx("SIGHUP: %s: reload failed -- keeping the "
+		    "already-running configuration", conf_path);
+		return;
+	}
+
+	if (strcmp(newconf.listen_addr, gconf->listen_addr) != 0 ||
+	    newconf.port_cleartext != gconf->port_cleartext ||
+	    newconf.port_implicit_tls != gconf->port_implicit_tls) {
+		log_warnx("SIGHUP: %s: \"listen on\" changed but listening "
+		    "sockets cannot be rebound without a restart -- still "
+		    "serving %s:%u / %s:%u", conf_path, gconf->listen_addr,
+		    gconf->port_cleartext, gconf->listen_addr,
+		    gconf->port_implicit_tls);
+	}
+	if (strcmp(newconf.cred_file, gconf->cred_file) != 0) {
+		log_warnx("SIGHUP: %s: \"credentials\" changed but auth is "
+		    "already chrooted for the old path -- a restart is "
+		    "required for this to take effect", conf_path);
+	}
+
+	/* Safe to adopt immediately -- see this function's header comment. */
+	(void)strlcpy(gconf->spool_root, newconf.spool_root,
+	    sizeof(gconf->spool_root));
+	gconf->bodystructure_read_max = newconf.bodystructure_read_max;
+
+	(void)strlcpy(gconf->tls_cert_file, newconf.tls_cert_file,
+	    sizeof(gconf->tls_cert_file));
+	(void)strlcpy(gconf->tls_key_file, newconf.tls_key_file,
+	    sizeof(gconf->tls_key_file));
+	send_tls_certs(iev_listener, gconf);
+
+	log_info("SIGHUP: reload complete");
+}
+
+static void
+sigterm_handler(int fd, short event, void *arg)
+{
+	struct child		*c;
+	struct store_child	*sc;
+
+	(void)fd; (void)event; (void)arg;
+	log_info("SIGTERM: shutting down");
+
+	TAILQ_FOREACH(c, &children, entry)
+		kill(c->pid, SIGTERM);
+	TAILQ_FOREACH(sc, &store_children, entry)
+		kill(sc->pid, SIGTERM);
+
+	exit(0);
+}
+
+static void
+sigchld_handler(int fd, short event, void *arg)
+{
+	pid_t	 pid;
+	int	 status;
+
+	(void)fd; (void)event; (void)arg;
+	while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
+		reap_child(pid, status);
+}
+
+/*
+ * Reaching the "children" (listener/auth) branch below always means an
+ * unexpected exit, never a graceful shutdown's own SIGCHLD: sigterm_
+ * handler() (this file) calls exit(0) immediately after kill()ing every
+ * child, without re-entering event_dispatch(), so this process never
+ * actually processes the SIGCHLD from children it deliberately killed.
+ *
+ * Deliberately NOT auto-restarted -- checked against both reference
+ * daemons this project follows before deciding, rather than assumed.
+ * smtpd's parent_sig_handler() SIGCHLD case (smtpd.c) treats its own
+ * core structural children (queue/control/lka/scheduler/dispatcher/ca --
+ * CHILD_DAEMON, the direct equivalent of listener/auth here) the same
+ * way this function now does: log a warning, keep running degraded,
+ * never respawn. (A different category, CHILD_PROCESSOR -- dynamically
+ * loaded table/filter plugins -- does trigger smtpd's own full shutdown
+ * on failure, but that's an externally-loaded plugin, not a core
+ * structural process, so it doesn't apply here.) httpd goes further
+ * still: its running parent (httpd.c) never even registers a SIGCHLD
+ * handler, so a dead child there goes completely unnoticed until actual
+ * shutdown (proc_kill(), proc.c). Neither reference daemon auto-restarts
+ * a crashed core child, so this one doesn't either -- matches this
+ * project's "smaller feature set" principle throughout, and avoids the
+ * real complexity (crash-loop protection, re-running the SETUP_PEER
+ * handshake and config delivery at *runtime* instead of only at boot) a
+ * real restart would need. Recovery is operator-driven: "rcctl restart
+ * imapd", same as an admin would do for smtpd or httpd today.
+ */
+static void
+reap_child(pid_t pid, int status)
+{
+	struct child		*c;
+	struct store_child	*sc;
+
+	TAILQ_FOREACH(c, &children, entry) {
+		if (c->pid == pid) {
+			const char	*what;
+
+			/*
+			 * listener owns every bound socket, so its death
+			 * takes down IMAP service entirely -- no new
+			 * connections can be accepted. auth's death is
+			 * narrower: already-authenticated sessions are
+			 * unaffected (post-login traffic is listener<->store
+			 * only, never listener<->auth -- see imapd.h's imsg
+			 * catalog), but new AUTHENTICATE attempts will fail,
+			 * since listener.c's own auth-channel-closed handling
+			 * (listener_dispatch_auth(), "auth closed channel")
+			 * never sends a client a completing reply either way.
+			 */
+			what = c->type == PROC_LISTENER ?
+			    "IMAP service is now unreachable (no listening "
+			    "sockets)" : "new logins will now fail "
+			    "(already-authenticated sessions are unaffected)";
+
+			if (WIFSIGNALED(status))
+				log_warnx("%s[%d] terminated by signal %d -- "
+				    "%s; run \"rcctl restart imapd\" to "
+				    "recover", log_procname(c->type), pid,
+				    WTERMSIG(status), what);
+			else if (WIFEXITED(status))
+				log_warnx("%s[%d] exited unexpectedly, "
+				    "status %d -- %s; run \"rcctl restart "
+				    "imapd\" to recover",
+				    log_procname(c->type), pid,
+				    WEXITSTATUS(status), what);
+			else
+				log_warnx("%s[%d] exited unexpectedly -- %s; "
+				    "run \"rcctl restart imapd\" to recover",
+				    log_procname(c->type), pid, what);
+			TAILQ_REMOVE(&children, c, entry);
+			free(c);
+			return;
+		}
+	}
+	TAILQ_FOREACH(sc, &store_children, entry) {
+		if (sc->pid == pid) {
+			log_debug("session %u: store child exited "
+			    "(status %d)", sc->session_id, status);
+			store_child_teardown(sc, 1);
+			return;
+		}
+	}
+}
blob - /dev/null
blob + 0614ff4d02f82aaa32ca6c562e0cd3e269a6126e (mode 644)
--- /dev/null
+++ src/parse.y
@@ -0,0 +1,881 @@
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * 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.
+ */
+
+/*
+ * parse.y -- imapd.conf grammar.
+ *
+ * config_load() (the only function this file exposes -- see imapd.h's
+ * prototype) replaces parent.c's old stub of the same name, which only
+ * ever filled in v1 defaults and ignored the config file path entirely.
+ *
+ * Structure and most of the machinery below (the pushfile()/popfile()
+ * file stack for "include", the lgetc()/lungetc()/findeol() character-
+ * pushback pair, the hand-written yylex() built on top of them, the
+ * symset()/symget()/cmdline_symset() $macro/-D variable table, and
+ * check_file_secrecy()) are this project's own rewrite of the same
+ * pattern used throughout the OpenBSD base system's own daemons -- sourced
+ * directly against ripd's parse.y (src/usr.sbin/ripd/parse.y, read in
+ * full this pass) for the generic boilerplate, and against smtpd's and
+ * httpd's parse.y (src/usr.sbin/smtpd/parse.y, src/usr.sbin/httpd/
+ * parse.y) for the "include" grammar rule and the "listen on <addr>
+ * [tls] port <port>" grammar shape respectively. None of these daemons'
+ * domain-specific grammar (RIP redistribution rules, SMTP rulesets,
+ * HTTP server blocks) is reused here -- only the parser skeleton and the
+ * listen-line shape. This project's own grammar covers exactly the eight
+ * fields in struct openimap_config (imapd.h): the two listeners,
+ * spool_root, cred_file, tls_cert_file, tls_key_file, and (added this
+ * pass) bodystructure_read_max via the "attachment max <bytes>"
+ * directive -- see that grammar rule's own comment below for why this
+ * one field gets range-validated where the others don't.
+ *
+ * Deliberately NOT following ripd/smtpd's convention in one place: those
+ * daemons' top-level parse function allocates and returns their own
+ * config struct. config_load()'s signature is already fixed by imapd.h
+ * (a caller-supplied struct openimap_config * to fill in, matching the
+ * existing prototype main.c already calls) and by the man page already
+ * documenting it, so this file fills that struct in place instead of
+ * allocating its own.
+ *
+ * Full-parity scope decision (AskUserQuestion, this pass): rather than a
+ * trimmed-down parser with just the core grammar, this includes the same
+ * $macro / -D command-line variable support and "include" file support
+ * every OpenBSD base daemon's config grammar has, even though a flat
+ * seven-field config has only modest use for either -- matching upstream
+ * convention closely was judged more valuable than the modest size
+ * savings a trimmed version would give, particularly given this project's
+ * long-term (if distant) OpenBSD base-inclusion aspiration.
+ */
+
+%{
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <sys/queue.h>
+#include <sys/socket.h>
+
+#include <arpa/inet.h>
+
+#include <ctype.h>
+#include <err.h>
+#include <errno.h>
+#include <limits.h>
+#include <netinet/in.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <syslog.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+
+TAILQ_HEAD(files, file)		 files = TAILQ_HEAD_INITIALIZER(files);
+static struct file {
+	TAILQ_ENTRY(file)	 entry;
+	FILE			*stream;
+	char			*name;
+	int			 lineno;
+	int			 errors;
+} *file, *topfile;
+struct file	*pushfile(const char *, int);
+int		 popfile(void);
+int		 yyparse(void);
+int		 yylex(void);
+int		 yyerror(const char *, ...)
+    __attribute__((__format__ (printf, 1, 2)))
+    __attribute__((__nonnull__ (1)));
+int		 kw_cmp(const void *, const void *);
+int		 lookup(char *);
+int		 lgetc(int);
+int		 lungetc(int);
+int		 findeol(void);
+
+TAILQ_HEAD(symhead, sym)	 symhead = TAILQ_HEAD_INITIALIZER(symhead);
+struct sym {
+	TAILQ_ENTRY(sym)	 entry;
+	int			 used;
+	int			 persist;
+	char			*nam;
+	char			*val;
+};
+int		 symset(const char *, const char *, int);
+char		*symget(const char *);
+
+static struct openimap_config	*conf;
+static int			 errors = 0;
+
+/*
+ * Tracks whether a "listen on ..." line for each of the two listeners has
+ * already been seen this parse, both to reject a second one for the same
+ * listener and to detect a second listener whose address doesn't match
+ * the first -- struct openimap_config has only one listen_addr shared by
+ * both ports (bind_listen_socket() is called once per port against that
+ * same field, parent.c), so two "listen on" lines naming different
+ * addresses would silently mean "the second one wins" without this check,
+ * which is far more likely to be a config mistake than an intentional
+ * per-listener address split this implementation doesn't actually support.
+ */
+static int			 have_cleartext = 0;
+static int			 have_tls_listen = 0;
+
+typedef struct {
+	union {
+		int64_t		 number;
+		char		*string;
+	} v;
+	int lineno;
+} YYSTYPE;
+
+%}
+
+%token	LISTEN ON TLS PORT
+%token	SPOOL CREDENTIALS CERTIFICATE KEY
+%token	ATTACHMENT MAX
+%token	INCLUDE
+%token	ERROR
+%token	<v.string>	STRING
+%token	<v.number>	NUMBER
+%type	<v.number>	opttls
+
+%%
+
+grammar		: /* empty */
+		| grammar '\n'
+		| grammar include '\n'
+		| grammar varset '\n'
+		| grammar main '\n'
+		| grammar error '\n'		{ file->errors++; }
+		;
+
+include		: INCLUDE STRING		{
+			struct file	*nfile;
+
+			if ((nfile = pushfile($2, 0)) == NULL) {
+				yyerror("failed to include file %s", $2);
+				free($2);
+				YYERROR;
+			}
+			free($2);
+
+			file = nfile;
+			lungetc('\n');
+		}
+		;
+
+varset		: STRING '=' STRING		{
+			char *s = $1;
+
+			while (*s++) {
+				if (isspace((unsigned char)*s)) {
+					yyerror("macro name cannot contain "
+					    "whitespace");
+					free($1);
+					free($3);
+					YYERROR;
+				}
+			}
+			if (symset($1, $3, 0) == -1)
+				fatal("cannot store variable");
+			free($1);
+			free($3);
+		}
+		;
+
+opttls		: /* empty */			{ $$ = 0; }
+		| TLS				{ $$ = 1; }
+		;
+
+main		: LISTEN ON STRING opttls PORT NUMBER	{
+			if ($6 < 1 || $6 > 65535) {
+				yyerror("invalid port: %lld", (long long)$6);
+				free($3);
+				YYERROR;
+			}
+
+			/*
+			 * "*" (dual-stack, both IPv4-any and IPv6-any) or a
+			 * literal IPv4/IPv6 address only -- no getaddrinfo(3)
+			 * hostname resolution, a deliberate scope decision
+			 * for this pass (see LISTENER_MAX_ADDRS's comment in
+			 * imapd.h for the full reasoning). Checked here, at
+			 * parse time, so a bad address is rejected with a
+			 * clear config-file error instead of surfacing later
+			 * as parent.c's bind_listen_socket() fatal()ing at
+			 * startup.
+			 */
+			if (strcmp($3, "*") != 0) {
+				struct in_addr	 ina;
+				struct in6_addr	 ina6;
+
+				if (inet_pton(AF_INET, $3, &ina) != 1 &&
+				    inet_pton(AF_INET6, $3, &ina6) != 1) {
+					yyerror("listen address \"%s\" is "
+					    "not \"*\" or a literal IPv4/"
+					    "IPv6 address", $3);
+					free($3);
+					YYERROR;
+				}
+			}
+
+			if (have_cleartext || have_tls_listen) {
+				if (strcmp(conf->listen_addr, $3) != 0) {
+					yyerror("listen address \"%s\" does "
+					    "not match earlier \"listen on "
+					    "%s\" -- imapd binds both "
+					    "listeners to the same address",
+					    $3, conf->listen_addr);
+					free($3);
+					YYERROR;
+				}
+			} else if (strlcpy(conf->listen_addr, $3,
+			    sizeof(conf->listen_addr)) >=
+			    sizeof(conf->listen_addr)) {
+				yyerror("listen address too long: %s", $3);
+				free($3);
+				YYERROR;
+			}
+			free($3);
+
+			if ($4) {
+				if (have_tls_listen) {
+					yyerror("tls listener already "
+					    "configured");
+					YYERROR;
+				}
+				conf->port_implicit_tls = (uint16_t)$6;
+				have_tls_listen = 1;
+			} else {
+				if (have_cleartext) {
+					yyerror("cleartext listener already "
+					    "configured");
+					YYERROR;
+				}
+				conf->port_cleartext = (uint16_t)$6;
+				have_cleartext = 1;
+			}
+		}
+		| SPOOL STRING			{
+			if (strlcpy(conf->spool_root, $2,
+			    sizeof(conf->spool_root)) >=
+			    sizeof(conf->spool_root)) {
+				yyerror("spool path too long: %s", $2);
+				free($2);
+				YYERROR;
+			}
+			free($2);
+		}
+		| CREDENTIALS STRING		{
+			if (strlcpy(conf->cred_file, $2,
+			    sizeof(conf->cred_file)) >=
+			    sizeof(conf->cred_file)) {
+				yyerror("credentials path too long: %s", $2);
+				free($2);
+				YYERROR;
+			}
+			free($2);
+		}
+		| TLS CERTIFICATE STRING	{
+			if (strlcpy(conf->tls_cert_file, $3,
+			    sizeof(conf->tls_cert_file)) >=
+			    sizeof(conf->tls_cert_file)) {
+				yyerror("tls certificate path too long: %s",
+				    $3);
+				free($3);
+				YYERROR;
+			}
+			free($3);
+		}
+		| TLS KEY STRING		{
+			if (strlcpy(conf->tls_key_file, $3,
+			    sizeof(conf->tls_key_file)) >=
+			    sizeof(conf->tls_key_file)) {
+				yyerror("tls key path too long: %s", $3);
+				free($3);
+				YYERROR;
+			}
+			free($3);
+		}
+		| ATTACHMENT MAX NUMBER	{
+			/*
+			 * Range-validated, unlike the other directives above:
+			 * this value flows straight into a per-FETCH malloc()
+			 * in store.c's read_message_body() (readbuf_size =
+			 * maxlen + 1), once per session-child process, so an
+			 * operator typo here (an extra zero, a value meant as
+			 * megabytes typed as bytes) has a real memory-exhaustion
+			 * consequence that a bad path string or port number
+			 * doesn't. Lower bound matches BODYSTRUCTURE_MAX
+			 * (imapd.h) as a floor -- below that, no message could
+			 * ever produce a usable BODYSTRUCTURE anyway, so a
+			 * smaller value is certainly a mistake, not a real
+			 * intended restriction. Upper bound (1 GiB) is a
+			 * generous, round sanity ceiling, not a protocol limit
+			 * -- personal mail attachments this large are not a
+			 * real-world case this implementation targets (see
+			 * BODYSTRUCTURE_READ_DEFAULT's own Gmail-sourced
+			 * reasoning), and rejecting it here surfaces the
+			 * mistake at config-parse time rather than as a
+			 * confusing malloc failure or slow FETCH later.
+			 */
+			if ($3 < BODYSTRUCTURE_MAX || $3 > 1073741824) {
+				yyerror("attachment max out of range "
+				    "(%d-1073741824 bytes): %lld",
+				    BODYSTRUCTURE_MAX, (long long)$3);
+				YYERROR;
+			}
+			conf->bodystructure_read_max = (uint32_t)$3;
+		}
+		;
+
+%%
+
+struct keywords {
+	const char	*k_name;
+	int		 k_val;
+};
+
+int
+yyerror(const char *fmt, ...)
+{
+	va_list		 ap;
+	char		*msg;
+
+	file->errors++;
+	va_start(ap, fmt);
+	if (vasprintf(&msg, fmt, ap) == -1)
+		fatalx("yyerror vasprintf");
+	va_end(ap);
+	logit(LOG_CRIT, "%s:%d: %s", file->name, yylval.lineno, msg);
+	free(msg);
+	return (0);
+}
+
+int
+kw_cmp(const void *k, const void *e)
+{
+	return (strcmp(k, ((const struct keywords *)e)->k_name));
+}
+
+int
+lookup(char *s)
+{
+	/* this has to be sorted always */
+	static const struct keywords keywords[] = {
+	    {"attachment",		ATTACHMENT},
+	    {"certificate",		CERTIFICATE},
+	    {"credentials",		CREDENTIALS},
+	    {"include",			INCLUDE},
+	    {"key",			KEY},
+	    {"listen",			LISTEN},
+	    {"max",			MAX},
+	    {"on",			ON},
+	    {"port",			PORT},
+	    {"spool",			SPOOL},
+	    {"tls",			TLS},
+	};
+	const struct keywords	*p;
+
+	p = bsearch(s, keywords, sizeof(keywords) / sizeof(keywords[0]),
+	    sizeof(keywords[0]), kw_cmp);
+
+	if (p)
+		return (p->k_val);
+	else
+		return (STRING);
+}
+
+#define MAXPUSHBACK	128
+
+static char	*parsebuf;
+static int	 parseindex;
+static char	 pushback_buffer[MAXPUSHBACK];
+static int	 pushback_index = 0;
+
+int
+lgetc(int quotec)
+{
+	int		c, next;
+
+	if (parsebuf) {
+		/* Read character from the parsebuffer instead of input. */
+		if (parseindex >= 0) {
+			c = (unsigned char)parsebuf[parseindex++];
+			if (c != '\0')
+				return (c);
+			parsebuf = NULL;
+		} else
+			parseindex++;
+	}
+
+	if (pushback_index)
+		return ((unsigned char)pushback_buffer[--pushback_index]);
+
+	if (quotec) {
+		if ((c = getc(file->stream)) == EOF) {
+			yyerror("reached end of file while parsing "
+			    "quoted string");
+			if (file == topfile || popfile() == EOF)
+				return (EOF);
+			return (quotec);
+		}
+		return (c);
+	}
+
+	while ((c = getc(file->stream)) == '\\') {
+		next = getc(file->stream);
+		if (next != '\n') {
+			c = next;
+			break;
+		}
+		yylval.lineno = file->lineno;
+		file->lineno++;
+	}
+
+	while (c == EOF) {
+		if (file == topfile || popfile() == EOF)
+			return (EOF);
+		c = getc(file->stream);
+	}
+	return (c);
+}
+
+int
+lungetc(int c)
+{
+	if (c == EOF)
+		return (EOF);
+	if (parsebuf) {
+		parseindex--;
+		if (parseindex >= 0)
+			return (c);
+	}
+	if (pushback_index + 1 >= MAXPUSHBACK)
+		return (EOF);
+	pushback_buffer[pushback_index++] = c;
+	return (c);
+}
+
+int
+findeol(void)
+{
+	int	c;
+
+	parsebuf = NULL;
+
+	/* skip to either EOF or the first real EOL */
+	while (1) {
+		if (pushback_index)
+			c = (unsigned char)pushback_buffer[--pushback_index];
+		else
+			c = lgetc(0);
+		if (c == '\n') {
+			file->lineno++;
+			break;
+		}
+		if (c == EOF)
+			break;
+	}
+	return (ERROR);
+}
+
+int
+yylex(void)
+{
+	char	 buf[8096];
+	char	*p, *val;
+	int	 quotec, next, c;
+	int	 token;
+
+top:
+	p = buf;
+	while ((c = lgetc(0)) == ' ' || c == '\t')
+		; /* nothing */
+
+	yylval.lineno = file->lineno;
+	if (c == '#')
+		while ((c = lgetc(0)) != '\n' && c != EOF)
+			; /* nothing */
+	if (c == '$' && parsebuf == NULL) {
+		while (1) {
+			if ((c = lgetc(0)) == EOF)
+				return (0);
+
+			if (p + 1 >= buf + sizeof(buf) - 1) {
+				yyerror("string too long");
+				return (findeol());
+			}
+			if (isalnum(c) || c == '_') {
+				*p++ = c;
+				continue;
+			}
+			*p = '\0';
+			lungetc(c);
+			break;
+		}
+		val = symget(buf);
+		if (val == NULL) {
+			yyerror("macro '%s' not defined", buf);
+			return (findeol());
+		}
+		parsebuf = val;
+		parseindex = 0;
+		goto top;
+	}
+
+	switch (c) {
+	case '\'':
+	case '"':
+		quotec = c;
+		while (1) {
+			if ((c = lgetc(quotec)) == EOF)
+				return (0);
+			if (c == '\n') {
+				file->lineno++;
+				continue;
+			} else if (c == '\\') {
+				if ((next = lgetc(quotec)) == EOF)
+					return (0);
+				if (next == quotec || next == ' ' ||
+				    next == '\t')
+					c = next;
+				else if (next == '\n') {
+					file->lineno++;
+					continue;
+				} else
+					lungetc(next);
+			} else if (c == quotec) {
+				*p = '\0';
+				break;
+			} else if (c == '\0') {
+				yyerror("syntax error");
+				return (findeol());
+			}
+			if (p + 1 >= buf + sizeof(buf) - 1) {
+				yyerror("string too long");
+				return (findeol());
+			}
+			*p++ = c;
+		}
+		yylval.v.string = strdup(buf);
+		if (yylval.v.string == NULL)
+			err(1, "%s", __func__);
+		return (STRING);
+	}
+
+#define allowed_to_end_number(x) \
+	(isspace(x) || x == ')' || x == ',' || x == '/' || x == '}' || \
+	x == '=')
+
+	if (c == '-' || isdigit(c)) {
+		do {
+			*p++ = c;
+			if ((size_t)(p - buf) >= sizeof(buf)) {
+				yyerror("string too long");
+				return (findeol());
+			}
+		} while ((c = lgetc(0)) != EOF && isdigit(c));
+		lungetc(c);
+		if (p == buf + 1 && buf[0] == '-')
+			goto nodigits;
+		if (c == EOF || allowed_to_end_number(c)) {
+			const char *errstr = NULL;
+
+			*p = '\0';
+			yylval.v.number = strtonum(buf, LLONG_MIN,
+			    LLONG_MAX, &errstr);
+			if (errstr) {
+				yyerror("\"%s\" invalid number: %s",
+				    buf, errstr);
+				return (findeol());
+			}
+			return (NUMBER);
+		} else {
+nodigits:
+			while (p > buf + 1)
+				lungetc((unsigned char)*--p);
+			c = (unsigned char)*--p;
+			if (c == '-')
+				return (c);
+		}
+	}
+
+#define allowed_in_string(x) \
+	(isalnum(x) || (ispunct(x) && x != '(' && x != ')' && \
+	x != '{' && x != '}' && \
+	x != '!' && x != '=' && x != '#' && \
+	x != ','))
+
+	/*
+	 * '*' added alongside the pre-existing ':' (there for bare IPv6
+	 * literals like "::") so the dual-stack "listen on *" wildcard from
+	 * the IPv6 pass works unquoted, matching every other address form
+	 * documented in imapd.conf.example -- found as a real bug, not
+	 * designed in up front: an unquoted "listen on * port 143" line
+	 * failed with a bare "syntax error" on real hardware (premio) before
+	 * this fix, since '*' alone satisfied none of the original starting
+	 * characters here and fell through to being returned as a raw,
+	 * unexpected single-character token instead of ever entering this
+	 * bareword-accumulation loop. Quoting it ("listen on \"*\" ...")
+	 * already worked, since the quoted-string branch above this one
+	 * doesn't consult allowed_in_string() at all -- this fix is only
+	 * about making the unquoted form work too.
+	 */
+	if (isalnum(c) || c == ':' || c == '_' || c == '*') {
+		do {
+			*p++ = c;
+			if ((size_t)(p - buf) >= sizeof(buf)) {
+				yyerror("string too long");
+				return (findeol());
+			}
+		} while ((c = lgetc(0)) != EOF && (allowed_in_string(c)));
+		lungetc(c);
+		*p = '\0';
+		if ((token = lookup(buf)) == STRING)
+			if ((yylval.v.string = strdup(buf)) == NULL)
+				err(1, "%s", __func__);
+		return (token);
+	}
+	if (c == '\n') {
+		yylval.lineno = file->lineno;
+		file->lineno++;
+	}
+	if (c == EOF)
+		return (0);
+	return (c);
+}
+
+/*
+ * Same root-owned-or-current-user, not-group-or-world-writable check this
+ * project already applies to the TLS private key file (parent.c's
+ * send_tls_certs(), added when that boot-deadlock/permission-check pass
+ * closed a real gap there) -- applied here to the top-level config file
+ * itself, since it can name the credentials file path and TLS key path,
+ * matching every base-system daemon's own parse.y (ripd's is the direct
+ * source for this function, byte-for-byte apart from the log_warn/
+ * log_warnx call signatures matching this project's own log.h instead of
+ * ripd's warn(3)/warnx(3)-based logit() wrappers). Not applied to
+ * "include"d files (pushfile()'s second argument is 0 for those) --
+ * matching smtpd's own convention of only checking the top-level file.
+ */
+static int
+check_file_secrecy(int fd, const char *fname)
+{
+	struct stat	st;
+
+	if (fstat(fd, &st)) {
+		log_warn("cannot stat %s", fname);
+		return (-1);
+	}
+	if (st.st_uid != 0 && st.st_uid != getuid()) {
+		log_warnx("%s: owner not root or current user", fname);
+		return (-1);
+	}
+	if (st.st_mode & (S_IWGRP | S_IXGRP | S_IRWXO)) {
+		log_warnx("%s: group writable or world read/writable", fname);
+		return (-1);
+	}
+	return (0);
+}
+
+struct file *
+pushfile(const char *name, int secret)
+{
+	struct file	*nfile;
+
+	if ((nfile = calloc(1, sizeof(struct file))) == NULL) {
+		log_warn("%s", __func__);
+		return (NULL);
+	}
+	if ((nfile->name = strdup(name)) == NULL) {
+		log_warn("%s", __func__);
+		free(nfile);
+		return (NULL);
+	}
+	if ((nfile->stream = fopen(nfile->name, "r")) == NULL) {
+		log_warn("%s: %s", __func__, nfile->name);
+		free(nfile->name);
+		free(nfile);
+		return (NULL);
+	} else if (secret &&
+	    check_file_secrecy(fileno(nfile->stream), nfile->name)) {
+		fclose(nfile->stream);
+		free(nfile->name);
+		free(nfile);
+		return (NULL);
+	}
+	nfile->lineno = 1;
+	TAILQ_INSERT_TAIL(&files, nfile, entry);
+	return (nfile);
+}
+
+int
+popfile(void)
+{
+	struct file	*prev;
+
+	if ((prev = TAILQ_PREV(file, files, entry)) != NULL)
+		prev->errors += file->errors;
+
+	TAILQ_REMOVE(&files, file, entry);
+	fclose(file->stream);
+	free(file->name);
+	free(file);
+	file = prev;
+	return (file ? 0 : EOF);
+}
+
+/*
+ * config_load(): imapd.h's public entry point (replaces parent.c's old
+ * config_load() stub of the same name/signature -- see this file's header
+ * comment). Fills xconf with the v1 defaults first (same values the old
+ * stub always used unconditionally), then parses path over those defaults
+ * -- so a config file that sets only, say, "spool" leaves every other
+ * field at its documented v1 default rather than requiring a client to
+ * spell out all seven fields every time. Returns 0 on success (xconf
+ * fully populated) or -1 (a parse error occurred; xconf's contents are
+ * unspecified) -- same "reject rather than run with a half-parsed config"
+ * precedent as every other MAX-constant/parse-failure case in this
+ * codebase, left to the caller (main.c) to treat as fatal.
+ */
+int
+config_load(const char *path, struct openimap_config *xconf)
+{
+	struct sym	*sym, *next;
+
+	conf = xconf;
+	memset(conf, 0, sizeof(*conf));
+
+	(void)strlcpy(conf->listen_addr, "0.0.0.0", sizeof(conf->listen_addr));
+	conf->port_cleartext = 143;
+	conf->port_implicit_tls = 993;
+	(void)strlcpy(conf->spool_root, "/var/mail/imapd",
+	    sizeof(conf->spool_root));
+	(void)strlcpy(conf->cred_file, "/etc/imapd/credentials",
+	    sizeof(conf->cred_file));
+	(void)strlcpy(conf->tls_cert_file, "/etc/ssl/imapd.crt",
+	    sizeof(conf->tls_cert_file));
+	(void)strlcpy(conf->tls_key_file, "/etc/ssl/private/imapd.key",
+	    sizeof(conf->tls_key_file));
+	conf->bodystructure_read_max = BODYSTRUCTURE_READ_DEFAULT;
+
+	have_cleartext = 0;
+	have_tls_listen = 0;
+	errors = 0;
+
+	if ((file = pushfile(path, 1)) == NULL)
+		return (-1);
+	topfile = file;
+
+	yyparse();
+	errors = file->errors;
+	popfile();
+
+	/* Free macros and warn about any that were never referenced. */
+	TAILQ_FOREACH_SAFE(sym, &symhead, entry, next) {
+		if (!sym->used)
+			log_debug("config_load: macro '%s' not used",
+			    sym->nam);
+		if (!sym->persist) {
+			free(sym->nam);
+			free(sym->val);
+			TAILQ_REMOVE(&symhead, sym, entry);
+			free(sym);
+		}
+	}
+
+	if (errors)
+		return (-1);
+
+	return (0);
+}
+
+int
+symset(const char *nam, const char *val, int persist)
+{
+	struct sym	*sym;
+
+	TAILQ_FOREACH(sym, &symhead, entry) {
+		if (strcmp(nam, sym->nam) == 0)
+			break;
+	}
+
+	if (sym != NULL) {
+		if (sym->persist == 1)
+			return (0);
+		else {
+			free(sym->nam);
+			free(sym->val);
+			TAILQ_REMOVE(&symhead, sym, entry);
+			free(sym);
+		}
+	}
+	if ((sym = calloc(1, sizeof(*sym))) == NULL)
+		return (-1);
+
+	sym->nam = strdup(nam);
+	if (sym->nam == NULL) {
+		free(sym);
+		return (-1);
+	}
+	sym->val = strdup(val);
+	if (sym->val == NULL) {
+		free(sym->nam);
+		free(sym);
+		return (-1);
+	}
+	sym->used = 0;
+	sym->persist = persist;
+	TAILQ_INSERT_TAIL(&symhead, sym, entry);
+	return (0);
+}
+
+/*
+ * cmdline_symset(): "-D name=value" command-line macro definitions,
+ * matching ripd/smtpd's own "-D" flag exactly -- called directly from
+ * main.c's getopt(3) loop, before config_load() runs, so these persist
+ * (the "1" argument to symset()) across the parse: a "-D" definition
+ * always wins over the same name defined inside the config file itself.
+ */
+int
+cmdline_symset(char *s)
+{
+	char	*sym, *val;
+	int	 ret;
+
+	if ((val = strrchr(s, '=')) == NULL)
+		return (-1);
+	sym = strndup(s, val - s);
+	if (sym == NULL)
+		fatal("%s: strndup", __func__);
+	ret = symset(sym, val + 1, 1);
+	free(sym);
+
+	return (ret);
+}
+
+char *
+symget(const char *nam)
+{
+	struct sym	*sym;
+
+	TAILQ_FOREACH(sym, &symhead, entry) {
+		if (strcmp(nam, sym->nam) == 0) {
+			sym->used = 1;
+			return (sym->val);
+		}
+	}
+	return (NULL);
+}
blob - /dev/null
blob + bbbf2d8cb4c55ae27eed5cb70cec9c3ff817bb28 (mode 755)
--- /dev/null
+++ src/rc.d/imapd
@@ -0,0 +1,83 @@
+#!/bin/ksh
+#
+# $OpenIMAPD$
+#
+# rc.d(8) service script for imapd(8). imapd does not detach from
+# its controlling terminal or otherwise background itself (see
+# imapd.8) -- rc_bg=YES tells rc.subr to run it under "set -o monitor"
+# instead (own process group, avoids SIGHUP at boot), which is rc.subr's
+# documented idiom for exactly this kind of foreground-only daemon. No
+# rc_start override is needed: the default "rc_exec ${daemon}
+# ${daemon_flags}" is sufficient with rc_bg=YES alone -- confirmed against
+# ypbind's real, shipped rc.d script (/etc/rc.d/ypbind), which uses the
+# identical rc_bg=YES-with-default-rc_start pattern for the same stated
+# reason ("avoid SIGHUP at boot").
+#
+# rc_stop needs no override either: parent.c's sigterm_handler already
+# cascades SIGTERM to every child (listener, auth, each per-session store
+# child) on receipt of a single SIGTERM to the root parent -- exactly what
+# rc.subr's default rc_stop() sends (pkill -TERM against the tracked
+# process). See parent.c for that cascade logic.
+
+daemon="/usr/local/sbin/imapd"
+
+. /etc/rc.d/rc.subr
+
+rc_bg=YES
+
+# --- boot-time relink consumer -------------------------------------------
+#
+# "make install" (see ../Makefile's RELINK=) drops a re-link kit at
+# ${_relink_tar} if the Makefile's RELINK variable is set, per bsd.prog.
+# mk's documented re-link-kit mechanism. Nothing on this system consumes
+# it automatically the way base does for libc/libcrypto/ld.so/sshd:
+# /etc/rc's reorder_libs() has a hardcoded allowlist (confirmed by reading
+# /etc/rc directly) that doesn't and won't include imapd -- extending
+# it would mean patching base /etc/rc, which gets clobbered on every base
+# upgrade. This hook is the substitute consumer.
+#
+# It runs every time this service is (re)started -- at boot via
+# pkg_scripts, or by hand via "rcctl restart imapd" -- checks for a
+# pending tarball, and if present, extracts it and runs the install.sh
+# that bsd.prog.mk generated inside it. That install.sh recompiles ${PROG}
+# from the tarball's own object files in a freshly randomized link order,
+# smoke-tests the result via the Makefile's RELINK command ("imapd -V",
+# see main.c -- prints a version string and exits 0 with no other side
+# effects), and only then installs the result over the currently running
+# binary.
+#
+# Failure here is deliberately non-fatal to starting the service: if the
+# relink or its smoke test fails, install.sh's own "install -c -s ..."
+# step never runs, so the binary already at ${daemon} (from the last
+# successful relink, or a plain "make install") is untouched. We log a
+# warning, leave the tarball in place for investigation, and fall through
+# to starting that known-good binary rather than taking the mail server
+# down over a relink hiccup -- KARL's own kernel relink (reorder_kernel.sh)
+# follows the same non-fatal-on-failure philosophy for the same reason.
+_relink_tar="/usr/share/relink/usr/local/sbin/imapd/imapd.tar"
+
+rc_pre() {
+	local _tmp
+
+	[[ -f ${_relink_tar} ]] || return 0
+
+	_tmp=$(mktemp -d /tmp/imapd-relink.XXXXXXXXXX) || {
+		logger -t imapd -p daemon.err \
+		    "boot-time relink: mktemp failed, skipping (${_relink_tar} left in place)"
+		return 0
+	}
+
+	if ( cd "${_tmp}" && tar xf "${_relink_tar}" && sh install.sh ) \
+	    >/tmp/imapd-relink.log 2>&1; then
+		rm -f "${_relink_tar}"
+		logger -t imapd -p daemon.info \
+		    "boot-time relink applied successfully"
+	else
+		logger -t imapd -p daemon.err \
+		    "boot-time relink failed -- starting previously installed binary; see /tmp/imapd-relink.log and ${_relink_tar}"
+	fi
+	rm -rf "${_tmp}"
+	return 0
+}
+
+rc_cmd $1
blob - /dev/null
blob + d53b32ebb2cc3d19a0d7784be92978875707d759 (mode 644)
--- /dev/null
+++ src/store.c
@@ -0,0 +1,7768 @@
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ *
+ * 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.
+ */
+
+/*
+ * store.c -- mailbox-store, filesystem process. Implements the
+ * "mailbox-store" section of openimap-privsep-design.md's fork-per-session
+ * design: a fresh process per authenticated session, privilege-dropped to
+ * that session's own uid/gid (via IMSG_STORE_INIT, received before any
+ * chroot/pledge/unveil -- see store_main() below), then wired to listener
+ * over a dedicated peer channel per the same SETUP_PEER/SETUP_DONE
+ * mechanism used at boot for listener<->auth.
+ *
+ * IMSG_MBOX_SELECT is now real: index_load()/index_append()/index_save()
+ * implement the line-oriented UIDVALIDITY:UIDNEXT + UID:basename:keywords
+ * index format resolved in openimap-storage-backend.md (flock(2)-guarded,
+ * full-rewrite-to-temp-file-then-rename(2) on every mutation), and
+ * handle_mbox_select() -- v1 is INBOX-only, openimap-v1-dispatch.md --
+ * scans new/ for not-yet-indexed messages and assigns them UIDs on every
+ * SELECT, per that document's own "notice externally-delivered files in
+ * new/ ... assign UID via index" note. IMSG_MBOX_FETCH (message metadata:
+ * FLAGS/UID/INTERNALDATE/RFC822.SIZE) and IMSG_MBOX_STORE (set/add/remove
+ * flags, RFC 9051 SS6.4.6) are real too now -- handle_mbox_fetch() and
+ * handle_mbox_store(), the latter also responsible for keeping a message's
+ * maildir flag-suffix letters and cur//new/ placement in sync with its
+ * flags, since the filename is maildir's sole source of truth for system
+ * flags (openimap-storage-backend.md's "no standard-flag caching in the
+ * index" decision). IMSG_MBOX_EXPUNGE (RFC 9051 SS6.4.3 -- also used, with
+ * silent=1, by CLOSE) is real too -- handle_mbox_expunge() permanently
+ * unlinks every \Deleted message and compacts the index in a single
+ * lower-numbered-first pass, which is also what produces SS7.5.1's
+ * "immediately decremented" per-message sequence numbers for free
+ * (verified against both of SS6.4.3/SS7.5.1's own worked examples).
+ * IMSG_MBOX_APPEND (RFC 9051 SS6.3.12) is real too now -- handle_mbox_
+ * append() delivers a new message via maildir's tmp/-then-cur/ atomic
+ * rename(2), assigns it a UID from the index (index_append(), same path
+ * handle_mbox_select() already uses for externally-delivered mail), and
+ * writes the index BEFORE the tmp->cur rename so a crash between the two
+ * leaves an indexed-but-not-yet-visible message rather than a delivered-
+ * but-unindexed one -- consistent with this file's existing tolerance
+ * elsewhere for "indexed but missing on disk" over the reverse. The
+ * message body itself arrives as variable-length trailing data on the
+ * same imsg as its struct imsg_mbox_append header (see that struct's
+ * comment in imapd.h for the imsg_get_buf()/imsg_get_len() wire-
+ * protocol verification this relies on); listener.c caps what it will
+ * ever send to APPEND_LITERAL_MAX (12000 bytes) to guarantee that fits.
+ * IMSG_MBOX_SEARCH (RFC 9051 SS6.4.4) is real too now -- handle_mbox_
+ * search() reads a flat postfix bytecode (struct search_node array,
+ * compiled by listener.c's parse_search_key()/parse_search_key_list()
+ * from the client's search-program, same variable-length-trailing-data
+ * imsg technique as IMSG_MBOX_APPEND) and evaluates it against every
+ * message via search_eval()/search_eval_leaf(), streaming matches back
+ * as IMSG_MBOX_SEARCH_MATCH. Covers flag-based (ANSWERED/DELETED/DRAFT/
+ * FLAGGED/SEEN and their UN- forms), KEYWORD/UNKEYWORD, BEFORE/ON/SINCE
+ * (internal date), LARGER/SMALLER (size), sequence-set and UID-range
+ * search keys, plus NOT/OR/parenthesized-AND-list combination -- not
+ * the content-and-header-based keys (BCC/BODY/CC/FROM/HEADER/
+ * SENTBEFORE/SENTON/SENTSINCE/SUBJECT/TEXT/TO), which listener.c rejects
+ * before this imsg is ever built (see imapd.h's imsg_mbox_search
+ * comment for the full v1-scope reasoning). This paragraph is a snapshot
+ * of the state as of the SEARCH pass; every other IMSG_MBOX_* case in
+ * store_dispatch()'s switch below (COPY/MOVE -- including cross-mailbox
+ * -- STATUS, UID forms, CONDSTORE/QRESYNC, NAMESPACE, CREATE/DELETE/
+ * RENAME, EXAMINE, IDLE) has since been implemented in later passes; see
+ * README.skeleton for the full history rather than trusting this
+ * comment's age. Privilege drop uses the
+ * actual uid/gid/spool_root/maildir delivered at runtime (maildir being
+ * new this pass too -- see imapd.h's imsg_store_init comment for the
+ * gap that closed), and unveil(2) is now scoped to this session's own
+ * maildir subdirectory specifically, not the whole shared chroot.
+ *
+ * API NAMES: checked against the real src/imsg.h this session -- see
+ * parent.c's header comment for the full verification note.
+ */
+
+#include <sys/types.h>
+#include <sys/file.h>
+#include <sys/stat.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <event.h>
+#include <fcntl.h>
+#include <grp.h>
+#include <imsg.h>
+#include <limits.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "imapd.h"
+#include "log.h"
+
+static struct imsgev	 iev_listener;
+static uint32_t		 session_id;
+static uint32_t		 bodystructure_read_max; /* from IMSG_STORE_INIT --
+					 * see imapd.h's struct imsg_store_init
+					 * comment and BODYSTRUCTURE_READ_
+					 * DEFAULT's comment for what this
+					 * gates and where the operator-
+					 * configured value comes from. */
+
+/*
+ * Per-store-child monotonic counter feeding the maildir basename uniquer
+ * (see handle_mbox_append()'s header comment for the full `<timestamp>.
+ * <pid>_<counter>.<hostname>` scheme). Was originally a function-local
+ * static inside handle_mbox_append() alone; hoisted to file scope this
+ * pass so handle_mbox_copy() -- COPY's own message-duplication path, which
+ * mints a brand new basename per copied message the same way APPEND does
+ * -- shares the same monotonic sequence rather than starting its own at 0,
+ * which could otherwise collide with an APPEND's basename minted in the
+ * same second by the same pid.
+ */
+static uint32_t		 append_counter;
+
+/*
+ * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 addition (flat multi-mailbox support,
+ * see docs/openimap-storage-backend.md's "Open items" #10): which named
+ * mailbox (if any) this store child's cwd is currently chdir'd into,
+ * relative to the session's own maildir root. Empty string means "at the
+ * root" -- i.e. INBOX, or nothing selected yet -- the same state every
+ * store child has always implicitly been in before this pass, when INBOX
+ * was the only mailbox that could ever exist. handle_mbox_select() is the
+ * only function that ever changes this; every other IMSG_MBOX_* handler
+ * (FETCH/STORE/EXPUNGE/IDLE-refresh/QRESYNC/...) keeps using bare
+ * relative paths exactly as before, unmodified, since cwd is always
+ * "wherever the currently selected mailbox is" by construction.
+ */
+static char		 current_mailbox_dir[MBOX_NAME_MAX];
+
+static void	 store_dispatch(int, short, void *);
+static void	 store_shutdown(void);
+static void	 handle_mbox_select(struct imsg_mbox_select *, struct imsgev *);
+static void	 handle_mbox_fetch(struct imsg_mbox_fetch *, struct imsgev *);
+static void	 handle_mbox_store(struct imsg_mbox_store *, struct imsgev *);
+static void	 handle_mbox_expunge(struct imsg_mbox_expunge *, struct imsgev *);
+static void	 handle_mbox_append(struct imsg_mbox_append *, const char *,
+		    size_t, struct imsgev *);
+static void	 handle_mbox_search(struct imsg_mbox_search *,
+		    struct search_node *, uint32_t, struct imsgev *);
+static void	 handle_mbox_status(struct imsg_mbox_status *, struct imsgev *);
+static void	 handle_mbox_copy(struct imsg_mbox_copy *, struct imsgev *);
+static void	 handle_mbox_move(struct imsg_mbox_copy *, struct imsgev *);
+static void	 handle_mbox_create(struct imsg_mbox_create *, struct imsgev *);
+static void	 handle_mbox_delete(struct imsg_mbox_delete *, struct imsgev *);
+static void	 handle_mbox_rename(struct imsg_mbox_rename *, struct imsgev *);
+static void	 handle_mbox_list(struct imsgev *);
+static int	 mailbox_name_valid(const char *);
+static int	 mailbox_name_is_inbox(const char *);
+static int	 select_mailbox_dir(const char *);
+static int	 locate_message_file(const char *, off_t *, char *, size_t);
+static int	 open_message_file(const char *);
+static int	 read_message_header(const char *, char **, uint32_t *);
+static int	 read_message_body(const char *, int, size_t, const char *,
+		    char **, uint32_t *);
+static int	 header_field_name_matches(const char *, size_t, const char *);
+static int	 read_message_header_fields(const char *, const char *, int,
+		    char **, uint32_t *);
+static void	 build_flags_string(const char *, const char *, char *,
+		    size_t);
+static int	 extract_header_field(const char *, size_t, const char *,
+		    char **, size_t *);
+static int	 envbuf_append(char *, size_t, size_t *, const char *, size_t);
+static int	 envbuf_append_str(char *, size_t, size_t *, const char *);
+static int	 envbuf_append_nstring(char *, size_t, size_t *, const char *,
+		    size_t);
+static int	 envbuf_append_one_address(char *, size_t, size_t *,
+		    const char *, size_t);
+static int	 envbuf_append_address_list(char *, size_t, size_t *,
+		    const char *, size_t);
+static int	 append_field_nstring(char *, size_t, size_t *, const char *,
+		    uint32_t, const char *);
+static int	 build_envelope(const char *, char **, uint32_t *);
+static int	 find_header_body_split(const char *, size_t, size_t *);
+static int	 mime_is_tspecial(char);
+static int	 mime_read_token_or_qstring(const char *, size_t, size_t *,
+		    char *, size_t);
+static void	 mime_str_upper(char *);
+static int	 parse_content_type(const char *, size_t, char *, size_t,
+		    char *, size_t, char *, size_t, char *, size_t, int *);
+static int	 split_multipart(const char *, size_t, const char *,
+		    size_t *, size_t *, int *, int);
+static int	 build_body_structure(int, int *, const char *, size_t,
+		    const char *, size_t, char *, size_t, size_t *);
+static int	 build_bodystructure(const char *, char **, uint32_t *);
+static int	 parse_section_part(const char *, int *, int);
+static int	 find_mime_part(int, const char *, size_t, const char *,
+		    size_t, const int *, int, const char **, size_t *);
+static int	 locate_mime_part(const char *, size_t, const char *, size_t,
+		    const int *, int, const char **, size_t *);
+static void	 apply_partial_range(const char *, size_t, int, uint32_t,
+		    uint32_t, const char **, size_t *);
+static int	 extract_mime_part(const char *, const int *, int, int,
+		    uint32_t, uint32_t, char **, uint32_t *);
+
+/*
+ * The maildir+index format resolved in openimap-storage-backend.md's
+ * "Recommendation" section: stock maildir (tmp/new/cur) for message
+ * bodies, plus one small line-oriented text index per mailbox holding
+ * UIDVALIDITY/UIDNEXT and the UID<->basename(<->keywords) map. v1 is
+ * INBOX-only (openimap-v1-dispatch.md), and that index lives directly in
+ * the maildir root store.c is now chdir'd into -- a sibling of tmp/new/
+ * cur, not inside any of them. Name is this implementation's own choice
+ * (the design doc never picked one): plain, `ls`-visible, matching the
+ * document's own stated preference for boring/inspectable formats over
+ * hidden dotfiles.
+ */
+#define STORE_INDEX_NAME	"imapd.index"
+#define STORE_INDEX_TMP_NAME	"imapd.index.tmp"
+#define STORE_INDEX_LINE_MAX	1024	/* matches auth.c's cred_lookup()
+					 * line-buffer precedent for the same
+					 * kind of small, personal-scope flat
+					 * text file */
+
+/*
+ * In-memory copy of one mailbox's index while a single IMSG_MBOX_SELECT
+ * (or, later, any other mutating mbox op) is being handled -- built fresh
+ * from the on-disk file, mutated, and rewritten in full per the design
+ * doc's resolved "whole file rewritten to a temp file and rename(2)'d
+ * over on every mutation" rule, then discarded. Never kept around between
+ * imsg messages.
+ */
+struct mbox_index {
+	uint32_t	  uidvalidity;
+	uint32_t	  uidnext;
+	uint64_t	  highestmodseq; /* RFC 7162 SS3.1: per-mailbox highest
+					 * mod-sequence, persisted as the
+					 * index header's third field (see
+					 * index_load()/index_save()). v1's
+					 * only mailbox always supports this
+					 * (there is no on-disk format that
+					 * predates it in a real deployment of
+					 * this not-yet-released server), so
+					 * the NOMODSEQ response code
+					 * (RFC 7162 SS3.1.2.2) is simply
+					 * unreachable in this implementation. */
+	char		**lines;	/* raw "UID:basename:keywords:MODSEQ"
+					 * lines, no trailing newline, one
+					 * malloc(3) each -- the MODSEQ field
+					 * is this pass's addition; see
+					 * index_parse_line() */
+	size_t		  nlines;
+	size_t		  cap;
+};
+
+/*
+ * One index message line, parsed. Introduced this pass (RFC 7162) to stop
+ * handle_mbox_fetch()/handle_mbox_store()/handle_mbox_expunge() from each
+ * hand-rolling their own "UID:basename:keywords" strchr() chain -- adding
+ * a fourth colon-delimited field (MODSEQ) to every one of those independently
+ * would have tripled the size of this change and the chance of one of them
+ * drifting out of sync with the on-disk format. basename/keywords are
+ * fixed-size copies (same 512-byte bound index_load()'s own STORE_INDEX_
+ * LINE_MAX line buffer already implies is generous for either field), not
+ * pointers into the original line -- callers are free to mutate or discard
+ * the line string after parsing.
+ *
+ * Backward compatibility: the MODSEQ field is new this pass. A line with
+ * only three colon-delimited fields (written by a pre-CONDSTORE build of
+ * this server) parses successfully with modseq defaulted to 1 -- this
+ * server has never had a real deployment to be compatible *with*, so this
+ * is a defensive nicety, not a migration guarantee this project is making
+ * any promise about.
+ */
+struct index_rec {
+	uint32_t	uid;
+	char		basename[512];
+	char		keywords[512];
+	uint64_t	modseq;
+};
+
+static int	 index_load(int, struct mbox_index *);
+static int	 index_has_basename(struct mbox_index *, const char *);
+static int	 index_append(struct mbox_index *, uint32_t, const char *);
+static int	 index_save(struct mbox_index *);
+static void	 index_free(struct mbox_index *);
+static int	 index_parse_line(const char *, struct index_rec *);
+static uint32_t	 index_max_uid(struct mbox_index *);
+static void	 send_vanished_range(struct mbox_index *, uint32_t, uint32_t,
+		    struct imsgev *);
+static int	 refresh_index(struct mbox_index *, int);
+static void	 handle_mbox_idle_refresh(struct imsgev *);
+
+__dead void
+store_main(void)
+{
+	struct imsgbuf		 ibuf3;
+	struct imsg		 imsg;
+	ssize_t			 n;
+	struct imsg_store_init	 init;
+	int			 peer_fd;
+	gid_t			 gid;
+
+	/* store children don't use imapd.conf directly -- no struct
+	 * openimap_config * parameter at all, same as listener_main()/
+	 * auth_main() now (see main.c's comment and imapd.h's prototype
+	 * comment). Everything store needs (uid/gid/spool_root) arrives via
+	 * IMSG_STORE_INIT below instead. */
+
+	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_STORE_INIT must be the first message read -- this is the
+	 * one departure from the generic setup_recv_*() helpers used by
+	 * listener.c/auth.c: a per-session store child's privilege target
+	 * is a runtime value, not fixed by its role, so it needs this
+	 * extra message before it can even chroot(). See the design doc's
+	 * "Departure #2" for the "why".
+	 */
+	/*
+	 * imsg_get() before imsgbuf_read() -- see imsgev.c's
+	 * setup_recv_one_peer() header comment for the real deadlock this
+	 * ordering caused elsewhere (two back-to-back sends on the same
+	 * SOCK_STREAM socketpair coalescing into one recvmsg(), leaving a
+	 * complete message sitting unread in userspace while a later,
+	 * naive imsgbuf_read()-first loop blocks forever for bytes that
+	 * already arrived). Applied here defensively for the same reason
+	 * auth.c's IMSG_AUTH_INIT read was: nothing currently forces
+	 * IMSG_STORE_INIT and this session's IMSG_SETUP_PEER into separate
+	 * reads.
+	 */
+	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("store: parent closed channel before INIT");
+	}
+	if (imsg_get_type(&imsg) != IMSG_STORE_INIT)
+		fatalx("store: expected IMSG_STORE_INIT, got %d",
+		    imsg_get_type(&imsg));
+	if (imsg_get_data(&imsg, &init, sizeof(init)) == -1)
+		fatalx("store: bad IMSG_STORE_INIT payload");
+	imsg_free(&imsg);
+
+	session_id = init.session_id;
+	bodystructure_read_max = init.bodystructure_read_max;
+
+	if (chroot(init.spool_root) == -1)
+		fatal("chroot %s", init.spool_root);
+	if (chdir("/") == -1)
+		fatal("chdir /");
+
+	/*
+	 * The actual privilege drop this whole per-session design exists
+	 * for: setresuid/setresgid to the AUTHENTICATED USER's own uid/gid
+	 * (not a fixed service account), so a bug in this process's
+	 * FETCH/STORE handling -- once that exists -- is confined by the
+	 * kernel's own uid permission checks to this one user's files, per
+	 * openimap-privsep-design.md's fork-per-session resolution.
+	 */
+	gid = init.gid;
+	if (setgroups(1, &gid) == -1 ||
+	    setresgid(init.gid, init.gid, init.gid) == -1 ||
+	    setresuid(init.uid, init.uid, init.uid) == -1)
+		fatal("session %u: cannot drop privileges to uid %u gid %u",
+		    session_id, init.uid, init.gid);
+
+	/* Now privilege-dropped: finish the handshake exactly like a
+	 * boot-time child (one peer -- listener -- then SETUP_DONE+ack). */
+	peer_fd = setup_recv_one_peer(&ibuf3);
+	setup_recv_done_and_ack(&ibuf3);
+
+	event_init();
+	imsgev_init(&iev_listener, peer_fd, store_dispatch, NULL);
+
+	/*
+	 * unveil() scoped to THIS session's own mailbox subdirectory, not
+	 * the whole (shared, multi-user) chroot -- tightened this pass now
+	 * that init.maildir actually arrives here (see imapd.h's
+	 * imsg_store_init comment: it didn't, until now). The chroot alone
+	 * was already shared across every store child regardless of user
+	 * (one global spool_root from imapd.conf); unveil is this
+	 * project's own established tool for narrowing a process's
+	 * filesystem view further than chroot can (auth.c does the same
+	 * thing -- unveils only its one credential-file path, not its
+	 * whole chroot directory) and the fork-per-session privilege drop
+	 * this file's header comment describes is exactly the kind of
+	 * "confine a bug in this one session's handling to this one
+	 * session's own files" reasoning unveil is for. This is this
+	 * implementation's own judgment call, not something openimap-
+	 * privsep-design.md's unveil discussion spells out explicitly --
+	 * that document argues for keeping unveil() at all (vs. relying on
+	 * chroot alone), not for which specific path to scope it to.
+	 */
+	{
+		char	unveil_path[sizeof(init.maildir) + 1];
+
+		if (snprintf(unveil_path, sizeof(unveil_path), "/%s",
+		    init.maildir) >= (int)sizeof(unveil_path))
+			fatalx("session %u: maildir path too long: %s",
+			    session_id, init.maildir);
+		if (unveil(unveil_path, "rwc") == -1)
+			fatal("unveil %s", unveil_path);
+
+		/*
+		 * chdir() into the unveiled directory now, once, so every
+		 * IMSG_MBOX_* handler below can use bare relative paths
+		 * ("imapd.index", "new/<basename>", ...) instead of
+		 * threading init.maildir through every single one of them.
+		 * unveil(2)'s restriction is on the resolved path, not the
+		 * literal argument a syscall is given, so a relative open()
+		 * against this cwd still resolves inside the path just
+		 * unveiled above -- same reasoning parent.c/auth.c/listener.c
+		 * already rely on after their own chroot()+chdir("/") pairs,
+		 * just one directory deeper here.
+		 */
+		if (chdir(unveil_path) == -1)
+			fatal("chdir %s", unveil_path);
+	}
+	if (unveil(NULL, NULL) == -1)
+		fatal("unveil lock");
+
+	/*
+	 * Resolved pledge string (openimap-privsep-design.md, checked
+	 * against smtpd's queue.c and the fork-per-session concurrency
+	 * argument in openimap-storage-backend.md): flock for the index
+	 * read-modify-write cycle, rpath/wpath/cpath for maildir's
+	 * rename(2)-based delivery and flag-suffix changes, no fattr
+	 * (nothing here calls chmod/utimes/chflags).
+	 */
+#ifdef __OpenBSD__
+	if (pledge("stdio rpath wpath cpath flock recvfd sendfd", NULL)
+	    == -1)
+		fatal("pledge");
+#endif
+
+	log_debug("session %u: store ready (uid %u, gid %u, spool %s, "
+	    "maildir %s)", session_id, init.uid, init.gid, init.spool_root,
+	    init.maildir);
+
+	event_dispatch();
+	fatalx("store: exited event loop");
+}
+
+/*
+ * Reads the index at STORE_INDEX_NAME (already open on fd, already
+ * flock(2)'d LOCK_EX by the caller) into *idx. An empty file (including
+ * one that was just created by handle_mbox_select()'s O_CREAT, i.e. this
+ * mailbox has never been indexed before) is not an error -- it means a
+ * fresh UIDVALIDITY/UIDNEXT get assigned, per RFC 9051 SS2.3.1.1: "A good
+ * UIDVALIDITY value to use is a 32-bit representation of the current
+ * date/time when the value is assigned: this ensures that the value is
+ * unique and always increases." UIDNEXT starts at 1, since UIDs are
+ * "unsigned non-zero" (same section).
+ *
+ * Reads via a dup(2)'d fd wrapped in stdio -- fclose() below closes the
+ * dup, not the caller's original fd, so the caller's flock(2) (which is
+ * associated with the open file description, and would be dropped by
+ * closing every fd referencing it) survives this function returning.
+ */
+static int
+index_load(int fd, struct mbox_index *idx)
+{
+	FILE	*fp;
+	char	 line[STORE_INDEX_LINE_MAX];
+	int	 dupfd;
+	int	 first = 1;
+
+	memset(idx, 0, sizeof(*idx));
+
+	if (lseek(fd, 0, SEEK_SET) == -1) {
+		log_warn("session %u: lseek %s", session_id, STORE_INDEX_NAME);
+		return (-1);
+	}
+	if ((dupfd = dup(fd)) == -1) {
+		log_warn("session %u: dup %s", session_id, STORE_INDEX_NAME);
+		return (-1);
+	}
+	if ((fp = fdopen(dupfd, "r")) == NULL) {
+		log_warn("session %u: fdopen %s", session_id, STORE_INDEX_NAME);
+		close(dupfd);
+		return (-1);
+	}
+
+	while (fgets(line, sizeof(line), fp) != NULL) {
+		line[strcspn(line, "\n")] = '\0';
+		if (line[0] == '\0')
+			continue;
+
+		if (first) {
+			char	*colon, *colon2, *ep;
+
+			first = 0;
+			if ((colon = strchr(line, ':')) == NULL) {
+				log_warnx("session %u: malformed index "
+				    "header: %s", session_id, line);
+				fclose(fp);
+				return (-1);
+			}
+			*colon = '\0';
+			errno = 0;
+			idx->uidvalidity = (uint32_t)strtoul(line, &ep, 10);
+			if (*ep != '\0' || errno != 0) {
+				log_warnx("session %u: malformed "
+				    "UIDVALIDITY: %s", session_id, line);
+				fclose(fp);
+				return (-1);
+			}
+
+			/*
+			 * RFC 7162 addition: an optional third header field,
+			 * HIGHESTMODSEQ. colon2 == NULL means a header
+			 * written before this pass (two fields only) --
+			 * tolerated, not an error, per this function's header
+			 * comment on backward compatibility; defaults to 1,
+			 * the same "start of the world" value a brand-new
+			 * index gets below.
+			 */
+			if ((colon2 = strchr(colon + 1, ':')) != NULL)
+				*colon2 = '\0';
+
+			errno = 0;
+			idx->uidnext = (uint32_t)strtoul(colon + 1, &ep, 10);
+			if (*ep != '\0' || errno != 0) {
+				log_warnx("session %u: malformed UIDNEXT: %s",
+				    session_id, colon + 1);
+				fclose(fp);
+				return (-1);
+			}
+
+			if (colon2 != NULL) {
+				errno = 0;
+				idx->highestmodseq = strtoull(colon2 + 1, &ep,
+				    10);
+				if (*ep != '\0' || errno != 0) {
+					log_warnx("session %u: malformed "
+					    "HIGHESTMODSEQ: %s", session_id,
+					    colon2 + 1);
+					fclose(fp);
+					return (-1);
+				}
+			} else
+				idx->highestmodseq = 1;
+			continue;
+		}
+
+		if (idx->nlines == idx->cap) {
+			size_t	  newcap = (idx->cap == 0) ? 16 : idx->cap * 2;
+			char	**newlines = reallocarray(idx->lines, newcap,
+			    sizeof(*idx->lines));
+
+			if (newlines == NULL) {
+				log_warn("session %u: reallocarray index",
+				    session_id);
+				fclose(fp);
+				return (-1);
+			}
+			idx->lines = newlines;
+			idx->cap = newcap;
+		}
+		if ((idx->lines[idx->nlines] = strdup(line)) == NULL) {
+			log_warn("session %u: strdup index line", session_id);
+			fclose(fp);
+			return (-1);
+		}
+		idx->nlines++;
+	}
+	if (ferror(fp)) {
+		log_warn("session %u: fgets %s", session_id, STORE_INDEX_NAME);
+		fclose(fp);
+		return (-1);
+	}
+	fclose(fp);
+
+	if (first) {
+		idx->uidvalidity = (uint32_t)time(NULL);
+		idx->uidnext = 1;
+		idx->highestmodseq = 1;
+	}
+
+	return (0);
+}
+
+/*
+ * Parses one "UID:basename:keywords[:MODSEQ]" index line -- see struct
+ * index_rec's comment above for why this exists (one shared parser instead
+ * of three-plus independent strchr() chains). Returns -1 (logged) on a
+ * corrupt line, matching the existing "log and skip/keep, don't fail the
+ * whole operation over one bad line" tolerance every caller below already
+ * had before this pass.
+ */
+static int
+index_parse_line(const char *line, struct index_rec *rec)
+{
+	const char	*p, *q, *r;
+	char		*ep;
+
+	memset(rec, 0, sizeof(*rec));
+
+	errno = 0;
+	rec->uid = (uint32_t)strtoul(line, &ep, 10);
+	if (*ep != ':') {
+		log_warnx("session %u: corrupt index line: %s", session_id,
+		    line);
+		return (-1);
+	}
+	p = ep + 1;
+	if ((q = strchr(p, ':')) == NULL) {
+		log_warnx("session %u: corrupt index line: %s", session_id,
+		    line);
+		return (-1);
+	}
+	if ((size_t)(q - p) >= sizeof(rec->basename)) {
+		log_warnx("session %u: basename too long in index line",
+		    session_id);
+		return (-1);
+	}
+	memcpy(rec->basename, p, (size_t)(q - p));
+	rec->basename[q - p] = '\0';
+
+	p = q + 1;
+	if ((r = strchr(p, ':')) != NULL) {
+		/* keywords field ends at the MODSEQ separator */
+		if ((size_t)(r - p) >= sizeof(rec->keywords)) {
+			log_warnx("session %u: keywords too long in index "
+			    "line", session_id);
+			return (-1);
+		}
+		memcpy(rec->keywords, p, (size_t)(r - p));
+		rec->keywords[r - p] = '\0';
+
+		errno = 0;
+		rec->modseq = strtoull(r + 1, &ep, 10);
+		if (*ep != '\0' || errno != 0) {
+			log_warnx("session %u: malformed per-message MODSEQ "
+			    "in index line: %s", session_id, line);
+			return (-1);
+		}
+	} else {
+		/* no MODSEQ field -- pre-CONDSTORE line, see struct
+		 * index_rec's backward-compatibility comment */
+		strlcpy(rec->keywords, p, sizeof(rec->keywords));
+		rec->modseq = 1;
+	}
+
+	return (0);
+}
+
+/*
+ * Highest UID currently assigned to a *present* message in idx, or 0 if
+ * the mailbox has never held one -- idx->lines is maintained in ascending
+ * UID order (see struct mbox_index's own comment), so the last line has
+ * the highest UID. This is "*" for UID SEARCH (SS9's `seq-number = nz-
+ * number / "*"`, "*" meaning "the largest number in use") and, this pass,
+ * for UID FETCH/UID STORE/UID EXPUNGE too -- deliberately NOT idx->uidnext
+ * - 1, which is the highest UID *ever assigned*, not the highest UID of a
+ * message that still exists (those differ whenever the highest-UID
+ * message has since been expunged). Factored out of what was previously
+ * inline duplicated logic in handle_mbox_search() alone; now shared by
+ * every UID-space "*" resolution in this file.
+ */
+static uint32_t
+index_max_uid(struct mbox_index *idx)
+{
+	const char	*line;
+	char		*ep;
+	uint32_t	 v;
+
+	if (idx->nlines == 0)
+		return (0);
+
+	line = idx->lines[idx->nlines - 1];
+	errno = 0;
+	v = (uint32_t)strtoul(line, &ep, 10);
+	if (*ep != ':')
+		return (0);	/* corrupt last line -- treat as "no UIDs in
+				 * use" rather than guessing; a hi_is_star
+				 * range simply won't match anything in that
+				 * case, which is safer than an arbitrary
+				 * wrong bound */
+	return (v);
+}
+
+/*
+ * Reports every UID in [lo, hi] not currently present in idx as one or
+ * more IMSG_MBOX_SELECT_VANISHED ranges -- the same "walk the present-
+ * message list once, report gaps" computation qresync_send_resync() uses
+ * for QRESYNC SELECT resync (see that function's own comment and
+ * imsg_mbox_select_vanished's SS5.1 minimal-state comment in imapd.h),
+ * factored out as an independent, simpler helper (no interleaved FETCH-
+ * resync emission) for RFC 7162 SS3.2.6's VANISHED UID FETCH modifier,
+ * which just needs the gap-reporting half against a UID FETCH's own
+ * seq_lo/seq_hi range rather than QRESYNC's known-uids range. Kept
+ * separate from qresync_send_resync() rather than sharing code with it,
+ * to avoid risking a regression in that already-working, RFC-example-
+ * verified path for the sake of a few dozen shared lines.
+ */
+static void
+send_vanished_range(struct mbox_index *idx, uint32_t lo, uint32_t hi,
+    struct imsgev *iev)
+{
+	uint32_t	want, i;
+
+	if (hi < lo)
+		return;
+
+	want = lo;
+	for (i = 0; i < idx->nlines && want <= hi; i++) {
+		struct index_rec	rec;
+
+		if (index_parse_line(idx->lines[i], &rec) == -1)
+			continue;
+		if (rec.uid < want)
+			continue;
+		if (rec.uid > hi)
+			break;
+
+		if (rec.uid > want) {
+			struct imsg_mbox_select_vanished	van;
+
+			memset(&van, 0, sizeof(van));
+			van.uid_lo = want;
+			van.uid_hi = rec.uid - 1;
+			if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED,
+			    0, 0, -1, &van, sizeof(van)) == -1)
+				log_warn("session %u: imsg_compose "
+				    "IMSG_MBOX_SELECT_VANISHED", session_id);
+		}
+
+		want = rec.uid + 1;
+	}
+
+	if (want <= hi) {
+		struct imsg_mbox_select_vanished	van;
+
+		memset(&van, 0, sizeof(van));
+		van.uid_lo = want;
+		van.uid_hi = hi;
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED, 0, 0,
+		    -1, &van, sizeof(van)) == -1)
+			log_warn("session %u: imsg_compose "
+			    "IMSG_MBOX_SELECT_VANISHED", session_id);
+	}
+}
+
+/*
+ * Linear scan for a basename already present in the index's UID<->basename
+ * map -- O(n) per lookup, O(n^2) total when called once per new/ entry
+ * during a scan, same "fine for v1's personal-use, modest-mailbox-size
+ * scope" reasoning openimap-storage-backend.md already applies to the
+ * full-file-rewrite cost of index_save() below; revisit only if that
+ * assumption stops holding.
+ */
+static int
+index_has_basename(struct mbox_index *idx, const char *basename)
+{
+	size_t	i;
+
+	for (i = 0; i < idx->nlines; i++) {
+		const char	*p = idx->lines[i];
+		const char	*basefield, *end;
+		size_t		 len;
+
+		if ((basefield = strchr(p, ':')) == NULL)
+			continue;
+		basefield++;
+		end = strchr(basefield, ':');
+		len = (end != NULL) ? (size_t)(end - basefield) :
+		    strlen(basefield);
+		if (strlen(basename) == len &&
+		    strncmp(basefield, basename, len) == 0)
+			return (1);
+	}
+	return (0);
+}
+
+/*
+ * Appends one new "UID:basename::MODSEQ" record (no keywords -- a freshly
+ * noticed message has none yet) to the in-memory index. Does not touch
+ * idx->uidnext itself; the caller owns incrementing it, since the caller
+ * (handle_mbox_select()) is the one that knows whether this was the last
+ * new/ entry or more remain.
+ *
+ * RFC 7162 addition: bumps idx->highestmodseq and assigns the new value to
+ * this message, per SS3.1's "When a message is appended to a mailbox (via
+ * the IMAP APPEND command, COPY to the mailbox, or using an external
+ * mechanism), the server generates a new modification sequence that is
+ * higher than the highest modification sequence of all messages in the
+ * mailbox and assigns it to the appended message." Both of this function's
+ * two callers (handle_mbox_select()'s new/-scan and handle_mbox_append())
+ * are exactly "external mechanism" and "APPEND command" respectively, so
+ * this single shared bump covers both per-message, even when a SELECT
+ * discovers several externally-delivered messages in the same scan (each
+ * gets its own distinct value, not one shared across the batch -- v1's own
+ * choice where the RFC doesn't say either way for multiple simultaneous
+ * arrivals; see this function's caller-side comments for why a STORE or
+ * EXPUNGE affecting several messages at once is different and shares one
+ * value instead).
+ */
+static int
+index_append(struct mbox_index *idx, uint32_t uid, const char *basename)
+{
+	char	line[STORE_INDEX_LINE_MAX];
+	int	len;
+
+	/*
+	 * F10 fix (defense in depth): refuse a basename containing the
+	 * index field separator ':' or a newline. refresh_index() already
+	 * pre-skips these when scanning new/; server-generated basenames
+	 * (APPEND/COPY) never contain them.
+	 */
+	if (strpbrk(basename, ":\r\n") != NULL) {
+		log_warnx("session %u: refusing index entry with unsafe "
+		    "basename: %s", session_id, basename);
+		return (-1);
+	}
+
+	idx->highestmodseq++;
+
+	len = snprintf(line, sizeof(line), "%u:%s::%llu", uid, basename,
+	    (unsigned long long)idx->highestmodseq);
+	if (len < 0 || (size_t)len >= sizeof(line)) {
+		log_warnx("session %u: index line too long for %s",
+		    session_id, basename);
+		return (-1);
+	}
+
+	if (idx->nlines == idx->cap) {
+		size_t	  newcap = (idx->cap == 0) ? 16 : idx->cap * 2;
+		char	**newlines = reallocarray(idx->lines, newcap,
+		    sizeof(*idx->lines));
+
+		if (newlines == NULL) {
+			log_warn("session %u: reallocarray index", session_id);
+			return (-1);
+		}
+		idx->lines = newlines;
+		idx->cap = newcap;
+	}
+	if ((idx->lines[idx->nlines] = strdup(line)) == NULL) {
+		log_warn("session %u: strdup index line", session_id);
+		return (-1);
+	}
+	idx->nlines++;
+	return (0);
+}
+
+/*
+ * Rewrites the whole index to STORE_INDEX_TMP_NAME and rename(2)s it over
+ * STORE_INDEX_NAME -- openimap-storage-backend.md's resolved mutation
+ * rule, and the same atomicity guarantee (confirmed against the real
+ * rename(2) man page during that design pass: "an instance of the
+ * destination name will always exist even if the system crashes
+ * mid-rename") already relied on for maildir delivery and flag-suffix
+ * renames elsewhere in this design -- so a reader can never observe a
+ * torn or partially-written index, only the old version or the new one.
+ */
+static int
+index_save(struct mbox_index *idx)
+{
+	FILE	*fp;
+	int	 fd;
+	size_t	 i;
+
+	/*
+	 * F9 fix: create the temp exclusively so a pre-planted symlink at
+	 * this fixed path cannot be followed on open. Unlink any stale temp
+	 * left by a previous crash first (it is ours to replace), ignoring
+	 * ENOENT.
+	 */
+	if (unlink(STORE_INDEX_TMP_NAME) == -1 && errno != ENOENT) {
+		log_warn("session %u: unlink %s", session_id,
+		    STORE_INDEX_TMP_NAME);
+		return (-1);
+	}
+	if ((fd = open(STORE_INDEX_TMP_NAME, O_WRONLY | O_CREAT | O_EXCL,
+	    0600)) == -1) {
+		log_warn("session %u: open %s", session_id,
+		    STORE_INDEX_TMP_NAME);
+		return (-1);
+	}
+	if ((fp = fdopen(fd, "w")) == NULL) {
+		log_warn("session %u: fdopen %s", session_id,
+		    STORE_INDEX_TMP_NAME);
+		close(fd);
+		return (-1);
+	}
+
+	if (fprintf(fp, "%u:%u:%llu\n", idx->uidvalidity, idx->uidnext,
+	    (unsigned long long)idx->highestmodseq) < 0) {
+		log_warnx("session %u: write index header failed",
+		    session_id);
+		fclose(fp);
+		return (-1);
+	}
+	for (i = 0; i < idx->nlines; i++) {
+		if (fprintf(fp, "%s\n", idx->lines[i]) < 0) {
+			log_warnx("session %u: write index line failed",
+			    session_id);
+			fclose(fp);
+			return (-1);
+		}
+	}
+	if (fclose(fp) != 0) {
+		log_warn("session %u: fclose %s", session_id,
+		    STORE_INDEX_TMP_NAME);
+		return (-1);
+	}
+
+	if (rename(STORE_INDEX_TMP_NAME, STORE_INDEX_NAME) == -1) {
+		log_warn("session %u: rename %s -> %s", session_id,
+		    STORE_INDEX_TMP_NAME, STORE_INDEX_NAME);
+		return (-1);
+	}
+	return (0);
+}
+
+static void
+index_free(struct mbox_index *idx)
+{
+	size_t	i;
+
+	for (i = 0; i < idx->nlines; i++)
+		free(idx->lines[i]);
+	free(idx->lines);
+	memset(idx, 0, sizeof(*idx));
+}
+
+/*
+ * IMSG_MBOX_SELECT handling. v1 scope: INBOX is the only mailbox that
+ * exists at all (openimap-v1-dispatch.md's SELECT row; see listener.c's
+ * cmd_namespace() comment for why a real multi-mailbox hierarchy isn't
+ * designed yet) -- anything else is answered ok=0 (listener.c turns that
+ * into a tagged NO, RFC 9051 SS6.3.2's own "no such mailbox" result),
+ * matched case-insensitively per RFC 9051 SS5.1 ("The special name INBOX
+ * is case-insensitive").
+ *
+ * On a real INBOX select: open/flock/parse the index, scan new/ for
+ * basenames the index doesn't know about yet and assign each one the
+ * next UID (openimap-storage-backend.md: "A store child does still need
+ * to notice externally-delivered files in new/ ... and assign them a UID
+ * via the index"), rewrite the index, and report back exists/uidvalidity/
+ * uidnext. Deliberately does NOT move anything from new/ to cur/ -- RFC
+ * 9051 deprecated \Recent (message flag), the untagged RECENT response,
+ * and RECENT STATUS entirely (SS8 erratum note 12), and this is a pure
+ * IMAP4rev2 server (no IMAP4rev1 back-compat), so there is no protocol
+ * reason left to track "recent" status via a new/->cur/ move at all --
+ * that migration is purely a maildir-hygiene/interop concern, tied to
+ * \Seen handling once FETCH/STORE exist, not something SELECT needs to
+ * do itself.
+ *
+ * reply->exists is currently exactly "every basename ever indexed" --
+ * correct for now since nothing in this codebase can remove an index
+ * entry yet (EXPUNGE is still a stub_not_implemented() in listener.c);
+ * will need to become "indexed minus expunged" once that changes.
+ *
+ * RFC 7162 additions this pass: reply.highestmodseq is always populated
+ * (see imsg_mbox_selected's comment in imapd.h). If req->qresync is set
+ * and req->qresync_uidvalidity matches the mailbox's real UIDVALIDITY,
+ * performs the QRESYNC resync described in RFC 7162 SS3.2.5.1 -- streams
+ * IMSG_MBOX_SELECT_VANISHED range(s) for the requested known-uids range (or
+ * the default 1:<uidnext-1>) followed by IMSG_MBOX_FETCH_META for every
+ * still-present message in that range whose mod-sequence exceeds req->
+ * qresync_modseq -- via qresync_send_resync(), before composing the
+ * terminal IMSG_MBOX_SELECTED. A UIDVALIDITY mismatch is not an error
+ * (SS3.2.5: "the server MUST ignore the remaining parameters and behave as
+ * if no dynamic message data changed") -- resync is simply skipped and a
+ * normal SELECT reply goes out.
+ */
+static void
+qresync_send_resync(struct imsg_mbox_select *req, struct mbox_index *idx,
+    struct imsgev *iev)
+{
+	uint32_t	uid_lo, uid_hi, want, i;
+
+	if (req->qresync_has_uids) {
+		uid_lo = req->qresync_uid_lo;
+		uid_hi = req->qresync_uid_hi;
+	} else {
+		/* SS3.2.5.1: "the server acts as if the client has specified
+		 * '1:<maxuid>' ... If the mailbox is empty and never had any
+		 * messages in it, then lack of the list of UIDs is
+		 * interpreted as an empty set of UIDs." maxuid is uidnext-1;
+		 * uidnext == 1 means no message has ever been assigned a UID
+		 * (index_load()'s fresh-file default), i.e. exactly that
+		 * empty case. */
+		if (idx->uidnext <= 1)
+			return;
+		uid_lo = 1;
+		uid_hi = idx->uidnext - 1;
+	}
+	if (uid_hi < uid_lo)
+		return;		/* empty requested range -- nothing to do */
+
+	/*
+	 * Single forward pass over idx->lines (bounded by mailbox size, not
+	 * by the requested UID range's numeric span -- see imsg_mbox_
+	 * select_vanished's comment in imapd.h for why iterating the
+	 * range itself, one UID at a time, isn't safe). want tracks the
+	 * lowest UID not yet accounted for; any present-message UID greater
+	 * than want means everything in [want, that UID - 1] vanished.
+	 */
+	want = uid_lo;
+	for (i = 0; i < idx->nlines && want <= uid_hi; i++) {
+		struct index_rec	rec;
+
+		if (index_parse_line(idx->lines[i], &rec) == -1)
+			continue;	/* corrupt line, already logged --
+					 * conservatively not reported either
+					 * way, same tolerance handle_mbox_
+					 * fetch() etc. already apply */
+		if (rec.uid < want)
+			continue;	/* below the requested range, or
+					 * already accounted for */
+		if (rec.uid > uid_hi)
+			break;
+
+		if (rec.uid > want) {
+			struct imsg_mbox_select_vanished	van;
+
+			memset(&van, 0, sizeof(van));
+			van.uid_lo = want;
+			van.uid_hi = rec.uid - 1;
+			if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED,
+			    0, 0, -1, &van, sizeof(van)) == -1)
+				log_warn("session %u: imsg_compose "
+				    "IMSG_MBOX_SELECT_VANISHED", session_id);
+		}
+
+		if (rec.modseq > req->qresync_modseq) {
+			struct imsg_mbox_fetch_meta	meta;
+			char				suffix[64];
+			off_t				size;
+
+			memset(&meta, 0, sizeof(meta));
+			meta.seqno = i + 1;
+			meta.uid = rec.uid;
+			meta.modseq = rec.modseq;
+			if (locate_message_file(rec.basename, &size, suffix,
+			    sizeof(suffix)) == 0) {
+				build_flags_string(suffix, rec.keywords,
+				    meta.flags, sizeof(meta.flags));
+			}
+			if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0,
+			    0, -1, &meta, sizeof(meta)) == -1)
+				log_warn("session %u: imsg_compose "
+				    "IMSG_MBOX_FETCH_META (qresync)",
+				    session_id);
+		}
+
+		want = rec.uid + 1;
+	}
+
+	if (want <= uid_hi) {
+		struct imsg_mbox_select_vanished	van;
+
+		memset(&van, 0, sizeof(van));
+		van.uid_lo = want;
+		van.uid_hi = uid_hi;
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECT_VANISHED, 0, 0,
+		    -1, &van, sizeof(van)) == -1)
+			log_warn("session %u: imsg_compose "
+			    "IMSG_MBOX_SELECT_VANISHED", session_id);
+	}
+}
+
+/*
+ * Loads the mailbox index (STORE_INDEX_NAME, already open on fd and
+ * already flock(2)'d LOCK_EX by the caller) and discovers any message
+ * files sitting in new/ that the index doesn't know about yet -- mail
+ * delivered directly into the maildir by something other than this
+ * daemon's own APPEND (migrating new/ to cur/ isn't done here or anywhere
+ * else in this codebase yet), appending each with the next UID and
+ * re-saving the index in full. Originally handle_mbox_select()'s own
+ * inline logic; factored out this pass so handle_mbox_idle_refresh()
+ * (RFC 9051 SS6.3.13) can be exactly as authoritative as a fresh SELECT
+ * would be, rather than just re-reading whatever the index file happened
+ * to say on disk before this call -- including picking up mail placed
+ * directly in new/ since the last time anything looked, regardless of
+ * which of the two callers triggered this particular refresh.
+ *
+ * On success, idx is populated and the caller owns it (index_free() when
+ * done, same as index_load() itself). On failure, idx has already been
+ * index_free()'d by this function -- the caller must not call index_free()
+ * itself in that case, matching every existing "index_free() already ran
+ * on this error path" precedent elsewhere in this file.
+ */
+static int
+refresh_index(struct mbox_index *idx, int fd)
+{
+	DIR		*dp;
+	struct dirent	*de;
+
+	if (index_load(fd, idx) == -1)
+		return (-1);
+
+	dp = opendir("new");
+	if (dp == NULL) {
+		if (errno == ENOENT)
+			return (0);	/* no new/ yet on a never-used
+					 * mailbox -- not an error, just
+					 * nothing to discover */
+		log_warn("session %u: opendir new", session_id);
+		index_free(idx);
+		return (-1);
+	}
+	while ((de = readdir(dp)) != NULL) {
+		if (de->d_name[0] == '.')
+			continue;	/* ".", "..", and dotfiles -- maildir
+					 * delivery never creates the latter
+					 * in new/ */
+		/*
+		 * F10 fix: never index a new/ filename containing the index
+		 * field separator ':' or a newline -- it would corrupt the
+		 * "uid:basename:keywords:modseq" line (splitting the basename
+		 * field, or a newline injecting a spurious record). A well-
+		 * formed maildir new/ name never contains either.
+		 */
+		if (strpbrk(de->d_name, ":\r\n") != NULL) {
+			log_warnx("session %u: skipping new/ file with unsafe "
+			    "name (contains ':' or newline): %s", session_id,
+			    de->d_name);
+			continue;
+		}
+		if (index_has_basename(idx, de->d_name))
+			continue;
+		if (index_append(idx, idx->uidnext, de->d_name) == -1) {
+			closedir(dp);
+			index_free(idx);
+			return (-1);
+		}
+		idx->uidnext++;
+	}
+	closedir(dp);
+
+	if (index_save(idx) == -1) {
+		index_free(idx);
+		return (-1);
+	}
+	return (0);
+}
+
+/*
+ * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 additions (flat multi-mailbox support,
+ * see docs/openimap-storage-backend.md's "Open items" #10). Re-validated
+ * here independently of listener.c's own client-side check on the same
+ * rules -- defense in depth across the privsep boundary, the same "don't
+ * trust the other side of an imsg channel" posture every other mailbox-
+ * name-carrying request in this file already gets.
+ */
+static int
+mailbox_name_valid(const char *name)
+{
+	size_t	i, len;
+
+	len = strlen(name);
+	if (len == 0 || len >= MBOX_NAME_MAX)
+		return (0);
+	for (i = 0; i < len; i++) {
+		unsigned char	c = (unsigned char)name[i];
+
+		/*
+		 * RFC 9051 SS5.1.1: hierarchy levels are separated by a
+		 * single reserved delimiter character ("/" -- see docs/
+		 * openimap-storage-backend.md item 9). v1 is flat, so a
+		 * name containing it can never be created or addressed --
+		 * not a hard spec violation to refuse, since SS5.1.1's
+		 * hierarchy support is itself conditional ("if it is
+		 * desired to export hierarchical mailbox names").
+		 */
+		if (c == '/')
+			return (0);
+		/*
+		 * SS5.1 point 2: "CTL and other non-graphic characters...
+		 * Servers MAY refuse to create mailbox names containing
+		 * Unicode CTL characters." Taking that MAY for the ASCII
+		 * C0/DEL range; full Net-Unicode validation is an
+		 * explicitly accepted gap (see the design doc).
+		 */
+		if (c < 0x20 || c == 0x7f)
+			return (0);
+	}
+
+	/*
+	 * Reserved: "tmp"/"new"/"cur" are INBOX's own maildir internals,
+	 * living as siblings of any named-mailbox subdirectory at the same
+	 * level (the session's maildir root). Refusing these as mailbox
+	 * names outright, at validation time, closes a real hazard rather
+	 * than just working around it at LIST-enumeration time: without
+	 * this, CREATE "tmp" issued before INBOX's own tmp/ has ever been
+	 * lazily created (ensure_maildir_dirs()) would succeed, and a
+	 * later ensure_maildir_dirs("") for INBOX itself would then treat
+	 * that user-created directory as INBOX's own tmp/ (EEXIST is
+	 * tolerated there by design) -- silent data corruption across two
+	 * unrelated mailboxes. Case-sensitive, matching maildir's own
+	 * lowercase convention exactly (Courier maildir(5)).
+	 */
+	if (strcmp(name, "tmp") == 0 || strcmp(name, "new") == 0 ||
+	    strcmp(name, "cur") == 0)
+		return (0);
+
+	/*
+	 * F3/F4 fix: also reject "." and ".." -- otherwise DELETE "."
+	 * resolves to the maildir root and destroys INBOX (unveil does not
+	 * contain ".", which stays inside the maildir) -- and reject the
+	 * on-disk index filenames, since a mailbox directory colliding with
+	 * them makes INBOX unusable.
+	 */
+	if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0)
+		return (0);
+	if (strcmp(name, STORE_INDEX_NAME) == 0 ||
+	    strcmp(name, STORE_INDEX_TMP_NAME) == 0)
+		return (0);
+
+	return (1);
+}
+
+static int
+mailbox_name_is_inbox(const char *name)
+{
+	return (strcasecmp(name, "INBOX") == 0);
+}
+
+/*
+ * Changes this store child's cwd to match `target` (the empty string for
+ * INBOX/the session's own maildir root, or an already mailbox_name_valid()
+ * name), tracking the transition in current_mailbox_dir so every other
+ * IMSG_MBOX_* handler can keep using bare relative paths unmodified. A
+ * no-op if `target` is already the currently selected mailbox (RFC 9051
+ * has no prohibition against re-SELECTing the same mailbox). On failure
+ * (target doesn't exist, or a chdir(2) itself fails), restores cwd to
+ * wherever it was before this call, so a failed SELECT never leaves this
+ * store child sitting somewhere unexpected for whatever command the
+ * client sends next.
+ */
+static int
+select_mailbox_dir(const char *target)
+{
+	if (strcmp(current_mailbox_dir, target) == 0)
+		return (0);
+
+	if (current_mailbox_dir[0] != '\0' && chdir("..") == -1) {
+		log_warn("session %u: chdir .. (leaving %s)", session_id,
+		    current_mailbox_dir);
+		return (-1);
+	}
+
+	if (target[0] != '\0') {
+		struct stat	st;
+		int		exists;
+
+		exists = (stat(target, &st) == 0 && S_ISDIR(st.st_mode));
+		if (!exists || chdir(target) == -1) {
+			/*
+			 * A missing/non-directory target is the common,
+			 * expected "no such mailbox" case -- not warning-
+			 * level. A chdir(2) failure on a target that does
+			 * exist (permissions, ENOTDIR race, ...) is
+			 * genuinely unexpected and worth a real log line.
+			 */
+			if (exists)
+				log_warn("session %u: chdir %s", session_id,
+				    target);
+			if (current_mailbox_dir[0] != '\0' &&
+			    chdir(current_mailbox_dir) == -1)
+				log_warn("session %u: chdir %s (restoring "
+				    "after failed select)", session_id,
+				    current_mailbox_dir);
+			return (-1);
+		}
+	}
+
+	strlcpy(current_mailbox_dir, target, sizeof(current_mailbox_dir));
+	return (0);
+}
+
+static void
+handle_mbox_select(struct imsg_mbox_select *req, struct imsgev *iev)
+{
+	struct mbox_index	 idx;
+	struct imsg_mbox_selected reply;
+	int			 fd;
+	const char		*target;
+
+	memset(&reply, 0, sizeof(reply));
+
+	if (mailbox_name_is_inbox(req->mailbox))
+		target = "";
+	else if (mailbox_name_valid(req->mailbox))
+		target = req->mailbox;
+	else {
+		log_debug("session %u: SELECT %s: invalid mailbox name",
+		    session_id, req->mailbox);
+		reply.ok = 0;
+		goto send;
+	}
+
+	if (select_mailbox_dir(target) == -1) {
+		log_debug("session %u: SELECT %s: no such mailbox",
+		    session_id, req->mailbox);
+		reply.ok = 0;
+		goto send;
+	}
+
+	if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+		reply.ok = 0;
+		goto send;
+	}
+	/*
+	 * flock(2) -- already in store.c's pledge string specifically for
+	 * this: more than one store child can run for the same user at
+	 * once (fork-per-session, not fork-per-user), so this read-modify-
+	 * write cycle needs mutual exclusion across processes, not just
+	 * within this one.
+	 */
+	if (flock(fd, LOCK_EX) == -1) {
+		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+		close(fd);
+		reply.ok = 0;
+		goto send;
+	}
+
+	if (refresh_index(&idx, fd) == -1) {
+		flock(fd, LOCK_UN);
+		close(fd);
+		reply.ok = 0;
+		goto send;
+	}
+
+	reply.ok = 1;
+	reply.exists = (uint32_t)idx.nlines;
+	reply.uidvalidity = idx.uidvalidity;
+	reply.uidnext = idx.uidnext;
+	reply.highestmodseq = idx.highestmodseq;
+
+	if (req->qresync && req->qresync_uidvalidity == idx.uidvalidity)
+		qresync_send_resync(req, &idx, iev);
+
+	index_free(&idx);
+	flock(fd, LOCK_UN);
+	close(fd);
+
+send:
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_SELECTED, 0, 0, -1, &reply,
+	    sizeof(reply)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_SELECTED",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * IMSG_MBOX_IDLE_REFRESH (RFC 9051 SS6.3.13, IDLE): no request payload --
+ * listener.c already knows which session's mailbox to check purely from
+ * which store child this arrived on. Streams one IMSG_MBOX_IDLE_UID per
+ * currently-existing message (ascending UID order, straight from refresh_
+ * index()'s idx.lines, same order handle_mbox_select()/handle_mbox_fetch()
+ * already rely on), then one terminal IMSG_MBOX_IDLE_REFRESHED.
+ *
+ * v1 scope (confirmed with the user): EXISTS/EXPUNGE only, not per-message
+ * FETCH -- so unlike handle_mbox_fetch()/handle_mbox_search(), this has no
+ * per-message flags/modseq to report, just the bare UID each message
+ * currently has. listener.c does its own diffing against whatever UID list
+ * it cached from this session's last SELECT or last idle-refresh -- this
+ * function has no idea whether that's the first refresh of a new IDLE or a
+ * change-triggered one, and doesn't need to: it just reports current,
+ * authoritative state, identically either way.
+ *
+ * ok=0 on any failure (index open/lock/load error, same failure modes as
+ * handle_mbox_select()) -- listener.c treats that as "nothing to report
+ * this round" rather than tearing anything down, same leniency reasoning
+ * as a single skipped message elsewhere in this file: a mailbox that's
+ * momentarily locked by another one of this same user's sessions shouldn't
+ * abort the IDLE session over it.
+ */
+static void
+handle_mbox_idle_refresh(struct imsgev *iev)
+{
+	struct mbox_index		 idx;
+	struct imsg_mbox_idle_refreshed reply;
+	struct imsg_mbox_idle_uid	 item;
+	int				 fd;
+	size_t				 i;
+	struct index_rec		 rec;
+
+	memset(&reply, 0, sizeof(reply));
+
+	if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+		goto send;
+	}
+	if (flock(fd, LOCK_EX) == -1) {
+		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+		close(fd);
+		goto send;
+	}
+	if (refresh_index(&idx, fd) == -1) {
+		flock(fd, LOCK_UN);
+		close(fd);
+		goto send;
+	}
+
+	for (i = 0; i < idx.nlines; i++) {
+		if (index_parse_line(idx.lines[i], &rec) == -1)
+			continue;	/* same "skip a malformed line rather
+					 * than fail the whole request"
+					 * leniency index_parse_line()'s own
+					 * callers already use elsewhere */
+		memset(&item, 0, sizeof(item));
+		item.uid = rec.uid;
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_UID, 0, 0, -1,
+		    &item, sizeof(item)) == -1)
+			log_warn("session %u: imsg_compose "
+			    "IMSG_MBOX_IDLE_UID", session_id);
+	}
+
+	reply.ok = 1;
+	reply.exists = (uint32_t)idx.nlines;
+	reply.uidvalidity = idx.uidvalidity;
+	reply.uidnext = idx.uidnext;
+	reply.highestmodseq = idx.highestmodseq;
+
+	index_free(&idx);
+	flock(fd, LOCK_UN);
+	close(fd);
+
+send:
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_IDLE_REFRESHED, 0, 0, -1,
+	    &reply, sizeof(reply)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_IDLE_REFRESHED",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * Finds basename's on-disk message file: new/<basename> exactly (the only
+ * place anything in this codebase currently puts a message -- see
+ * handle_mbox_select()'s comment on why new/->cur/ migration isn't done
+ * here), falling back to a "<basename>:2,*" prefix scan of cur/ for
+ * robustness against a message an external tool or administrator moved
+ * there by hand. Returns 0 and fills size_out and suffix_out (the matched
+ * file's maildir flag-suffix, e.g. ":2,FS", or "" if found in new/ --
+ * unflagged, per maildir convention) on success, -1 if the message can't
+ * be found in either place (logged, not fatal -- handle_mbox_fetch()
+ * skips just that one message rather than failing the whole FETCH).
+ */
+static int
+locate_message_file(const char *basename, off_t *size_out, char *suffix_out,
+    size_t suffix_out_size)
+{
+	struct stat	 st;
+	char		 path[600];
+
+	suffix_out[0] = '\0';
+
+	if (snprintf(path, sizeof(path), "new/%s", basename) >=
+	    (int)sizeof(path)) {
+		log_warnx("session %u: basename too long: %s", session_id,
+		    basename);
+		return (-1);
+	}
+	if (stat(path, &st) == 0) {
+		*size_out = st.st_size;
+		return (0);
+	}
+	if (errno != ENOENT) {
+		log_warn("session %u: stat %s", session_id, path);
+		return (-1);
+	}
+
+	{
+		DIR		*dp;
+		struct dirent	*de;
+		size_t		 baselen = strlen(basename);
+		int		 rv = -1;
+
+		if ((dp = opendir("cur")) == NULL) {
+			if (errno != ENOENT)
+				log_warn("session %u: opendir cur", session_id);
+			return (-1);
+		}
+		while ((de = readdir(dp)) != NULL) {
+			if (strncmp(de->d_name, basename, baselen) != 0 ||
+			    de->d_name[baselen] != ':')
+				continue;
+			if (snprintf(path, sizeof(path), "cur/%s", de->d_name)
+			    >= (int)sizeof(path))
+				break;
+			if (stat(path, &st) == 0) {
+				*size_out = st.st_size;
+				strlcpy(suffix_out, de->d_name + baselen,
+				    suffix_out_size);
+				rv = 0;
+			}
+			break;
+		}
+		closedir(dp);
+		return (rv);
+	}
+}
+
+/*
+ * Same new/->cur/ fallback lookup as locate_message_file() (Courier
+ * maildir(5): a message lives in new/ until some client/MDA moves it to
+ * cur/ and appends its flag suffix), but opens the file and returns a
+ * readable fd instead of just stat()'ing it -- locate_message_file()
+ * itself isn't changed to also return a path/fd, to avoid touching its
+ * eight existing call sites (RFC822.SIZE/FLAGS lookups across FETCH,
+ * STORE, COPY, MOVE, SEARCH) for a capability only BODY.PEEK[HEADER]
+ * needs. The duplication is small and self-contained; see read_message_
+ * header() below for the one caller.
+ */
+static int
+open_message_file(const char *basename)
+{
+	char	 path[600];
+	int	 fd;
+
+	if (snprintf(path, sizeof(path), "new/%s", basename) >=
+	    (int)sizeof(path)) {
+		log_warnx("session %u: basename too long: %s", session_id,
+		    basename);
+		return (-1);
+	}
+	if ((fd = open(path, O_RDONLY)) != -1)
+		return (fd);
+	if (errno != ENOENT) {
+		log_warn("session %u: open %s", session_id, path);
+		return (-1);
+	}
+
+	{
+		DIR		*dp;
+		struct dirent	*de;
+		size_t		 baselen = strlen(basename);
+		int		 rv = -1;
+
+		if ((dp = opendir("cur")) == NULL) {
+			if (errno != ENOENT)
+				log_warn("session %u: opendir cur", session_id);
+			return (-1);
+		}
+		while ((de = readdir(dp)) != NULL) {
+			if (strncmp(de->d_name, basename, baselen) != 0 ||
+			    de->d_name[baselen] != ':')
+				continue;
+			if (snprintf(path, sizeof(path), "cur/%s", de->d_name)
+			    >= (int)sizeof(path))
+				break;
+			rv = open(path, O_RDONLY);
+			break;
+		}
+		closedir(dp);
+		return (rv);
+	}
+}
+
+/*
+ * Reads basename's on-disk message file (open_message_file() above) and
+ * returns just its raw RFC 5322 header block: everything from the start of
+ * the file through and including the blank-line separator between headers
+ * and body. Including the separator itself in the returned bytes is this
+ * implementation's own judgment call -- RFC 9051 SS6.4.5 only says HEADER
+ * means "the [RFC5322] header of the message", without spelling out the
+ * exact byte boundary -- but it matches that section's own SS8 worked
+ * example (a blank line appears inside the {342}-octet literal, right
+ * before the response's closing paren) and is what lets BODY[HEADER] +
+ * BODY[TEXT] concatenate back into the original message with nothing
+ * missing, which is the common expectation among real IMAP servers.
+ * Accepts either "\r\n\r\n" or bare "\n\n" as the separator, since this
+ * implementation's APPEND stores literal bytes exactly as the client sent
+ * them, unnormalized -- a maildir message here isn't guaranteed to be
+ * strictly CRLF-terminated.
+ *
+ * Returns 0 and a malloc(3)'d *buf_out (caller frees) + *len_out on
+ * success. Returns -1 (nothing left to free) if: the message file can't be
+ * located; no blank-line separator is found within the first
+ * FETCH_HEADER_MAX+1 bytes read (treated as "can't confidently say where
+ * the header ends" rather than guessing -- the same "give up rather than
+ * guess" principle this file's CRLF-literal parsing already follows); the
+ * separator itself lands past FETCH_HEADER_MAX (the header is simply too
+ * big for this pass's inline-imsg design, same size-cap precedent as
+ * APPEND_LITERAL_MAX); or a NUL byte appears within the header region
+ * (defensive -- nothing downstream expects message content read this way
+ * to flow through a NUL-terminated C string, so this rejects the unusual
+ * case outright rather than risk truncating it somewhere later).
+ */
+static int
+read_message_header(const char *basename, char **buf_out, uint32_t *len_out)
+{
+	char	 readbuf[FETCH_HEADER_MAX + 1];
+	int	 fd;
+	ssize_t	 n, total = 0;
+	size_t	 i;
+	int	 sepindex = -1;
+
+	if ((fd = open_message_file(basename)) == -1)
+		return (-1);
+
+	while (total < (ssize_t)sizeof(readbuf)) {
+		n = read(fd, readbuf + total, sizeof(readbuf) - total);
+		if (n == -1) {
+			log_warn("session %u: read message header (%s)",
+			    session_id, basename);
+			close(fd);
+			return (-1);
+		}
+		if (n == 0)
+			break;
+		total += n;
+	}
+	close(fd);
+
+	for (i = 0; i + 1 < (size_t)total; i++) {
+		if (readbuf[i] == '\0') {
+			log_warnx("session %u: message %s has a NUL byte in "
+			    "its header -- BODY.PEEK[HEADER] skipped",
+			    session_id, basename);
+			return (-1);
+		}
+		if (i + 3 < (size_t)total && readbuf[i] == '\r' &&
+		    readbuf[i + 1] == '\n' && readbuf[i + 2] == '\r' &&
+		    readbuf[i + 3] == '\n') {
+			sepindex = (int)i + 4;
+			break;
+		}
+		if (readbuf[i] == '\n' && readbuf[i + 1] == '\n') {
+			sepindex = (int)i + 2;
+			break;
+		}
+	}
+
+	if (sepindex == -1 || sepindex > FETCH_HEADER_MAX) {
+		log_warnx("session %u: message %s: no header/body separator "
+		    "found within %d bytes -- BODY.PEEK[HEADER] skipped",
+		    session_id, basename, FETCH_HEADER_MAX);
+		return (-1);
+	}
+
+	if ((*buf_out = malloc((size_t)sepindex)) == NULL) {
+		log_warn("session %u: malloc message header (%s)",
+		    session_id, basename);
+		return (-1);
+	}
+	memcpy(*buf_out, readbuf, (size_t)sepindex);
+	*len_out = (uint32_t)sepindex;
+	return (0);
+}
+
+/*
+ * Reads basename's on-disk message file (open_message_file() above) and
+ * returns either the entire raw RFC 5322 message (text_only == 0) or
+ * everything after the same header/body blank-line separator read_
+ * message_header() scans for (text_only == 1) -- SS6.4.5.1: "The TEXT part
+ * specifier refers to the text body of the message, omitting the
+ * [RFC5322] header." Unlike read_message_header(), which only needs to
+ * read far enough to find that separator, this has to read the whole
+ * file: BODY.PEEK[] wants every byte, BODY.PEEK[TEXT] can't know how much
+ * to return without first knowing the total length, and build_
+ * bodystructure() needs the whole file to find every MIME part's own
+ * boundary, even though its own *output* stays small.
+ *
+ * maxlen and label are supplied per call site rather than hardcoded,
+ * because this function's two callers have genuinely different size
+ * realities: BODY.PEEK[]/BODY.PEEK[TEXT] (handle_mbox_fetch()) pass
+ * APPEND_LITERAL_MAX, since whatever comes out of this function still has
+ * to fit whole on a single imsg to reach listener.c (struct imsg_mbox_
+ * fetch_body's own bodylen comment, imapd.h) -- raising that would need
+ * real multi-imsg streaming, out of scope this pass. build_bodystructure()
+ * passes the much larger bodystructure_read_max instead (operator-
+ * configurable via imapd.conf's "attachment max" directive, received
+ * from parent over IMSG_STORE_INIT -- see BODYSTRUCTURE_READ_DEFAULT's
+ * comment in imapd.h for the default and full rationale), since it 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 over the wire, so it
+ * isn't limited by imsg's own size ceiling the way BODY.PEEK[]/[TEXT] is.
+ * (This split was added after real-hardware testing showed a genuine
+ * Apple Mail message with a small image attachment -- unsurprisingly,
+ * larger than 12000 bytes once base64-encoded -- silently failed
+ * BODYSTRUCTURE entirely, because this function used to share one
+ * hardcoded APPEND_LITERAL_MAX-sized buffer across both callers.) label
+ * (e.g. "BODY[]", "BODY[TEXT]", "BODYSTRUCTURE") is used only so a
+ * failure here is logged accurately regardless of which caller hit it,
+ * rather than this function unconditionally assuming it's always being
+ * called for a BODY.PEEK[...] request.
+ *
+ * readbuf is heap-allocated (maxlen + 1 bytes) rather than a fixed-size
+ * stack array, sized per-call to the caller's own maxlen -- necessary now
+ * that the two callers want very different sizes, and BODYSTRUCTURE_READ_
+ * MAX is far too large for a stack allocation anyway.
+ *
+ * Returns 0 and a malloc(3)'d *buf_out (caller frees; left NULL if
+ * *len_out comes back 0 -- an empty body is legitimately different from
+ * "not found", same distinction APPEND already makes for a zero-length
+ * message) + *len_out on success. Returns -1 (nothing left to free) if:
+ * the message file can't be located; the file is larger than maxlen (same
+ * "reject rather than truncate" precedent as read_message_header()/APPEND
+ * itself); the content contains a NUL byte (same defensive reasoning as
+ * read_message_header()); or (text_only only) no header/body separator
+ * can be found anywhere in the file, which this implementation treats as
+ * "nothing to give" rather than guessing that an entire header-less file
+ * is all body -- a deliberate simplification, not a claim that's the only
+ * reasonable reading of SS6.4.5.1 for a malformed or genuinely header-less
+ * message.
+ */
+static int
+read_message_body(const char *basename, int text_only, size_t maxlen,
+    const char *label, char **buf_out, uint32_t *len_out)
+{
+	char	*readbuf;
+	size_t	 readbuf_size = maxlen + 1;
+	int	 fd;
+	ssize_t	 n, total = 0;
+	size_t	 i;
+	int	 sepindex = -1;
+
+	*buf_out = NULL;
+	*len_out = 0;
+
+	if ((readbuf = malloc(readbuf_size)) == NULL) {
+		log_warn("session %u: malloc message body readbuf (%s)",
+		    session_id, basename);
+		return (-1);
+	}
+
+	if ((fd = open_message_file(basename)) == -1) {
+		free(readbuf);
+		return (-1);
+	}
+
+	while (total < (ssize_t)readbuf_size) {
+		n = read(fd, readbuf + total, readbuf_size - total);
+		if (n == -1) {
+			log_warn("session %u: read message body (%s)",
+			    session_id, basename);
+			close(fd);
+			free(readbuf);
+			return (-1);
+		}
+		if (n == 0)
+			break;
+		total += n;
+	}
+	close(fd);
+
+	if (total > (ssize_t)maxlen) {
+		log_warnx("session %u: message %s exceeds %zu bytes -- "
+		    "%s skipped", session_id, basename, maxlen, label);
+		free(readbuf);
+		return (-1);
+	}
+
+	for (i = 0; i < (size_t)total; i++) {
+		if (readbuf[i] == '\0') {
+			log_warnx("session %u: message %s has a NUL byte -- "
+			    "%s skipped", session_id, basename, label);
+			free(readbuf);
+			return (-1);
+		}
+	}
+
+	if (text_only) {
+		for (i = 0; i + 1 < (size_t)total; i++) {
+			if (i + 3 < (size_t)total && readbuf[i] == '\r' &&
+			    readbuf[i + 1] == '\n' && readbuf[i + 2] == '\r' &&
+			    readbuf[i + 3] == '\n') {
+				sepindex = (int)i + 4;
+				break;
+			}
+			if (readbuf[i] == '\n' && readbuf[i + 1] == '\n') {
+				sepindex = (int)i + 2;
+				break;
+			}
+		}
+		if (sepindex == -1) {
+			log_warnx("session %u: message %s: no header/body "
+			    "separator found -- %s skipped",
+			    session_id, basename, label);
+			free(readbuf);
+			return (-1);
+		}
+	} else {
+		sepindex = 0;
+	}
+
+	*len_out = (uint32_t)((size_t)total - (size_t)sepindex);
+	if (*len_out > 0) {
+		if ((*buf_out = malloc(*len_out)) == NULL) {
+			log_warn("session %u: malloc message body (%s)",
+			    session_id, basename);
+			*len_out = 0;
+			free(readbuf);
+			return (-1);
+		}
+		memcpy(*buf_out, readbuf + sepindex, *len_out);
+	}
+	free(readbuf);
+	return (0);
+}
+
+/*
+ * True if the header field name spanning name[0..namelen) case-
+ * insensitively (ASCII-range, per RFC 9051 SS6.4.5.1: "The field-matching
+ * is ASCII-range case insensitive but is otherwise exact") matches one of
+ * the space-separated names in list. Re-copies and re-tokenizes list on
+ * every call -- called once per header field in a message, and real
+ * messages have at most a few dozen header fields, so the clarity of a
+ * fresh strtok_r() pass each time outweighs the cost of not caching a
+ * pre-split array.
+ */
+static int
+header_field_name_matches(const char *name, size_t namelen, const char *list)
+{
+	char	 listcopy[HEADER_FIELDS_MAX];
+	char	*tok, *save;
+
+	if (namelen == 0 || namelen >= sizeof(listcopy))
+		return (0);
+
+	strlcpy(listcopy, list, sizeof(listcopy));
+	for (tok = strtok_r(listcopy, " ", &save); tok != NULL;
+	    tok = strtok_r(NULL, " ", &save)) {
+		if (strlen(tok) == namelen &&
+		    strncasecmp(tok, name, namelen) == 0)
+			return (1);
+	}
+	return (0);
+}
+
+/*
+ * BODY.PEEK[HEADER.FIELDS (fields_spec)] (want_not == 0) or BODY.PEEK
+ * [HEADER.FIELDS.NOT (fields_spec)] (want_not == 1) -- RFC 9051 SS6.4.5.1.
+ * fields_spec is listener.c's already-grammar-validated, space-joined
+ * field-name list (see struct imsg_mbox_fetch's header_fields comment in
+ * imapd.h) -- this function trusts it's well-formed rather than
+ * re-validating.
+ *
+ * Builds on read_message_header() rather than re-deriving the header
+ * block from disk: gets the same whole raw header (through and including
+ * the trailing blank-line separator) that BODY.PEEK[HEADER] already
+ * returns, then splits *that* into individual RFC 5322 fields itself,
+ * respecting obs-fold continuation lines (RFC 5322 SS2.2.3: a field's
+ * value may continue onto following lines, each of which "begins with a
+ * space or tab" per section 3.2.2's WSP-based folding) -- a continuation
+ * line is included as part of whichever field precedes it, never treated
+ * as its own field. Each field's *complete* raw byte span (its own
+ * "Name:" line through the last byte of its final continuation line) is
+ * copied verbatim into the output if its name matches fields_spec (for
+ * HEADER.FIELDS) or doesn't (for HEADER.FIELDS.NOT); order and every
+ * repeated occurrence of a field name (e.g. multiple "Received:" lines)
+ * are both preserved exactly as they appear in the source, not
+ * deduplicated or reordered. The trailing blank line itself is always
+ * copied unconditionally, matching SS6.4.5.1's "Subsetting does not
+ * exclude the [RFC5322] delimiting blank line... the blank line is
+ * included in all header fetches" -- regardless of whether any field
+ * matched at all (an empty-but-valid result is legitimate: HEADER.FIELDS
+ * with a field-name list that matches nothing in this particular message
+ * still returns just the blank line, found == 1, not "not found").
+ *
+ * Returns 0 and a malloc(3)'d *buf_out (caller frees) + *len_out (always
+ * at least 1, the blank line's own terminator) on success. Returns -1
+ * (nothing left to free) if read_message_header() itself fails (message
+ * not found, oversized, NUL byte, no separator -- see that function's
+ * comment) or if the header block it returns is somehow malformed enough
+ * that this function's own line-walk never reaches a blank line before
+ * running off the end -- shouldn't happen, since read_message_header()
+ * only ever returns a block that already ends at one, but checked
+ * defensively rather than assumed.
+ */
+static int
+read_message_header_fields(const char *basename, const char *fields_spec,
+    int want_not, char **buf_out, uint32_t *len_out)
+{
+	char		*hdrbuf = NULL;
+	uint32_t	 hdrlen = 0;
+	char		*out;
+	size_t		 outlen = 0;
+	size_t		 off = 0;
+	int		 rc = -1;
+
+	*buf_out = NULL;
+	*len_out = 0;
+
+	if (read_message_header(basename, &hdrbuf, &hdrlen) == -1)
+		return (-1);
+
+	/* Filtered output can never exceed the unfiltered header's own
+	 * size -- every byte copied below comes verbatim from hdrbuf. */
+	if ((out = malloc(hdrlen)) == NULL) {
+		log_warn("session %u: malloc HEADER.FIELDS buffer (%s)",
+		    session_id, basename);
+		free(hdrbuf);
+		return (-1);
+	}
+
+	while (off < hdrlen) {
+		size_t	 field_start = off, line_end, i;
+		int	 is_blank, matched, include;
+
+		i = off;
+		while (i < hdrlen && hdrbuf[i] != '\n')
+			i++;
+		if (i >= hdrlen)
+			break;	/* malformed -- see this function's comment */
+		line_end = (i > field_start && hdrbuf[i - 1] == '\r') ?
+		    i - 1 : i;
+		off = i + 1;
+
+		is_blank = (line_end == field_start);
+		if (is_blank) {
+			memcpy(out + outlen, hdrbuf + field_start,
+			    off - field_start);
+			outlen += off - field_start;
+			rc = 0;
+			break;
+		}
+
+		/* consume obs-fold continuation lines (start with SP/HTAB)
+		 * belonging to this same field */
+		while (off < hdrlen &&
+		    (hdrbuf[off] == ' ' || hdrbuf[off] == '\t')) {
+			size_t	 j = off;
+
+			while (j < hdrlen && hdrbuf[j] != '\n')
+				j++;
+			if (j >= hdrlen) {
+				off = hdrlen;
+				break;
+			}
+			off = j + 1;
+		}
+
+		{
+			size_t	 namelen = 0, k;
+
+			for (k = field_start; k < line_end; k++) {
+				if (hdrbuf[k] == ':')
+					break;
+			}
+			namelen = k - field_start;
+
+			matched = header_field_name_matches(hdrbuf +
+			    field_start, namelen, fields_spec);
+			include = want_not ? !matched : matched;
+
+			if (include) {
+				memcpy(out + outlen, hdrbuf + field_start,
+				    off - field_start);
+				outlen += off - field_start;
+			}
+		}
+	}
+
+	free(hdrbuf);
+
+	if (rc == -1) {
+		free(out);
+		return (-1);
+	}
+
+	*buf_out = out;
+	*len_out = (uint32_t)outlen;
+	return (0);
+}
+
+/*
+ * Finds the first header field named `name` (ASCII-range case-insensitive,
+ * same match rule as header_field_name_matches() above) in the raw header
+ * block hdr[0..hdrlen), and returns its *unfolded* value: RFC 5322 SS2.2.3
+ * "Unfolding is accomplished by simply removing any CRLF that is
+ * immediately followed by WSP" -- the WSP itself is retained, so "Subject:
+ * foo\r\n bar" unfolds to "foo bar", not "foobar". Every fold point inside
+ * the value is guaranteed to be followed by WSP by construction: this
+ * function walks continuation lines with the exact same "starts with SP or
+ * HTAB" loop read_message_header_fields() already uses, so every internal
+ * line break in the span it collects is, by definition, one of those
+ * fold points. Leading FWS immediately after the colon is trimmed (SS3.6.5's
+ * conventional single-space separator is not itself part of the field
+ * body); the field's own final line terminator is trimmed from the end.
+ *
+ * Only the header block itself needs to be well-formed for this to work --
+ * hdr is trusted to already be NUL-free (read_message_header() guarantees
+ * that, rejecting any header containing one) and to end at a header/body
+ * blank-line separator, same preconditions read_message_header_fields()
+ * already relies on.
+ *
+ * Returns 0 and a malloc(3)'d *val_out (caller frees; may be a valid
+ * zero-length allocation for a present-but-empty field, e.g. "Subject:\r\n"
+ * -- RFC 9051 SS7.5.2 distinguishes "absent" (NIL) from "present but
+ * empty" (empty string) for exactly this reason) + *vallen_out on success.
+ * Returns -1 (nothing to free) if the field is not present in the header
+ * at all.
+ */
+static int
+extract_header_field(const char *hdr, size_t hdrlen, const char *name,
+    char **val_out, size_t *vallen_out)
+{
+	size_t	 namelen = strlen(name);
+	size_t	 off = 0;
+
+	*val_out = NULL;
+	*vallen_out = 0;
+
+	while (off < hdrlen) {
+		size_t	 field_start = off, line_end, i, k;
+
+		i = off;
+		while (i < hdrlen && hdr[i] != '\n')
+			i++;
+		if (i >= hdrlen)
+			break;	/* malformed -- no trailing blank line found */
+		line_end = (i > field_start && hdr[i - 1] == '\r') ? i - 1 : i;
+		off = i + 1;
+
+		if (line_end == field_start)
+			break;	/* blank line -- end of header, not found */
+
+		for (k = field_start; k < line_end; k++) {
+			if (hdr[k] == ':')
+				break;
+		}
+
+		/* Consume this field's obs-fold continuation lines
+		 * regardless of whether its name matches below -- off has
+		 * to land past them either way to keep scanning correctly
+		 * positioned for the next field. */
+		while (off < hdrlen && (hdr[off] == ' ' || hdr[off] == '\t')) {
+			size_t	 j = off;
+
+			while (j < hdrlen && hdr[j] != '\n')
+				j++;
+			if (j >= hdrlen) {
+				off = hdrlen;
+				break;
+			}
+			off = j + 1;
+		}
+
+		if (k - field_start != namelen ||
+		    strncasecmp(hdr + field_start, name, namelen) != 0)
+			continue;
+
+		{
+			size_t	 vstart = k + 1;
+			size_t	 vend = off;
+			size_t	 p;
+			char	*out;
+			size_t	 outlen = 0;
+
+			if (vend > vstart && hdr[vend - 1] == '\n')
+				vend--;
+			if (vend > vstart && hdr[vend - 1] == '\r')
+				vend--;
+			while (vstart < vend &&
+			    (hdr[vstart] == ' ' || hdr[vstart] == '\t'))
+				vstart++;
+
+			if ((out = malloc(vend - vstart + 1)) == NULL)
+				return (-1);
+
+			for (p = vstart; p < vend; ) {
+				if (hdr[p] == '\r' && p + 1 < vend &&
+				    hdr[p + 1] == '\n') {
+					p += 2;
+					continue;
+				}
+				if (hdr[p] == '\n') {
+					p += 1;
+					continue;
+				}
+				out[outlen++] = hdr[p];
+				p++;
+			}
+
+			*val_out = out;
+			*vallen_out = outlen;
+			return (0);
+		}
+	}
+
+	return (-1);
+}
+
+/*
+ * envbuf_append()/envbuf_append_str(): bounded-buffer append primitives
+ * shared by every ENVELOPE formatting function below. Unlike strlcat(3),
+ * these reject (return -1, buffer left as it was before the call) rather
+ * than truncate on overflow -- matching this project's usual "reject rather
+ * than silently do something the client didn't ask for" precedent (see
+ * HEADER_FIELDS_MAX's comment in imapd.h for the same reasoning applied
+ * elsewhere), and letting build_envelope() propagate that as ENVELOPE_MAX
+ * exceeded (found = 0) rather than ever emitting truncated, syntactically-
+ * broken envelope text.
+ */
+static int
+envbuf_append(char *buf, size_t bufsize, size_t *outlen, const char *data,
+    size_t datalen)
+{
+	if (*outlen + datalen > bufsize)
+		return (-1);
+	memcpy(buf + *outlen, data, datalen);
+	*outlen += datalen;
+	return (0);
+}
+
+static int
+envbuf_append_str(char *buf, size_t bufsize, size_t *outlen, const char *s)
+{
+	return (envbuf_append(buf, bufsize, outlen, s, strlen(s)));
+}
+
+/*
+ * Appends one RFC 9051 nstring: `NIL` if val is NULL, else an IMAP quoted
+ * string with backslash and double-quote escaped (SS9's `quoted-specials =
+ * DQUOTE / "\"` -- a quoted string's QUOTED-CHAR is "any TEXT-CHAR except
+ * quoted-specials" or "\" followed by a quoted-special, so exactly those
+ * two bytes need escaping, nothing else). Deliberately does not decode RFC
+ * 2047 encoded-words (e.g. "=?UTF-8?B?...?=") in val -- RFC 9051 SS7.5.2
+ * doesn't require it (ENVELOPE fields are described as extracted from the
+ * RFC 5322 header, not MIME-decoded), and skipping it keeps this pass
+ * scoped to RFC 5322 header parsing rather than also pulling in RFC 2047
+ * decoding; a client sees the raw encoded-word text verbatim, same as it
+ * would from the header itself, and can decode it exactly the same way it
+ * always has to for extension fields. 8-bit bytes (raw unencoded UTF-8 in a
+ * technically-non-conformant header) are passed through as-is rather than
+ * rejected -- this server has no CHARSET negotiation for ENVELOPE and RFC
+ * 9051 does not define an error path for it here, so passing the bytes
+ * through unmodified (same as most real-world server implementations do)
+ * is the more useful behavior than refusing the whole field.
+ */
+static int
+envbuf_append_nstring(char *buf, size_t bufsize, size_t *outlen,
+    const char *val, size_t vallen)
+{
+	size_t	 i;
+
+	if (val == NULL)
+		return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
+
+	if (envbuf_append(buf, bufsize, outlen, "\"", 1) == -1)
+		return (-1);
+	for (i = 0; i < vallen; i++) {
+		if ((val[i] == '"' || val[i] == '\\') &&
+		    envbuf_append(buf, bufsize, outlen, "\\", 1) == -1)
+			return (-1);
+		if (envbuf_append(buf, bufsize, outlen, &val[i], 1) == -1)
+			return (-1);
+	}
+	return (envbuf_append(buf, bufsize, outlen, "\"", 1));
+}
+
+/*
+ * Parses and formats a single RFC 5322 mailbox (one entry from an address-
+ * list field, already isolated by envbuf_append_address_list()'s top-level-
+ * comma split below) into one IMAP `address` tuple, `"(" addr-name SP
+ * addr-adl SP addr-mailbox SP addr-host ")"` (RFC 9051 SS9). This is a
+ * deliberately scoped-down RFC 5322 address parser, not a complete one --
+ * consistent with this project's smaller-feature-set-over-completeness
+ * philosophy (see openimap-privsep-design.md's design philosophy section)
+ * and the explicit v1 ENVELOPE-only scope decision (see this file's header
+ * comment and imapd.h's MBOX_FETCH_ENVELOPE comment). Specifically NOT
+ * supported, by design:
+ *
+ *   - RFC 5322 `group` syntax ("Undisclosed-recipients:;") -- rare in
+ *     modern mail; addr-host's NIL-marks-a-group convention (SS7.5.2's
+ *     "If the mailbox name field is also NIL, this is an end-of-group
+ *     marker") is simply never produced by this implementation.
+ *   - obs-route / addr-adl -- obsolete since RFC 2822 (2001); always
+ *     formatted as NIL, which addr-adl's own ABNF comment in imapd.h
+ *     confirms is spec-legal ("Holds route from RFC5322 obs-route if
+ *     non-NIL").
+ *   - A quoted local-part containing an unescaped "@" (e.g.
+ *     `"foo@bar"@host.example`) -- this function finds the mailbox/host
+ *     split at the *last* unquoted "@", which is correct for the
+ *     overwhelming majority of real addresses but not that specific edge
+ *     case.
+ *   - RFC 5322 `comment` ("(...)") stripping -- comments are not
+ *     specially recognized or removed; since this parser never uses "("/
+ *     ")" as a structural delimiter anywhere, an address containing a
+ *     comment doesn't break parsing, it's just included verbatim as part
+ *     of whichever component (display name or mailbox/host) it falls
+ *     within, which may look odd but is not a correctness hazard.
+ *
+ * A mailbox this function can't make sense of (no "@" found in the
+ * addr-spec portion, or a `<...>` with no matching closing angle bracket)
+ * is reported via -1 -- the caller skips it and continues with the rest of
+ * the list, rather than failing the whole ENVELOPE for one malformed
+ * address (see envbuf_append_address_list()'s comment).
+ *
+ * Writes into a fixed local stack buffer sized generously for any
+ * realistic single address; a single address whose formatted form
+ * (including escaping) would exceed that buffer is also treated as -1 --
+ * an even more defensible simplification than the ones above, since a
+ * multi-kilobyte single address is already well outside anything a real
+ * mail client would ever produce.
+ */
+static int
+envbuf_append_one_address(char *buf, size_t bufsize, size_t *outlen,
+    const char *tok, size_t toklen)
+{
+	char		 addrbuf[1024];
+	size_t		 addrlen = 0;
+	const char	*name = NULL;
+	size_t		 namelen = 0;
+	const char	*spec;
+	size_t		 speclen;
+	const char	*mailbox, *host;
+	size_t		 mailboxlen, hostlen;
+	size_t		 at;
+	int		 found_at;
+	size_t		 lt;
+
+	while (toklen > 0 && (tok[0] == ' ' || tok[0] == '\t')) {
+		tok++;
+		toklen--;
+	}
+	while (toklen > 0 && (tok[toklen - 1] == ' ' || tok[toklen - 1] == '\t'))
+		toklen--;
+	if (toklen == 0)
+		return (-1);
+
+	/* Find an unquoted '<' -- if present, everything before it is the
+	 * display-name, and the addr-spec is the (still unquoted-tracked)
+	 * span up to the matching unquoted '>'. */
+	lt = toklen;
+	{
+		size_t	 i;
+		int	 q = 0;
+
+		for (i = 0; i < toklen; i++) {
+			if (tok[i] == '"')
+				q = !q;
+			else if (!q && tok[i] == '<') {
+				lt = i;
+				break;
+			}
+		}
+	}
+
+	if (lt < toklen) {
+		size_t	 gt = toklen, i;
+		int	 q = 0;
+
+		for (i = lt + 1; i < toklen; i++) {
+			if (tok[i] == '"')
+				q = !q;
+			else if (!q && tok[i] == '>') {
+				gt = i;
+				break;
+			}
+		}
+		if (gt >= toklen)
+			return (-1);	/* unmatched '<' -- malformed, skip */
+
+		{
+			const char	*disp = tok;
+			size_t		 displen = lt;
+
+			while (displen > 0 && (disp[0] == ' ' || disp[0] == '\t')) {
+				disp++;
+				displen--;
+			}
+			while (displen > 0 &&
+			    (disp[displen - 1] == ' ' || disp[displen - 1] == '\t'))
+				displen--;
+
+			if (displen >= 2 && disp[0] == '"' &&
+			    disp[displen - 1] == '"') {
+				/* quoted-string display-name: strip the
+				 * surrounding quotes; the emission loop
+				 * below (the "if (name != NULL)" block)
+				 * undoes RFC 5322 quoted-pair escaping
+				 * ("\" + escaped octet) as it walks name[],
+				 * then re-escapes for the IMAP wire
+				 * independently, so this round-trips
+				 * correctly without a separate unescape pass
+				 * here. Applying that same backslash-aware
+				 * walk to an *unquoted* phrase (the else
+				 * branch below) is harmless -- RFC 5322's
+				 * `atext`/`word` grammar for an unquoted
+				 * phrase has no quoted-pair mechanism, so a
+				 * literal backslash there would already be
+				 * non-conformant input, not a real case this
+				 * needs to get right. */
+				disp++;
+				displen -= 2;
+			}
+			if (displen > 0) {
+				name = disp;
+				namelen = displen;
+			}
+		}
+
+		spec = tok + lt + 1;
+		speclen = gt - (lt + 1);
+	} else {
+		spec = tok;
+		speclen = toklen;
+	}
+
+	while (speclen > 0 && (spec[0] == ' ' || spec[0] == '\t')) {
+		spec++;
+		speclen--;
+	}
+	while (speclen > 0 && (spec[speclen - 1] == ' ' || spec[speclen - 1] == '\t'))
+		speclen--;
+
+	found_at = 0;
+	at = 0;
+	{
+		size_t	 i;
+		int	 q = 0;
+
+		for (i = 0; i < speclen; i++) {
+			if (spec[i] == '"')
+				q = !q;
+			else if (!q && spec[i] == '@') {
+				at = i;
+				found_at = 1;
+			}
+		}
+	}
+	if (!found_at || at == 0 || at + 1 >= speclen)
+		return (-1);	/* no usable local-part@domain split */
+
+	mailbox = spec;
+	mailboxlen = at;
+	host = spec + at + 1;
+	hostlen = speclen - at - 1;
+
+	/* strip surrounding quotes from a quoted local-part, same
+	 * unescaping as the display-name case above -- deliberately not
+	 * handling an unescaped "@" inside a quoted local-part, see this
+	 * function's header comment */
+	if (mailboxlen >= 2 && mailbox[0] == '"' && mailbox[mailboxlen - 1] == '"') {
+		mailbox++;
+		mailboxlen -= 2;
+	}
+
+	if (name != NULL) {
+		size_t	 j;
+
+		if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\"", 1) == -1)
+			return (-1);
+		for (j = 0; j < namelen; j++) {
+			char	c = name[j];
+
+			if (c == '\\' && j + 1 < namelen) {
+				j++;
+				c = name[j];
+			}
+			if ((c == '"' || c == '\\') &&
+			    envbuf_append(addrbuf, sizeof(addrbuf), &addrlen,
+			    "\\", 1) == -1)
+				return (-1);
+			if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen,
+			    &c, 1) == -1)
+				return (-1);
+		}
+		if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "\"", 1) == -1)
+			return (-1);
+	} else {
+		if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen, "NIL") == -1)
+			return (-1);
+	}
+
+	if (envbuf_append_str(addrbuf, sizeof(addrbuf), &addrlen, " NIL ") == -1)
+		return (-1);
+	if (envbuf_append_nstring(addrbuf, sizeof(addrbuf), &addrlen, mailbox,
+	    mailboxlen) == -1)
+		return (-1);
+	if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, " ", 1) == -1)
+		return (-1);
+	if (envbuf_append_nstring(addrbuf, sizeof(addrbuf), &addrlen, host,
+	    hostlen) == -1)
+		return (-1);
+
+	if (envbuf_append(buf, bufsize, outlen, "(", 1) == -1)
+		return (-1);
+	if (envbuf_append(buf, bufsize, outlen, addrbuf, addrlen) == -1)
+		return (-1);
+	return (envbuf_append(buf, bufsize, outlen, ")", 1));
+}
+
+/*
+ * Formats an RFC 5322 address-list field value (From/Sender/Reply-To/To/
+ * Cc/Bcc's raw, already-unfolded value) as an IMAP `env-from`/`env-to`/etc.
+ * production: `"(" 1*address ")"` if at least one address parses, `NIL`
+ * otherwise (RFC 9051 SS7.5.2: "If the ... header fields are absent ... or
+ * are present but empty, the corresponding member of the envelope is
+ * NIL" -- this implementation extends that to "or contains nothing this
+ * parser could make an address out of", rather than distinguishing
+ * "empty" from "unparseable" at the wire level, since RFC 9051 gives no
+ * separate response for the latter).
+ *
+ * Splits val on top-level commas -- respecting RFC 5322 quoted-string and
+ * angle-addr nesting, so a quoted display name containing a literal comma
+ * ("Doe, John" <j@example.com>) is not mistaken for a list separator.
+ * Adjacent `address` tuples are written back-to-back with no separator
+ * between them (`"(" 1*address ")"`'s own ABNF has none -- each `address`
+ * is already self-delimiting via its own parens, confirmed against RFC
+ * 9051 SS7.5.2's own worked example, which shows
+ * `((NIL NIL "minutes" "..." )("John Klensin" NIL "KLENSIN" "MIT.EDU"))`
+ * with no space or comma between the two address tuples).
+ */
+static int
+envbuf_append_address_list(char *buf, size_t bufsize, size_t *outlen,
+    const char *val, size_t vallen)
+{
+	size_t	 save = *outlen;
+	size_t	 i = 0;
+	int	 any = 0;
+
+	while (vallen > 0 && (val[0] == ' ' || val[0] == '\t')) {
+		val++;
+		vallen--;
+	}
+	while (vallen > 0 && (val[vallen - 1] == ' ' || val[vallen - 1] == '\t'))
+		vallen--;
+
+	if (vallen == 0)
+		return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
+
+	if (envbuf_append(buf, bufsize, outlen, "(", 1) == -1)
+		return (-1);
+
+	while (i < vallen) {
+		size_t	 tok_start;
+		size_t	 tok_len;
+		int	 in_quotes = 0, in_angle = 0;
+
+		while (i < vallen && (val[i] == ' ' || val[i] == '\t' ||
+		    val[i] == ','))
+			i++;
+		tok_start = i;
+		while (i < vallen) {
+			char	c = val[i];
+
+			if (c == '"')
+				in_quotes = !in_quotes;
+			else if (!in_quotes && c == '<')
+				in_angle = 1;
+			else if (!in_quotes && c == '>')
+				in_angle = 0;
+			else if (!in_quotes && !in_angle && c == ',')
+				break;
+			i++;
+		}
+		tok_len = i - tok_start;
+		while (tok_len > 0 && (val[tok_start + tok_len - 1] == ' ' ||
+		    val[tok_start + tok_len - 1] == '\t'))
+			tok_len--;
+
+		if (tok_len > 0) {
+			if (envbuf_append_one_address(buf, bufsize, outlen,
+			    val + tok_start, tok_len) == 0)
+				any = 1;
+			else if (*outlen > bufsize) {
+				/* can't actually happen -- envbuf_append()
+				 * never leaves *outlen past bufsize -- but
+				 * checked defensively rather than assumed */
+				return (-1);
+			}
+			/* malformed single address: envbuf_append_one_
+			 * address() left the buffer exactly as it found it
+			 * on failure (see that function's own local addrbuf
+			 * staging, which is only ever flushed to buf/outlen
+			 * as one atomic append), so skipping it here is
+			 * safe -- not a hard failure for the whole list */
+		}
+	}
+
+	if (!any) {
+		*outlen = save;
+		return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
+	}
+	return (envbuf_append(buf, bufsize, outlen, ")", 1));
+}
+
+/*
+ * Looks up header field `name`, appends its nstring form (see envbuf_
+ * append_nstring()) -- NIL if the field is absent, an escaped quoted
+ * string (possibly empty, `""`) if present. Shared by ENVELOPE's four
+ * plain-string members (date, subject, in-reply-to, message-id).
+ */
+static int
+append_field_nstring(char *out, size_t outsize, size_t *outlen,
+    const char *hdrbuf, uint32_t hdrlen, const char *name)
+{
+	char	*val;
+	size_t	 vallen;
+	int	 rc;
+
+	if (extract_header_field(hdrbuf, hdrlen, name, &val, &vallen) == 0) {
+		rc = envbuf_append_nstring(out, outsize, outlen, val, vallen);
+		free(val);
+	} else {
+		rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
+	}
+	return (rc);
+}
+
+/*
+ * Builds the complete RFC 9051 SS7.5.2 ENVELOPE parenthesized-list text for
+ * one message: `"(" env-date SP env-subject SP env-from SP env-sender SP
+ * env-reply-to SP env-to SP env-cc SP env-bcc SP env-in-reply-to SP
+ * env-message-id ")"`, field order taken directly from SS7.5.2 ("The
+ * fields of the envelope structure are in the following order: date,
+ * subject, from, sender, reply-to, to, cc, bcc, in-reply-to, and
+ * message-id"). Reads the header via read_message_header() independently
+ * of any other requested FETCH content item -- same "each content item's
+ * store.c handler does its own read_message_header()/read_message_body()
+ * call" pattern already used for BODY.PEEK[HEADER] vs. BODY.PEEK[HEADER.
+ * FIELDS...] vs. BODY.PEEK[]/[TEXT] (see handle_mbox_fetch()'s comment);
+ * not sharing one read across FETCH items in the same request is a known,
+ * accepted minor inefficiency, not a new one introduced here.
+ *
+ * Sender/Reply-To default to the already-formatted From value when their
+ * own header field is absent or present-but-completely-empty (RFC 9051
+ * SS7.5.2: "If the Sender or Reply-To header fields are absent ..., or are
+ * present but empty, the server sets the corresponding member of the
+ * envelope to be the same value as the from member"). "Present but empty"
+ * here specifically means the raw header value is zero bytes after
+ * unfolding (e.g. "Sender:\r\n") -- a value that's present but entirely
+ * whitespace is treated as a normal (if unusual) address-list value that
+ * happens to parse to no addresses, which independently produces the same
+ * NIL rather than the from-value fallback; a narrow, documented difference
+ * from a fully literal reading of "empty", not expected to matter for any
+ * real message.
+ *
+ * Returns 0 and a malloc(3)'d *buf_out (caller frees) + *len_out on
+ * success. Returns -1 (nothing to free) if the message's header can't be
+ * read at all, or if the fully-formatted envelope text would exceed
+ * ENVELOPE_MAX -- both cases are "ENVELOPE not found" for this message,
+ * same as every other content item's own not-found handling.
+ */
+static int
+build_envelope(const char *basename, char **buf_out, uint32_t *len_out)
+{
+	char		*hdrbuf = NULL;
+	uint32_t	 hdrlen = 0;
+	char		 out[ENVELOPE_MAX];
+	size_t		 outlen = 0;
+	char		 from_formatted[ENVELOPE_MAX];
+	size_t		 from_len = 0;
+
+	*buf_out = NULL;
+	*len_out = 0;
+
+	if (read_message_header(basename, &hdrbuf, &hdrlen) == -1)
+		return (-1);
+
+	if (envbuf_append(out, sizeof(out), &outlen, "(", 1) == -1)
+		goto fail;
+
+	if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+	    "Date") == -1)
+		goto fail;
+	if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+		goto fail;
+	if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+	    "Subject") == -1)
+		goto fail;
+	if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+		goto fail;
+
+	/* from */
+	{
+		char	*val;
+		size_t	 vallen;
+
+		if (extract_header_field(hdrbuf, hdrlen, "From", &val,
+		    &vallen) == 0) {
+			int	rc = envbuf_append_address_list(from_formatted,
+			    sizeof(from_formatted), &from_len, val, vallen);
+			free(val);
+			if (rc == -1)
+				goto fail;
+		} else {
+			if (envbuf_append_str(from_formatted,
+			    sizeof(from_formatted), &from_len, "NIL") == -1)
+				goto fail;
+		}
+	}
+	if (envbuf_append(out, sizeof(out), &outlen, from_formatted,
+	    from_len) == -1)
+		goto fail;
+	if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+		goto fail;
+
+	/* sender, reply-to: default to from_formatted per SS7.5.2 */
+	{
+		static const char *const fallback_fields[] =
+		    { "Sender", "Reply-To" };
+		size_t	 fi;
+
+		for (fi = 0; fi < 2; fi++) {
+			char	*val;
+			size_t	 vallen;
+			int	 used_value = 0;
+
+			if (extract_header_field(hdrbuf, hdrlen,
+			    fallback_fields[fi], &val, &vallen) == 0) {
+				if (vallen > 0) {
+					int	rc = envbuf_append_address_list(
+					    out, sizeof(out), &outlen, val,
+					    vallen);
+					used_value = 1;
+					free(val);
+					if (rc == -1)
+						goto fail;
+				} else
+					free(val);
+			}
+			if (!used_value &&
+			    envbuf_append(out, sizeof(out), &outlen,
+			    from_formatted, from_len) == -1)
+				goto fail;
+			if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+				goto fail;
+		}
+	}
+
+	/* to, cc, bcc */
+	{
+		static const char *const addr_fields[] = { "To", "Cc", "Bcc" };
+		size_t	 fi;
+
+		for (fi = 0; fi < 3; fi++) {
+			char	*val;
+			size_t	 vallen;
+
+			if (extract_header_field(hdrbuf, hdrlen,
+			    addr_fields[fi], &val, &vallen) == 0) {
+				int	rc = envbuf_append_address_list(out,
+				    sizeof(out), &outlen, val, vallen);
+				free(val);
+				if (rc == -1)
+					goto fail;
+			} else {
+				if (envbuf_append_str(out, sizeof(out),
+				    &outlen, "NIL") == -1)
+					goto fail;
+			}
+			if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+				goto fail;
+		}
+	}
+
+	if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+	    "In-Reply-To") == -1)
+		goto fail;
+	if (envbuf_append(out, sizeof(out), &outlen, " ", 1) == -1)
+		goto fail;
+	if (append_field_nstring(out, sizeof(out), &outlen, hdrbuf, hdrlen,
+	    "Message-Id") == -1)
+		goto fail;
+
+	if (envbuf_append(out, sizeof(out), &outlen, ")", 1) == -1)
+		goto fail;
+
+	free(hdrbuf);
+
+	if ((*buf_out = malloc(outlen)) == NULL) {
+		log_warn("session %u: malloc ENVELOPE buffer (%s)",
+		    session_id, basename);
+		return (-1);
+	}
+	memcpy(*buf_out, out, outlen);
+	*len_out = (uint32_t)outlen;
+	return (0);
+
+fail:
+	log_warnx("session %u: message %s: formatted ENVELOPE exceeds "
+	    "ENVELOPE_MAX -- ENVELOPE skipped", session_id, basename);
+	free(hdrbuf);
+	return (-1);
+}
+
+/*
+ * ---------------------------------------------------------------------
+ * BODYSTRUCTURE (RFC 9051 SS7.5.2) -- recursive MIME structure parsing.
+ * Scope, sourced from RFC 2045/RFC 2046 (fetched and read directly this
+ * pass, saved under research/) and an explicit user choice (AskUserQuestion,
+ * "full recursive, depth-capped" over "single-part only" or "one level of
+ * multipart, no nesting"): parses Content-Type/Content-Transfer-Encoding/
+ * Content-Id/Content-Description and recurses into multipart bodies via
+ * RFC 2046 SS5.1.1's boundary-delimited body-part grammar, bounded by
+ * MIME_MAX_DEPTH/MIME_MAX_PARTS (imapd.h). Deliberately does NOT
+ * implement: RFC 9051's optional extension data (body MD5/disposition/
+ * language/location -- SS7.5.2 says this "can be returned... if present"
+ * with BODYSTRUCTURE, not that it MUST be, so omitting it entirely means
+ * BODYSTRUCTURE and the non-extensible "BODY" macro produce identical
+ * output here, which is spec-legal); message/rfc822 and message/global
+ * parts (body-type-msg needs a full nested ENVELOPE plus a nested BODY
+ * structure of the *embedded* message, i.e. recursively re-deriving
+ * everything this file already does for a top-level message, just against
+ * an inner message extracted from a part's own body -- scoped out as a
+ * separate, larger unit of work, not attempted this pass; a message
+ * containing one anywhere in its structure gets found=0 for its whole
+ * BODYSTRUCTURE, not a partially-correct structure); and RFC 2231
+ * parameter-value continuations/charset encoding (SS7.5.2's own "Servers
+ * SHOULD decode parameter-value continuations" language is a SHOULD, not a
+ * MUST -- unhandled continuation parameters like "name*0"/"name*1" are
+ * passed through as literal, un-joined attribute names instead, a cosmetic
+ * rather than structural gap).
+ * ---------------------------------------------------------------------
+ */
+
+/*
+ * Same header/body blank-line-separator scan as read_message_header()'s own
+ * (see that function's comment), just operating on a buffer already fully
+ * in memory rather than reading fresh from a file descriptor -- needed
+ * because BODYSTRUCTURE's recursive walk works entirely off of one whole-
+ * message read (build_bodystructure()'s read_message_body() call) and has
+ * to re-locate this same separator both for the top-level message and for
+ * every multipart sub-part carved out of it along the way.
+ */
+static int
+find_header_body_split(const char *buf, size_t len, size_t *hdrend_out)
+{
+	size_t	 i;
+
+	for (i = 0; i + 1 < len; i++) {
+		if (i + 3 < len && buf[i] == '\r' && buf[i + 1] == '\n' &&
+		    buf[i + 2] == '\r' && buf[i + 3] == '\n') {
+			*hdrend_out = i + 4;
+			return (0);
+		}
+		if (buf[i] == '\n' && buf[i + 1] == '\n') {
+			*hdrend_out = i + 2;
+			return (0);
+		}
+	}
+	return (-1);
+}
+
+/*
+ * RFC 2045 SS5.1: `tspecials := "(" / ")" / "<" / ">" / "@" / "," / ";" /
+ * ":" / "\" / <"> / "/" / "[" / "]" / "?" / "="` -- the RFC 822 `specials`
+ * set plus "/", "?", "=", minus ".". A `token` is any US-ASCII CHAR except
+ * SPACE, CTLs, or one of these.
+ */
+static int
+mime_is_tspecial(char c)
+{
+	return (strchr("()<>@,;:\\\"/[]?=", c) != NULL);
+}
+
+/*
+ * Reads one RFC 2045 `token` or `quoted-string` starting at s[*pos],
+ * advancing *pos past it, into a NUL-terminated out[]. A leading '"'
+ * switches to quoted-string mode: reads until the matching unescaped '"',
+ * undoing RFC 822 quoted-pair escaping ("\" + one CHAR) as it goes --
+ * needed in practice because most real message generators quote the
+ * multipart "boundary" parameter's value even though a bare token would be
+ * legal, precisely because boundary strings very often contain characters
+ * (like "=", part of base64-derived boundary strings such as
+ * "----=_NextPart_...") that are tspecials and thus illegal in a bare,
+ * unquoted token. Shared by every Content-Type token/value this file reads
+ * (type, subtype, attribute names, and parameter values) -- type/subtype/
+ * attribute are always plain `token`s in practice (RFC 2045's grammar
+ * never allows them to be quoted-strings in the first place, so a leading
+ * '"' at those positions would itself be a tspecial and simply fail to
+ * read as a valid bare token, correctly rejected).
+ *
+ * Returns 0 on success (out[] is NUL-terminated, possibly the empty string
+ * only in the quoted-string case). Returns -1 or a zero-length result for
+ * a bare token (RFC 2045 tokens are 1*<...>, never zero-length) if nothing
+ * valid could be read, an unterminated quoted-string, or a value too long
+ * for outsize -- callers treat any of these as "stop parsing further
+ * parameters, keep what's already been parsed" rather than failing the
+ * whole Content-Type field over one malformed trailing parameter.
+ */
+static int
+mime_read_token_or_qstring(const char *s, size_t len, size_t *pos,
+    char *out, size_t outsize)
+{
+	size_t	 outlen = 0;
+
+	if (outsize == 0)
+		return (-1);
+
+	if (*pos < len && s[*pos] == '"') {
+		(*pos)++;
+		while (*pos < len && s[*pos] != '"') {
+			char	c = s[*pos];
+
+			if (c == '\\' && *pos + 1 < len) {
+				(*pos)++;
+				c = s[*pos];
+			}
+			if (outlen + 1 >= outsize)
+				return (-1);
+			out[outlen++] = c;
+			(*pos)++;
+		}
+		if (*pos >= len)
+			return (-1);	/* unterminated quoted-string */
+		(*pos)++;
+		out[outlen] = '\0';
+		return (0);
+	}
+
+	while (*pos < len && !mime_is_tspecial(s[*pos]) &&
+	    s[*pos] != ' ' && s[*pos] != '\t' &&
+	    (unsigned char)s[*pos] >= 0x20 && (unsigned char)s[*pos] != 0x7f) {
+		if (outlen + 1 >= outsize)
+			return (-1);
+		out[outlen++] = s[*pos];
+		(*pos)++;
+	}
+	out[outlen] = '\0';
+	if (outlen == 0)
+		return (-1);
+	return (0);
+}
+
+/* In-place ASCII-range uppercase -- used to canonicalize MIME type/
+ * subtype/attribute names for output, matching RFC 9051 SS7.5.2's own
+ * worked examples ("TEXT" "PLAIN" ("CHARSET" ...)). RFC 2045 SS5.1 says
+ * type/subtype/attribute matching is "ALWAYS case-insensitive", so this is
+ * a display-canonicalization choice, not a correctness requirement --
+ * parameter *values* (e.g. a filename) are deliberately left as-is,
+ * un-uppercased, since those are often case-sensitive in practice
+ * (filenames, charset aliases some clients treat case-sensitively, etc). */
+static void
+mime_str_upper(char *s)
+{
+	for (; *s != '\0'; s++) {
+		if (*s >= 'a' && *s <= 'z')
+			*s -= ('a' - 'A');
+	}
+}
+
+/*
+ * RFC 2045 SS5.1: `content := "Content-Type" ":" type "/" subtype
+ * *(";" parameter)`, `parameter := attribute "=" value`. Parses the
+ * Content-Type header field of hdr[0..hdrlen) (a message or MIME body-
+ * part's own header block) into type_out/subtype_out (both uppercased --
+ * see mime_str_upper()'s comment) and params_fmt_out (the RFC 9051
+ * body-fld-param list, already formatted as IMAP wire text: `("CHARSET"
+ * "US-ASCII")`-style, or "NIL" if there were no parameters). Separately
+ * captures the "boundary" parameter's raw (unescaped) value in
+ * boundary_out / has_boundary_out if present, needed by split_multipart()
+ * for multipart types -- extracted in the same single pass rather than
+ * re-parsing the field twice.
+ *
+ * RFC 2045 SS5.2: "Default RFC 822 messages without a MIME Content-Type
+ * header are taken by this protocol to be plain text in the US-ASCII
+ * character set... It is also recommended that this default be assumed
+ * when a syntactically invalid Content-Type header field is encountered."
+ * -- both the absent case and the type/subtype-unparseable case fall back
+ * to this exact default. A malformed *parameter* partway through an
+ * otherwise-valid "type/subtype" (e.g. a parameter with no "=", or an
+ * unterminated quoted-string value) does NOT trigger this fallback --
+ * parameter parsing simply stops there, keeping type/subtype and whatever
+ * parameters were already successfully parsed, rather than discarding a
+ * perfectly good type/subtype over one bad trailing parameter.
+ *
+ * Returns 0 on success (always -- the RFC 2045 SS5.2 default means there's
+ * always *something* valid to report) or -1 only if writing params_fmt_out
+ * itself overflows params_fmt_outsize (propagated from envbuf_append*()),
+ * which the caller treats as a hard BODYSTRUCTURE_MAX-exceeded-style
+ * failure for this part.
+ */
+static int
+parse_content_type(const char *hdr, size_t hdrlen, char *type_out,
+    size_t typesize, char *subtype_out, size_t subtypesize,
+    char *params_fmt_out, size_t params_fmt_outsize, char *boundary_out,
+    size_t boundary_outsize, int *has_boundary_out)
+{
+	char	*val = NULL;
+	size_t	 vallen = 0;
+	size_t	 pos = 0;
+	size_t	 plen = 0;
+	int	 nparams = 0;
+	int	 use_default = 0;
+	/*
+	 * envbuf_append()/envbuf_append_str()/envbuf_append_nstring() track
+	 * length purely via the outlen pointer and never write a
+	 * terminating NUL -- correct for their original ENVELOPE callers,
+	 * which only ever consume the tracked length, never strlen(). This
+	 * function's caller (build_body_structure()) does call
+	 * strlen(params_fmt) on the finished buffer, so one byte of
+	 * params_fmt_out's capacity is reserved here for an explicit NUL
+	 * written at every return point below, rather than leaving that
+	 * byte to whatever uninitialized stack content the caller's buffer
+	 * happened to start with. (Found via real-hardware testing: a
+	 * genuine multipart message's BODYSTRUCTURE came back with a run of
+	 * stray bytes -- leftover stack content from the message's own
+	 * base64 attachment data sitting in scope earlier -- spliced in
+	 * right after the params list, because strlen() ran past the
+	 * intended content looking for a NUL that was never written.)
+	 */
+	size_t	 pfsize = (params_fmt_outsize > 0) ? params_fmt_outsize - 1 : 0;
+
+	*has_boundary_out = 0;
+	boundary_out[0] = '\0';
+
+	if (pfsize == 0)
+		return (-1);
+
+	if (extract_header_field(hdr, hdrlen, "Content-Type", &val,
+	    &vallen) == -1 || vallen == 0)
+		use_default = 1;
+
+	if (!use_default && (mime_read_token_or_qstring(val, vallen, &pos,
+	    type_out, typesize) == -1 || pos >= vallen || val[pos] != '/'))
+		use_default = 1;
+	if (!use_default) {
+		pos++;
+		if (mime_read_token_or_qstring(val, vallen, &pos, subtype_out,
+		    subtypesize) == -1)
+			use_default = 1;
+	}
+
+	if (use_default) {
+		free(val);
+		strlcpy(type_out, "TEXT", typesize);
+		strlcpy(subtype_out, "PLAIN", subtypesize);
+		if (envbuf_append_str(params_fmt_out, pfsize, &plen,
+		    "(\"CHARSET\" \"US-ASCII\")") == -1)
+			return (-1);
+		params_fmt_out[plen] = '\0';
+		return (0);
+	}
+
+	mime_str_upper(type_out);
+	mime_str_upper(subtype_out);
+
+	for (;;) {
+		char	attr[64], value[256];
+
+		while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
+			pos++;
+		if (pos >= vallen || val[pos] != ';')
+			break;
+		pos++;
+		while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
+			pos++;
+		if (mime_read_token_or_qstring(val, vallen, &pos, attr,
+		    sizeof(attr)) == -1)
+			break;
+		while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
+			pos++;
+		if (pos >= vallen || val[pos] != '=')
+			break;
+		pos++;
+		while (pos < vallen && (val[pos] == ' ' || val[pos] == '\t'))
+			pos++;
+		if (mime_read_token_or_qstring(val, vallen, &pos, value,
+		    sizeof(value)) == -1)
+			break;
+
+		mime_str_upper(attr);
+		if (envbuf_append_str(params_fmt_out, pfsize,
+		    &plen, nparams == 0 ? "(" : " ") == -1 ||
+		    envbuf_append_nstring(params_fmt_out, pfsize,
+		    &plen, attr, strlen(attr)) == -1 ||
+		    envbuf_append(params_fmt_out, pfsize, &plen,
+		    " ", 1) == -1 ||
+		    envbuf_append_nstring(params_fmt_out, pfsize,
+		    &plen, value, strlen(value)) == -1) {
+			free(val);
+			return (-1);
+		}
+		nparams++;
+
+		if (strcasecmp(attr, "BOUNDARY") == 0) {
+			strlcpy(boundary_out, value, boundary_outsize);
+			*has_boundary_out = 1;
+		}
+	}
+
+	free(val);
+	if (nparams > 0) {
+		if (envbuf_append_str(params_fmt_out, pfsize, &plen,
+		    ")") == -1)
+			return (-1);
+		params_fmt_out[plen] = '\0';
+		return (0);
+	}
+	if (envbuf_append_str(params_fmt_out, pfsize, &plen, "NIL") == -1)
+		return (-1);
+	params_fmt_out[plen] = '\0';
+	return (0);
+}
+
+/*
+ * RFC 2046 SS5.1.1: splits body[0..bodylen) -- a multipart Content-Type's
+ * own body, i.e. everything after that part/message's header/body blank
+ * line -- into its `body-part`s, per `multipart-body := [preamble CRLF]
+ * dash-boundary transport-padding CRLF body-part *encapsulation close-
+ * delimiter transport-padding [CRLF epilogue]`. Each returned span
+ * part_starts[i]..part_ends[i] is one body-part's raw bytes (its own
+ * MIME-part-headers through the end of its content, not yet split into
+ * header/body -- the caller does that separately per sub-part, via
+ * find_header_body_split(), since a sub-part's header/body separator is
+ * unrelated to this function's own boundary-scanning).
+ *
+ * A `dash-boundary` ("--" + the Content-Type's boundary parameter value)
+ * only counts as a real delimiter if it appears at the start of a line
+ * (position 0, or immediately after a CRLF or bare LF -- RFC 2046's own
+ * requirement that "Lines in a body-part must not start with the
+ * specified dash-boundary" means a conformant generator guarantees this
+ * check is sufficient) and is immediately followed -- after optional
+ * `transport-padding` (spaces/tabs) -- by either a CRLF/LF (a normal
+ * delimiter, more parts follow) or "--" then CRLF/LF/end-of-body (the
+ * close-delimiter, no more parts). Anything else at a boundary-shaped
+ * line start is treated as a coincidental non-match, not a delimiter.
+ *
+ * Returns 0 and *nparts_out >= 1 on success (a valid multipart body has at
+ * least the preamble's dash-boundary and a close-delimiter bracketing at
+ * least one body-part -- RFC 2046's own `1*body-part` isn't quite right
+ * here since this implementation doesn't distinguish "zero-body-part
+ * multipart" as separately illegal from "no close-delimiter found", both
+ * simply fail). Returns -1 (nothing found or usable) if the boundary
+ * parameter is malformed/oversized, no close-delimiter is ever found, or
+ * more than maxparts body-parts would result -- all treated by the caller
+ * as "can't build a BODYSTRUCTURE for this multipart part", not a partial
+ * result.
+ */
+static int
+split_multipart(const char *body, size_t bodylen, const char *boundary,
+    size_t *part_starts, size_t *part_ends, int *nparts_out, int maxparts)
+{
+	char	 needle[2 + 70 + 1];	/* "--" + boundary; RFC 2046 SS5.1.1
+					 * caps boundary at 70 characters */
+	size_t	 needlelen;
+	size_t	 pos = 0;
+	int	 found_first = 0;
+	int	 n = 0;
+	int	 closed = 0;
+
+	strlcpy(needle, "--", sizeof(needle));
+	needlelen = strlcat(needle, boundary, sizeof(needle));
+	if (needlelen >= sizeof(needle))
+		return (-1);	/* boundary too long -- non-conformant */
+
+	*nparts_out = 0;
+
+	while (pos < bodylen) {
+		int	 at_bol = (pos == 0) ||
+		    (pos >= 2 && body[pos - 2] == '\r' &&
+		    body[pos - 1] == '\n') ||
+		    (pos >= 1 && body[pos - 1] == '\n');
+		size_t	 after;
+		int	 is_close = 0;
+
+		if (!at_bol || pos + needlelen > bodylen ||
+		    memcmp(body + pos, needle, needlelen) != 0) {
+			pos++;
+			continue;
+		}
+
+		after = pos + needlelen;
+		if (after + 1 < bodylen && body[after] == '-' &&
+		    body[after + 1] == '-') {
+			is_close = 1;
+			after += 2;
+		}
+		while (after < bodylen &&
+		    (body[after] == ' ' || body[after] == '\t'))
+			after++;
+		if (after < bodylen && body[after] != '\r' &&
+		    body[after] != '\n') {
+			/* not actually followed by CRLF/LF/end-of-body --
+			 * coincidental match inside real content, not a
+			 * delimiter */
+			pos++;
+			continue;
+		}
+
+		if (found_first) {
+			size_t	 content_end = pos;
+			size_t	 part_start = part_starts[n - 1];
+
+			/*
+			 * Bug found during manual security review: an empty
+			 * body-part (this boundary line immediately following
+			 * the previous one, with no content line between them)
+			 * previously let this strip read/subtract bytes that
+			 * actually belong to the *previous* boundary's own
+			 * line-ending, not this (empty) part's content --
+			 * driving content_end below part_start. Every caller
+			 * computes part_ends[i] - part_starts[i] as unsigned
+			 * size_t arithmetic, so that underflowed to a
+			 * near-SIZE_MAX length, later used as a memcpy/scan
+			 * bound over attacker-controlled MIME content.
+			 * Confirmed empirically with a standalone harness
+			 * against body "--X\r\n--X--\r\n": content_end came out
+			 * to pos-2 = 3, less than part_start = 5. Bounding the
+			 * strip to never go below part_start keeps an empty
+			 * part's length at a legitimate 0 instead.
+			 */
+			if (content_end >= part_start + 2 &&
+			    body[content_end - 2] == '\r' &&
+			    body[content_end - 1] == '\n')
+				content_end -= 2;
+			else if (content_end >= part_start + 1 &&
+			    body[content_end - 1] == '\n')
+				content_end -= 1;
+			part_ends[n - 1] = content_end;
+		}
+
+		if (is_close) {
+			closed = 1;
+			break;
+		}
+
+		if (after < bodylen && body[after] == '\r' &&
+		    after + 1 < bodylen && body[after + 1] == '\n')
+			after += 2;
+		else if (after < bodylen && body[after] == '\n')
+			after += 1;
+		else
+			break;	/* delimiter runs to end of body with no
+				 * CRLF -- no body-part can follow */
+
+		if (n >= maxparts)
+			return (-1);
+		part_starts[n] = after;
+		n++;
+		found_first = 1;
+		pos = after;
+	}
+
+	if (!closed || !found_first)
+		return (-1);
+
+	*nparts_out = n;
+	return (0);
+}
+
+/*
+ * Recursively builds one RFC 9051 SS7.5.2 `body` -- `"(" (body-type-1part /
+ * body-type-mpart) ")"` -- for the MIME entity whose header is hdr[0..
+ * hdrlen) and whose (still-encoded) content is body[0..bodylen), appending
+ * the formatted text directly onto out/outlen (the same shared buffer the
+ * whole recursive walk writes into, so a deeply nested structure is built
+ * with a single top-level BODYSTRUCTURE_MAX size check rather than N
+ * separate per-part buffers).
+ *
+ * depth/nparts_used together enforce MIME_MAX_DEPTH/MIME_MAX_PARTS
+ * (imapd.h's comment on those constants has the full "message content
+ * is attacker/sender-controlled, both need a hard ceiling" reasoning):
+ * depth increases by exactly one per multipart nesting level (checked
+ * before doing any work at this level); nparts_used is a single counter
+ * threaded through the *entire* recursive call tree by pointer (not a
+ * per-multipart-parent count), incremented once per call regardless of
+ * whether this call turns out to be a leaf or another multipart container,
+ * so it bounds total part count across the whole structure, not just
+ * immediate siblings.
+ *
+ * Three shapes, matching RFC 9051's body-type-1part/body-type-mpart
+ * grammar:
+ *
+ *   - MULTIPART: requires a "boundary" Content-Type parameter (RFC 2046
+ *     SS5.1.1: "The only mandatory global parameter for the multipart
+ *     media type is the boundary parameter"); split_multipart() carves
+ *     body into its body-parts, each of which is itself split into
+ *     header/body (find_header_body_split()) and recursed into. Formatted
+ *     as `"(" body body ... SP media-subtype ")"` -- RFC 9051's own
+ *     SS7.5.2 example shows adjacent address/body tuples with no
+ *     separator between them, since each is already self-delimiting via
+ *     its own parens.
+ *   - message/rfc822 or message/global: scoped out entirely (see this
+ *     section's header comment) -- returns -1, which propagates as
+ *     "BODYSTRUCTURE not found" for the whole top-level message, not a
+ *     partially-correct structure with a fake or missing part.
+ *   - Everything else (body-type-basic / body-type-text): a single,
+ *     non-multipart part. body-fields (RFC 9051 SS9: `body-fld-param SP
+ *     body-fld-id SP body-fld-desc SP body-fld-enc SP body-fld-octets`)
+ *     are always emitted in full -- these are required basic fields, not
+ *     the optional extension data this implementation omits (see this
+ *     section's header comment) -- followed by body-fld-lines only for
+ *     "TEXT" parts (body-type-text's own extra field). body-fld-octets is
+ *     simply bodylen: RFC 9051 SS7.5.2 "the size in its transfer encoding
+ *     and not the resulting size after any decoding" means the *encoded*
+ *     byte count is exactly right, with no need to ever actually decode
+ *     base64/quoted-printable content. body-fld-lines similarly counts
+ *     raw '\n' occurrences in the still-encoded body bytes, not decoded
+ *     text lines -- same "size in its content transfer encoding" framing,
+ *     and the counting convention real servers commonly use.
+ *
+ * Returns 0 on success, -1 (propagated all the way up to build_
+ * bodystructure()'s caller as "not found") on any depth/part-count/
+ * malformed-multipart/message-rfc822/output-overflow failure.
+ */
+static int
+build_body_structure(int depth, int *nparts_used, const char *hdr,
+    size_t hdrlen, const char *body, size_t bodylen, char *out,
+    size_t outsize, size_t *outlen)
+{
+	char	type[64], subtype[64];
+	char	params_fmt[600];
+	char	boundary[70 + 1];	/* RFC 2046 SS5.1.1 caps boundary at
+					 * 70 characters, +1 for NUL */
+	int	has_boundary;
+
+	if (depth > MIME_MAX_DEPTH)
+		return (-1);
+	if (++*nparts_used > MIME_MAX_PARTS)
+		return (-1);
+
+	params_fmt[0] = '\0';
+	if (parse_content_type(hdr, hdrlen, type, sizeof(type), subtype,
+	    sizeof(subtype), params_fmt, sizeof(params_fmt), boundary,
+	    sizeof(boundary), &has_boundary) == -1)
+		return (-1);
+
+	if (strcasecmp(type, "MULTIPART") == 0) {
+		size_t	 part_starts[MIME_MAX_PARTS], part_ends[MIME_MAX_PARTS];
+		int	 n, i;
+
+		if (!has_boundary)
+			return (-1);
+		if (split_multipart(body, bodylen, boundary, part_starts,
+		    part_ends, &n, MIME_MAX_PARTS) == -1)
+			return (-1);
+
+		if (envbuf_append(out, outsize, outlen, "(", 1) == -1)
+			return (-1);
+		for (i = 0; i < n; i++) {
+			const char	*pbuf = body + part_starts[i];
+			size_t		 plen = part_ends[i] - part_starts[i];
+			size_t		 phdrend;
+
+			/*
+			 * A genuinely empty body-part (RFC 2046 SS5.1.1's
+			 * body-part := MIME-part-headers [CRLF *OCTET] --
+			 * the CRLF-and-octets half is itself optional, so
+			 * zero bytes between two boundary delimiters is
+			 * spec-legal, not malformed) has no header/body
+			 * separator to find at all, by construction. Found
+			 * via real-hardware testing once the split_multipart()
+			 * empty-part fix (above) started correctly producing
+			 * plen == 0 for a part like this, instead of the
+			 * huge underflowed length that used to reach here
+			 * first: find_header_body_split() correctly reports
+			 * "not found" for a zero-length buffer (there IS no
+			 * separator in zero bytes), but treating that as this
+			 * whole part's failure -- and this loop returning -1
+			 * for the *entire* multipart structure the moment any
+			 * single part fails -- meant one legitimately empty
+			 * part broke BODYSTRUCTURE for the whole message.
+			 * Treat plen == 0 as hdrlen == 0 / bodylen == 0
+			 * directly instead: parse_content_type() with
+			 * hdrlen == 0 already falls through its own
+			 * "Content-Type field absent" path to the RFC 2045
+			 * SS5.2 default (text/plain; charset=us-ascii), so
+			 * this recurses into an ordinary empty leaf part
+			 * rather than aborting.
+			 */
+			if (plen == 0)
+				phdrend = 0;
+			else if (find_header_body_split(pbuf, plen,
+			    &phdrend) == -1)
+				return (-1);
+			if (build_body_structure(depth + 1, nparts_used, pbuf,
+			    phdrend, pbuf + phdrend, plen - phdrend, out,
+			    outsize, outlen) == -1)
+				return (-1);
+		}
+		if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+			return (-1);
+		if (envbuf_append_nstring(out, outsize, outlen, subtype,
+		    strlen(subtype)) == -1)
+			return (-1);
+		return (envbuf_append(out, outsize, outlen, ")", 1));
+	}
+
+	if (strcasecmp(type, "MESSAGE") == 0 &&
+	    (strcasecmp(subtype, "RFC822") == 0 ||
+	    strcasecmp(subtype, "GLOBAL") == 0))
+		return (-1);	/* scoped out -- see this section's header
+				 * comment */
+
+	{
+		char	*idval = NULL, *descval = NULL, *encval = NULL;
+		size_t	 idlen = 0, desclen = 0, enclen = 0;
+		char	 encstr[40];
+		int	 is_text = (strcasecmp(type, "TEXT") == 0);
+		int	 rc = 0;
+
+		if (envbuf_append(out, outsize, outlen, "(", 1) == -1)
+			return (-1);
+		if (envbuf_append_nstring(out, outsize, outlen, type,
+		    strlen(type)) == -1)
+			return (-1);
+		if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+			return (-1);
+		if (envbuf_append_nstring(out, outsize, outlen, subtype,
+		    strlen(subtype)) == -1)
+			return (-1);
+		if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+			return (-1);
+		if (envbuf_append(out, outsize, outlen, params_fmt,
+		    strlen(params_fmt)) == -1)
+			return (-1);
+		if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+			return (-1);
+
+		if (extract_header_field(hdr, hdrlen, "Content-Id", &idval,
+		    &idlen) == 0)
+			rc = envbuf_append_nstring(out, outsize, outlen, idval,
+			    idlen);
+		else
+			rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
+		free(idval);
+		if (rc == -1)
+			return (-1);
+		if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+			return (-1);
+
+		if (extract_header_field(hdr, hdrlen, "Content-Description",
+		    &descval, &desclen) == 0)
+			rc = envbuf_append_nstring(out, outsize, outlen,
+			    descval, desclen);
+		else
+			rc = envbuf_append_nstring(out, outsize, outlen, NULL, 0);
+		free(descval);
+		if (rc == -1)
+			return (-1);
+		if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+			return (-1);
+
+		if (extract_header_field(hdr, hdrlen,
+		    "Content-Transfer-Encoding", &encval, &enclen) == 0 &&
+		    enclen > 0 && enclen < sizeof(encstr)) {
+			memcpy(encstr, encval, enclen);
+			encstr[enclen] = '\0';
+		} else {
+			strlcpy(encstr, "7BIT", sizeof(encstr));
+		}
+		free(encval);
+		{
+			static const char *const known[] = { "7BIT", "8BIT",
+			    "BINARY", "BASE64", "QUOTED-PRINTABLE" };
+			size_t	 ki;
+
+			for (ki = 0; ki < 5; ki++) {
+				if (strcasecmp(encstr, known[ki]) == 0) {
+					strlcpy(encstr, known[ki],
+					    sizeof(encstr));
+					break;
+				}
+			}
+		}
+		if (envbuf_append_nstring(out, outsize, outlen, encstr,
+		    strlen(encstr)) == -1)
+			return (-1);
+		if (envbuf_append(out, outsize, outlen, " ", 1) == -1)
+			return (-1);
+
+		{
+			char	numbuf[32];
+
+			snprintf(numbuf, sizeof(numbuf), "%zu", bodylen);
+			if (envbuf_append_str(out, outsize, outlen, numbuf) == -1)
+				return (-1);
+		}
+
+		if (is_text) {
+			size_t	lines = 0, li;
+			char	numbuf2[32];
+
+			for (li = 0; li < bodylen; li++) {
+				if (body[li] == '\n')
+					lines++;
+			}
+			snprintf(numbuf2, sizeof(numbuf2), " %zu", lines);
+			if (envbuf_append_str(out, outsize, outlen, numbuf2) == -1)
+				return (-1);
+		}
+
+		return (envbuf_append(out, outsize, outlen, ")", 1));
+	}
+}
+
+/*
+ * Top-level entry point: reads the whole message once (read_message_body(),
+ * text_only=0 -- same underlying whole-message reader BODY.PEEK[] uses,
+ * but capped at the much larger bodystructure_read_max rather than
+ * APPEND_LITERAL_MAX -- see read_message_body()'s own comment for why
+ * these two callers need different caps), locates its own header/body
+ * split, and kicks off the recursive walk at depth 0. Returns 0 and a
+ * malloc(3)'d *buf_out (caller frees) + *len_out on success; -1 (nothing
+ * to free) if the message can't be read at all, has no header/body
+ * separator, or build_body_structure() failed for any of its own
+ * documented reasons (depth/part-count exceeded, malformed multipart, a
+ * message/rfc822 part, or BODYSTRUCTURE_MAX exceeded) -- all "not found"
+ * for this message's BODYSTRUCTURE, same failure handling as every other
+ * content item in this file.
+ */
+static int
+build_bodystructure(const char *basename, char **buf_out, uint32_t *len_out)
+{
+	char		*wholebuf = NULL;
+	uint32_t	 wholelen = 0;
+	size_t		 hdrend;
+	char		 out[BODYSTRUCTURE_MAX];
+	size_t		 outlen = 0;
+	int		 nparts_used = 0;
+
+	*buf_out = NULL;
+	*len_out = 0;
+
+	if (read_message_body(basename, 0, bodystructure_read_max,
+	    "BODYSTRUCTURE", &wholebuf, &wholelen) == -1)
+		return (-1);
+	if (wholelen == 0 ||
+	    find_header_body_split(wholebuf, wholelen, &hdrend) == -1) {
+		free(wholebuf);
+		return (-1);
+	}
+
+	if (build_body_structure(0, &nparts_used, wholebuf, hdrend,
+	    wholebuf + hdrend, wholelen - hdrend, out, sizeof(out),
+	    &outlen) == -1) {
+		free(wholebuf);
+		return (-1);
+	}
+	free(wholebuf);
+
+	if ((*buf_out = malloc(outlen)) == NULL) {
+		log_warn("session %u: malloc BODYSTRUCTURE buffer (%s)",
+		    session_id, basename);
+		return (-1);
+	}
+	memcpy(*buf_out, out, outlen);
+	*len_out = (uint32_t)outlen;
+	return (0);
+}
+
+/*
+ * Parses a dotted-numeric section-part string (RFC 9051 SS6.4.5.1's
+ * section-part = nz-number *("." nz-number), e.g. "3.1") into an array of
+ * 1-based part numbers. listener.c's tokenizer has already validated this
+ * exact grammar before ever setting struct imsg_mbox_fetch's section_part
+ * (see that field's comment in imapd.h) -- this function re-validates
+ * anyway, cheap insurance rather than trusting a cross-process field
+ * unconditionally, matching how every other store.c parser in this file
+ * (parse_content_type(), the index line parser, etc.) treats its own input
+ * as untrusted regardless of what already checked it upstream.
+ *
+ * Returns the number of parts on success (>= 1), or -1 if s is empty,
+ * contains anything but digits and '.', has a leading-zero or otherwise
+ * non-nz-number component, or has more components than maxpath (the
+ * caller passes MIME_MAX_DEPTH, the same bound build_body_structure()'s
+ * own recursion enforces -- a path deeper than that could never match
+ * anything build_bodystructure() would have described in the first
+ * place).
+ */
+static int
+parse_section_part(const char *s, int *path, int maxpath)
+{
+	int	 n = 0;
+
+	if (s == NULL || *s == '\0')
+		return (-1);
+
+	while (*s != '\0') {
+		long	 val;
+		char	*end;
+
+		if (*s < '1' || *s > '9')	/* nz-number: digit-nz first */
+			return (-1);
+		if (n >= maxpath)
+			return (-1);
+
+		errno = 0;
+		val = strtol(s, &end, 10);
+		if (val <= 0 || val > INT_MAX || errno != 0)
+			return (-1);
+		path[n++] = (int)val;
+
+		s = end;
+		if (*s == '\0')
+			break;
+		if (*s != '.')
+			return (-1);
+		s++;
+		if (*s == '\0')
+			return (-1);	/* trailing dot */
+	}
+	return (n);
+}
+
+/*
+ * Recursive descent used only once path[0] has already been established to
+ * select among hdr/body's own children -- i.e. hdr/body is known-MULTIPART
+ * before this is ever called (see locate_mime_part() below, the only
+ * caller). Mirrors build_body_structure()'s own MULTIPART branch almost
+ * exactly (same parse_content_type()/split_multipart()/find_header_body_
+ * split() calls, same depth cap), but walks down exactly one child per
+ * level -- the one path[0] names -- instead of visiting every child to
+ * format output text.
+ *
+ * path[0..pathlen) is remaining, relative to hdr/body: path[0] selects
+ * which child of *this* multipart to descend into; the rest resolves
+ * relative to that child. pathlen is always >= 1 on entry (locate_mime_
+ * part() guarantees this before the first call, and this function only
+ * ever recurses with pathlen - 1 after confirming the next level is
+ * itself MULTIPART, so a pathlen of 0 here would be a caller bug, not a
+ * legal "arrived" state -- unlike build_body_structure(), there is no
+ * separate depth-0-only entry point to special-case, since that's
+ * locate_mime_part()'s job).
+ *
+ * Returns 0 and sets *part_out / *partlen_out to the target leaf part's raw
+ * (still transfer-encoded) body bytes on success. Returns -1 if: depth
+ * exceeds MIME_MAX_DEPTH; path[0] names a child that doesn't exist at
+ * this level; the named child fails to parse as a header/body pair; or
+ * (recursing further) the child path continues into isn't itself
+ * MULTIPART where more path remains, or is a MULTIPART/MESSAGE-typed part
+ * where no path remains (a container, not a leaf -- see MBOX_FETCH_BODY_
+ * PART's imapd.h comment for why only leaf parts are returned) --
+ * detected next call in either case, since every call re-parses its own
+ * hdr/body's Content-Type first.
+ */
+static int
+find_mime_part(int depth, const char *hdr, size_t hdrlen, const char *body,
+    size_t bodylen, const int *path, int pathlen, const char **part_out,
+    size_t *partlen_out)
+{
+	char	type[64], subtype[64];
+	char	params_fmt[600];
+	char	boundary[70 + 1];
+	int	has_boundary;
+	size_t	part_starts[MIME_MAX_PARTS], part_ends[MIME_MAX_PARTS];
+	int	n, want;
+	const char	*pbuf;
+	size_t		 plen, phdrend;
+
+	if (depth > MIME_MAX_DEPTH)
+		return (-1);
+
+	params_fmt[0] = '\0';
+	if (parse_content_type(hdr, hdrlen, type, sizeof(type), subtype,
+	    sizeof(subtype), params_fmt, sizeof(params_fmt), boundary,
+	    sizeof(boundary), &has_boundary) == -1)
+		return (-1);
+	if (strcasecmp(type, "MULTIPART") != 0 || !has_boundary)
+		return (-1);
+	if (split_multipart(body, bodylen, boundary, part_starts, part_ends,
+	    &n, MIME_MAX_PARTS) == -1)
+		return (-1);
+
+	want = path[0];
+	if (want < 1 || want > n)
+		return (-1);	/* no such part at this level */
+
+	pbuf = body + part_starts[want - 1];
+	plen = part_ends[want - 1] - part_starts[want - 1];
+	/* Same empty-part handling as build_body_structure() above -- see
+	 * its comment. */
+	if (plen == 0)
+		phdrend = 0;
+	else if (find_header_body_split(pbuf, plen, &phdrend) == -1)
+		return (-1);
+
+	if (pathlen == 1) {
+		/*
+		 * path fully consumed by selecting this child. It must
+		 * itself be a genuine leaf (not MULTIPART, not MESSAGE/
+		 * RFC822|GLOBAL) to be returnable -- both remain out of
+		 * v1 scope, same cut build_body_structure() already makes
+		 * for MESSAGE/RFC822|GLOBAL, extended here to MULTIPART
+		 * containers too, since there is no "combined raw bytes of
+		 * all my children" concept to return for one.
+		 */
+		char	 ctype[64], csub[64], cparams[600];
+		char	 cboundary[70 + 1];
+		int	 chb;
+
+		cparams[0] = '\0';
+		if (parse_content_type(pbuf, phdrend, ctype, sizeof(ctype),
+		    csub, sizeof(csub), cparams, sizeof(cparams), cboundary,
+		    sizeof(cboundary), &chb) == -1)
+			return (-1);
+		if (strcasecmp(ctype, "MULTIPART") == 0 ||
+		    (strcasecmp(ctype, "MESSAGE") == 0 &&
+		    (strcasecmp(csub, "RFC822") == 0 ||
+		    strcasecmp(csub, "GLOBAL") == 0)))
+			return (-1);
+
+		*part_out = pbuf + phdrend;
+		*partlen_out = plen - phdrend;
+		return (0);
+	}
+
+	return (find_mime_part(depth + 1, pbuf, phdrend, pbuf + phdrend,
+	    plen - phdrend, path + 1, pathlen - 1, part_out, partlen_out));
+}
+
+/*
+ * Top-level entry point for locating one leaf MIME part's raw body bytes
+ * by dotted-numeric path (path[0..pathlen), from parse_section_part()).
+ * Handles a case find_mime_part() itself deliberately doesn't: RFC 9051
+ * SS6.4.5.1's "Every message has at least one part number... Messages
+ * that do not use MIME, ... only have a part 1" -- i.e. a non-multipart
+ * top-level message's *own* body is addressed as section-part "1", with
+ * nothing to descend into, which is a different rule than any nested
+ * level (where path[0] always selects a real child of a real MULTIPART
+ * container). Folding that special case into find_mime_part() itself
+ * would make its own contract asymmetric between depth 0 and every other
+ * depth; splitting it out here keeps both functions' contracts simple.
+ *
+ * Returns 0 and sets *part_out / *partlen_out on success. Returns -1 if:
+ * pathlen is 0; the top-level message is non-multipart (or itself
+ * MESSAGE/RFC822|GLOBAL, also scoped out -- see find_mime_part()'s
+ * comment) and path isn't exactly {1}; or find_mime_part() failed for
+ * any of its own documented reasons.
+ */
+static int
+locate_mime_part(const char *hdr, size_t hdrlen, const char *body,
+    size_t bodylen, const int *path, int pathlen, const char **part_out,
+    size_t *partlen_out)
+{
+	char	type[64], subtype[64];
+	char	params_fmt[600];
+	char	boundary[70 + 1];
+	int	has_boundary;
+
+	if (pathlen < 1)
+		return (-1);
+
+	params_fmt[0] = '\0';
+	if (parse_content_type(hdr, hdrlen, type, sizeof(type), subtype,
+	    sizeof(subtype), params_fmt, sizeof(params_fmt), boundary,
+	    sizeof(boundary), &has_boundary) == -1)
+		return (-1);
+
+	if (strcasecmp(type, "MULTIPART") == 0)
+		return (find_mime_part(1, hdr, hdrlen, body, bodylen, path,
+		    pathlen, part_out, partlen_out));
+
+	if (pathlen != 1 || path[0] != 1)
+		return (-1);
+	*part_out = body;
+	*partlen_out = bodylen;
+	return (0);
+}
+
+/*
+ * Applies a <<partial>> range (RFC 9051 SS6.4.5's "<start.count>") to
+ * content[0..contentlen), used uniformly by handle_mbox_fetch() for
+ * BODY.PEEK[]/BODY.PEEK[TEXT]/BODY.PEEK[<section-part>] alike -- content
+ * itself is never copied here, only *out / *outlen (a subrange of content)
+ * computed; the caller does the one real memcpy(3), same "compute
+ * offsets, let the caller own the copy" shape as every other length-
+ * tracking helper in this file.
+ *
+ * !has_partial (no <<...>> suffix in the request) means "the whole
+ * content, subject only to FETCH_PART_MAX" -- *outlen is silently
+ * clamped down to that cap here (empty-string special case aside, RFC
+ * 9051 doesn't distinguish a server-imposed cap from any other partial
+ * response; a client that wants guaranteed-complete large content is
+ * expected to use its own <<partial>> ranging, same real-world pattern
+ * observed from Apple Mail's own "BODY.PEEK[TEXT]<0.16384>" traffic).
+ * has_partial clamps the requested partial_count down to FETCH_PART_MAX
+ * too, and separately clamps partial_start/partial_count against
+ * contentlen itself per 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."
+ */
+static void
+apply_partial_range(const char *content, size_t contentlen, int has_partial,
+    uint32_t partial_start, uint32_t partial_count, const char **out,
+    size_t *outlen)
+{
+	if (!has_partial) {
+		*out = content;
+		*outlen = contentlen;
+		if (*outlen > FETCH_PART_MAX)
+			*outlen = FETCH_PART_MAX;
+		return;
+	}
+
+	if (partial_start >= contentlen) {
+		*out = content;
+		*outlen = 0;
+		return;
+	}
+
+	*out = content + partial_start;
+	*outlen = contentlen - partial_start;
+	if (*outlen > partial_count)
+		*outlen = partial_count;
+	if (*outlen > FETCH_PART_MAX)
+		*outlen = FETCH_PART_MAX;
+}
+
+/*
+ * Top-level entry point for BODY.PEEK[<section-part>]: reads the whole
+ * message once (read_message_body(), text_only=0, bodystructure_read_max
+ * -- same large cap build_bodystructure() uses, and for the same reason:
+ * a message can legitimately be far larger than APPEND_LITERAL_MAX once
+ * it carries an attachment, even though what's ultimately returned here
+ * -- one leaf part, sliced -- is small), locates its own header/body
+ * split, resolves path[0..pathlen) via locate_mime_part(), then applies
+ * the caller's partial range via apply_partial_range(). Returns 0 and a
+ * malloc(3)'d *buf_out (caller frees; left NULL if *len_out comes back 0)
+ * + *len_out on success. Returns -1 (nothing to free) if the message
+ * can't be read, has no header/body separator, or locate_mime_part()
+ * failed for any of its own documented reasons -- all "not found" for
+ * this content item, same failure handling as every other one in this
+ * file.
+ */
+static int
+extract_mime_part(const char *basename, const int *path, int pathlen,
+    int has_partial, uint32_t partial_start, uint32_t partial_count,
+    char **buf_out, uint32_t *len_out)
+{
+	char		*wholebuf = NULL;
+	uint32_t	 wholelen = 0;
+	size_t		 hdrend;
+	const char	*part = NULL;
+	size_t		 partlen = 0;
+	const char	*out;
+	size_t		 outlen;
+
+	*buf_out = NULL;
+	*len_out = 0;
+
+	if (read_message_body(basename, 0, bodystructure_read_max,
+	    "BODY[<part>]", &wholebuf, &wholelen) == -1)
+		return (-1);
+	if (wholelen == 0 ||
+	    find_header_body_split(wholebuf, wholelen, &hdrend) == -1) {
+		free(wholebuf);
+		return (-1);
+	}
+
+	if (locate_mime_part(wholebuf, hdrend, wholebuf + hdrend,
+	    wholelen - hdrend, path, pathlen, &part, &partlen) == -1) {
+		free(wholebuf);
+		return (-1);
+	}
+
+	apply_partial_range(part, partlen, has_partial, partial_start,
+	    partial_count, &out, &outlen);
+
+	if (outlen > 0) {
+		if ((*buf_out = malloc(outlen)) == NULL) {
+			log_warn("session %u: malloc BODY[<part>] buffer (%s)",
+			    session_id, basename);
+			free(wholebuf);
+			return (-1);
+		}
+		memcpy(*buf_out, out, outlen);
+	}
+	*len_out = (uint32_t)outlen;
+	free(wholebuf);
+	return (0);
+}
+
+/*
+ * Builds the space-separated IMAP flag-atom list for one message from two
+ * sources: the maildir flag-suffix letters (from locate_message_file()'s
+ * suffix_out, e.g. ":2,FS" -- "" if the message is still in new/,
+ * unflagged) and the index's own comma-separated keyword field. Letter ->
+ * flag mapping is Courier's maildir(5) (already sourced in openimap-
+ * storage-backend.md): D=Draft, F=Flagged, R=Answered (historically
+ * "Replied"), S=Seen, T=Deleted (historically "Trashed").
+ */
+static void
+build_flags_string(const char *maildir_suffix, const char *keywords,
+    char *out, size_t outsize)
+{
+	static const struct {
+		char		 letter;
+		const char	*flag;
+	} stdflags[] = {
+		{ 'D', "\\Draft" },
+		{ 'F', "\\Flagged" },
+		{ 'R', "\\Answered" },
+		{ 'S', "\\Seen" },
+		{ 'T', "\\Deleted" },
+	};
+	const char	*letters;
+	size_t		 i;
+	int		 first = 1;
+
+	out[0] = '\0';
+
+	letters = strstr(maildir_suffix, "2,");
+	letters = (letters != NULL) ? letters + 2 : "";
+
+	for (i = 0; i < sizeof(stdflags) / sizeof(stdflags[0]); i++) {
+		if (strchr(letters, stdflags[i].letter) == NULL)
+			continue;
+		if (!first)
+			strlcat(out, " ", outsize);
+		strlcat(out, stdflags[i].flag, outsize);
+		first = 0;
+	}
+
+	if (keywords != NULL && keywords[0] != '\0') {
+		char	 kwbuf[512];
+		char	*kw, *save;
+
+		strlcpy(kwbuf, keywords, sizeof(kwbuf));
+		for (kw = strtok_r(kwbuf, ",", &save); kw != NULL;
+		    kw = strtok_r(NULL, ",", &save)) {
+			if (!first)
+				strlcat(out, " ", outsize);
+			strlcat(out, kw, outsize);
+			first = 0;
+		}
+	}
+}
+
+/*
+ * RFC 9051 SS2.3.1.1 INTERNALDATE is "the internal date of the message" --
+ * this implementation's own choice (not something the design docs
+ * resolve) is the delivery timestamp already encoded in the maildir
+ * basename's own leading field (Courier maildir(5)'s unique-name format,
+ * `<timestamp>.<uniquer>.<hostname>`, already sourced in openimap-
+ * storage-backend.md), not the file's mtime -- more portable across
+ * backups/copies that can touch mtime without touching content. Falls
+ * back to the current time for a foreign/hand-placed file that doesn't
+ * follow that convention, rather than failing the whole FETCH over one
+ * oddly-named message.
+ */
+static int64_t
+parse_maildir_timestamp(const char *basename)
+{
+	char		*ep;
+	long long	 ts;
+
+	errno = 0;
+	ts = strtoll(basename, &ep, 10);
+	if (ep == basename || *ep != '.' || errno != 0 || ts < 0)
+		return ((int64_t)time(NULL));
+	return ((int64_t)ts);
+}
+
+/*
+ * IMSG_MBOX_FETCH handling -- v1 scope is message metadata only (FLAGS,
+ * UID, INTERNALDATE, RFC822.SIZE), one sequence-set range per request; see
+ * imapd.h's imsg_mbox_fetch comment and listener.c's cmd_fetch() for
+ * the full reasoning. Sends one IMSG_MBOX_FETCH_META per matching message,
+ * in ascending sequence order (the index is UID-ordered; meta.seqno is
+ * always this message's live 1-based position within the index freshly
+ * loaded a few lines below, i.e. i itself in the loop below -- never a
+ * separately cached number, so it's automatically correct even though
+ * EXPUNGE now exists and can renumber later messages between one FETCH
+ * and the next; a correction to this comment's own earlier claim, from
+ * before EXPUNGE was implemented, that index order and sequence-number
+ * order were "always identical" specifically because EXPUNGE didn't exist
+ * yet -- the real reason is simpler and still holds regardless: there was
+ * never any separate bookkeeping to go stale in the first place), then
+ * exactly one terminal IMSG_MBOX_RESULT.
+ *
+ * Uses LOCK_SH (not handle_mbox_select()'s LOCK_EX): FETCH only reads the
+ * index, never mutates it, so multiple concurrent FETCHes (from different
+ * store children for the same user -- fork-per-session, not
+ * fork-per-user) can proceed together; a shared lock still blocks against
+ * a concurrent SELECT's exclusive lock, so a FETCH can never observe an
+ * index mid-rewrite (belt-and-suspenders on top of the rename(2)-based
+ * atomicity that already prevents a torn read either way).
+ *
+ * RFC 7162 additions this pass: meta.modseq is always populated from the
+ * index's per-message field (index_parse_line()) -- cheap, already parsed
+ * -- and req->has_changedsince (CHANGEDSINCE fetch-modifier, SS3.1.4.1)
+ * skips any message whose mod-sequence is not strictly greater than the
+ * given value, exactly the "only returned for messages that have a
+ * mod-sequence bigger than <mod-sequence>" rule. req->attrs & MBOX_FETCH_
+ * MODSEQ is not consulted here at all -- meta.modseq is unconditionally
+ * computed regardless (matching every other field in this struct), and it
+ * is listener.c's session_send_fetch_response() that decides whether to
+ * print it, same split as FLAGS/UID/INTERNALDATE/RFC822.SIZE already use.
+ *
+ * RFC 9051 SS6.4.9 addition (UID command): req->by_uid switches lo/hi (and
+ * "*") to UID-space resolution instead of position-space, and req->want_
+ * vanished (RFC 7162 SS3.2.6) reports gaps in that range as VANISHED
+ * (EARLIER) via send_vanished_range() before any FETCH response -- see
+ * both fields' comments in imapd.h and this function's own lo/hi-
+ * resolution comment below. index order and sequence-number order are
+ * still always identical regardless of by_uid (v1 has EXPUNGE now, but
+ * meta.seqno = i is still each message's live, correctly-numbered
+ * position in the just-loaded idx -- EXPUNGE only ever runs under its own
+ * exclusive lock, never concurrently with this shared-locked read).
+ */
+static void
+handle_mbox_fetch(struct imsg_mbox_fetch *req, struct imsgev *iev)
+{
+	struct mbox_index	 idx;
+	struct imsg_mbox_result	 result;
+	int			 fd;
+	uint32_t		 lo, hi, i, sent = 0;
+	int			 ok = 1;
+
+	memset(&idx, 0, sizeof(idx));
+
+	if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+		ok = 0;
+		goto done;
+	}
+	if (flock(fd, LOCK_SH) == -1) {
+		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+		close(fd);
+		ok = 0;
+		goto done;
+	}
+	if (index_load(fd, &idx) == -1) {
+		flock(fd, LOCK_UN);
+		close(fd);
+		ok = 0;
+		goto done;
+	}
+	flock(fd, LOCK_UN);
+	close(fd);
+
+	/*
+	 * RFC 9051 SS6.4.9: UID FETCH's sequence-set argument is UIDs, not
+	 * positions -- lo/hi (and "*") resolve against index_max_uid(), the
+	 * same UID-space upper bound SEARCH's UIDSET criterion already uses,
+	 * not idx.nlines. The hi-clamp-to-nlines below only makes sense in
+	 * position space, so it's skipped for by_uid -- the unified loop
+	 * below bounds itself against rec.uid instead.
+	 */
+	if (req->by_uid) {
+		uint32_t	max_uid = index_max_uid(&idx);
+
+		lo = req->lo_is_star ? max_uid : req->seq_lo;
+		hi = req->hi_is_star ? max_uid : req->seq_hi;
+	} else {
+		lo = req->lo_is_star ? (uint32_t)idx.nlines : req->seq_lo;
+		hi = req->hi_is_star ? (uint32_t)idx.nlines : req->seq_hi;
+	}
+	if (lo < 1)
+		lo = 1;
+	if (!req->by_uid && hi > (uint32_t)idx.nlines)
+		hi = (uint32_t)idx.nlines;
+
+	/*
+	 * RFC 7162 SS3.2.6 VANISHED UID FETCH modifier: report gaps in
+	 * [lo, hi] as VANISHED (EARLIER) *before* any FETCH response below
+	 * ("Any VANISHED (EARLIER) responses MUST be returned before any
+	 * FETCH responses") -- guaranteed here purely by send order, since
+	 * imsg messages on one channel are read by listener.c in the order
+	 * they're sent, and this call is textually before the loop that
+	 * sends IMSG_MBOX_FETCH_META below.
+	 */
+	if (req->by_uid && req->want_vanished)
+		send_vanished_range(&idx, lo, hi, iev);
+
+	for (i = 1; i <= (uint32_t)idx.nlines; i++) {
+		struct imsg_mbox_fetch_meta	 meta;
+		struct index_rec		 rec;
+		off_t				 size = 0;
+		char				 suffix[64];
+		int				 have_file = 0;
+
+		if (index_parse_line(idx.lines[i - 1], &rec) == -1)
+			continue;
+
+		/*
+		 * Position-space (i) or UID-space (rec.uid) range check
+		 * depending on req->by_uid -- see the lo/hi resolution
+		 * above. Both sides are ascending (i by loop construction,
+		 * rec.uid because the index itself is UID-ordered), so a
+		 * plain "below range, skip; above range, stop" test is
+		 * correct and only ever makes one pass over idx.lines.
+		 */
+		if (req->by_uid) {
+			if (rec.uid < lo)
+				continue;
+			if (rec.uid > hi)
+				break;
+		} else {
+			if (i < lo)
+				continue;
+			if (i > hi)
+				break;
+		}
+
+		if (req->has_changedsince && rec.modseq <= req->changedsince)
+			continue;
+
+		memset(&meta, 0, sizeof(meta));
+		meta.seqno = i;
+		meta.uid = rec.uid;
+		meta.modseq = rec.modseq;
+
+		if (req->attrs & (MBOX_FETCH_RFC822_SIZE | MBOX_FETCH_FLAGS)) {
+			if (locate_message_file(rec.basename, &size, suffix,
+			    sizeof(suffix)) == -1) {
+				log_warnx("session %u: message %s (uid %u) "
+				    "indexed but missing on disk -- skipped",
+				    session_id, rec.basename, meta.uid);
+				continue;
+			}
+			have_file = 1;
+		}
+
+		if (req->attrs & MBOX_FETCH_RFC822_SIZE)
+			meta.size = (uint64_t)size;	/* F13 fix: was (uint32_t) */
+		if (req->attrs & MBOX_FETCH_FLAGS)
+			build_flags_string(have_file ? suffix : "",
+			    rec.keywords, meta.flags, sizeof(meta.flags));
+		if (req->attrs & MBOX_FETCH_INTERNALDATE)
+			meta.internaldate = parse_maildir_timestamp(
+			    rec.basename);
+
+		/*
+		 * IMSG_MBOX_FETCH_HEADER, sent before this message's own
+		 * IMSG_MBOX_FETCH_META -- see that struct's comment in
+		 * imapd.h for why listener.c depends on this exact
+		 * ordering. Always sent (with found=0 on any failure) when
+		 * either MBOX_FETCH_BODY_HEADER or MBOX_FETCH_HEADER_FIELDS
+		 * was requested, rather than only sent on success, so
+		 * listener.c never has to distinguish "no header message
+		 * arrived for this seqno" from "one legitimately hasn't been
+		 * processed yet" -- it just checks found on whatever it
+		 * gets. The two requested-item kinds share this one imsg/
+		 * struct wholesale (see MBOX_FETCH_HEADER_FIELDS's comment
+		 * in imapd.h for why) -- listener.c's parse_fetch_atts()
+		 * already made HEADER win if a client somehow requested both
+		 * in the same FETCH, so at most one of the two bits is set
+		 * here in practice, but the check below still prefers plain
+		 * HEADER defensively either way.
+		 */
+		if (req->attrs & (MBOX_FETCH_BODY_HEADER |
+		    MBOX_FETCH_HEADER_FIELDS)) {
+			struct imsg_mbox_fetch_header	 hdrmeta;
+			char				*hdrbuf = NULL;
+			uint32_t			 hdrlen = 0;
+			char				*combined;
+			size_t				 combined_len;
+			int				 rc;
+
+			memset(&hdrmeta, 0, sizeof(hdrmeta));
+			hdrmeta.seqno = i;
+			hdrmeta.uid = rec.uid;
+			if (req->attrs & MBOX_FETCH_BODY_HEADER)
+				rc = read_message_header(rec.basename,
+				    &hdrbuf, &hdrlen);
+			else
+				rc = read_message_header_fields(rec.basename,
+				    req->header_fields,
+				    req->header_fields_not, &hdrbuf, &hdrlen);
+			if (rc == 0) {
+				hdrmeta.found = 1;
+				hdrmeta.hdrlen = hdrlen;
+			}
+
+			combined_len = sizeof(hdrmeta) +
+			    (hdrmeta.found ? hdrlen : 0);
+			if ((combined = malloc(combined_len)) == NULL) {
+				log_warn("session %u: malloc "
+				    "IMSG_MBOX_FETCH_HEADER buffer",
+				    session_id);
+			} else {
+				memcpy(combined, &hdrmeta, sizeof(hdrmeta));
+				if (hdrmeta.found)
+					memcpy(combined + sizeof(hdrmeta),
+					    hdrbuf, hdrlen);
+				if (imsg_compose(&iev->ibuf,
+				    IMSG_MBOX_FETCH_HEADER, 0, 0, -1, combined,
+				    combined_len) == -1)
+					log_warn("session %u: imsg_compose "
+					    "IMSG_MBOX_FETCH_HEADER",
+					    session_id);
+				free(combined);
+			}
+			free(hdrbuf);
+		}
+
+		/*
+		 * IMSG_MBOX_FETCH_BODY, same "always sent when requested,
+		 * found=0 on any failure, ordered right before this
+		 * message's own IMSG_MBOX_FETCH_META" contract as the
+		 * IMSG_MBOX_FETCH_HEADER block just above -- see struct
+		 * imsg_mbox_fetch_body's comment in imapd.h. WHOLE wins
+		 * over TEXT if a client somehow requested both (see MBOX_
+		 * FETCH_BODY_TEXT's comment in imapd.h for why that's a
+		 * deliberate, not accidental, choice); listener.c's tokenizer
+		 * never sets more than one of WHOLE/TEXT/PART together (each
+		 * comes from a distinct, mutually exclusive section-spec), so
+		 * PART is simply a third case here, not a fourth combination
+		 * to disambiguate.
+		 *
+		 * Partial-range handling (<<start.count>>, req->has_partial/
+		 * partial_start/partial_count) applies uniformly across all
+		 * three: for PART, extract_mime_part() does its own read +
+		 * locate + range-slice internally (it needs the large
+		 * bodystructure_read_max read cap regardless of whether a
+		 * range was requested, since the target part's *offset*
+		 * within the message isn't known until the whole message is
+		 * read and walked). For WHOLE/TEXT, read_cap switches to that
+		 * same large cap only when a partial range was requested --
+		 * see apply_partial_range()'s own comment for why a *ranged*
+		 * request on an over-APPEND_LITERAL_MAX message should still
+		 * succeed (this is exactly the gap real Apple Mail traffic
+		 * hit: "BODY.PEEK[TEXT]<0.16384>" against a message that only
+		 * exceeds 12000 bytes because of its own attachment) while a
+		 * *whole*-content request keeps the tighter reject-not-
+		 * truncate behavior it always had.
+		 */
+		if (req->attrs & (MBOX_FETCH_BODY_WHOLE | MBOX_FETCH_BODY_TEXT |
+		    MBOX_FETCH_BODY_PART)) {
+			struct imsg_mbox_fetch_body	 bodymeta;
+			char				*bodybuf = NULL;
+			uint32_t			 bodylen = 0;
+			char				*combined;
+			size_t				 combined_len;
+			int				 want_whole =
+			    (req->attrs & MBOX_FETCH_BODY_WHOLE) != 0;
+			int				 want_part = !want_whole &&
+			    (req->attrs & MBOX_FETCH_BODY_PART) != 0 &&
+			    !(req->attrs & MBOX_FETCH_BODY_TEXT);
+			int				 want_text = !want_whole &&
+			    !want_part;
+
+			memset(&bodymeta, 0, sizeof(bodymeta));
+			bodymeta.seqno = i;
+			bodymeta.uid = rec.uid;
+			bodymeta.is_text = want_text;
+
+			if (want_part) {
+				int	path[MIME_MAX_DEPTH];
+				int	pathlen;
+
+				pathlen = parse_section_part(req->section_part,
+				    path, MIME_MAX_DEPTH);
+				if (pathlen != -1 &&
+				    extract_mime_part(rec.basename, path,
+				    pathlen, req->has_partial,
+				    req->partial_start, req->partial_count,
+				    &bodybuf, &bodylen) == 0)
+					bodymeta.found = 1;
+			} else {
+				size_t		 read_cap = req->has_partial ?
+				    bodystructure_read_max : APPEND_LITERAL_MAX;
+				char		*wholebuf = NULL;
+				uint32_t	 wholelen = 0;
+
+				if (read_message_body(rec.basename, want_text,
+				    read_cap, want_text ? "BODY[TEXT]" :
+				    "BODY[]", &wholebuf, &wholelen) == 0) {
+					const char	*out;
+					size_t		 outlen;
+
+					apply_partial_range(wholebuf, wholelen,
+					    req->has_partial,
+					    req->partial_start,
+					    req->partial_count, &out, &outlen);
+					if (outlen == 0) {
+						bodymeta.found = 1;
+					} else if ((bodybuf = malloc(outlen)) ==
+					    NULL) {
+						log_warn("session %u: malloc "
+						    "IMSG_MBOX_FETCH_BODY "
+						    "slice (%s)", session_id,
+						    rec.basename);
+					} else {
+						memcpy(bodybuf, out, outlen);
+						bodylen = (uint32_t)outlen;
+						bodymeta.found = 1;
+					}
+				}
+				free(wholebuf);
+			}
+			bodymeta.bodylen = bodylen;
+
+			combined_len = sizeof(bodymeta) +
+			    (bodymeta.found ? bodylen : 0);
+			if ((combined = malloc(combined_len)) == NULL) {
+				log_warn("session %u: malloc "
+				    "IMSG_MBOX_FETCH_BODY buffer", session_id);
+			} else {
+				memcpy(combined, &bodymeta, sizeof(bodymeta));
+				if (bodymeta.found && bodylen > 0)
+					memcpy(combined + sizeof(bodymeta),
+					    bodybuf, bodylen);
+				if (imsg_compose(&iev->ibuf,
+				    IMSG_MBOX_FETCH_BODY, 0, 0, -1, combined,
+				    combined_len) == -1)
+					log_warn("session %u: imsg_compose "
+					    "IMSG_MBOX_FETCH_BODY", session_id);
+				free(combined);
+			}
+			free(bodybuf);
+		}
+
+		/*
+		 * IMSG_MBOX_FETCH_ENVELOPE, same "always sent when
+		 * requested, found=0 on any failure, ordered right before
+		 * this message's own IMSG_MBOX_FETCH_META" contract as the
+		 * IMSG_MBOX_FETCH_HEADER/IMSG_MBOX_FETCH_BODY blocks above --
+		 * see struct imsg_mbox_fetch_envelope's comment in imapd.h.
+		 * Unlike those two, the trailing bytes here are build_
+		 * envelope()'s already-formatted response text, not raw
+		 * message bytes -- see that function's own comment.
+		 */
+		if (req->attrs & MBOX_FETCH_ENVELOPE) {
+			struct imsg_mbox_fetch_envelope	 envmeta;
+			char					*envbuf = NULL;
+			uint32_t				 envlen = 0;
+			char					*combined;
+			size_t					 combined_len;
+
+			memset(&envmeta, 0, sizeof(envmeta));
+			envmeta.seqno = i;
+			envmeta.uid = rec.uid;
+			if (build_envelope(rec.basename, &envbuf, &envlen) == 0) {
+				envmeta.found = 1;
+				envmeta.envlen = envlen;
+			}
+
+			combined_len = sizeof(envmeta) +
+			    (envmeta.found ? envlen : 0);
+			if ((combined = malloc(combined_len)) == NULL) {
+				log_warn("session %u: malloc "
+				    "IMSG_MBOX_FETCH_ENVELOPE buffer", session_id);
+			} else {
+				memcpy(combined, &envmeta, sizeof(envmeta));
+				if (envmeta.found && envlen > 0)
+					memcpy(combined + sizeof(envmeta),
+					    envbuf, envlen);
+				if (imsg_compose(&iev->ibuf,
+				    IMSG_MBOX_FETCH_ENVELOPE, 0, 0, -1, combined,
+				    combined_len) == -1)
+					log_warn("session %u: imsg_compose "
+					    "IMSG_MBOX_FETCH_ENVELOPE", session_id);
+				free(combined);
+			}
+			free(envbuf);
+		}
+
+		/*
+		 * IMSG_MBOX_FETCH_BODYSTRUCTURE, same "always sent when
+		 * requested, found=0 on any failure, ordered right before
+		 * this message's own IMSG_MBOX_FETCH_META" contract, and same
+		 * "already-formatted response text" shape, as IMSG_MBOX_
+		 * FETCH_ENVELOPE just above -- see struct imsg_mbox_fetch_
+		 * bodystructure's comment in imapd.h.
+		 */
+		if (req->attrs & MBOX_FETCH_BODYSTRUCTURE) {
+			struct imsg_mbox_fetch_bodystructure	 bsmeta;
+			char					*bsbuf = NULL;
+			uint32_t				 bslen = 0;
+			char					*combined;
+			size_t					 combined_len;
+
+			memset(&bsmeta, 0, sizeof(bsmeta));
+			bsmeta.seqno = i;
+			bsmeta.uid = rec.uid;
+			if (build_bodystructure(rec.basename, &bsbuf, &bslen) == 0) {
+				bsmeta.found = 1;
+				bsmeta.bslen = bslen;
+			}
+
+			combined_len = sizeof(bsmeta) +
+			    (bsmeta.found ? bslen : 0);
+			if ((combined = malloc(combined_len)) == NULL) {
+				log_warn("session %u: malloc "
+				    "IMSG_MBOX_FETCH_BODYSTRUCTURE buffer",
+				    session_id);
+			} else {
+				memcpy(combined, &bsmeta, sizeof(bsmeta));
+				if (bsmeta.found && bslen > 0)
+					memcpy(combined + sizeof(bsmeta),
+					    bsbuf, bslen);
+				if (imsg_compose(&iev->ibuf,
+				    IMSG_MBOX_FETCH_BODYSTRUCTURE, 0, 0, -1,
+				    combined, combined_len) == -1)
+					log_warn("session %u: imsg_compose "
+					    "IMSG_MBOX_FETCH_BODYSTRUCTURE",
+					    session_id);
+				free(combined);
+			}
+			free(bsbuf);
+		}
+
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0, 0, -1,
+		    &meta, sizeof(meta)) == -1)
+			log_warn("session %u: imsg_compose "
+			    "IMSG_MBOX_FETCH_META", session_id);
+		else
+			sent++;
+	}
+
+done:
+	index_free(&idx);
+
+	memset(&result, 0, sizeof(result));
+	result.ok = ok;
+	result.count = sent;
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	    sizeof(result)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * Inverse of build_flags_string()'s letter table: maps a maildir
+ * flag-suffix's letters (the part after "2,", e.g. "FS") back to the
+ * MBOX_FLAG_* bitmask. Needed only here -- FETCH only ever renders flags
+ * outward and never needed to merge new ones in, but STORE has to know a
+ * message's *current* system flags before it can compute what ADD/REMOVE
+ * (as opposed to SET) leaves it with.
+ */
+static uint32_t
+letters_to_sysflags(const char *letters)
+{
+	uint32_t	 f = 0;
+
+	if (strchr(letters, 'D') != NULL)
+		f |= MBOX_FLAG_DRAFT;
+	if (strchr(letters, 'F') != NULL)
+		f |= MBOX_FLAG_FLAGGED;
+	if (strchr(letters, 'R') != NULL)
+		f |= MBOX_FLAG_ANSWERED;
+	if (strchr(letters, 'S') != NULL)
+		f |= MBOX_FLAG_SEEN;
+	if (strchr(letters, 'T') != NULL)
+		f |= MBOX_FLAG_DELETED;
+	return (f);
+}
+
+/*
+ * Renders sysflags back into the maildir suffix's required letter order --
+ * Courier maildir(5): "The letters must be in ASCII order" -- D, F, R, S,
+ * T, the same order build_flags_string()'s table already uses.
+ */
+static void
+sysflags_to_letters(uint32_t sysflags, char *out, size_t outsize)
+{
+	size_t	 i = 0;
+
+	if ((sysflags & MBOX_FLAG_DRAFT) && i + 1 < outsize)
+		out[i++] = 'D';
+	if ((sysflags & MBOX_FLAG_FLAGGED) && i + 1 < outsize)
+		out[i++] = 'F';
+	if ((sysflags & MBOX_FLAG_ANSWERED) && i + 1 < outsize)
+		out[i++] = 'R';
+	if ((sysflags & MBOX_FLAG_SEEN) && i + 1 < outsize)
+		out[i++] = 'S';
+	if ((sysflags & MBOX_FLAG_DELETED) && i + 1 < outsize)
+		out[i++] = 'T';
+	out[i] = '\0';
+}
+
+/*
+ * RFC 9051 SS6.3.11 STATUS. Modeled directly on handle_mbox_select()'s
+ * open/flock/index_load/new-mail-scan/index_save sequence -- STATUS "does
+ * not change the currently selected mailbox, nor does it affect the state
+ * of any messages" (SS6.3.11), so unlike a real SELECT this never touches
+ * s->state or holds the fd open past this one call, but it deliberately
+ * reuses the *same* new/ scan SELECT does (picking up messages delivered
+ * since the index was last written and appending them with fresh UIDs)
+ * rather than answering from a possibly-stale on-disk index: MESSAGES/
+ * UIDNEXT/UNSEEN would otherwise be able to under-report mail that already
+ * arrived. This is a judgment call, not something the RFC mandates --
+ * SS6.3.11 only says STATUS "MUST NOT be used as a check for new messages"
+ * (i.e. clients shouldn't rely on it *instead of* EXISTS/RECENT/IDLE), which
+ * is a client-behavior note, not a server prohibition on noticing new mail
+ * while it's already got the index open.
+ *
+ * MESSAGES/UIDNEXT/UIDVALIDITY/HIGHESTMODSEQ are always computed (free --
+ * index header fields once index_load() has run), matching imsg_mbox_
+ * selected's own "always compute" precedent. UNSEEN/DELETED/SIZE are the
+ * deliberate exception: computing any of them requires calling locate_
+ * message_file() once per message, which RFC 9051 SS6.3.11 explicitly
+ * flags as potentially expensive ("the STATUS command SIZE...can take a
+ * significant amount of time...clients should use STATUS SIZE cautiously")
+ * -- so that scan runs only when req->attrs asks for at least one of the
+ * three, and once it runs, 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 a single call, so there is
+ * no marginal cost to computing all three vs. one).
+ */
+static void
+handle_mbox_status(struct imsg_mbox_status *req, struct imsgev *iev)
+{
+	struct mbox_index		 idx;
+	struct imsg_mbox_status_result	 reply;
+	int				 fd;
+	DIR				*dp;
+	struct dirent			*de;
+	char				 saved[MBOX_NAME_MAX];
+	const char			*target;
+	int				 switched = 0;
+
+	memset(&reply, 0, sizeof(reply));
+
+	/*
+	 * RFC 9051 SS6.3.11: STATUS targets a mailbox independent of
+	 * whatever this session currently has selected -- "without...
+	 * opening a mailbox" -- so this can't just answer for cwd the way
+	 * FETCH/STORE/EXPUNGE correctly do. select_mailbox_dir() is reused
+	 * here as a temporary visit-and-restore primitive rather than a
+	 * lasting selection change: resolve the target, remember whatever
+	 * was selected before, and switch back to it before replying
+	 * regardless of how this function exits (see the "send:" label).
+	 */
+	strlcpy(saved, current_mailbox_dir, sizeof(saved));
+	if (mailbox_name_is_inbox(req->mailbox))
+		target = "";
+	else if (mailbox_name_valid(req->mailbox))
+		target = req->mailbox;
+	else {
+		log_debug("session %u: STATUS %s: invalid mailbox name",
+		    session_id, req->mailbox);
+		reply.ok = 0;
+		goto send;
+	}
+	if (select_mailbox_dir(target) == -1) {
+		log_debug("session %u: STATUS %s: no such mailbox",
+		    session_id, req->mailbox);
+		reply.ok = 0;
+		goto send;
+	}
+	switched = 1;
+
+	if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+		reply.ok = 0;
+		goto send;
+	}
+	if (flock(fd, LOCK_EX) == -1) {
+		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+		close(fd);
+		reply.ok = 0;
+		goto send;
+	}
+
+	if (index_load(fd, &idx) == -1) {
+		flock(fd, LOCK_UN);
+		close(fd);
+		reply.ok = 0;
+		goto send;
+	}
+
+	dp = opendir("new");
+	if (dp == NULL) {
+		if (errno != ENOENT)
+			log_warn("session %u: opendir new", session_id);
+	} else {
+		while ((de = readdir(dp)) != NULL) {
+			if (de->d_name[0] == '.')
+				continue;
+			if (index_has_basename(&idx, de->d_name))
+				continue;
+			if (index_append(&idx, idx.uidnext, de->d_name)
+			    == -1) {
+				closedir(dp);
+				index_free(&idx);
+				flock(fd, LOCK_UN);
+				close(fd);
+				reply.ok = 0;
+				goto send;
+			}
+			idx.uidnext++;
+		}
+		closedir(dp);
+	}
+
+	if (index_save(&idx) == -1) {
+		index_free(&idx);
+		flock(fd, LOCK_UN);
+		close(fd);
+		reply.ok = 0;
+		goto send;
+	}
+
+	reply.ok = 1;
+	reply.messages = (uint32_t)idx.nlines;
+	reply.uidnext = idx.uidnext;
+	reply.uidvalidity = idx.uidvalidity;
+	reply.highestmodseq = idx.highestmodseq;
+
+	if (req->attrs &
+	    (STATUS_ATT_UNSEEN | STATUS_ATT_DELETED | STATUS_ATT_SIZE)) {
+		size_t	i;
+
+		for (i = 0; i < idx.nlines; i++) {
+			struct index_rec	 rec;
+			const char		*lp;
+			uint32_t		 sysflags;
+			char			 suffix[64];
+			off_t			 size;
+
+			if (index_parse_line(idx.lines[i], &rec) == -1)
+				continue;
+			if (locate_message_file(rec.basename, &size, suffix,
+			    sizeof(suffix)) == -1) {
+				log_warnx("session %u: message %s indexed but "
+				    "missing on disk -- skipped for STATUS "
+				    "UNSEEN/DELETED/SIZE", session_id,
+				    rec.basename);
+				continue;
+			}
+
+			lp = strstr(suffix, "2,");
+			sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
+			if (!(sysflags & MBOX_FLAG_SEEN))
+				reply.unseen++;
+			if (sysflags & MBOX_FLAG_DELETED)
+				reply.deleted++;
+			reply.size += (uint64_t)size;
+		}
+	}
+
+	index_free(&idx);
+	flock(fd, LOCK_UN);
+	close(fd);
+
+send:
+	/* Restore whatever this session actually had selected before this
+	 * STATUS call -- select_mailbox_dir() is a no-op if that's already
+	 * where we are (e.g. STATUS on the currently selected mailbox, or
+	 * the "switched" gate below wasn't even reached). */
+	if (switched && select_mailbox_dir(saved) == -1)
+		log_warnx("session %u: STATUS: failed to restore selection "
+		    "to %s -- session may be left in an inconsistent state",
+		    session_id, saved[0] != '\0' ? saved : "INBOX");
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_STATUS_RESULT, 0, 0, -1, &reply,
+	    sizeof(reply)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_STATUS_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * True if comma-separated list contains kw as a whole token (not a
+ * substring match) -- used by merge_keywords() below to dedupe and to
+ * implement REMOVE. O(n) per lookup, same "fine at v1's scale" reasoning
+ * index_has_basename() already documents for the index itself: per-message
+ * keyword lists are short (a handful of entries at most), not the mailbox
+ * as a whole.
+ */
+static int
+kw_list_contains(const char *list, const char *kw)
+{
+	char	 tmp[MBOX_FLAGS_MAX];
+	char	*tok, *save;
+
+	strlcpy(tmp, list, sizeof(tmp));
+	for (tok = strtok_r(tmp, ",", &save); tok != NULL;
+	    tok = strtok_r(NULL, ",", &save)) {
+		if (strcmp(tok, kw) == 0)
+			return (1);
+	}
+	return (0);
+}
+
+/*
+ * Computes *out, a comma-separated keyword list (matching the index's own
+ * on-disk delimiter -- see imapd.h's imsg_mbox_store comment), as the
+ * result of applying mode/new_kws to a message's current old_kws. RFC 9051
+ * SS6.4.6: "FLAGS <flag list> Replace the flags for the message with the
+ * argument" -- keywords are flags too (SS2.3.2), so an unqualified FLAGS
+ * STORE really does replace the entire keyword set, not just the five
+ * system flags; that's why MBOX_STORE_SET ignores old_kws entirely below,
+ * unlike ADD/REMOVE. Deduplicates in every mode -- a client naming a
+ * keyword twice, or one already present, shouldn't produce a doubled
+ * index entry.
+ */
+static void
+merge_keywords(int mode, const char *old_kws, const char *new_kws,
+    char *out, size_t outsize)
+{
+	char	 tmp[MBOX_FLAGS_MAX];
+	char	*tok, *save;
+	int	 first = 1;
+
+	out[0] = '\0';
+
+	if (mode == MBOX_STORE_REMOVE) {
+		strlcpy(tmp, old_kws, sizeof(tmp));
+		for (tok = strtok_r(tmp, ",", &save); tok != NULL;
+		    tok = strtok_r(NULL, ",", &save)) {
+			if (kw_list_contains(new_kws, tok))
+				continue;
+			if (!first)
+				strlcat(out, ",", outsize);
+			strlcat(out, tok, outsize);
+			first = 0;
+		}
+		return;
+	}
+
+	if (mode == MBOX_STORE_ADD) {
+		strlcpy(tmp, old_kws, sizeof(tmp));
+		for (tok = strtok_r(tmp, ",", &save); tok != NULL;
+		    tok = strtok_r(NULL, ",", &save)) {
+			if (!first)
+				strlcat(out, ",", outsize);
+			strlcat(out, tok, outsize);
+			first = 0;
+		}
+	}
+
+	/* SET starts from nothing (old_kws is discarded); ADD continues
+	 * from the copy of old_kws just built above. Either way, append
+	 * anything in new_kws not already present. */
+	strlcpy(tmp, new_kws, sizeof(tmp));
+	for (tok = strtok_r(tmp, ",", &save); tok != NULL;
+	    tok = strtok_r(NULL, ",", &save)) {
+		if (kw_list_contains(out, tok))
+			continue;
+		if (!first)
+			strlcat(out, ",", outsize);
+		strlcat(out, tok, outsize);
+		first = 0;
+	}
+}
+
+/*
+ * Per-message context handed to search_eval()/search_eval_leaf() during
+ * handle_mbox_search()'s scan below -- one message's worth of the fields
+ * any in-scope search key (SEARCH_OP_*, imapd.h) might need to test.
+ */
+struct search_msg_ctx {
+	uint32_t	seqno;
+	uint32_t	uid;
+	uint32_t	sysflags;
+	const char	*keywords;	/* comma-separated, same convention as
+					 * everywhere else in this file */
+	int64_t		internaldate;
+	uint64_t	size;		/* F13 fix: 64-bit for SEARCH LARGER/SMALLER */
+	uint64_t	modseq;		/* RFC 7162 SS3.1.5 MODSEQ search key */
+};
+
+/*
+ * One postfix node's worth of evaluation against a single message.
+ * SEARCH_OP_AND/OR/NOT never reach here -- search_eval()'s stack machine
+ * below handles those three directly, since they combine *other* nodes'
+ * results rather than testing the message themselves.
+ */
+static int
+search_eval_leaf(const struct search_node *n, const struct search_msg_ctx *m)
+{
+	switch (n->op) {
+	case SEARCH_OP_ALL:
+		return (1);
+	case SEARCH_OP_ANSWERED:
+		return ((m->sysflags & MBOX_FLAG_ANSWERED) != 0);
+	case SEARCH_OP_UNANSWERED:
+		return ((m->sysflags & MBOX_FLAG_ANSWERED) == 0);
+	case SEARCH_OP_DELETED:
+		return ((m->sysflags & MBOX_FLAG_DELETED) != 0);
+	case SEARCH_OP_UNDELETED:
+		return ((m->sysflags & MBOX_FLAG_DELETED) == 0);
+	case SEARCH_OP_DRAFT:
+		return ((m->sysflags & MBOX_FLAG_DRAFT) != 0);
+	case SEARCH_OP_UNDRAFT:
+		return ((m->sysflags & MBOX_FLAG_DRAFT) == 0);
+	case SEARCH_OP_FLAGGED:
+		return ((m->sysflags & MBOX_FLAG_FLAGGED) != 0);
+	case SEARCH_OP_UNFLAGGED:
+		return ((m->sysflags & MBOX_FLAG_FLAGGED) == 0);
+	case SEARCH_OP_SEEN:
+		return ((m->sysflags & MBOX_FLAG_SEEN) != 0);
+	case SEARCH_OP_UNSEEN:
+		return ((m->sysflags & MBOX_FLAG_SEEN) == 0);
+	case SEARCH_OP_KEYWORD:
+		return (kw_list_contains(m->keywords, n->keyword));
+	case SEARCH_OP_UNKEYWORD:
+		return (!kw_list_contains(m->keywords, n->keyword));
+	case SEARCH_OP_BEFORE:
+		return (m->internaldate < n->num);
+	case SEARCH_OP_ON:
+		return (m->internaldate >= n->num &&
+		    m->internaldate < n->num + 86400);
+	case SEARCH_OP_SINCE:
+		return (m->internaldate >= n->num);
+	case SEARCH_OP_LARGER:
+		return ((int64_t)m->size > n->num);
+	case SEARCH_OP_SMALLER:
+		return ((int64_t)m->size < n->num);
+	case SEARCH_OP_SEQSET:
+		return (m->seqno >= n->seq_lo && m->seqno <= n->seq_hi);
+	case SEARCH_OP_UIDSET:
+		return (m->uid >= n->seq_lo && m->uid <= n->seq_hi);
+	case SEARCH_OP_MODSEQ:
+		return (m->modseq >= (uint64_t)n->num);
+	default:
+		return (0);
+	}
+}
+
+/*
+ * Evaluates nodes[0..nnodes) -- a postfix (reverse Polish) boolean
+ * expression compiled by listener.c's parse_search_key()/parse_search_
+ * key_list() -- against one message. Bounded by SEARCH_PROGRAM_MAX_NODES
+ * (imapd.h): listener.c never emits a program longer than that, and
+ * never emits a malformed one (AND/OR/NOT with too few operands already
+ * on the stack) -- the bounds checks below are defensive against a
+ * build-time struct-layout skew between listener and store, the same
+ * posture store_dispatch()'s IMSG_MBOX_SEARCH case already takes on a
+ * raw length mismatch, not something a client can trigger through normal
+ * protocol use.
+ */
+static int
+search_eval(const struct search_node *nodes, uint32_t nnodes,
+    const struct search_msg_ctx *m)
+{
+	int		stack[SEARCH_PROGRAM_MAX_NODES];
+	uint32_t	sp = 0, i;
+
+	for (i = 0; i < nnodes; i++) {
+		const struct search_node *n = &nodes[i];
+
+		switch (n->op) {
+		case SEARCH_OP_AND:
+			if (sp < 2)
+				return (0);
+			sp--;
+			stack[sp - 1] = stack[sp - 1] && stack[sp];
+			break;
+		case SEARCH_OP_OR:
+			if (sp < 2)
+				return (0);
+			sp--;
+			stack[sp - 1] = stack[sp - 1] || stack[sp];
+			break;
+		case SEARCH_OP_NOT:
+			if (sp < 1)
+				return (0);
+			stack[sp - 1] = !stack[sp - 1];
+			break;
+		default:
+			if (sp >= SEARCH_PROGRAM_MAX_NODES)
+				return (0);
+			stack[sp++] = search_eval_leaf(n, m);
+			break;
+		}
+	}
+
+	return (sp == 1 ? stack[0] : 0);
+}
+
+/*
+ * IMSG_MBOX_SEARCH handling. Same index-loading/locking shape as handle_
+ * mbox_fetch() (LOCK_SH -- SEARCH only reads), but iterates every message
+ * in the mailbox (a search key can be anything, so there's no shortcut
+ * range the way FETCH's own sequence-set bounds the scan) and, for each,
+ * evaluates the compiled postfix program via search_eval(). Matches are
+ * streamed as IMSG_MBOX_SEARCH_MATCH in ascending sequence order (same
+ * order handle_mbox_fetch() already streams in, for the same "index
+ * order == sequence-number order, v1 has no reordering operation"
+ * reason), then one terminal IMSG_MBOX_RESULT.
+ *
+ * SEQSET/UIDSET nodes' "*" is resolved once, up front, against this
+ * scan's own idx.nlines (sequence numbers) or highest in-use UID (UID
+ * ranges) -- not per message, since neither changes mid-scan (LOCK_SH is
+ * held only long enough to read the index into memory, same as FETCH;
+ * nothing else in this process mutates it afterward). "Largest UID in
+ * use" is taken from the last index line (index_append() always appends
+ * in increasing-UID order, and v1 has no operation that reorders or
+ * renumbers existing entries), not from idx.uidnext - 1, so this stays
+ * correct even if a future pass ever introduces UID gaps.
+ *
+ * A message that's indexed but missing on disk is skipped, same
+ * tolerance handle_mbox_fetch()/handle_mbox_store()/handle_mbox_
+ * expunge() already document -- unlike those, SEARCH always needs the
+ * on-disk file (locate_message_file()) for both its flag-suffix letters
+ * and its size, since almost any real query touches at least one of
+ * ANSWERED/DELETED/DRAFT/FLAGGED/SEEN/KEYWORD/LARGER/SMALLER; there's no
+ * cheaper partial path worth special-casing here the way FETCH's attrs
+ * bitmask lets it skip the stat(2) entirely for a FLAGS-less request.
+ */
+static void
+handle_mbox_search(struct imsg_mbox_search *req, struct search_node *nodes,
+    uint32_t nnodes, struct imsgev *iev)
+{
+	struct mbox_index	 idx;
+	struct imsg_mbox_result	 result;
+	int			 fd;
+	int			 ok = 1;
+	uint32_t		 sent = 0;
+	uint32_t		 max_uid = 0;
+	uint32_t		 i;
+
+	(void)req;	/* nnodes (passed separately, already used by the
+			 * caller to size the node array) is currently its
+			 * only field -- see imapd.h's imsg_mbox_search
+			 * comment */
+
+	memset(&idx, 0, sizeof(idx));
+
+	if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+		ok = 0;
+		goto done;
+	}
+	if (flock(fd, LOCK_SH) == -1) {
+		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+		close(fd);
+		ok = 0;
+		goto done;
+	}
+	if (index_load(fd, &idx) == -1) {
+		flock(fd, LOCK_UN);
+		close(fd);
+		ok = 0;
+		goto done;
+	}
+	flock(fd, LOCK_UN);
+	close(fd);
+
+	max_uid = index_max_uid(&idx);	/* factored out this pass -- UID
+					 * FETCH/UID STORE/UID EXPUNGE's own
+					 * "*" resolution now shares this same
+					 * helper, see its comment */
+
+	for (i = 0; i < nnodes; i++) {
+		struct search_node *n = &nodes[i];
+
+		if (n->op != SEARCH_OP_SEQSET && n->op != SEARCH_OP_UIDSET)
+			continue;
+
+		if (n->lo_is_star)
+			n->seq_lo = (n->op == SEARCH_OP_SEQSET) ?
+			    (uint32_t)idx.nlines : max_uid;
+		if (n->hi_is_star)
+			n->seq_hi = (n->op == SEARCH_OP_SEQSET) ?
+			    (uint32_t)idx.nlines : max_uid;
+	}
+
+	for (i = 1; i <= (uint32_t)idx.nlines; i++) {
+		struct search_msg_ctx	 m;
+		struct index_rec	 rec;
+		const char		*letters;
+		off_t			 size = 0;
+		char			 suffix[64];
+
+		if (index_parse_line(idx.lines[i - 1], &rec) == -1)
+			continue;
+
+		memset(&m, 0, sizeof(m));
+		m.seqno = i;
+		m.uid = rec.uid;
+		m.modseq = rec.modseq;
+
+		if (locate_message_file(rec.basename, &size, suffix,
+		    sizeof(suffix)) == -1) {
+			log_warnx("session %u: message %s (uid %u) indexed "
+			    "but missing on disk -- skipped", session_id,
+			    rec.basename, m.uid);
+			continue;
+		}
+
+		letters = strstr(suffix, "2,");
+		letters = (letters != NULL) ? letters + 2 : "";
+		m.sysflags = letters_to_sysflags(letters);
+		m.keywords = rec.keywords;
+		m.internaldate = parse_maildir_timestamp(rec.basename);
+		m.size = (uint64_t)size;	/* F13 fix: was (uint32_t) */
+
+		if (search_eval(nodes, nnodes, &m)) {
+			struct imsg_mbox_search_match	 match;
+
+			memset(&match, 0, sizeof(match));
+			match.seqno = i;
+			match.uid = m.uid;
+			match.modseq = m.modseq;
+			if (imsg_compose(&iev->ibuf, IMSG_MBOX_SEARCH_MATCH,
+			    0, 0, -1, &match, sizeof(match)) == -1)
+				log_warn("session %u: imsg_compose "
+				    "IMSG_MBOX_SEARCH_MATCH", session_id);
+			else
+				sent++;
+		}
+	}
+
+done:
+	index_free(&idx);
+
+	memset(&result, 0, sizeof(result));
+	result.ok = ok;
+	result.count = sent;
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	    sizeof(result)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * IMSG_MBOX_STORE handling -- see imapd.h's imsg_mbox_store comment for
+ * the wire shape and cmd_store_cmd() in listener.c for the RFC 9051
+ * SS6.4.6 parsing this responds to. Uses LOCK_EX (like handle_mbox_
+ * select(), unlike handle_mbox_fetch()'s LOCK_SH): STORE mutates both the
+ * index (keywords) and, for any message whose system flags actually
+ * change, the maildir filename itself, so it needs the same mutual-
+ * exclusion-across-processes guarantee SELECT's read-modify-write cycle
+ * already established, for the same fork-per-session (not fork-per-user)
+ * reason.
+ *
+ * Sends one IMSG_MBOX_FETCH_META per modified message (skipped if
+ * req->silent) and exactly one terminal IMSG_MBOX_RESULT, matching
+ * handle_mbox_fetch()'s own reply shape -- see imapd.h's comment on
+ * why STORE reuses FETCH's reply types rather than inventing new ones.
+ *
+ * RFC 7162 additions this pass:
+ *
+ * - req->has_unchangedsince (UNCHANGEDSINCE store-modifier, SS3.1.3): a
+ *   message whose current mod-sequence exceeds req->unchangedsince fails
+ *   the conditional test -- the requested flag operation is skipped for
+ *   it entirely, and its seqno/uid are reported via one IMSG_MBOX_STORE_
+ *   MODIFIED (listener.c folds these into the tagged response's MODIFIED
+ *   response code). This overrides req->silent for messages that *pass*
+ *   the test: SS3.1.3 "An untagged FETCH response MUST be sent, even if
+ *   the .SILENT suffix is specified, and the response MUST include the
+ *   MODSEQ message data item" -- see send_fetch below.
+ *
+ * - One shared mod-sequence bump per STORE command, applied to every
+ *   message this command actually changes -- not a fresh bump per
+ *   message. Sourced from RFC 7162 SS3.1.3's own worked examples (9 and
+ *   10 especially): a range STORE that changes many messages shows the
+ *   *identical* MODSEQ value on every changed message's FETCH echo, and
+ *   SS3.1's guarantee is phrased per *command* ("each STORE command...
+ *   will get a different mod-sequence value"), not per message. new_
+ *   modseq below is computed once, up front, and only actually committed
+ *   to idx.highestmodseq (via the `changed` flag) if at least one message
+ *   ends up using it -- see also SS3.1.11/SS3.1.12: adding an
+ *   already-set flag (or removing an already-unset one) SHOULD NOT bump
+ *   the mod-sequence at all, so a message whose resulting flags/keywords
+ *   come out identical to what it already had keeps its *old* mod-
+ *   sequence rather than taking new_modseq.
+ *
+ * RFC 9051 SS6.4.9 addition (UID command): req->by_uid switches lo/hi (and
+ * "*") to UID-space resolution -- see handle_mbox_fetch()'s identical
+ * mechanism and comment. meta.uid is already unconditionally populated in
+ * the FETCH echo regardless of by_uid; only listener.c's decision to print
+ * it changes.
+ */
+static void
+handle_mbox_store(struct imsg_mbox_store *req, struct imsgev *iev)
+{
+	struct mbox_index	 idx;
+	struct imsg_mbox_result	 result;
+	int			 fd = -1;
+	uint32_t		 lo, hi, i, sent = 0;
+	uint64_t		 new_modseq;
+	int			 ok = 1, changed = 0, locked = 0;
+
+	memset(&idx, 0, sizeof(idx));
+
+	if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+		ok = 0;
+		goto done;
+	}
+	if (flock(fd, LOCK_EX) == -1) {
+		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+		ok = 0;
+		goto done;
+	}
+	locked = 1;
+
+	if (index_load(fd, &idx) == -1) {
+		ok = 0;
+		goto done;
+	}
+
+	new_modseq = idx.highestmodseq + 1;
+
+	/* RFC 9051 SS6.4.9: UID STORE's sequence-set is UID-space -- same
+	 * lo/hi resolution switch as handle_mbox_fetch(), see that
+	 * function's comment for the full reasoning. */
+	if (req->by_uid) {
+		uint32_t	max_uid = index_max_uid(&idx);
+
+		lo = req->lo_is_star ? max_uid : req->seq_lo;
+		hi = req->hi_is_star ? max_uid : req->seq_hi;
+	} else {
+		lo = req->lo_is_star ? (uint32_t)idx.nlines : req->seq_lo;
+		hi = req->hi_is_star ? (uint32_t)idx.nlines : req->seq_hi;
+	}
+	if (lo < 1)
+		lo = 1;
+	if (!req->by_uid && hi > (uint32_t)idx.nlines)
+		hi = (uint32_t)idx.nlines;
+
+	for (i = 1; i <= (uint32_t)idx.nlines; i++) {
+		struct imsg_mbox_fetch_meta	 meta;
+		struct index_rec		 rec;
+		const char			*lp;
+		uint32_t			 old_sysflags, new_sysflags;
+		char				 suffix[64], newletters[8];
+		char				 newkeywords[MBOX_FLAGS_MAX];
+		char				 newline[STORE_INDEX_LINE_MAX];
+		char				 oldpath[600], newpath[600];
+		off_t				 size;
+		uint64_t			 this_modseq;
+		int				 in_new, len, real_change, send_fetch;
+
+		if (index_parse_line(idx.lines[i - 1], &rec) == -1)
+			continue;
+
+		/* Same position-space/UID-space range check as handle_mbox_
+		 * fetch() -- see that function's comment. */
+		if (req->by_uid) {
+			if (rec.uid < lo)
+				continue;
+			if (rec.uid > hi)
+				break;
+		} else {
+			if (i < lo)
+				continue;
+			if (i > hi)
+				break;
+		}
+
+		if (req->has_unchangedsince &&
+		    rec.modseq > req->unchangedsince) {
+			struct imsg_mbox_store_modified	mod;
+
+			memset(&mod, 0, sizeof(mod));
+			mod.seqno = i;
+			mod.uid = rec.uid;
+			if (imsg_compose(&iev->ibuf, IMSG_MBOX_STORE_MODIFIED,
+			    0, 0, -1, &mod, sizeof(mod)) == -1)
+				log_warn("session %u: imsg_compose "
+				    "IMSG_MBOX_STORE_MODIFIED", session_id);
+			continue;
+		}
+
+		if (locate_message_file(rec.basename, &size, suffix,
+		    sizeof(suffix)) == -1) {
+			log_warnx("session %u: message %s (uid %u) indexed "
+			    "but missing on disk -- skipped", session_id,
+			    rec.basename, rec.uid);
+			continue;
+		}
+		in_new = (suffix[0] == '\0');
+
+		lp = strstr(suffix, "2,");
+		old_sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
+
+		switch (req->mode) {
+		case MBOX_STORE_SET:
+			new_sysflags = req->sysflags;
+			break;
+		case MBOX_STORE_ADD:
+			new_sysflags = old_sysflags | req->sysflags;
+			break;
+		case MBOX_STORE_REMOVE:
+		default:
+			new_sysflags = old_sysflags & ~req->sysflags;
+			break;
+		}
+		merge_keywords(req->mode, rec.keywords, req->keywords,
+		    newkeywords, sizeof(newkeywords));
+		sysflags_to_letters(new_sysflags, newletters,
+		    sizeof(newletters));
+
+		real_change = (new_sysflags != old_sysflags) ||
+		    (strcmp(newkeywords, rec.keywords) != 0);
+		this_modseq = real_change ? new_modseq : rec.modseq;
+
+		/*
+		 * Once a message is targeted by STORE at all, it moves (if
+		 * not already there) from new/ to cur/ with an explicit
+		 * ":2,<letters>" suffix, even if <letters> ends up empty --
+		 * maildir's new/ specifically means "not yet seen by any
+		 * client" (Courier maildir(5)), and a STORE is unambiguous
+		 * evidence the client has now processed the message, the
+		 * same "no protocol reason left to leave it in new/ once
+		 * touched" reasoning handle_mbox_select()'s header comment
+		 * already applies to \Recent. Never moves cur/ -> new/ --
+		 * no maildir tool does; new/ is one-directional. Skipped
+		 * entirely if the message is already in cur/ with exactly
+		 * this letter set, to avoid a no-op rename on every STORE
+		 * that only touches keywords.
+		 */
+		if (in_new || strcmp(lp != NULL ? lp + 2 : "",
+		    newletters) != 0) {
+			if (snprintf(oldpath, sizeof(oldpath), "%s/%s%s",
+			    in_new ? "new" : "cur", rec.basename, suffix) >=
+			    (int)sizeof(oldpath) ||
+			    snprintf(newpath, sizeof(newpath), "cur/%s:2,%s",
+			    rec.basename, newletters) >= (int)sizeof(newpath)) {
+				log_warnx("session %u: path too long for %s",
+				    session_id, rec.basename);
+				continue;
+			}
+			if (rename(oldpath, newpath) == -1) {
+				log_warn("session %u: rename %s -> %s",
+				    session_id, oldpath, newpath);
+				continue;
+			}
+		}
+
+		len = snprintf(newline, sizeof(newline), "%u:%s:%s:%llu",
+		    rec.uid, rec.basename, newkeywords,
+		    (unsigned long long)this_modseq);
+		if (len < 0 || (size_t)len >= sizeof(newline)) {
+			log_warnx("session %u: new index line too long for "
+			    "%s", session_id, rec.basename);
+			continue;
+		}
+		free(idx.lines[i - 1]);
+		if ((idx.lines[i - 1] = strdup(newline)) == NULL) {
+			log_warn("session %u: strdup index line", session_id);
+			ok = 0;
+			goto done;
+		}
+		if (real_change)
+			changed = 1;
+
+		/* SS3.1.3: UNCHANGEDSINCE forces the FETCH echo (with
+		 * MODSEQ) even under .SILENT, for every message that passed
+		 * the conditional test -- independent of req->silent. */
+		send_fetch = !req->silent || req->has_unchangedsince;
+
+		if (send_fetch) {
+			char	newsuffix[16];
+
+			memset(&meta, 0, sizeof(meta));
+			meta.seqno = i;
+			meta.uid = rec.uid;
+			meta.modseq = this_modseq;
+			snprintf(newsuffix, sizeof(newsuffix), "2,%s",
+			    newletters);
+			build_flags_string(newsuffix, newkeywords, meta.flags,
+			    sizeof(meta.flags));
+
+			if (imsg_compose(&iev->ibuf, IMSG_MBOX_FETCH_META, 0,
+			    0, -1, &meta, sizeof(meta)) == -1)
+				log_warn("session %u: imsg_compose "
+				    "IMSG_MBOX_FETCH_META", session_id);
+			else
+				sent++;
+		}
+	}
+
+	if (changed) {
+		idx.highestmodseq = new_modseq;
+		if (index_save(&idx) == -1)
+			ok = 0;
+	}
+
+done:
+	if (locked)
+		flock(fd, LOCK_UN);
+	if (fd != -1)
+		close(fd);
+
+	memset(&result, 0, sizeof(result));
+	result.ok = ok;
+	result.count = sent;
+	result.highestmodseq = idx.highestmodseq;
+	index_free(&idx);
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	    sizeof(result)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * IMSG_MBOX_EXPUNGE handling -- see imapd.h's imsg_mbox_expunge comment
+ * for the wire shape (also used, with silent=1, by CLOSE). Uses LOCK_EX,
+ * same reason as handle_mbox_store(): this mutates the index structurally
+ * (removes lines, not just their content) and unlinks message files.
+ *
+ * Compacts idx.lines[] in place with a classic remove_if two-pointer scan
+ * (in reads every line, out is where the next *kept* line goes) rather
+ * than building a second array -- removed lines are simply never copied
+ * to out and their storage is free()d immediately; kept lines are moved
+ * down by however many removals preceded them. This single pass is also
+ * what produces RFC 9051 SS7.5.1's "immediately decremented" sequence
+ * numbers for free: at the moment a message at idx.lines[in] is found to
+ * be \Deleted, out already equals the count of kept messages before it,
+ * which -- because every earlier removal already shifted everything after
+ * it down by one -- is exactly that message's current sequence number
+ * minus one. See imapd.h's imsg_mbox_expunge comment for the worked
+ * check against SS6.4.3's own example (3, 3, 5, 8).
+ *
+ * A message that can't be classified for some reason (corrupt index
+ * line, missing on-disk file, path too long, or a failed unlink(2)) is
+ * conservatively kept in the index rather than dropped -- losing track of
+ * a message store.c couldn't actually verify as removed would be worse
+ * than a missed expunge of it.
+ *
+ * UIDVALIDITY and UIDNEXT are both left untouched: removing messages must
+ * never cause a UID to be reused (RFC 9051 SS2.3.1.1's whole point), and
+ * since this function only removes index entries -- never renumbers or
+ * reassigns the UIDs of messages that remain -- there's no reason either
+ * value would need to change.
+ *
+ * RFC 7162 additions this pass: exp.uid is now populated too (see imapd.h's
+ * imsg_mbox_expunged comment -- needed once QRESYNC is enabled, since
+ * listener.c then reports VANISHED, which is UID-based, instead of EXPUNGE).
+ * One shared mod-sequence bump for the whole EXPUNGE/UID EXPUNGE/CLOSE
+ * command, applied (conceptually -- this implementation doesn't persist a
+ * per-expunge-event value at all, see imsg_mbox_select_vanished's SS5.1
+ * minimal-state comment) to every message removed by it, same "one bump
+ * per command, not per message" reasoning as handle_mbox_store() -- RFC
+ * 7162 SS3.2.7's own example shows a single HIGHESTMODSEQ value covering
+ * an entire multi-message EXPUNGE. Applies regardless of req->silent
+ * (CLOSE): SS3.2.8 requires the mod-sequence bump for CLOSE too, it just
+ * additionally forbids CLOSE's tagged OK from *reporting* the new value
+ * (a listener.c-side choice, not something this function needs to know
+ * about).
+ *
+ * RFC 9051 SS6.4.9 addition (UID command): req->by_uid, when set, restricts
+ * removal to \Deleted messages whose UID also falls in [seq_lo, seq_hi]
+ * (resolved once, up front, via index_max_uid() for "*" -- same mechanism
+ * FETCH/STORE now share) -- "If a message... has a UID that is not
+ * included in the specified sequence set, it is not affected." A message
+ * excluded this way is kept in the index exactly like the "not \Deleted at
+ * all" case, so it still gets its own turn at a later EXPUNGE/UID EXPUNGE
+ * that does include it.
+ */
+static void
+handle_mbox_expunge(struct imsg_mbox_expunge *req, struct imsgev *iev)
+{
+	struct mbox_index	 idx;
+	struct imsg_mbox_result	 result;
+	int			 fd = -1;
+	size_t			 in, out;
+	uint32_t		 sent = 0;
+	uint32_t		 uid_lo, uid_hi;
+	int			 ok = 1, changed = 0, locked = 0;
+
+	memset(&idx, 0, sizeof(idx));
+
+	if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+		ok = 0;
+		goto done;
+	}
+	if (flock(fd, LOCK_EX) == -1) {
+		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+		ok = 0;
+		goto done;
+	}
+	locked = 1;
+
+	if (index_load(fd, &idx) == -1) {
+		ok = 0;
+		goto done;
+	}
+
+	/*
+	 * RFC 9051 SS6.4.9's second UID command form (UID EXPUNGE): resolve
+	 * the required UID range once, up front, against the mailbox as
+	 * loaded -- same index_max_uid() "*" resolution as UID FETCH/UID
+	 * STORE. Meaningless (and unused below) when !req->by_uid, since a
+	 * plain EXPUNGE takes no arguments at all.
+	 */
+	uid_lo = uid_hi = 0;
+	if (req->by_uid) {
+		uint32_t	max_uid = index_max_uid(&idx);
+
+		uid_lo = req->lo_is_star ? max_uid : req->seq_lo;
+		uid_hi = req->hi_is_star ? max_uid : req->seq_hi;
+		if (uid_lo < 1)
+			uid_lo = 1;
+	}
+
+	out = 0;
+	for (in = 0; in < idx.nlines; in++) {
+		struct index_rec	 rec;
+		const char		*lp;
+		uint32_t		 sysflags;
+		char			 suffix[64], path[600];
+		off_t			 size;
+
+		if (index_parse_line(idx.lines[in], &rec) == -1) {
+			idx.lines[out++] = idx.lines[in];
+			continue;
+		}
+
+		if (locate_message_file(rec.basename, &size, suffix,
+		    sizeof(suffix)) == -1) {
+			log_warnx("session %u: message %s indexed but missing "
+			    "on disk -- kept in index, not counted as "
+			    "expunged", session_id, rec.basename);
+			idx.lines[out++] = idx.lines[in];
+			continue;
+		}
+
+		lp = strstr(suffix, "2,");
+		sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
+		if (!(sysflags & MBOX_FLAG_DELETED)) {
+			idx.lines[out++] = idx.lines[in];
+			continue;
+		}
+
+		/*
+		 * RFC 9051 SS6.4.9: "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" -- a \Deleted
+		 * message outside the UID EXPUNGE range is kept, exactly
+		 * like the "not \Deleted at all" case just above.
+		 */
+		if (req->by_uid && (rec.uid < uid_lo || rec.uid > uid_hi)) {
+			idx.lines[out++] = idx.lines[in];
+			continue;
+		}
+
+		if (snprintf(path, sizeof(path), "%s/%s%s",
+		    suffix[0] == '\0' ? "new" : "cur", rec.basename, suffix) >=
+		    (int)sizeof(path)) {
+			log_warnx("session %u: path too long for %s -- kept "
+			    "in index", session_id, rec.basename);
+			idx.lines[out++] = idx.lines[in];
+			continue;
+		}
+		if (unlink(path) == -1 && errno != ENOENT) {
+			log_warn("session %u: unlink %s", session_id, path);
+			idx.lines[out++] = idx.lines[in];
+			continue;
+		}
+
+		if (!req->silent) {
+			struct imsg_mbox_expunged	 exp;
+
+			memset(&exp, 0, sizeof(exp));
+			exp.seqno = (uint32_t)(out + 1);
+			exp.uid = rec.uid;
+			if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0,
+			    -1, &exp, sizeof(exp)) == -1)
+				log_warn("session %u: imsg_compose "
+				    "IMSG_MBOX_EXPUNGED", session_id);
+			else
+				sent++;
+		}
+
+		free(idx.lines[in]);
+		changed = 1;
+	}
+	idx.nlines = out;
+
+	if (changed) {
+		idx.highestmodseq++;
+		if (index_save(&idx) == -1)
+			ok = 0;
+	}
+
+done:
+	if (locked)
+		flock(fd, LOCK_UN);
+	if (fd != -1)
+		close(fd);
+
+	memset(&result, 0, sizeof(result));
+	result.ok = ok;
+	result.count = sent;
+	result.highestmodseq = idx.highestmodseq;
+	index_free(&idx);
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	    sizeof(result)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * Ensures tmp/, new/, and cur/ all exist -- APPEND is the first thing in
+ * this codebase that ever needs to *deliver* a message itself (see
+ * handle_mbox_append()'s header comment): every other IMSG_MBOX_* handler
+ * only ever reads directories assumed to already exist, populated by
+ * something external (smtpd's native maildir delivery action, per
+ * openimap-storage-backend.md). A mailbox that has only ever received
+ * mail that way could plausibly have new/ and cur/ but genuinely lack
+ * tmp/ (nothing external needs it -- smtpd's own maildir action manages
+ * its own tmp/ usage internally and doesn't leave evidence of it here).
+ * mkdir(2) with EEXIST tolerated is the standard idempotent "ensure
+ * exists" idiom; 0700 matches the store child's own already-narrow
+ * privilege scope (privilege-dropped to the session's uid/gid, chroot'd,
+ * unveiled to just this maildir subdirectory).
+ *
+ * Generalized this pass (flat multi-mailbox support) to take a path
+ * prefix rather than always assuming cwd is the mailbox root: "" for
+ * INBOX (every existing caller, unchanged behavior) or "name/" for a
+ * named mailbox, e.g. CREATE or an APPEND targeting a mailbox other than
+ * the one currently selected. Every existing call site already passes
+ * "" explicitly, so this is a signature change, not a behavior change,
+ * for anything that isn't new this pass.
+ */
+static int
+ensure_maildir_dirs(const char *prefix)
+{
+	static const char *dirs[] = { "tmp", "new", "cur" };
+	char	 path[MBOX_NAME_MAX + 8];
+	size_t	 i;
+
+	for (i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
+		if (snprintf(path, sizeof(path), "%s%s", prefix, dirs[i]) >=
+		    (int)sizeof(path)) {
+			log_warnx("session %u: mailbox path too long",
+			    session_id);
+			return (-1);
+		}
+		if (mkdir(path, 0700) == -1 && errno != EEXIST) {
+			log_warn("session %u: mkdir %s", session_id, path);
+			return (-1);
+		}
+	}
+	return (0);
+}
+
+/*
+ * IMSG_MBOX_CREATE (RFC 9051 SS6.3.4). See docs/openimap-storage-backend.md
+ * item 10 for the full design: CREATE needs no index-initialization code
+ * of its own at all -- index_load() already default-initializes a fresh
+ * UIDVALIDITY/UIDNEXT when handed an empty index fd, so the first command
+ * that actually opens this mailbox (SELECT, APPEND, ...) does that work,
+ * exactly as it always has for INBOX. This handler is therefore just:
+ * mkdir the mailbox directory itself (EEXIST here means "already exists",
+ * SS6.3.4's required refusal, unlike ensure_maildir_dirs()'s idempotent
+ * EEXIST-tolerant use elsewhere), then the same tmp/new/cur scaffolding
+ * APPEND already needs.
+ */
+static void
+handle_mbox_create(struct imsg_mbox_create *req, struct imsgev *iev)
+{
+	struct imsg_mbox_result	 result;
+	char				 prefix[MBOX_NAME_MAX + 1];
+	char				 saved[MBOX_NAME_MAX];
+	int				 switched = 0;
+
+	memset(&result, 0, sizeof(result));
+
+	if (mailbox_name_is_inbox(req->mailbox) ||
+	    !mailbox_name_valid(req->mailbox)) {
+		log_debug("session %u: CREATE %s: invalid name", session_id,
+		    req->mailbox);
+		result.ok = 0;
+		goto send;
+	}
+
+	/*
+	 * mkdir(2)/ensure_maildir_dirs() below take req->mailbox as a bare
+	 * path relative to cwd -- correct only when cwd is this session's
+	 * maildir root, which is true by default but no longer guaranteed:
+	 * if this session currently has some *other* named mailbox SELECTed
+	 * (select_mailbox_dir() having already chdir'd into it), cwd sits
+	 * one level below root. Temporarily visiting root first -- the same
+	 * select_mailbox_dir()-based "visit and restore" pattern handle_
+	 * mbox_status() already established for this identical problem --
+	 * makes every relative path below correct regardless of what's
+	 * currently selected. Found by real-hardware testing on premio:
+	 * CREATE from a session with nothing else selected happened to work
+	 * (cwd was already root by luck), but a RENAME issued against the
+	 * mailbox that was itself currently SELECTed failed with a spurious
+	 * "no such mailbox" -- same root cause; see handle_mbox_rename()'s
+	 * identical fix, and handle_mbox_delete()'s/handle_mbox_list()'s.
+	 */
+	strlcpy(saved, current_mailbox_dir, sizeof(saved));
+	if (select_mailbox_dir("") == -1) {
+		log_warnx("session %u: CREATE %s: couldn't reach maildir "
+		    "root", session_id, req->mailbox);
+		result.ok = 0;
+		goto send;
+	}
+	switched = 1;
+
+	if (mkdir(req->mailbox, 0700) == -1) {
+		if (errno != EEXIST)
+			log_warn("session %u: CREATE: mkdir %s", session_id,
+			    req->mailbox);
+		else
+			log_debug("session %u: CREATE %s: already exists",
+			    session_id, req->mailbox);
+		result.ok = 0;
+		goto send;
+	}
+
+	if (snprintf(prefix, sizeof(prefix), "%s/", req->mailbox) >=
+	    (int)sizeof(prefix) || ensure_maildir_dirs(prefix) == -1) {
+		/* Best-effort cleanup: a half-initialized mailbox (directory
+		 * exists, tmp/new/cur don't) would otherwise be stuck in a
+		 * state CREATE can never retry (mkdir would now see EEXIST)
+		 * but that can't actually be SELECTed usefully either. */
+		rmdir(req->mailbox);
+		result.ok = 0;
+		goto send;
+	}
+
+	result.ok = 1;
+
+send:
+	if (switched && select_mailbox_dir(saved) == -1)
+		log_warnx("session %u: CREATE %s: couldn't restore "
+		    "previously selected mailbox %s", session_id,
+		    req->mailbox, saved);
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	    sizeof(result)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * Bounded removal of one flat mailbox's own tmp/new/cur contents plus its
+ * index file -- deliberately NOT a general-purpose recursive delete.
+ * Refuses (logs and stops, leaving whatever was already removed rather
+ * than guessing further) if tmp/, new/, or cur/ contains anything that
+ * isn't a regular file: this codebase only ever creates that exact
+ * three-subdirectory-of-plain-files shape, so anything else there means
+ * something unexpected is going on and blind recursion would be unsafe.
+ * Does not remove the mailbox directory itself -- the caller (handle_
+ * mbox_delete()) does that last, once this returns success.
+ */
+static int
+remove_maildir_subtree(const char *prefix)
+{
+	static const char *dirs[] = { "tmp", "new", "cur" };
+	/*
+	 * F14 fix: buffer was MBOX_NAME_MAX + 8, too small to append
+	 * "imapd.index" (STORE_INDEX_NAME, 11 bytes) to a near-maximal
+	 * mailbox name. DELETE of such a mailbox failed with "index path
+	 * too long" *after* already removing tmp/new/cur -- a partial
+	 * delete that left the mailbox undeletable. + 32 covers the
+	 * longest suffix this function appends.
+	 */
+	char	path[MBOX_NAME_MAX + 32];
+	size_t	i;
+
+	for (i = 0; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
+		DIR		*dp;
+		struct dirent	*de;
+		int		 ok = 1;
+
+		if (snprintf(path, sizeof(path), "%s%s", prefix, dirs[i]) >=
+		    (int)sizeof(path)) {
+			log_warnx("session %u: DELETE: path too long",
+			    session_id);
+			return (-1);
+		}
+		if ((dp = opendir(path)) == NULL) {
+			if (errno == ENOENT)
+				continue;	/* tmp/ in particular may never
+						 * have been created -- see
+						 * ensure_maildir_dirs()'s own
+						 * comment */
+			log_warn("session %u: DELETE: opendir %s",
+			    session_id, path);
+			return (-1);
+		}
+		while ((de = readdir(dp)) != NULL) {
+			char		entpath[sizeof(path) + 300];
+			struct stat	st;
+
+			if (strcmp(de->d_name, ".") == 0 ||
+			    strcmp(de->d_name, "..") == 0)
+				continue;
+			if (snprintf(entpath, sizeof(entpath), "%s/%s", path,
+			    de->d_name) >= (int)sizeof(entpath)) {
+				log_warnx("session %u: DELETE: entry path "
+				    "too long", session_id);
+				ok = 0;
+				continue;
+			}
+			if (lstat(entpath, &st) == -1) {
+				log_warn("session %u: DELETE: lstat %s",
+				    session_id, entpath);
+				ok = 0;
+				continue;
+			}
+			if (!S_ISREG(st.st_mode)) {
+				log_warnx("session %u: DELETE: refusing -- "
+				    "%s is not a regular file", session_id,
+				    entpath);
+				ok = 0;
+				continue;
+			}
+			if (unlink(entpath) == -1) {
+				log_warn("session %u: DELETE: unlink %s",
+				    session_id, entpath);
+				ok = 0;
+			}
+		}
+		closedir(dp);
+		if (!ok)
+			return (-1);
+		if (rmdir(path) == -1 && errno != ENOENT) {
+			log_warn("session %u: DELETE: rmdir %s", session_id,
+			    path);
+			return (-1);
+		}
+	}
+
+	if (snprintf(path, sizeof(path), "%s%s", prefix, STORE_INDEX_NAME) >=
+	    (int)sizeof(path)) {
+		log_warnx("session %u: DELETE: index path too long",
+		    session_id);
+		return (-1);
+	}
+	if (unlink(path) == -1 && errno != ENOENT) {
+		log_warn("session %u: DELETE: unlink %s", session_id, path);
+		return (-1);
+	}
+
+	return (0);
+}
+
+/*
+ * IMSG_MBOX_DELETE (RFC 9051 SS6.3.5). v1 is flat (docs/openimap-storage-
+ * backend.md item 10), so no mailbox can ever have children -- SS6.3.5's
+ * "inferior hierarchical names"/HASCHILDREN carve-out never applies here;
+ * deletion is unconditional once the name resolves to a real, non-INBOX
+ * mailbox. The UID-preservation requirement ("value of the highest-used
+ * unique identifier... MUST be preserved... unless the new incarnation
+ * has a different unique identifier validity value") is satisfied for
+ * free: removing the directory outright and letting a later CREATE of the
+ * same name start from a brand-new time(NULL)-based UIDVALIDITY (see
+ * handle_mbox_create()) makes the "different UIDVALIDITY" escape clause
+ * trivially true.
+ *
+ * Known, accepted limitation, same category as handle_mbox_rename()'s own
+ * documented one below: no special check exists for "the mailbox being
+ * deleted is this session's own currently SELECTed mailbox" (RFC 9051
+ * doesn't forbid DELETE of a selected mailbox outright, and real servers
+ * differ on how they handle it). If that happens, the send: label's
+ * restore-to-saved-selection select_mailbox_dir() call will itself fail
+ * (the directory it's trying to chdir back into no longer exists), which
+ * is logged but otherwise silently leaves this store child sitting at
+ * root while its own current_mailbox_dir bookkeeping and listener.c's
+ * s->state/s->selected_mailbox still believe the deleted mailbox is
+ * selected -- until that session's next SELECT/EXAMINE/CLOSE/UNSELECT
+ * naturally resolves the mismatch. Not solved here; flagged, not silent.
+ */
+static void
+handle_mbox_delete(struct imsg_mbox_delete *req, struct imsgev *iev)
+{
+	struct imsg_mbox_result	 result;
+	struct stat			 st;
+	char				 prefix[MBOX_NAME_MAX + 1];
+	char				 saved[MBOX_NAME_MAX];
+	int				 switched = 0;
+
+	memset(&result, 0, sizeof(result));
+
+	if (mailbox_name_is_inbox(req->mailbox) ||
+	    !mailbox_name_valid(req->mailbox)) {
+		log_debug("session %u: DELETE %s: invalid name", session_id,
+		    req->mailbox);
+		result.ok = 0;
+		goto send;
+	}
+
+	/* Same "req->mailbox is a bare path relative to cwd, which is only
+	 * guaranteed to be root by default" fix as handle_mbox_create()'s
+	 * identical comment -- see that function for the real-hardware bug
+	 * this closes. */
+	strlcpy(saved, current_mailbox_dir, sizeof(saved));
+	if (select_mailbox_dir("") == -1) {
+		log_warnx("session %u: DELETE %s: couldn't reach maildir "
+		    "root", session_id, req->mailbox);
+		result.ok = 0;
+		goto send;
+	}
+	switched = 1;
+
+	if (stat(req->mailbox, &st) == -1 || !S_ISDIR(st.st_mode)) {
+		log_debug("session %u: DELETE %s: no such mailbox",
+		    session_id, req->mailbox);
+		result.ok = 0;
+		goto send;
+	}
+
+	if (snprintf(prefix, sizeof(prefix), "%s/", req->mailbox) >=
+	    (int)sizeof(prefix)) {
+		result.ok = 0;
+		goto send;
+	}
+
+	if (remove_maildir_subtree(prefix) == -1) {
+		result.ok = 0;
+		goto send;
+	}
+	if (rmdir(req->mailbox) == -1 && errno != ENOENT) {
+		log_warn("session %u: DELETE: rmdir %s", session_id,
+		    req->mailbox);
+		result.ok = 0;
+		goto send;
+	}
+
+	result.ok = 1;
+
+send:
+	if (switched && select_mailbox_dir(saved) == -1)
+		log_warnx("session %u: DELETE %s: couldn't restore "
+		    "previously selected mailbox %s", session_id,
+		    req->mailbox, saved);
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	    sizeof(result)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * IMSG_MBOX_RENAME (RFC 9051 SS6.3.6). Source and destination are always
+ * direct siblings under the same already-unveiled, single-filesystem
+ * maildir root (docs/openimap-storage-backend.md item 6's EXDEV note), so
+ * this is one rename(2) call after existence checks.
+ *
+ * Refusing INBOX specifically as a RENAME *source* is a v1 judgment call,
+ * not a hard spec requirement -- SS6.3.6 permits renaming INBOX (with
+ * special content-move semantics: INBOX ends up empty, its messages move
+ * to the new name) but also explicitly sanctions refusal: "some servers
+ * disallow renaming INBOX... clients need to be able to handle the
+ * failure." INBOX's directory can't simply be renamed away (store.c's
+ * unveil(2)/chroot(2) structure assumes it's always the session's own
+ * root), and actually moving message content instead is real, separate
+ * complexity not taken on here. No special-case is needed to refuse a
+ * RENAME *to* "INBOX" -- it always exists, so that's already caught by
+ * the ordinary destination-already-exists check below.
+ *
+ * Known, accepted limitation (not solved here), revised now that this
+ * handler visits root rather than trusting cwd (see the real-hardware bug
+ * note below): if some *other* session belonging to the same user
+ * currently has the mailbox being renamed SELECTed, that other store
+ * child's own cwd remains validly referencing the same directory by inode
+ * after the rename (POSIX cwd tracking survives a rename of the directory
+ * it points into), so its own FETCH/STORE/EXPUNGE keep working -- but its
+ * current_mailbox_dir string cache goes stale (still holds the old name)
+ * until that session's next SELECT. If instead it's *this* session's own
+ * currently-selected mailbox being renamed, the send: label's restore-to-
+ * saved-selection call will itself fail the same way handle_mbox_delete()'s
+ * identical restore can (the old name no longer resolves), silently
+ * leaving this store child's cwd at root while listener.c's own
+ * s->state/s->selected_mailbox still believe the old name is selected --
+ * until that session's next SELECT/EXAMINE/CLOSE/UNSELECT naturally
+ * resolves it. Solving either properly would need the same kind of
+ * cross-session coordination the deferred COPY/MOVE-to-other-mailbox work
+ * (see the design doc) does, and is out of scope for this pass.
+ *
+ * Real-hardware bug found testing this on premio: req->oldname/req->newname
+ * are bare paths relative to cwd, which stat(2)/rename(2) below assumed was
+ * always this session's maildir root -- true by default, but not once a
+ * *different* named mailbox is the one currently SELECTed (cwd sits one
+ * level below root). Renaming "Drafts" while "Drafts" itself was the
+ * currently selected mailbox failed with a spurious "no such mailbox"
+ * (looking for maildir-root/Drafts/Drafts, which naturally doesn't exist).
+ * Fixed the same way handle_mbox_create()/handle_mbox_delete() are: visit
+ * root via select_mailbox_dir("") first, do the real work, restore
+ * whatever was selected before on the way out.
+ */
+static void
+handle_mbox_rename(struct imsg_mbox_rename *req, struct imsgev *iev)
+{
+	struct imsg_mbox_result	 result;
+	struct stat			 st;
+	char				 saved[MBOX_NAME_MAX];
+	int				 switched = 0;
+
+	memset(&result, 0, sizeof(result));
+
+	if (mailbox_name_is_inbox(req->oldname) ||
+	    !mailbox_name_valid(req->oldname) ||
+	    !mailbox_name_valid(req->newname)) {
+		log_debug("session %u: RENAME %s -> %s: invalid name(s)",
+		    session_id, req->oldname, req->newname);
+		result.ok = 0;
+		goto send;
+	}
+
+	strlcpy(saved, current_mailbox_dir, sizeof(saved));
+	if (select_mailbox_dir("") == -1) {
+		log_warnx("session %u: RENAME %s -> %s: couldn't reach "
+		    "maildir root", session_id, req->oldname, req->newname);
+		result.ok = 0;
+		goto send;
+	}
+	switched = 1;
+
+	if (stat(req->oldname, &st) == -1 || !S_ISDIR(st.st_mode)) {
+		log_debug("session %u: RENAME %s: no such mailbox",
+		    session_id, req->oldname);
+		result.ok = 0;
+		goto send;
+	}
+
+	/*
+	 * SS6.3.6: "error to... rename to a mailbox name that already
+	 * exists." Checked explicitly rather than relying on rename(2)'s
+	 * own directory-replace-if-empty semantics, which would otherwise
+	 * let RENAME silently succeed against a stray empty directory that
+	 * was never really a mailbox at all.
+	 */
+	if (stat(req->newname, &st) == 0 || errno != ENOENT) {
+		log_debug("session %u: RENAME %s -> %s: destination exists",
+		    session_id, req->oldname, req->newname);
+		result.ok = 0;
+		goto send;
+	}
+
+	if (rename(req->oldname, req->newname) == -1) {
+		log_warn("session %u: RENAME: rename %s -> %s", session_id,
+		    req->oldname, req->newname);
+		result.ok = 0;
+		goto send;
+	}
+
+	result.ok = 1;
+
+send:
+	if (switched) {
+		const char	*restore = saved;
+
+		/*
+		 * If this session's own currently-selected mailbox is the
+		 * one that just got renamed, follow it to its new name
+		 * rather than trying (and failing) to restore a name that
+		 * no longer exists -- keeps this store child's own cwd/
+		 * current_mailbox_dir internally consistent even though
+		 * listener.c's separate s->selected_mailbox bookkeeping
+		 * still needs its own fix for the same case (see cmd_
+		 * rename()/session_finish_mbox_op() in listener.c).
+		 */
+		if (result.ok && strcmp(saved, req->oldname) == 0)
+			restore = req->newname;
+		if (select_mailbox_dir(restore) == -1)
+			log_warnx("session %u: RENAME %s -> %s: couldn't "
+			    "restore previously selected mailbox (as %s)",
+			    session_id, req->oldname, req->newname, restore);
+	}
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	    sizeof(result)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * IMSG_MBOX_LIST (RFC 9051 SS6.3.9). Streams one IMSG_MBOX_LIST_ITEM per
+ * real, on-disk mailbox subdirectory found at the session's maildir root,
+ * then a terminal IMSG_MBOX_RESULT (count = number streamed, ok = 0 only
+ * on a real I/O error opening the root itself). INBOX is never included
+ * here -- see the IMSG_MBOX_LIST_ITEM enum comment in imapd.h --
+ * listener.c already handles it locally and unconditionally. "tmp"/"new"/
+ * "cur" (INBOX's own maildir internals, living as siblings of any named
+ * mailbox at this same level) and the index file are skipped, along with
+ * anything that isn't a directory or fails mailbox_name_valid() (should
+ * never happen for anything this codebase itself created, but a stray
+ * unexpected entry shouldn't crash or misreport LIST -- it's just
+ * silently omitted, the same "ignore what doesn't fit" posture SS6.3.9
+ * itself takes for unaccepted patterns).
+ *
+ * opendir(".") is only correct when cwd is this session's maildir root --
+ * true by default, but not once a named mailbox is the one currently
+ * SELECTed (cwd sits one level below root). Real-hardware bug found
+ * testing this on premio: with "Drafts" selected, LIST enumerated Drafts'
+ * own tmp/new/cur instead of the maildir root's mailboxes, silently
+ * making every named mailbox but the selected one invisible. Fixed the
+ * same visit-root-then-restore way handle_mbox_create()/handle_mbox_
+ * delete()/handle_mbox_rename() are.
+ */
+static void
+handle_mbox_list(struct imsgev *iev)
+{
+	struct imsg_mbox_result	 result;
+	struct imsg_mbox_list_item item;
+	DIR				*dp;
+	struct dirent			*de;
+	char				 saved[MBOX_NAME_MAX];
+	int				 switched = 0;
+
+	memset(&result, 0, sizeof(result));
+
+	strlcpy(saved, current_mailbox_dir, sizeof(saved));
+	if (select_mailbox_dir("") == -1) {
+		log_warnx("session %u: LIST: couldn't reach maildir root",
+		    session_id);
+		result.ok = 0;
+		goto send;
+	}
+	switched = 1;
+
+	if ((dp = opendir(".")) == NULL) {
+		log_warn("session %u: LIST: opendir .", session_id);
+		result.ok = 0;
+		goto send;
+	}
+
+	while ((de = readdir(dp)) != NULL) {
+		struct stat	st;
+
+		if (strcmp(de->d_name, ".") == 0 ||
+		    strcmp(de->d_name, "..") == 0)
+			continue;
+		if (strcmp(de->d_name, "tmp") == 0 ||
+		    strcmp(de->d_name, "new") == 0 ||
+		    strcmp(de->d_name, "cur") == 0 ||
+		    strcmp(de->d_name, STORE_INDEX_NAME) == 0)
+			continue;
+		if (!mailbox_name_valid(de->d_name))
+			continue;
+		if (stat(de->d_name, &st) == -1 || !S_ISDIR(st.st_mode))
+			continue;
+
+		memset(&item, 0, sizeof(item));
+		strlcpy(item.mailbox, de->d_name, sizeof(item.mailbox));
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_LIST_ITEM, 0, 0, -1,
+		    &item, sizeof(item)) == -1) {
+			log_warn("session %u: imsg_compose "
+			    "IMSG_MBOX_LIST_ITEM", session_id);
+			continue;
+		}
+		result.count++;
+	}
+	closedir(dp);
+	result.ok = 1;
+
+send:
+	if (switched && select_mailbox_dir(saved) == -1)
+		log_warnx("session %u: LIST: couldn't restore previously "
+		    "selected mailbox %s", session_id, saved);
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	    sizeof(result)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * IMSG_MBOX_APPEND handling -- see imapd.h's imsg_mbox_append comment
+ * for the wire shape (header struct plus variable-length trailing message
+ * bytes in the same imsg). v1 is INBOX-only, checked the same case-
+ * insensitive way handle_mbox_select() already does.
+ *
+ * Delivery follows maildir's own atomic contract (openimap-storage-
+ * backend.md: "write to tmp/, rename(2) into new/"): write the full
+ * message to a freshly-named tmp/ file, fsync, then rename directly into
+ * cur/ with whatever flags the client requested (or none) encoded in the
+ * maildir suffix immediately -- not into new/. Unlike externally
+ * (MTA-)delivered mail, which genuinely hasn't been seen by any client
+ * yet, an APPENDed message is by construction something the appending
+ * client already knows about (importing old mail, saving a draft or sent
+ * copy, etc.) -- there's no "unseen" period to represent, so there's no
+ * reason to place it in new/ the way SELECT's new/ scan handles
+ * externally-delivered mail. Not directly sourced -- openimap-storage-
+ * backend.md doesn't discuss APPEND at all -- this is this
+ * implementation's own inference from what new/ vs. cur/ actually mean,
+ * consistent with the "touched by any client action -> cur/, with an
+ * explicit (possibly empty) suffix" precedent handle_mbox_store() already
+ * established.
+ *
+ * The basename's uniquer (`<timestamp>.<pid>_<counter>.<hostname>`) is
+ * this implementation's own scheme, not a reproduction of Courier's exact
+ * algorithm (not directly sourced this session) -- it only has to satisfy
+ * maildir's actual requirement (a basename is never reused within this
+ * mailbox), which timestamp + pid + a per-process counter already does on
+ * its own; the trailing hostname field is included anyway to match
+ * Courier's own `<timestamp>.<uniquer>.<hostname>` shape (used there to
+ * disambiguate multiple physical delivery hosts sharing one NFS-mounted
+ * maildir -- a deployment v1 doesn't support, but there's no reason not
+ * to carry the real value once it's available for free). append_
+ * hostname() below calls the real gethostname(2) -- confirmed safe under
+ * pledge(2) this session by reading the real pledge_sysctl() in
+ * openbsd_source/sys/kern/kern_pledge.c directly: the KERN_HOSTNAME case
+ * (what gethostname(2) resolves to internally) returns success
+ * unconditionally, with no `pledge &` gate on any specific promise the
+ * way most of that function's other cases have -- so store's minimal
+ * "stdio" pledge already covers it. Not sanitized against unusual
+ * characters: ':' is the one character that would actually be dangerous
+ * here, since it also introduces the maildir flag-suffix in the final
+ * on-disk filename (locate_message_file()'s own basename-prefix-plus-
+ * ':'  matching). Standard DNS hostnames don't contain ':' -- this is
+ * general knowledge, not something re-verified against an RFC this
+ * session -- so this is treated as safe in practice rather than
+ * defended against explicitly; a hostname from a source that could
+ * return one (not gethostname(2) on a normally configured system) would
+ * need this revisited.
+ *
+ * Ordering matters for failure-mode safety: the index is updated (UID
+ * assigned, index_save()'d) *before* the tmp/ -> cur/ rename, not after.
+ * If the rename then fails, the result is an index entry pointing at a
+ * basename that was never actually created in cur/ -- exactly the
+ * "message indexed but missing on disk" case handle_mbox_fetch()/
+ * handle_mbox_store()/handle_mbox_expunge() already handle gracefully
+ * (log, skip). The other ordering (rename first, index second) would
+ * instead risk a file sitting in cur/ with no index entry at all if the
+ * index step failed afterward -- invisible forever, since (unlike new/)
+ * nothing ever scans cur/ for unindexed basenames. Per RFC 9051 SS6.3.12,
+ * "the mailbox MUST be restored to its state before the APPEND attempt
+ * (other than possibly keeping the changed mailbox's UIDNEXT value)" on
+ * failure -- UIDNEXT is explicitly allowed to stay bumped; this
+ * implementation additionally, in the specific rename-fails-after-index-
+ * save case, leaves behind the phantom index entry rather than reopening
+ * the index a second time to roll it back -- a small, deliberately
+ * accepted imperfection in an already-rare failure path, not a silent
+ * one.
+ */
+
+/*
+ * Returns this host's name for the maildir basename uniquer above,
+ * calling the real gethostname(2) once per store child and caching the
+ * result (a session's hostname can't change mid-process, so there's no
+ * reason to re-enter the kernel on every APPEND). Falls back to the
+ * literal string "imapd" -- the same placeholder this code used
+ * unconditionally before gethostname(2)'s pledge(2) coverage was
+ * confirmed -- if the call itself ever fails; genuinely unlikely on a
+ * normally configured system, but a failure here has never been fatal
+ * to the basename's own uniqueness guarantee (which only ever depended
+ * on timestamp+pid+counter, see this function's header comment), so
+ * there's no reason to treat it as fatal to the whole APPEND either.
+ */
+static const char *
+append_hostname(void)
+{
+	static char	hostbuf[256];
+	static int	resolved;
+
+	if (!resolved) {
+		if (gethostname(hostbuf, sizeof(hostbuf)) == -1) {
+			log_warn("session %u: gethostname", session_id);
+			strlcpy(hostbuf, "imapd", sizeof(hostbuf));
+		}
+		resolved = 1;
+	}
+	return (hostbuf);
+}
+
+static void
+handle_mbox_append(struct imsg_mbox_append *req, const char *msgbody,
+    size_t msglen, struct imsgev *iev)
+{
+	struct mbox_index		 idx;
+	struct imsg_mbox_appended	 reply;
+	int				 fd = -1, tmpfd = -1, locked = 0;
+	char				 basename[256];
+	char				 tmppath[300], curpath[320];
+	char				 target[MBOX_NAME_MAX];
+	char				 letters[8], line[STORE_INDEX_LINE_MAX];
+	int64_t				 delivery_ts;
+	char				 saved[MBOX_NAME_MAX];
+	int				 switched = 0;
+
+	memset(&idx, 0, sizeof(idx));
+	memset(&reply, 0, sizeof(reply));
+
+	/*
+	 * RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9 addition: APPEND's destination is
+	 * independent of whatever this session currently has SELECTed (RFC
+	 * 9051 SS6.3.12: valid in the authenticated state with nothing
+	 * selected at all), so -- unlike FETCH/STORE/EXPUNGE, which stay
+	 * cwd-relative and unchanged because they never need to go anywhere
+	 * else -- this resolves and *moves to* its own target directory via
+	 * select_mailbox_dir(), then uses bare, cwd-relative paths for
+	 * everything from here down, exactly the way handle_mbox_status()
+	 * and (for its destination side) handle_mbox_copy()/handle_mbox_
+	 * move()'s commit_copy_messages() already do.
+	 *
+	 * This function used to do it differently -- stay at the maildir
+	 * root and build every path with an explicit "name/" prefix string,
+	 * the same shape CREATE/DELETE/RENAME/LIST use (see handle_mbox_
+	 * create()'s comment for that pattern's own real-hardware bug and
+	 * fix). That approach worked for every path this function *directly*
+	 * builds (the tmp/ write, the cur/ rename, and the initial indexed
+	 * open via a prefixed idxpath) -- but it silently broke on the final
+	 * index_save() call, which is a shared helper that has no prefix
+	 * parameter at all and unconditionally operates on bare "imapd.
+	 * index"/"imapd.index.tmp" relative to cwd. Since this function
+	 * never actually left the maildir root, every APPEND to a *named*
+	 * mailbox correctly read that mailbox's own real index (via the
+	 * prefixed open+fd) but then wrote the updated result to the
+	 * session's INBOX index instead -- overwriting whatever was really
+	 * there, once per APPEND, with a fresh one-line index for whichever
+	 * message happened to be appended last. The actual message file
+	 * still landed in the right place (cur/ under the correct mailbox,
+	 * via the prefixed tmp/cur paths, which don't go through index_
+	 * save()) -- only the index bookkeeping went to the wrong file, so
+	 * this was a real hazard to a mailbox's *index* integrity, not to
+	 * message data itself.
+	 *
+	 * Found on premio testing real Apple Mail draft autosaves against a
+	 * named Drafts mailbox: four rapid APPENDs each correctly wrote
+	 * their message into Drafts/cur/, but Drafts/openimap.index stayed
+	 * at UIDNEXT 1 (never advanced), while the session's real INBOX
+	 * index -- untouched by anything else in the same window -- ended
+	 * up replaced by a single line pointing at the fourth APPEND's own
+	 * basename. INBOX's original message files were never touched
+	 * (still present in its own cur/), so nothing was lost, but INBOX
+	 * became briefly unreadable through IMAP until the index was
+	 * repaired. Switching this function to actually chdir into the
+	 * target mailbox before doing any of this work -- rather than
+	 * merely building path strings that describe where it is -- closes
+	 * the whole class: index_save() (and everything else below) is now
+	 * cwd-relative and correct by the same construction FETCH/STORE/
+	 * EXPUNGE/STATUS already rely on, with nothing left that needs its
+	 * own prefix parameter to get right.
+	 */
+	strlcpy(saved, current_mailbox_dir, sizeof(saved));
+
+	if (mailbox_name_is_inbox(req->mailbox)) {
+		target[0] = '\0';
+	} else if (mailbox_name_valid(req->mailbox)) {
+		strlcpy(target, req->mailbox, sizeof(target));
+	} else {
+		log_debug("session %u: APPEND %s: invalid mailbox name",
+		    session_id, req->mailbox);
+		reply.no_such_mailbox = 1;
+		goto done_reply;
+	}
+
+	if (select_mailbox_dir(target) == -1) {
+		log_debug("session %u: APPEND %s: no such mailbox",
+		    session_id, req->mailbox);
+		reply.no_such_mailbox = 1;
+		goto done_reply;
+	}
+	switched = 1;
+
+	if (ensure_maildir_dirs("") == -1)
+		goto done_reply;
+
+	delivery_ts = req->has_date ? req->date : (int64_t)time(NULL);
+
+	if (snprintf(basename, sizeof(basename), "%lld.%d_%u.%s",
+	    (long long)delivery_ts, (int)getpid(), append_counter++,
+	    append_hostname()) >= (int)sizeof(basename)) {
+		log_warnx("session %u: generated basename too long",
+		    session_id);
+		goto done_reply;
+	}
+	if (snprintf(tmppath, sizeof(tmppath), "tmp/%s", basename) >=
+	    (int)sizeof(tmppath)) {
+		log_warnx("session %u: tmp path too long", session_id);
+		goto done_reply;
+	}
+
+	if ((tmpfd = open(tmppath, O_WRONLY | O_CREAT | O_EXCL, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, tmppath);
+		goto done_reply;
+	}
+	{
+		size_t	 written = 0;
+
+		while (written < msglen) {
+			ssize_t	 n;
+
+			n = write(tmpfd, msgbody + written, msglen - written);
+			if (n == -1) {
+				if (errno == EINTR)
+					continue;
+				log_warn("session %u: write %s", session_id,
+				    tmppath);
+				close(tmpfd);
+				tmpfd = -1;
+				unlink(tmppath);
+				goto done_reply;
+			}
+			written += (size_t)n;
+		}
+	}
+	if (fsync(tmpfd) == -1)
+		log_warn("session %u: fsync %s (continuing)", session_id,
+		    tmppath);
+	close(tmpfd);
+	tmpfd = -1;
+
+	if ((fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+		goto done_unlink;
+	}
+	if (flock(fd, LOCK_EX) == -1) {
+		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+		goto done_unlink;
+	}
+	locked = 1;
+	if (index_load(fd, &idx) == -1)
+		goto done_unlink;
+
+	reply.uid = idx.uidnext;
+	if (index_append(&idx, reply.uid, basename) == -1)
+		goto done_unlink;
+	idx.uidnext++;
+
+	if (req->keywords[0] != '\0') {
+		int	 n;
+
+		/*
+		 * index_append() just assigned this message idx.highestmodseq
+		 * (its own bump, above) and wrote an empty-keywords line --
+		 * this rewrite has to carry that same value forward, not drop
+		 * it, or an APPEND with an initial flag-list would silently
+		 * lose its per-message mod-sequence (RFC 7162 field added this
+		 * pass; see index_append()'s comment).
+		 */
+		n = snprintf(line, sizeof(line), "%u:%s:%s:%llu", reply.uid,
+		    basename, req->keywords,
+		    (unsigned long long)idx.highestmodseq);
+		if (n < 0 || (size_t)n >= sizeof(line)) {
+			log_warnx("session %u: index line too long for %s",
+			    session_id, basename);
+			goto done_unlink;
+		}
+		free(idx.lines[idx.nlines - 1]);
+		if ((idx.lines[idx.nlines - 1] = strdup(line)) == NULL) {
+			log_warn("session %u: strdup index line", session_id);
+			goto done_unlink;
+		}
+	}
+
+	if (index_save(&idx) == -1)
+		goto done_unlink;
+
+	reply.uidvalidity = idx.uidvalidity;
+	reply.exists = (uint32_t)idx.nlines;
+
+	flock(fd, LOCK_UN);
+	close(fd);
+	fd = -1;
+	locked = 0;
+	index_free(&idx);
+
+	/* Index committed -- now the rename; see this function's header
+	 * comment for why this order (index-then-rename, not rename-then-
+	 * index) is the safer one to fail partway through. */
+	sysflags_to_letters(req->sysflags, letters, sizeof(letters));
+	if (snprintf(curpath, sizeof(curpath), "cur/%s:2,%s", basename,
+	    letters) >= (int)sizeof(curpath)) {
+		log_warnx("session %u: cur path too long for %s", session_id,
+		    basename);
+		unlink(tmppath);
+		goto done_reply;
+	}
+	if (rename(tmppath, curpath) == -1) {
+		log_warn("session %u: rename %s -> %s", session_id, tmppath,
+		    curpath);
+		unlink(tmppath);
+		goto done_reply;
+	}
+
+	reply.ok = 1;
+	goto done_reply;
+
+done_unlink:
+	unlink(tmppath);
+	if (locked)
+		flock(fd, LOCK_UN);
+	if (fd != -1)
+		close(fd);
+	index_free(&idx);
+
+done_reply:
+	if (switched && select_mailbox_dir(saved) == -1)
+		log_warnx("session %u: APPEND %s: couldn't restore "
+		    "previously selected mailbox %s", session_id,
+		    req->mailbox, saved);
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_APPENDED, 0, 0, -1, &reply,
+	    sizeof(reply)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_APPENDED",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * One message staged by stage_copy_messages() for handle_mbox_copy()/
+ * handle_mbox_move() -- read fully into memory from the source mailbox,
+ * not yet written anywhere. File-scope (not local to either function)
+ * since move_cross_mailbox() below needs the same shape too.
+ */
+
+/*
+ * F6 fix: bound how much message data COPY/MOVE holds in memory at once.
+ * stage_copy_messages() reads every message in the range fully into RAM
+ * before commit (required for RFC 9051 6.4.7 all-or-nothing COPY), so an
+ * unbounded range of large, externally-delivered messages could exhaust
+ * the store child's address space. Exceeding either limit fails the whole
+ * COPY/MOVE cleanly (no partial copy). Tunable for larger deployments.
+ */
+#define COPY_STAGE_MSG_MAX	((uint64_t)64 * 1024 * 1024)	/* per message */
+#define COPY_STAGE_TOTAL_MAX	((uint64_t)512 * 1024 * 1024)	/* per operation */
+
+struct copy_staged {
+	uint32_t	src_uid;
+	uint32_t	dest_uid;
+	char		basename[256];
+	char		keywords[512];
+	uint32_t	sysflags;
+	void		*body;
+	size_t		bodylen;
+};
+
+/*
+ * Resolves a client-supplied mailbox name to the bare "target" string
+ * select_mailbox_dir() expects: "" for INBOX, or the name itself once
+ * mailbox_name_valid() has passed syntax. Returns 0 and fills *target on
+ * success, -1 (target left untouched) if the name is syntactically
+ * invalid -- this only checks syntax, not existence; select_mailbox_dir()'s
+ * own stat(2) call is still what determines whether the mailbox genuinely
+ * exists on disk. Added this pass (RFC 9051 SS6.4.7/SS6.4.8 cross-mailbox
+ * COPY/MOVE, docs/openimap-storage-backend.md item 10's own follow-up) so
+ * handle_mbox_copy()/handle_mbox_move() can resolve a destination name the
+ * identical way handle_mbox_select()/handle_mbox_create() already resolve
+ * theirs, rather than re-deriving the same mailbox_name_is_inbox()/
+ * mailbox_name_valid()/else three-way split a third and fourth time.
+ */
+static int
+resolve_mailbox_target(const char *name, char *target, size_t targetlen)
+{
+	if (mailbox_name_is_inbox(name)) {
+		target[0] = '\0';
+		return (0);
+	}
+	if (mailbox_name_valid(name)) {
+		if (strlcpy(target, name, targetlen) >= targetlen)
+			return (-1);
+		return (0);
+	}
+	return (-1);
+}
+
+/*
+ * Pass 1 of COPY/MOVE: reads every message in the requested sequence/UID
+ * range (by_uid-or-sequence interpreted the same way handle_mbox_search()/
+ * handle_mbox_fetch() already do) from whichever mailbox is currently
+ * cwd-resident into memory, read-only -- no disk writes, no index
+ * mutation. Shared by handle_mbox_copy() and move_cross_mailbox() (a
+ * cross-mailbox MOVE is, per RFC 9051 SS6.4.8's own "COPY... followed by
+ * removal" equivalence, structurally a COPY that additionally removes the
+ * originals afterward -- see move_cross_mailbox()'s own header comment),
+ * and by both of COPY's same-mailbox/cross-mailbox code paths, since
+ * staging never depends on where the *destination* is, only on where the
+ * source already is (cwd, by the time this is called).
+ *
+ * Returns 1 and fills *staged_out and *nstaged_out on success (caller frees
+ * each entry's .body plus the array itself), 0 if any message failed to
+ * stage -- per COPY's own all-or-nothing requirement (SS6.4.7 -- "server
+ * implementations MUST restore the destination mailbox to its state
+ * before the COPY attempt... i.e., partial copy MUST NOT be done"),
+ * nothing has been written anywhere yet either way.
+ *
+ * New basenames reuse parse_maildir_timestamp() against the *source*
+ * basename (not the current time) so the copy's own INTERNALDATE --
+ * derived from that same leading field, see that function's comment --
+ * matches the original's, per SS6.4.7: "The flags and internal date of
+ * the message(s) SHOULD be preserved in the copy." append_counter/
+ * append_hostname() (file-scope, see append_counter's own comment) mint
+ * the rest of the basename exactly like APPEND's own basenames.
+ */
+static int
+stage_copy_messages(struct mbox_index *idx, struct imsg_mbox_copy *req,
+    struct copy_staged **staged_out, size_t *nstaged_out)
+{
+	struct copy_staged	*staged = NULL;
+	size_t			 nstaged = 0, stagedcap = 0, i;
+	uint64_t		 staged_total = 0;	/* F6: bytes staged so far */
+	uint32_t		 lo, hi;
+
+	lo = hi = 0;
+	if (req->by_uid) {
+		uint32_t	max_uid = index_max_uid(idx);
+
+		lo = req->lo_is_star ? max_uid : req->seq_lo;
+		hi = req->hi_is_star ? max_uid : req->seq_hi;
+		if (lo < 1)
+			lo = 1;
+	} else {
+		lo = req->seq_lo;
+		hi = req->hi_is_star ? (uint32_t)idx->nlines : req->seq_hi;
+	}
+
+	for (i = 0; i < idx->nlines; i++) {
+		struct index_rec	 rec;
+		const char		*lp;
+		char			 suffix[64];
+		off_t			 size;
+		char			 path[600];
+		int			 srcfd;
+		struct copy_staged	 cs;
+
+		if (index_parse_line(idx->lines[i], &rec) == -1)
+			continue;
+
+		if (req->by_uid) {
+			if (rec.uid < lo)
+				continue;
+			if (rec.uid > hi)
+				break;
+		} else {
+			if (i + 1 < lo)
+				continue;
+			if (i + 1 > hi)
+				break;
+		}
+
+		if (locate_message_file(rec.basename, &size, suffix,
+		    sizeof(suffix)) == -1) {
+			log_warnx("session %u: COPY: message %s indexed but "
+			    "missing on disk -- failing whole COPY (partial "
+			    "copy not permitted, RFC 9051 SS6.4.7)",
+			    session_id, rec.basename);
+			goto fail;
+		}
+
+		/* F6 fix: refuse before allocating if this message, or the
+		 * running total, would exceed the staging limits. */
+		if ((uint64_t)size > COPY_STAGE_MSG_MAX) {
+			log_warnx("session %u: COPY: message %s is %lld bytes, "
+			    "over the per-message staging limit -- failing COPY",
+			    session_id, rec.basename, (long long)size);
+			goto fail;
+		}
+		if ((uint64_t)size > COPY_STAGE_TOTAL_MAX - staged_total) {
+			log_warnx("session %u: COPY: staged data would exceed the "
+			    "total staging limit -- failing COPY", session_id);
+			goto fail;
+		}
+		staged_total += (uint64_t)size;
+
+		memset(&cs, 0, sizeof(cs));
+		cs.src_uid = rec.uid;
+		strlcpy(cs.keywords, rec.keywords, sizeof(cs.keywords));
+		lp = strstr(suffix, "2,");
+		cs.sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
+
+		if (snprintf(cs.basename, sizeof(cs.basename),
+		    "%lld.%d_%u.%s",
+		    (long long)parse_maildir_timestamp(rec.basename),
+		    (int)getpid(), append_counter++, append_hostname()) >=
+		    (int)sizeof(cs.basename)) {
+			log_warnx("session %u: COPY: generated basename too "
+			    "long", session_id);
+			goto fail;
+		}
+
+		if (snprintf(path, sizeof(path), "%s/%s%s",
+		    suffix[0] == '\0' ? "new" : "cur", rec.basename, suffix)
+		    >= (int)sizeof(path)) {
+			log_warnx("session %u: COPY: source path too long "
+			    "for %s", session_id, rec.basename);
+			goto fail;
+		}
+		if ((srcfd = open(path, O_RDONLY)) == -1) {
+			log_warn("session %u: COPY: open %s", session_id,
+			    path);
+			goto fail;
+		}
+		cs.bodylen = (size_t)size;
+		if (cs.bodylen > 0 && (cs.body = malloc(cs.bodylen)) == NULL) {
+			log_warn("session %u: COPY: malloc %zu bytes",
+			    session_id, cs.bodylen);
+			close(srcfd);
+			goto fail;
+		}
+		{
+			size_t	 rd = 0;
+
+			while (rd < cs.bodylen) {
+				ssize_t	n = read(srcfd,
+				    (char *)cs.body + rd, cs.bodylen - rd);
+				if (n == -1) {
+					if (errno == EINTR)
+						continue;
+					log_warn("session %u: COPY: read %s",
+					    session_id, path);
+					close(srcfd);
+					free(cs.body);
+					goto fail;
+				}
+				if (n == 0)
+					break;	/* short file -- copy what's
+						 * actually there rather than
+						 * fail */
+				rd += (size_t)n;
+			}
+			cs.bodylen = rd;
+		}
+		close(srcfd);
+
+		if (nstaged == stagedcap) {
+			size_t		     newcap = (stagedcap == 0) ? 8 :
+			    stagedcap * 2;
+			struct copy_staged  *newstaged = reallocarray(staged,
+			    newcap, sizeof(*staged));
+
+			if (newstaged == NULL) {
+				log_warn("session %u: COPY: reallocarray "
+				    "staged", session_id);
+				free(cs.body);
+				goto fail;
+			}
+			staged = newstaged;
+			stagedcap = newcap;
+		}
+		staged[nstaged++] = cs;
+	}
+
+	*staged_out = staged;
+	*nstaged_out = nstaged;
+	return (1);
+
+fail:
+	for (i = 0; i < nstaged; i++)
+		free(staged[i].body);
+	free(staged);
+	*staged_out = NULL;
+	*nstaged_out = 0;
+	return (0);
+}
+
+/*
+ * Pass 2+3 of COPY/MOVE: commits every staged message (see stage_copy_
+ * messages()) to whichever mailbox is currently cwd-resident -- tmp/
+ * write + rename into cur/ per message, then one index_append() per
+ * message into destidx and a single index_save() once every corresponding
+ * cur/ file is already durably in place, mirroring APPEND's own rename-
+ * then-index ordering. Streams one IMSG_MBOX_COPY_MAPPING per committed
+ * message as it's indexed, ready for session_finish_copy_or_move() in
+ * listener.c to compact into COPYUID's two UID sets.
+ *
+ * Caller must already have select_mailbox_dir()'d to the destination and
+ * ensure_maildir_dirs("")'d it. Returns 1 if every message committed, 0 on
+ * the first failure -- a mid-pass-2 failure (much rarer than a pass-1 read
+ * failure, since the source files are already known to exist) leaves
+ * destidx's UIDNEXT completely untouched, better than RFC 9051 SS6.4.7's
+ * own minimum bar, which explicitly allows UIDNEXT to have moved -- at the
+ * cost of a small, deliberately accepted imperfection: any cur/ files
+ * already renamed earlier in pass 2 before the failure are left behind
+ * rather than transactionally rolled back, the same category of rare-
+ * failure-path tradeoff APPEND's own header comment already flags for its
+ * own rename-ordering choice.
+ */
+static int
+commit_copy_messages(struct mbox_index *destidx, struct copy_staged *staged,
+    size_t nstaged, struct imsgev *iev)
+{
+	size_t	i;
+
+	for (i = 0; i < nstaged; i++) {
+		char	tmppath[300], curpath[320];
+		char	letters[8];
+		int	tmpfd;
+
+		if (snprintf(tmppath, sizeof(tmppath), "tmp/%s",
+		    staged[i].basename) >= (int)sizeof(tmppath)) {
+			log_warnx("session %u: COPY: tmp path too long",
+			    session_id);
+			return (0);
+		}
+		if ((tmpfd = open(tmppath, O_WRONLY | O_CREAT | O_EXCL,
+		    0600)) == -1) {
+			log_warn("session %u: COPY: open %s", session_id,
+			    tmppath);
+			return (0);
+		}
+		{
+			size_t	 written = 0;
+
+			while (written < staged[i].bodylen) {
+				ssize_t	n = write(tmpfd,
+				    (char *)staged[i].body + written,
+				    staged[i].bodylen - written);
+				if (n == -1) {
+					if (errno == EINTR)
+						continue;
+					log_warn("session %u: COPY: write %s",
+					    session_id, tmppath);
+					close(tmpfd);
+					unlink(tmppath);
+					return (0);
+				}
+				written += (size_t)n;
+			}
+		}
+		if (fsync(tmpfd) == -1)
+			log_warn("session %u: COPY: fsync %s (continuing)",
+			    session_id, tmppath);
+		close(tmpfd);
+
+		sysflags_to_letters(staged[i].sysflags, letters,
+		    sizeof(letters));
+		if (snprintf(curpath, sizeof(curpath), "cur/%s:2,%s",
+		    staged[i].basename, letters) >= (int)sizeof(curpath)) {
+			log_warnx("session %u: COPY: cur path too long",
+			    session_id);
+			unlink(tmppath);
+			return (0);
+		}
+		if (rename(tmppath, curpath) == -1) {
+			log_warn("session %u: COPY: rename %s -> %s",
+			    session_id, tmppath, curpath);
+			unlink(tmppath);
+			return (0);
+		}
+	}
+
+	for (i = 0; i < nstaged; i++) {
+		uint32_t	 dest_uid = destidx->uidnext;
+
+		if (index_append(destidx, dest_uid, staged[i].basename) ==
+		    -1)
+			return (0);
+		destidx->uidnext++;
+		staged[i].dest_uid = dest_uid;
+
+		if (staged[i].keywords[0] != '\0') {
+			char	line[STORE_INDEX_LINE_MAX];
+			int	n;
+
+			n = snprintf(line, sizeof(line), "%u:%s:%s:%llu",
+			    dest_uid, staged[i].basename, staged[i].keywords,
+			    (unsigned long long)destidx->highestmodseq);
+			if (n < 0 || (size_t)n >= sizeof(line)) {
+				log_warnx("session %u: COPY: index line too "
+				    "long", session_id);
+				return (0);
+			}
+			free(destidx->lines[destidx->nlines - 1]);
+			if ((destidx->lines[destidx->nlines - 1] =
+			    strdup(line)) == NULL) {
+				log_warn("session %u: COPY: strdup index "
+				    "line", session_id);
+				return (0);
+			}
+		}
+	}
+
+	if (index_save(destidx) == -1)
+		return (0);
+
+	for (i = 0; i < nstaged; i++) {
+		struct imsg_mbox_copy_mapping	 mapping;
+
+		memset(&mapping, 0, sizeof(mapping));
+		mapping.src_uid = staged[i].src_uid;
+		mapping.dest_uid = staged[i].dest_uid;
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_COPY_MAPPING, 0, 0, -1,
+		    &mapping, sizeof(mapping)) == -1)
+			log_warn("session %u: imsg_compose "
+			    "IMSG_MBOX_COPY_MAPPING", session_id);
+	}
+
+	return (1);
+}
+
+/*
+ * RFC 9051 SS6.4.7 COPY (and, via handle_mbox_move() just below, half of
+ * SS6.4.8 MOVE too). Destination resolution (RFC 9051 SS6.3.4-SS6.3.6 flat
+ * multi-mailbox support, docs/openimap-storage-backend.md item 10's own
+ * follow-up): req->destname names any real, existing mailbox, not just
+ * the one already SELECTed the way the original v1 code required. Two
+ * structurally different paths follow, chosen once near the top:
+ *
+ *   - destname resolves to the mailbox already SELECTed: a single index,
+ *     opened and flock(2)'d exactly once, cwd never moves -- the original
+ *     v1 code, unchanged in spirit. This isn't just simpler than the
+ *     cross-mailbox path below -- opening and flock(2)ing the *same* path
+ *     a second time from this same process would self-deadlock (BSD
+ *     flock(2) locks belong to the open file description, not the
+ *     process, so a second independent open()+flock(LOCK_EX) here would
+ *     block forever on the lock this same process already holds on the
+ *     first).
+ *
+ *   - destname resolves to a genuinely different mailbox: both mailboxes'
+ *     index files are opened and flock(2)'d for the duration. Two
+ *     sessions issuing opposite-direction copies between the same two
+ *     mailboxes (session A: source X, dest Y; session B: source Y, dest
+ *     X) must not each acquire one mailbox's lock and then block waiting
+ *     for the other -- classic AB-BA cross-session deadlock. Locking in a
+ *     fixed order derived only from the two mailbox names themselves,
+ *     identical for both sessions regardless of which one is "source" and
+ *     which is "destination" for *that* session, closes it: strcmp()
+ *     between the two target strings (INBOX's "" sorts first against any
+ *     named mailbox for free, no special-casing needed) picks the same
+ *     first-to-lock mailbox both sessions above contend for, so they
+ *     queue on it in the same order every time rather than in mirror-
+ *     image order.
+ *
+ * See stage_copy_messages()/commit_copy_messages() above for the actual
+ * staging/commit work and their own all-or-nothing guarantees, shared
+ * verbatim between both paths.
+ */
+static void
+handle_mbox_copy(struct imsg_mbox_copy *req, struct imsgev *iev)
+{
+	struct mbox_index		 idx_a, idx_b;
+	struct mbox_index		*srcidx = NULL, *destidx = NULL;
+	struct imsg_mbox_result	 result;
+	struct copy_staged		*staged = NULL;
+	size_t				 nstaged = 0, i;
+	int				 fd_a = -1, fd_a_locked = 0;
+	int				 fd_b = -1, fd_b_locked = 0;
+	int				 ok = 1;
+	char				 saved[MBOX_NAME_MAX];
+	char				 desttarget[MBOX_NAME_MAX];
+	int				 cross_mailbox;
+
+	memset(&idx_a, 0, sizeof(idx_a));
+	memset(&idx_b, 0, sizeof(idx_b));
+	memset(&result, 0, sizeof(result));
+
+	strlcpy(saved, current_mailbox_dir, sizeof(saved));
+
+	if (resolve_mailbox_target(req->destname, desttarget,
+	    sizeof(desttarget)) == -1) {
+		log_debug("session %u: COPY %s: invalid destination mailbox "
+		    "name", session_id, req->destname);
+		result.no_such_mailbox = 1;
+		ok = 0;
+		goto done;
+	}
+	cross_mailbox = (strcmp(saved, desttarget) != 0);
+
+	if (!cross_mailbox) {
+		if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+		    -1) {
+			log_warn("session %u: open %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto done;
+		}
+		if (flock(fd_a, LOCK_EX) == -1) {
+			log_warn("session %u: flock %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto done;
+		}
+		fd_a_locked = 1;
+		if (index_load(fd_a, &idx_a) == -1) {
+			ok = 0;
+			goto done;
+		}
+		srcidx = &idx_a;
+		destidx = &idx_a;
+	} else {
+		char	first[MBOX_NAME_MAX], second[MBOX_NAME_MAX];
+		int	first_is_dest;
+
+		if (strcmp(saved, desttarget) <= 0) {
+			strlcpy(first, saved, sizeof(first));
+			strlcpy(second, desttarget, sizeof(second));
+			first_is_dest = 0;
+		} else {
+			strlcpy(first, desttarget, sizeof(first));
+			strlcpy(second, saved, sizeof(second));
+			first_is_dest = 1;
+		}
+
+		if (select_mailbox_dir(first) == -1) {
+			if (first_is_dest)
+				result.no_such_mailbox = 1;
+			else
+				log_warnx("session %u: COPY: couldn't reach "
+				    "%s", session_id, first);
+			ok = 0;
+			goto done;
+		}
+		if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+		    -1) {
+			log_warn("session %u: open %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto restore_saved;
+		}
+		if (flock(fd_a, LOCK_EX) == -1) {
+			log_warn("session %u: flock %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto restore_saved;
+		}
+		fd_a_locked = 1;
+		if (index_load(fd_a, &idx_a) == -1) {
+			ok = 0;
+			goto restore_saved;
+		}
+
+		if (select_mailbox_dir(second) == -1) {
+			if (!first_is_dest)
+				result.no_such_mailbox = 1;
+			else
+				log_warnx("session %u: COPY: couldn't reach "
+				    "%s", session_id, second);
+			ok = 0;
+			goto restore_saved;
+		}
+		if ((fd_b = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+		    -1) {
+			log_warn("session %u: open %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto restore_saved;
+		}
+		if (flock(fd_b, LOCK_EX) == -1) {
+			log_warn("session %u: flock %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto restore_saved;
+		}
+		fd_b_locked = 1;
+		if (index_load(fd_b, &idx_b) == -1) {
+			ok = 0;
+			goto restore_saved;
+		}
+
+		srcidx = first_is_dest ? &idx_b : &idx_a;
+		destidx = first_is_dest ? &idx_a : &idx_b;
+
+		/* Back to source -- staging below reads relative to cwd. */
+		if (select_mailbox_dir(saved) == -1) {
+			log_warnx("session %u: COPY: couldn't return to %s "
+			    "to stage messages", session_id, saved);
+			ok = 0;
+			goto restore_saved;
+		}
+	}
+
+	if (!stage_copy_messages(srcidx, req, &staged, &nstaged)) {
+		ok = 0;
+		goto restore_saved;
+	}
+
+	if (nstaged > 0) {
+		if (cross_mailbox && select_mailbox_dir(desttarget) == -1) {
+			log_warnx("session %u: COPY: couldn't reach "
+			    "destination %s", session_id, desttarget);
+			ok = 0;
+			goto restore_saved;
+		}
+		if (ensure_maildir_dirs("") == -1) {
+			ok = 0;
+			goto restore_saved;
+		}
+		if (!commit_copy_messages(destidx, staged, nstaged, iev))
+			ok = 0;
+	}
+
+	if (ok) {
+		result.count = (uint32_t)nstaged;
+		result.uidvalidity = destidx->uidvalidity;
+		result.highestmodseq = destidx->highestmodseq;
+	}
+
+restore_saved:
+	if (cross_mailbox && select_mailbox_dir(saved) == -1)
+		log_warnx("session %u: COPY: couldn't restore previously "
+		    "selected mailbox %s", session_id, saved);
+
+done:
+	for (i = 0; i < nstaged; i++)
+		free(staged[i].body);
+	free(staged);
+
+	if (fd_a_locked)
+		flock(fd_a, LOCK_UN);
+	if (fd_a != -1)
+		close(fd_a);
+	if (fd_b_locked)
+		flock(fd_b, LOCK_UN);
+	if (fd_b != -1)
+		close(fd_b);
+
+	result.ok = ok;
+	index_free(&idx_a);
+	index_free(&idx_b);
+
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	    sizeof(result)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+/*
+ * MOVE when the destination is the mailbox already SELECTed -- the
+ * original v1 algorithm, unchanged: moving a message to the mailbox it's
+ * already in needs no file I/O at all, the same physical cur/ file just
+ * gets reindexed under a fresh UID. This is "MOVE = COPY + STORE
+ * +FLAGS.SILENT \Deleted + UID EXPUNGE" (SS6.4.8's own equivalence)
+ * collapsed to its net effect once source and destination coincide: the
+ * old index line disappears, a new one (same basename, new UID) appears
+ * at the tail, and nothing on disk changes at all. See handle_mbox_
+ * move()'s own header comment for why this stays a distinct, cheaper path
+ * from move_cross_mailbox() below rather than being folded into the
+ * general "copy then remove" shape a genuinely different destination
+ * requires.
+ *
+ * Unlike COPY, MOVE is explicitly allowed to fail partway through its set
+ * ("Regardless of whether the command is successful in moving the entire
+ * set, each individual message MUST be either moved or unaffected" --
+ * SS6.4.8), so this doesn't need COPY's stage-then-commit split -- given
+ * the zero-copy design, the only failure modes left are index_append()'s
+ * own malloc/line-length checks and the final index_save(), both of which
+ * fail before any disk state changes, so each message really is
+ * atomically either moved or unaffected in this implementation.
+ *
+ * Two passes over idx, the first shaped exactly like handle_mbox_
+ * expunge()'s own compaction algorithm: pass 1 walks and compacts,
+ * removing each matched message's old line and recording (old UID, old
+ * seqno, basename, keywords) for pass 2, which re-appends each recorded
+ * message at the tail with a fresh UID via index_append() (same
+ * per-message HIGHESTMODSEQ bump as an APPEND or COPY arrival -- see that
+ * function's own RFC 7162 SS3.1 citation: "the server generates a new
+ * modification sequence... when a message is appended... via APPEND,
+ * COPY to the mailbox, or using an external mechanism"; a MOVE's
+ * destination-side effect is exactly this same kind of arrival).
+ * old_seqno is computed the identical way handle_mbox_expunge() already
+ * does ("immediately decremented" as earlier matches are removed), not
+ * recomputed afterward.
+ *
+ * Per SS6.4.8, "servers are also REQUIRED to send the COPYUID response
+ * code in an untagged OK before sending EXPUNGE" -- this function sends
+ * every IMSG_MBOX_COPY_MAPPING first (naturally, since pass 2 runs before
+ * the final notification loop below), then every IMSG_MBOX_EXPUNGED,
+ * matching send order to the required response order; listener.c
+ * additionally buffers and re-flushes in this same fixed order regardless
+ * (see imapd.h's imsg_mbox_copy_mapping comment), so this function's
+ * own ordering isn't the only thing enforcing it, just a natural match.
+ *
+ * Real bug caught testing MOVE live on premio, before flat multi-mailbox
+ * support existed: "MOVE 2 INBOX" against a 3-message mailbox moved
+ * messages 2 *and* 3, not just 2. Root cause was using "out" (this loop's
+ * compaction *write* index, which stalls -- doesn't increment -- every
+ * time a message matches and gets removed) instead of "in" (the original
+ * read position) to test sequence-range membership. "in" is the correct
+ * original 1-based sequence number for the line currently being examined;
+ * old_seqno just below is a *different*, intentionally correct use of
+ * "out + 1" -- it reports each EXPUNGE's sequence number relative to the
+ * mailbox state after earlier removals in the same command, which is
+ * exactly what the "immediately decremented" value is for.
+ */
+static int
+move_same_mailbox(struct imsg_mbox_copy *req, struct mbox_index *idx,
+    uint32_t *nmoved_out, struct imsgev *iev)
+{
+	struct moved {
+		uint32_t	old_uid;
+		uint32_t	old_seqno;
+		uint32_t	dest_uid;
+		char		basename[512];
+		char		keywords[512];
+	};
+
+	struct moved	*moved = NULL;
+	size_t		 nmoved = 0, movedcap = 0, in, out, i;
+	uint32_t	 lo, hi;
+	int		 ok = 1;
+
+	*nmoved_out = 0;
+
+	lo = hi = 0;
+	if (req->by_uid) {
+		uint32_t	max_uid = index_max_uid(idx);
+
+		lo = req->lo_is_star ? max_uid : req->seq_lo;
+		hi = req->hi_is_star ? max_uid : req->seq_hi;
+		if (lo < 1)
+			lo = 1;
+	} else {
+		lo = req->seq_lo;
+		hi = req->hi_is_star ? (uint32_t)idx->nlines : req->seq_hi;
+	}
+
+	out = 0;
+	for (in = 0; in < idx->nlines; in++) {
+		struct index_rec	 rec;
+		int			 matched;
+
+		if (index_parse_line(idx->lines[in], &rec) == -1) {
+			idx->lines[out++] = idx->lines[in];
+			continue;
+		}
+
+		if (req->by_uid)
+			matched = (rec.uid >= lo && rec.uid <= hi);
+		else
+			matched = ((uint32_t)(in + 1) >= lo &&
+			    (uint32_t)(in + 1) <= hi);
+
+		if (!matched) {
+			idx->lines[out++] = idx->lines[in];
+			continue;
+		}
+
+		if (nmoved == movedcap) {
+			size_t		newcap = (movedcap == 0) ? 8 :
+			    movedcap * 2;
+			struct moved   *newmoved = reallocarray(moved, newcap,
+			    sizeof(*moved));
+
+			if (newmoved == NULL) {
+				log_warn("session %u: MOVE: reallocarray "
+				    "moved", session_id);
+				free(idx->lines[in]);
+				ok = 0;
+				goto done;
+			}
+			moved = newmoved;
+			movedcap = newcap;
+		}
+		moved[nmoved].old_uid = rec.uid;
+		moved[nmoved].old_seqno = (uint32_t)(out + 1);
+		strlcpy(moved[nmoved].basename, rec.basename,
+		    sizeof(moved[nmoved].basename));
+		strlcpy(moved[nmoved].keywords, rec.keywords,
+		    sizeof(moved[nmoved].keywords));
+		nmoved++;
+
+		free(idx->lines[in]);
+	}
+	idx->nlines = out;
+
+	for (i = 0; i < nmoved; i++) {
+		uint32_t	 dest_uid = idx->uidnext;
+
+		if (index_append(idx, dest_uid, moved[i].basename) == -1) {
+			ok = 0;
+			goto done;
+		}
+		idx->uidnext++;
+		moved[i].dest_uid = dest_uid;
+
+		if (moved[i].keywords[0] != '\0') {
+			char	line[STORE_INDEX_LINE_MAX];
+			int	n;
+
+			n = snprintf(line, sizeof(line), "%u:%s:%s:%llu",
+			    dest_uid, moved[i].basename, moved[i].keywords,
+			    (unsigned long long)idx->highestmodseq);
+			if (n < 0 || (size_t)n >= sizeof(line)) {
+				log_warnx("session %u: MOVE: index line too "
+				    "long", session_id);
+				ok = 0;
+				goto done;
+			}
+			free(idx->lines[idx->nlines - 1]);
+			if ((idx->lines[idx->nlines - 1] = strdup(line)) ==
+			    NULL) {
+				log_warn("session %u: MOVE: strdup index "
+				    "line", session_id);
+				ok = 0;
+				goto done;
+			}
+		}
+	}
+
+	if (index_save(idx) == -1) {
+		ok = 0;
+		goto done;
+	}
+
+	/* COPYUID mapping data first... */
+	for (i = 0; i < nmoved; i++) {
+		struct imsg_mbox_copy_mapping	 mapping;
+
+		memset(&mapping, 0, sizeof(mapping));
+		mapping.src_uid = moved[i].old_uid;
+		mapping.dest_uid = moved[i].dest_uid;
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_COPY_MAPPING, 0, 0, -1,
+		    &mapping, sizeof(mapping)) == -1)
+			log_warn("session %u: imsg_compose "
+			    "IMSG_MBOX_COPY_MAPPING", session_id);
+	}
+	/* ...then EXPUNGE notices for the old UIDs/seqnos, per SS6.4.8's
+	 * required ordering. */
+	for (i = 0; i < nmoved; i++) {
+		struct imsg_mbox_expunged	 exp;
+
+		memset(&exp, 0, sizeof(exp));
+		exp.seqno = moved[i].old_seqno;
+		exp.uid = moved[i].old_uid;
+		if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0, -1,
+		    &exp, sizeof(exp)) == -1)
+			log_warn("session %u: imsg_compose IMSG_MBOX_EXPUNGED",
+			    session_id);
+	}
+
+done:
+	*nmoved_out = (uint32_t)nmoved;
+	free(moved);
+	return (ok);
+}
+
+/*
+ * MOVE when the destination genuinely differs from the mailbox already
+ * SELECTed. Per RFC 9051 SS6.4.8's own definition ("MOVE... has the same
+ * properties as COPY, followed by removal of the copied messages"), this
+ * is exactly that: stage_copy_messages()/commit_copy_messages() (shared
+ * with handle_mbox_copy() above) do the "COPY" half -- reusing all of
+ * COPY's own all-or-nothing staging/commit guarantees for messages that
+ * cross-mailbox MOVE now needs too, since (unlike move_same_mailbox()'s
+ * zero-copy design above) real file I/O against two different
+ * directories is unavoidable here -- and this function's own second half
+ * then removes each successfully-committed message from the source:
+ * unlink(2) the original cur/ or new/ file and compact it out of srcidx.
+ *
+ * RFC 9051 SS6.4.8 only requires "each individual message MUST be either
+ * moved or unaffected", which permits a partial MOVE across a large set;
+ * this implementation is deliberately stricter, matching COPY's own
+ * all-or-nothing choice (stage_copy_messages()/commit_copy_messages()'s
+ * own comments already flag that as a safe superset of the RFC's minimum
+ * bar) -- simpler to reason about, and the risk of a large cross-mailbox
+ * MOVE landing half-done is the same category of surprise a client would
+ * get from a half-done COPY, which SS6.4.7 already forbids outright.
+ *
+ * One accepted asymmetry remains, and is called out explicitly rather
+ * than silently: once commit_copy_messages() has durably rename(2)d every
+ * copy into the destination's cur/ and index_save()'d there, this
+ * function still has to unlink(2) each source file and index_save() the
+ * source's own compaction separately -- a real (if narrow) window where a
+ * crash could leave a message duplicated in both mailboxes rather than
+ * moved. Preferred over the alternative of removing from source *before*
+ * the destination commit is confirmed, which could instead lose the
+ * message outright on the same kind of crash -- duplication is the
+ * recoverable failure mode, data loss is not.
+ *
+ * Messages are matched for removal by the UID stage_copy_messages()
+ * already recorded (staged[i].src_uid), not by re-deriving sequence-range
+ * membership a second time -- sidesteps entirely the by-sequence
+ * "in"-vs-"out" bug class move_same_mailbox()'s own header comment
+ * documents, since there's no ambiguity left to get wrong once the exact
+ * UID set is already known.
+ */
+static int
+move_cross_mailbox(struct imsg_mbox_copy *req, struct mbox_index *srcidx,
+    struct mbox_index *destidx, const char *desttarget, const char *saved,
+    uint32_t *nmoved_out, struct imsgev *iev)
+{
+	struct copy_staged	*staged = NULL;
+	size_t			 nstaged = 0, i;
+	int			 ok = 1, any_removed = 0;
+
+	*nmoved_out = 0;
+
+	if (!stage_copy_messages(srcidx, req, &staged, &nstaged))
+		return (0);
+
+	if (nstaged == 0)
+		return (1);
+
+	if (select_mailbox_dir(desttarget) == -1) {
+		log_warnx("session %u: MOVE: couldn't reach destination %s",
+		    session_id, desttarget);
+		ok = 0;
+		goto cleanup;
+	}
+	if (ensure_maildir_dirs("") == -1) {
+		ok = 0;
+		goto cleanup;
+	}
+	if (!commit_copy_messages(destidx, staged, nstaged, iev)) {
+		ok = 0;
+		goto cleanup;
+	}
+
+	/*
+	 * Destination commit is durable -- now remove each original from
+	 * the source. Back to source's own directory first (cwd is
+	 * currently sitting at the destination, from the commit above).
+	 */
+	if (select_mailbox_dir(saved) == -1) {
+		log_warnx("session %u: MOVE: committed to destination but "
+		    "couldn't return to source %s to remove the originals "
+		    "-- message(s) now duplicated in both mailboxes rather "
+		    "than moved", session_id, saved);
+		ok = 0;
+		goto cleanup;
+	}
+
+	for (i = 0; i < nstaged; i++) {
+		struct index_rec	 rec;
+		size_t			 j, out;
+		uint32_t		 old_seqno = 0;
+		int			 found = 0;
+		off_t			 size;
+		char			 suffix[64], path[600];
+
+		/*
+		 * Re-locate under its *original* basename (staged[i]'s own
+		 * .basename is the freshly minted destination name) via a
+		 * fresh scan of srcidx -- deliberately not a single
+		 * combined compaction pass across every staged[] entry at
+		 * once, so each removal's old_seqno reflects the mailbox
+		 * state after every earlier removal in this same command,
+		 * matching move_same_mailbox()'s "immediately decremented"
+		 * semantics exactly rather than approximating it.
+		 */
+		for (j = 0, out = 0; j < srcidx->nlines; j++) {
+			if (!found && index_parse_line(srcidx->lines[j],
+			    &rec) == 0 && rec.uid == staged[i].src_uid) {
+				old_seqno = (uint32_t)(out + 1);
+				found = 1;
+				free(srcidx->lines[j]);
+				continue;
+			}
+			srcidx->lines[out++] = srcidx->lines[j];
+		}
+		srcidx->nlines = out;
+
+		if (!found) {
+			/*
+			 * Already gone from the source index -- another of
+			 * this same user's sessions raced an EXPUNGE/STORE/
+			 * MOVE of the same message between staging and
+			 * here. The copy at the destination is still valid
+			 * and durable; nothing left to remove.
+			 */
+			continue;
+		}
+		any_removed = 1;
+
+		if (locate_message_file(rec.basename, &size, suffix,
+		    sizeof(suffix)) == 0) {
+			if (snprintf(path, sizeof(path), "%s/%s%s",
+			    suffix[0] == '\0' ? "new" : "cur", rec.basename,
+			    suffix) < (int)sizeof(path))
+				unlink(path);
+		}
+
+		{
+			struct imsg_mbox_expunged	exp;
+
+			memset(&exp, 0, sizeof(exp));
+			exp.seqno = old_seqno;
+			exp.uid = staged[i].src_uid;
+			if (imsg_compose(&iev->ibuf, IMSG_MBOX_EXPUNGED, 0, 0,
+			    -1, &exp, sizeof(exp)) == -1)
+				log_warn("session %u: imsg_compose "
+				    "IMSG_MBOX_EXPUNGED", session_id);
+		}
+	}
+
+	/* RFC 7162 SS3.1: same single per-operation HIGHESTMODSEQ bump
+	 * handle_mbox_expunge() already uses for its own compaction. */
+	if (any_removed)
+		srcidx->highestmodseq++;
+
+	if (index_save(srcidx) == -1) {
+		log_warnx("session %u: MOVE: destination commit succeeded "
+		    "but saving the source's compacted index failed -- "
+		    "message(s) may remain duplicated", session_id);
+		ok = 0;
+		goto cleanup;
+	}
+
+	*nmoved_out = (uint32_t)nstaged;
+
+cleanup:
+	for (i = 0; i < nstaged; i++)
+		free(staged[i].body);
+	free(staged);
+	return (ok);
+}
+
+/*
+ * RFC 9051 SS6.4.8 MOVE, dispatcher half: resolves the destination and
+ * acquires whichever index file(s) are needed exactly the way handle_
+ * mbox_copy() does (see that function's header comment for the same-
+ * mailbox-vs-cross-mailbox split and the lock-ordering reasoning -- not
+ * re-explained here), then hands off to move_same_mailbox() or move_
+ * cross_mailbox() above for the actual work.
+ */
+static void
+handle_mbox_move(struct imsg_mbox_copy *req, struct imsgev *iev)
+{
+	struct mbox_index		 idx_a, idx_b;
+	struct mbox_index		*srcidx = NULL, *destidx = NULL;
+	struct imsg_mbox_result	 result;
+	uint32_t			 nmoved = 0;
+	int				 fd_a = -1, fd_a_locked = 0;
+	int				 fd_b = -1, fd_b_locked = 0;
+	int				 ok = 1;
+	char				 saved[MBOX_NAME_MAX];
+	char				 desttarget[MBOX_NAME_MAX];
+	int				 cross_mailbox;
+
+	memset(&idx_a, 0, sizeof(idx_a));
+	memset(&idx_b, 0, sizeof(idx_b));
+	memset(&result, 0, sizeof(result));
+
+	strlcpy(saved, current_mailbox_dir, sizeof(saved));
+
+	if (resolve_mailbox_target(req->destname, desttarget,
+	    sizeof(desttarget)) == -1) {
+		log_debug("session %u: MOVE %s: invalid destination mailbox "
+		    "name", session_id, req->destname);
+		result.no_such_mailbox = 1;
+		ok = 0;
+		goto done;
+	}
+	cross_mailbox = (strcmp(saved, desttarget) != 0);
+
+	if (!cross_mailbox) {
+		if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+		    -1) {
+			log_warn("session %u: open %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto done;
+		}
+		if (flock(fd_a, LOCK_EX) == -1) {
+			log_warn("session %u: flock %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto done;
+		}
+		fd_a_locked = 1;
+		if (index_load(fd_a, &idx_a) == -1) {
+			ok = 0;
+			goto done;
+		}
+		srcidx = &idx_a;
+		destidx = &idx_a;
+	} else {
+		char	first[MBOX_NAME_MAX], second[MBOX_NAME_MAX];
+		int	first_is_dest;
+
+		if (strcmp(saved, desttarget) <= 0) {
+			strlcpy(first, saved, sizeof(first));
+			strlcpy(second, desttarget, sizeof(second));
+			first_is_dest = 0;
+		} else {
+			strlcpy(first, desttarget, sizeof(first));
+			strlcpy(second, saved, sizeof(second));
+			first_is_dest = 1;
+		}
+
+		if (select_mailbox_dir(first) == -1) {
+			if (first_is_dest)
+				result.no_such_mailbox = 1;
+			else
+				log_warnx("session %u: MOVE: couldn't reach "
+				    "%s", session_id, first);
+			ok = 0;
+			goto done;
+		}
+		if ((fd_a = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+		    -1) {
+			log_warn("session %u: open %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto restore_saved;
+		}
+		if (flock(fd_a, LOCK_EX) == -1) {
+			log_warn("session %u: flock %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto restore_saved;
+		}
+		fd_a_locked = 1;
+		if (index_load(fd_a, &idx_a) == -1) {
+			ok = 0;
+			goto restore_saved;
+		}
+
+		if (select_mailbox_dir(second) == -1) {
+			if (!first_is_dest)
+				result.no_such_mailbox = 1;
+			else
+				log_warnx("session %u: MOVE: couldn't reach "
+				    "%s", session_id, second);
+			ok = 0;
+			goto restore_saved;
+		}
+		if ((fd_b = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) ==
+		    -1) {
+			log_warn("session %u: open %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto restore_saved;
+		}
+		if (flock(fd_b, LOCK_EX) == -1) {
+			log_warn("session %u: flock %s", session_id,
+			    STORE_INDEX_NAME);
+			ok = 0;
+			goto restore_saved;
+		}
+		fd_b_locked = 1;
+		if (index_load(fd_b, &idx_b) == -1) {
+			ok = 0;
+			goto restore_saved;
+		}
+
+		srcidx = first_is_dest ? &idx_b : &idx_a;
+		destidx = first_is_dest ? &idx_a : &idx_b;
+
+		if (select_mailbox_dir(saved) == -1) {
+			log_warnx("session %u: MOVE: couldn't return to %s",
+			    session_id, saved);
+			ok = 0;
+			goto restore_saved;
+		}
+	}
+
+	if (!cross_mailbox) {
+		if (!move_same_mailbox(req, srcidx, &nmoved, iev))
+			ok = 0;
+	} else {
+		if (!move_cross_mailbox(req, srcidx, destidx, desttarget,
+		    saved, &nmoved, iev))
+			ok = 0;
+	}
+
+	if (ok) {
+		result.count = nmoved;
+		result.uidvalidity = destidx->uidvalidity;
+		result.highestmodseq = destidx->highestmodseq;
+	}
+
+restore_saved:
+	if (cross_mailbox && select_mailbox_dir(saved) == -1)
+		log_warnx("session %u: MOVE: couldn't restore previously "
+		    "selected mailbox %s", session_id, saved);
+
+done:
+	if (fd_a_locked)
+		flock(fd_a, LOCK_UN);
+	if (fd_a != -1)
+		close(fd_a);
+	if (fd_b_locked)
+		flock(fd_b, LOCK_UN);
+	if (fd_b != -1)
+		close(fd_b);
+
+	result.ok = ok;
+	index_free(&idx_a);
+	index_free(&idx_b);
+
+	if (imsg_compose(&iev->ibuf, IMSG_MBOX_RESULT, 0, 0, -1, &result,
+	    sizeof(result)) == -1)
+		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
+		    session_id);
+	imsgev_add(iev);
+}
+
+static void
+store_dispatch(int fd, short event, void *arg)
+{
+	struct imsgev	*iev = arg;
+	struct imsg	 imsg;
+	ssize_t		 n;
+
+	/*
+	 * EV_WRITE: same real bug as listener.c's listener_dispatch_auth()/
+	 * auth.c's auth_dispatch() -- see those header comments for the
+	 * full citation against imsg_init(3). Every IMSG_MBOX_*_RESULT this
+	 * process sends back is queued via imsg_compose() and needs an
+	 * actual imsgbuf_write() once the fd is writable.
+	 */
+	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) {
+			/* listener's end closed -- treat like
+			 * IMSG_STORE_SHUTDOWN: nothing left to serve. */
+			store_shutdown();
+			/* NOTREACHED */
+		}
+	}
+
+	for (;;) {
+		if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsg_get");
+		if (n == 0)
+			break;
+
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_STORE_SHUTDOWN:
+			imsg_free(&imsg);
+			store_shutdown();
+			/* NOTREACHED */
+			break;
+		case IMSG_MBOX_SELECT: {
+			struct imsg_mbox_select		 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_SELECT");
+				break;
+			}
+			/* handle_mbox_select() composes its own replies
+			 * (IMSG_MBOX_SELECT_VANISHED / IMSG_MBOX_FETCH_META x N
+			 * for a QRESYNC resync, then one IMSG_MBOX_SELECTED)
+			 * and calls imsgev_add(iev) itself -- same streaming
+			 * pattern as handle_mbox_fetch()/handle_mbox_store()/
+			 * handle_mbox_expunge()/handle_mbox_search() below. */
+			handle_mbox_select(&req, iev);
+			break;
+		}
+		case IMSG_MBOX_FETCH: {
+			struct imsg_mbox_fetch	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_FETCH");
+				break;
+			}
+			/* handle_mbox_fetch() composes its own replies
+			 * (IMSG_MBOX_FETCH_META x N, then one
+			 * IMSG_MBOX_RESULT) and calls imsgev_add(iev) itself
+			 * once at the end -- unlike IMSG_MBOX_SELECT above,
+			 * which sends exactly one reply and re-arms right
+			 * after composing it, a variable-length stream of
+			 * replies is cleaner re-armed once, after the whole
+			 * batch is queued, than after each individual
+			 * imsg_compose(). */
+			handle_mbox_fetch(&req, iev);
+			break;
+		}
+		case IMSG_MBOX_STORE: {
+			struct imsg_mbox_store	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_STORE");
+				break;
+			}
+			/* Same reply-composition shape as IMSG_MBOX_FETCH
+			 * above -- handle_mbox_store() composes its own
+			 * IMSG_MBOX_FETCH_META x N / IMSG_MBOX_RESULT stream
+			 * and calls imsgev_add(iev) itself once at the end. */
+			handle_mbox_store(&req, iev);
+			break;
+		}
+		case IMSG_MBOX_EXPUNGE: {
+			struct imsg_mbox_expunge	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_EXPUNGE");
+				break;
+			}
+			/* Same reply-composition shape as IMSG_MBOX_FETCH/
+			 * IMSG_MBOX_STORE above -- handle_mbox_expunge()
+			 * composes its own IMSG_MBOX_EXPUNGED x N /
+			 * IMSG_MBOX_RESULT stream and calls imsgev_add(iev)
+			 * itself once at the end. Also the request CLOSE
+			 * (listener.c's cmd_close()) sends, with silent=1. */
+			handle_mbox_expunge(&req, iev);
+			break;
+		}
+		case IMSG_MBOX_IDLE_REFRESH:
+			/* No request payload -- see imapd.h's imsg_mbox_
+			 * idle_uid/imsg_mbox_idle_refreshed comment. handle_
+			 * mbox_idle_refresh() composes its own IMSG_MBOX_
+			 * IDLE_UID x N / IMSG_MBOX_IDLE_REFRESHED stream and
+			 * calls imsgev_add(iev) itself, same shape as every
+			 * other streaming handler above. */
+			handle_mbox_idle_refresh(iev);
+			break;
+		case IMSG_MBOX_APPEND: {
+			struct imsg_mbox_append	 req;
+			size_t			 bodylen;
+			char			*body = NULL;
+
+			/*
+			 * imsg_get_data() (used by every other case here)
+			 * requires an *exact* length match and can't be used
+			 * for a header-plus-variable-body imsg -- imsg_get_
+			 * buf() (sequential, no length check) plus imsg_get_
+			 * len() (bytes *remaining*, since it's ibuf_size() =
+			 * wpos - rpos) is the verified-against-real-imsg-
+			 * buffer.c pattern; see imapd.h's imsg_mbox_append
+			 * comment for the full citation.
+			 *
+			 * Any failure here (bad header, a msglen mismatch, a
+			 * malloc failure) is treated the same as every other
+			 * "bad IMSG_MBOX_*" case in this switch: logged and
+			 * dropped, no reply sent. That's a pre-existing
+			 * pattern, not a new gap -- every other case's
+			 * imsg_get_data() failure path does the same, on the
+			 * premise that a malformed imsg between listener and
+			 * store (as opposed to a client protocol error, which
+			 * never reaches here) means a build-time struct-
+			 * layout skew between the two binaries, not something
+			 * a client action can trigger.
+			 */
+			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_APPEND (header)");
+				break;
+			}
+			bodylen = imsg_get_len(&imsg);
+			if (bodylen != req.msglen) {
+				log_warnx("session %u: IMSG_MBOX_APPEND "
+				    "length mismatch (header says %u, imsg "
+				    "has %zu)", session_id, req.msglen,
+				    bodylen);
+				break;
+			}
+			if (bodylen > 0) {
+				if ((body = malloc(bodylen)) == NULL) {
+					log_warn("session %u: malloc APPEND "
+					    "body", session_id);
+					break;
+				}
+				if (imsg_get_buf(&imsg, body, bodylen) == -1) {
+					log_warnx("bad IMSG_MBOX_APPEND "
+					    "(body)");
+					free(body);
+					break;
+				}
+			}
+			handle_mbox_append(&req, body, bodylen, iev);
+			free(body);
+			break;
+		}
+		case IMSG_MBOX_SEARCH: {
+			struct imsg_mbox_search	 req;
+			size_t			 bodylen;
+			struct search_node	*nodes = NULL;
+
+			/* Same header-plus-variable-body shape and the same
+			 * imsg_get_buf()/imsg_get_len() technique as
+			 * IMSG_MBOX_APPEND just above -- see imapd.h's
+			 * imsg_mbox_search comment. Trailing data here is an
+			 * array of struct search_node, not raw message bytes,
+			 * but the wire mechanics are identical. */
+			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_SEARCH (header)");
+				break;
+			}
+			bodylen = imsg_get_len(&imsg);
+			if (bodylen != (size_t)req.nnodes *
+			    sizeof(struct search_node)) {
+				log_warnx("session %u: IMSG_MBOX_SEARCH length "
+				    "mismatch (header says %u nodes, imsg has "
+				    "%zu bytes)", session_id, req.nnodes,
+				    bodylen);
+				break;
+			}
+			if (bodylen > 0) {
+				if ((nodes = malloc(bodylen)) == NULL) {
+					log_warn("session %u: malloc SEARCH "
+					    "nodes", session_id);
+					break;
+				}
+				if (imsg_get_buf(&imsg, nodes, bodylen) == -1) {
+					log_warnx("bad IMSG_MBOX_SEARCH (nodes)");
+					free(nodes);
+					break;
+				}
+			}
+			handle_mbox_search(&req, nodes, req.nnodes, iev);
+			free(nodes);
+			break;
+		}
+		case IMSG_MBOX_STATUS: {
+			struct imsg_mbox_status	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_STATUS");
+				break;
+			}
+			handle_mbox_status(&req, iev);
+			break;
+		}
+		case IMSG_MBOX_COPY: {
+			struct imsg_mbox_copy	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_COPY");
+				break;
+			}
+			handle_mbox_copy(&req, iev);
+			break;
+		}
+		case IMSG_MBOX_MOVE: {
+			struct imsg_mbox_copy	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_MOVE");
+				break;
+			}
+			handle_mbox_move(&req, iev);
+			break;
+		}
+		case IMSG_MBOX_EXAMINE:
+			/*
+			 * Permanently unreachable: EXAMINE reuses IMSG_MBOX_
+			 * SELECT wholesale (struct imsg_mbox_select's own
+			 * "readonly" field distinguishes them) -- see
+			 * select_or_examine() in listener.c. This enum value
+			 * predates that design decision and was never
+			 * removed.
+			 */
+			log_debug("session %u: unimplemented mbox op %d",
+			    session_id, imsg_get_type(&imsg));
+			break;
+		case IMSG_MBOX_LIST:
+			handle_mbox_list(iev);
+			break;
+		case IMSG_MBOX_CREATE: {
+			struct imsg_mbox_create	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_CREATE");
+				break;
+			}
+			handle_mbox_create(&req, iev);
+			break;
+		}
+		case IMSG_MBOX_DELETE: {
+			struct imsg_mbox_delete	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_DELETE");
+				break;
+			}
+			handle_mbox_delete(&req, iev);
+			break;
+		}
+		case IMSG_MBOX_RENAME: {
+			struct imsg_mbox_rename	 req;
+
+			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_RENAME");
+				break;
+			}
+			handle_mbox_rename(&req, iev);
+			break;
+		}
+		default:
+			log_debug("store_dispatch: unhandled %d (session %u)",
+			    imsg_get_type(&imsg), session_id);
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	/*
+	 * Real bug caught on first real-hardware run, same shape and same
+	 * fix as auth.c's auth_dispatch() (see its header comment for the
+	 * full citation against imsg_init(3)): every case above delegates
+	 * its own re-arm to whichever handle_mbox_*() function it calls
+	 * (or, for a couple of cases, none at all yet -- see the TODO
+	 * cases above), on the assumption this function always gets called
+	 * with something new to process. That assumption breaks once
+	 * EV_WRITE is actually handled (just above): a pure EV_WRITE
+	 * firing with nothing new to read runs no case at all, so nothing
+	 * re-arms this channel -- and since imsgev_init() registers plain
+	 * EV_READ, not EV_PERSIST, that silently drops store's only
+	 * connection to listener for good. Unconditional call here closes
+	 * that gap regardless of which path (or no path) was taken above.
+	 */
+	imsgev_add(iev);
+	(void)fd;
+}
+
+/*
+ * Per the design doc: IMSG_STORE_SHUTDOWN arrives directly from listener
+ * over the peer channel, no round-trip through parent -- "parent isn't on
+ * this path once wiring completes." Flush-then-exit; there's currently
+ * nothing to flush (no open index/message fds are held across dispatch
+ * calls in this skeleton), but the function exists as the one place
+ * that'll need to change once there is.
+ */
+static __dead void
+store_shutdown(void)
+{
+	log_debug("session %u: store shutting down", session_id);
+	exit(0);
+}