Commit Diff


commit - 845c36149fc11758db2f7d64937a70de2a56ed67
commit + 2a6467d3157cdcdc9f6a69080095e37a528abd2c
blob - 7f147c923fac828c5211a4b66dfef861d9307ab8
blob + cc65af9f3e5ce618d06b770df9b41cf25029c780
--- README.md
+++ README.md
@@ -1,25 +1,29 @@
 # 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.
+A from-scratch IMAP4rev2 ([RFC 9051](https://www.rfc-editor.org/rfc/rfc9051)) server for OpenBSD, written in C in the privilege-separated tradition of `smtpd(8)`, `httpd(8)`, and `ntpd(8)`. No third-party IMAP library.
 
-**Status:** pre-release, version 0.1.1 Actively developed. Not a port. See [Getting the source](#getting-the-source) below for the repository.
+**Status:** Pre-release, actively developed. Not a port. See [Getting the source](#getting-the-source) below for the repository.
 
 ## What it is
 
-- **Privilege-separated**, `smtpd`-style: 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.
+- **Privilege-separated**, `smtpd`-style, across six processes: a root *parent* reads configuration and binds the listening sockets; unprivileged *listener* and *auth* children handle the network and credential checks; *keymgr* holds the TLS private key and performs every private-key operation on request, so the process terminating TLS never has the key in its address space; a per-connection *search-oracle* parses the `SEARCH` grammar — the largest attacker-reachable parser in the daemon — in a process with no descriptors and no filesystem; and 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: *parent* is the only process that can pass a file descriptor at all, and *search-oracle* runs on bare `stdio`.
 - **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.
+`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 the cross-session caveat noted below), and the [RFC 7162](https://www.rfc-editor.org/rfc/rfc7162) `CONDSTORE`/`QRESYNC` extensions.
 
 `SUBSCRIBE`, `UNSUBSCRIBE`, and ACL/shared-mailbox support are deliberately left out.
 
+**`IDLE` is a poll, not a kernel-driven push.** An `IDLE`ing session rechecks its selected mailbox every `idle poll` seconds (default 5, see `imapd.conf`), so new mail — whether delivered by an external MTA or by another IMAP session — is reported within one interval rather than instantly. Most polls are two `stat(2)` calls and no lock: the store child only re-reads the index and streams UIDs when the mailbox directory or `new/` has actually been touched. Setting `idle poll 0` disables polling entirely, which restores the earlier behaviour where an `IDLE`ing session saw nothing until it sent `DONE`.
+
 ## Requirements
 
-OpenBSD only. This depends on `<imsg.h>`, `pledge(2)`, `unveil(2)`, and libutil's `imsgbuf_*` API, none of which exist outside OpenBSD. Developed and tested against OpenBSD 8.0. Links against libevent, libtls/libssl/libcrypto, and libutil — all base-system libraries (see `src/Makefile`).
+OpenBSD only. This depends on `<imsg.h>`, `pledge(2)`, `unveil(2)`, and libutil's `imsgbuf_*` API, none of which exist outside OpenBSD. Developed and tested against OpenBSD 8.0. Links against libevent, libtls/libssl/libcrypto, and libutil, all base-system libraries (see `src/Makefile`).
 
+`keymgr`, the process that isolates the TLS private key from `listener`, additionally depends on `tls_config_use_fake_private_key()` and undocumented ex_data-tagging behavior inside `tls_keypair_load()` — both unexported libtls/LibreSSL internals with no compatibility promise, and neither declared in libtls's public, installed `tls.h`.
+
 ## Building and installing
 
 ```
blob - c805bc504412d3fbd4b2ce04c460968abe84b808
blob + 83844217b4c7c6b04ec517451e215b394640e175
--- contrib/imapduser.8
+++ contrib/imapduser.8
@@ -2,7 +2,7 @@
 .\"
 .\" Written for the OpenIMAPD project. Public domain / no rights reserved.
 .\"
-.Dd $Mdocdate: August 19 2026 $
+.Dd $Mdocdate: September 6 2026 $
 .Dt IMAPDUSER 8
 .Os
 .Sh NAME
blob - b470a59c6c9e616124c7179097afd260205e1236
blob + 1cc25d6cde58222be9921a9f95c5ee229e4510a8
--- src/Makefile
+++ src/Makefile
@@ -6,10 +6,12 @@
 
 PROG=		imapd
 
-SRCS=		main.c parent.c log.c imsgev.c parse.y \
+SRCS=		main.c parent.c log.c imsgev.c parse.y utf8.c \
 		listener.c auth_cmd.c mailbox_cmd.c append_cmd.c fetch_cmd.c \
 		search_cmd.c store_cmd.c store_ipc.c \
 		auth.c \
+		keymgr.c \
+		search_oracle.c \
 		store.c index.c mime.c envelope.c mbox_fetch.c mbox_search.c \
 		mbox_store.c mbox_manage.c mbox_copy.c
 
blob - 3e52fad9d3e1bff8f2aa3c844b32c0036673d4cb
blob + 0c2a64146c0476e8b02ed9b60b02b6b2ca467835
--- src/append_cmd.c
+++ src/append_cmd.c
@@ -85,6 +85,20 @@ parse_date_time(const char *s, int64_t *out)
 	if (zsign == '-')
 		zoff = -zoff;
 
+	/*
+	 * The year is already required to be 1970 or later, but nothing bounds
+	 * the zone -- sscanf's %c%2d%2d accepts up to +9999, and RFC 9051 SS9's
+	 * "zone = ("+" / "-") 4DIGIT" does not constrain the value either. So
+	 * "01-Jan-1970 00:00:00 +9959" resolves to -359940, and
+	 * handle_mbox_append() formats the delivery timestamp straight into the
+	 * maildir basename -- producing a file called "-359940.pid_n.host" in a
+	 * maildir the README advertises as readable with ls(1) and grep(1),
+	 * where any shell glob over cur/ hands that name to rm(1) as an
+	 * option rather than a file. Out of the intended range, so refuse it.
+	 */
+	if ((int64_t)t - zoff < 0)
+		return (-1);
+
 	*out = (int64_t)t - zoff;
 	return (0);
 }
@@ -114,46 +128,13 @@ parse_append_args(char *args, struct append_parsed *ou
 		return (-1);
 	}
 
-	while (*p == ' ')
-		p++;
-	if (*p == '"') {
-		const 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 {
-		const 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';
+	/* one parser for every mailbox argument in the tree; see mailbox_cmd.c */
+	if (parse_mailbox_name(&p, out->mailbox, sizeof(out->mailbox),
+	    errmsg) == -1)
+		return (-1);
+	if (out->mailbox[0] == '\0') {
+		*errmsg = "empty mailbox name";
+		return (-1);
 	}
 
 	while (*p == ' ')
@@ -240,6 +221,19 @@ parse_append_args(char *args, struct append_parsed *ou
 		memcpy(digitsbuf, start, digits_len);
 		digitsbuf[digits_len] = '\0';
 
+		/*
+		 * RFC 9051 SS9: literal = "{" number64 ["+"] "}", and
+		 * number64 = 1*DIGIT -- no sign. strtoull(3) accepts one, so
+		 * "{-1}" would arrive as ULLONG_MAX with errno untouched and
+		 * be answered NO [LIMIT] "message too large" by cmd_append()
+		 * rather than the BAD a syntax error deserves. Same
+		 * first-character-is-a-digit guard listener.c's own literal
+		 * pre-scan already applies to the same announcement.
+		 */
+		if (digitsbuf[0] < '0' || digitsbuf[0] > '9') {
+			*errmsg = "malformed literal octet count";
+			return (-1);
+		}
 		errno = 0;
 		litlen = strtoull(digitsbuf, &digits_end, 10);
 		if (*digits_end != '\0' || errno == ERANGE) {
@@ -332,10 +326,23 @@ cmd_append(struct session *s, const char *tag, char *a
 	s->literal_remaining = parsed.litlen;
 	s->literal_pending = 1;
 
-	/* RFC 9051 SS4.3: only synchronizing literals need a "+" continuation; harmless but misleading to send for non-sync. */
-	if (!parsed.litnonsync)
-		session_write(s, "+ Ready for literal data\r\n", 27);
+	/*
+	 * RFC 9051 SS4.3: only synchronizing literals need a "+" continuation;
+	 * harmless but misleading to send for non-sync.
+	 *
+	 * sizeof() - 1, not a hand-counted constant: this line used to pass 27
+	 * for a 26-byte string, which wrote the string literal's own NUL
+	 * terminator onto the wire ahead of the tagged APPEND response. NUL is
+	 * not a legal octet in the IMAP stream; clients mostly tolerate it,
+	 * which is why it went unnoticed. Same idiom as listener.c's own
+	 * session_write(s, bad, sizeof(bad) - 1) call sites.
+	 */
+	if (!parsed.litnonsync) {
+		static const char cont[] = "+ Ready for literal data\r\n";
 
+		session_write(s, cont, sizeof(cont) - 1);
+	}
+
 	return (1);
 }
 
@@ -395,11 +402,22 @@ session_finish_append(struct session *s)
 
 	s->state = SESSION_APPENDING;
 
+	/*
+	 * Same fail-soft shape send_mbox_request() now uses: a compose failure
+	 * would otherwise leave s->state at SESSION_APPENDING with nothing in
+	 * flight to move it back, and session_is_busy() then blocks every
+	 * further command while the client waits for a tagged reply.
+	 */
 	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_APPEND, 0, 0, -1,
-	    combined, combined_len) == -1)
+	    combined, combined_len) == -1) {
 		log_warn("session %u: imsg_compose IMSG_MBOX_APPEND", s->id);
+		free(combined);
+		s->state = s->append_prev_state;
+		session_reply(s, s->pending_tag, "NO",
+		    "[SERVERBUG] internal error");
+		return (1);
+	}
 	free(combined);
-	imsgev_add(s->store_iev);
 
 	return (1);
 }
@@ -431,8 +449,6 @@ session_handle_mbox_appended(struct session *s,
 		appended_to_selected =
 		    (strcmp(s->append_mailbox, s->selected_mailbox) == 0);
 
-	/* RFC 9051 SS6.3.13: APPEND always adds one message, unlike EXPUNGE/CLOSE, no res->count gate needed. */
-	session_notify_idle_peers(s);
 
 	if (s->append_prev_state == SESSION_SELECTED && appended_to_selected) {
 		char	buf[32];
blob - 374c9f5dc398eca7c5d2b03b65e9d36cc15cf558
blob + 6e913abfb622ccd27527ba535ef21fdd7468da89
--- src/auth.c
+++ src/auth.c
@@ -17,6 +17,7 @@
 /* auth.c, credential verification process: AUTHENTICATE PLAIN against the flat cred file. */
 
 #include <sys/types.h>
+#include <sys/stat.h>
 
 #include <ctype.h>
 #include <errno.h>
@@ -51,6 +52,95 @@ static void	 auth_verify(struct imsg_auth_request *,
 static void	 auth_dispatch(int, short, void *);
 static void	 auth_dispatch_parent(int, short, void *);
 
+/*
+ * SS6.2's retrofit: does a second IMSG_AUTH_REQUEST for a session_id
+ * auth already resolved successfully get treated as a fresh login
+ * attempt, or refused? A correctly-behaving listener never sends one
+ * -- AUTHENTICATE is only offered from SESSION_NOT_AUTH (listener.c's
+ * command table), and a session leaves that state for good the moment
+ * auth grants it -- so this is pure defense against a compromised or
+ * buggy listener replaying (or fabricating a duplicate of) a grant it
+ * already received, mirroring keymgr.c's SS6.1 keymgr_got_init gate:
+ * explicit and checkable, not merely "the code that could send this
+ * doesn't exist yet."
+ *
+ * A fixed-size ring, not a TAILQ: auth is never told when a session
+ * ends (listener/store don't notify it -- only parent's
+ * store_children TAILQ has session lifecycle visibility, over a
+ * different channel), so there is no event to free an entry on. The
+ * alternative is unbounded growth; a ring just ages the oldest entry
+ * out instead. That's safe, not merely convenient: session_id is
+ * parent.c's monotonically increasing next_session_id (spawn_
+ * connection()), minted once per accepted connection and never
+ * reused for the life of the daemon -- SS7 moved this counter, and
+ * the accept() loop that used to feed it, from listener.c to
+ * parent.c (see parent.c's own header comment). SS7 also makes
+ * reuse-within-one-process a stronger guarantee than that alone:
+ * each auth-worker is now spawned fresh per connection and paired
+ * with exactly one listener-worker for its own whole (short) life
+ * (parent.c's spawn_connection() again), so a single auth-worker
+ * can structurally never observe more than one distinct session_id
+ * in the first place. Kept as a ring rather than a single slot
+ * anyway, so this file doesn't need to change again if that ever
+ * stops being true. Sized well above parent.c's
+ * STORE_CHILD_MAX (64 concurrent sessions) so eviction shouldn't
+ * happen at any realistic session volume.
+ */
+/*
+ * Cap on failed authentication attempts this auth-worker will spend a
+ * bcrypt on, i.e. sshd_config(5)'s MaxAuthTries, whose own default this
+ * matches.
+ *
+ * A plain static counter IS a per-connection counter here: SS7 spawns one
+ * auth-worker per connection, paired with exactly one listener-worker for
+ * its whole (short) life (parent.c's spawn_connection()) -- the same fact
+ * the auth_resolved comment above relies on.
+ *
+ * Why it is needed: listener.c returns a failed session to
+ * SESSION_NOT_AUTH and AUTHENTICATE is ST_NOTAUTH, so a client may retry
+ * without limit. Each retry costs this process one bcrypt -- ~100ms of
+ * CPU, deliberately -- and costs the client one small packet on a
+ * connection it already holds. That asymmetry is backwards: bcrypt's work
+ * factor is meant to be paid by whoever is guessing, and without a cap an
+ * attacker converts cheap packets into unbounded server CPU across as
+ * many connections as MaxStartups allows.
+ *
+ * Past the cap, requests are refused WITHOUT calling crypt_checkpass(3),
+ * which is what removes the cost. The connection is not dropped -- doing
+ * that needs a listener-side change and a way to say so on the wire; the
+ * CPU asymmetry, which is the actual damage, is closed either way.
+ */
+#define AUTH_MAX_TRIES	6
+static unsigned int	 auth_failures;
+
+#define AUTH_RESOLVED_MAX	256
+static uint32_t	 auth_resolved[AUTH_RESOLVED_MAX];
+static size_t	 auth_resolved_next;	/* ring cursor */
+static int	 auth_resolved_full;	/* 1 once the ring has wrapped once */
+
+static int
+auth_session_already_resolved(uint32_t sid)
+{
+	size_t	limit = auth_resolved_full ? AUTH_RESOLVED_MAX : auth_resolved_next;
+	size_t	i;
+
+	for (i = 0; i < limit; i++) {
+		if (auth_resolved[i] == sid)
+			return (1);
+	}
+	return (0);
+}
+
+static void
+auth_session_mark_resolved(uint32_t sid)
+{
+	auth_resolved[auth_resolved_next++] = sid;
+	if (auth_resolved_next == AUTH_RESOLVED_MAX) {
+		auth_resolved_next = 0;
+		auth_resolved_full = 1;
+	}
+}
+
 __dead void
 auth_main(void)
 {
@@ -63,14 +153,13 @@ auth_main(void)
 	char			 chrootdir[1024];
 	ssize_t			 n;
 
-	if (imsgbuf_init(&ibuf3, 3) == -1)
-		fatal("imsgbuf_init");
-	imsgbuf_allow_fdpass(&ibuf3);	/* for the fd-passed IMSG_SETUP_PEER peer fd below */
+	/* fd-passing is allowed on this channel for the IMSG_SETUP_PEER peer fd below; see imsgev_ibuf_init()'s own comment */
+	imsgev_ibuf_init(&ibuf3, 3);
 
 	/* IMSG_AUTH_INIT must be read first: cred_file is needed before chroot() can be computed */
 	for (;;) {
-		if ((n = imsg_get(&ibuf3, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n != 0)
 			break;
 		if ((n = imsgbuf_read(&ibuf3)) == -1)
@@ -83,6 +172,15 @@ auth_main(void)
 		    imsg_get_type(&imsg));
 	if (imsg_get_data(&imsg, &init, sizeof(init)) == -1)
 		fatalx("auth: bad IMSG_AUTH_INIT payload");
+	/*
+	 * imsg_get_data() guarantees size, not NUL termination, force it --
+	 * same rule as req.username/req.password below, and parent.c's own
+	 * inbound maildir. strlcpy(3) reads its source to the NUL to compute
+	 * its return value, so an unterminated field here would be an
+	 * unbounded read past this stack struct, on the message that decides
+	 * what directory this process chroot(2)s into.
+	 */
+	init.cred_file[sizeof(init.cred_file) - 1] = '\0';
 	imsg_free(&imsg);
 
 	/* auth's own daemon-user identity; distinct from listener's _imapd. */
@@ -94,13 +192,26 @@ auth_main(void)
 	if (strlcpy(chrootdir, init.cred_file, sizeof(chrootdir)) >=
 	    sizeof(chrootdir))
 		fatalx("cred_file too long: %s", init.cred_file);
+	/*
+	 * Actually test what the message below claims. strrchr() finding a
+	 * '/' only means the path HAS a directory component: "etc/creds"
+	 * would pass and then chroot(2) relative to whatever working
+	 * directory rc.d(8) left this daemon in. parse.y only length-checks
+	 * the "credentials" directive, so nothing upstream enforces this.
+	 */
+	if (init.cred_file[0] != '/')
+		fatalx("cred_file must be an absolute path: %s",
+		    init.cred_file);
 	if ((slash = strrchr(chrootdir, '/')) == NULL)
 		fatalx("cred_file must be an absolute path: %s",
 		    init.cred_file);
 	if (strlcpy(cred_file_basename, slash + 1,
 	    sizeof(cred_file_basename)) >= sizeof(cred_file_basename))
 		fatalx("cred_file basename too long: %s", init.cred_file);
-	*slash = '\0';
+	if (slash == chrootdir)
+		chrootdir[1] = '\0';	/* "/creds" -> chroot("/"), not chroot("") */
+	else
+		*slash = '\0';
 
 	if (chroot(chrootdir) == -1)
 		fatal("chroot %s", chrootdir);
@@ -112,9 +223,18 @@ auth_main(void)
 	    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 */
+	/*
+	 * SS7: auth-worker's one and only peer, wired by parent.c's
+	 * spawn_connection() via setup_peer_send() the moment both it
+	 * and the listener-worker it's paired with exist. No
+	 * IMSG_SETUP_DONE ack round-trip -- see parent.c's header
+	 * comment for why spawn_connection() doesn't use one for
+	 * per-connection wiring; this boot sequence already reads a
+	 * fixed, statically known set of messages (just IMSG_AUTH_INIT
+	 * above, then this) before ever touching the event loop,
+	 * regardless of any ack.
+	 */
 	peer_fd = setup_recv_one_peer(&ibuf3);
-	setup_recv_done_and_ack(&ibuf3);
 
 	event_init();
 	imsgev_init(&iev_listener, peer_fd, auth_dispatch, NULL);
@@ -133,8 +253,29 @@ auth_main(void)
 			fatal("unveil lock");
 	}
 
+	/*
+	 * No recvfd, no sendfd. This process receives exactly one descriptor
+	 * in its life -- the peer fd from setup_recv_one_peer() above, which
+	 * has already arrived by the time this line runs -- and it never
+	 * sends one: the parent is the only process in the tree that attaches
+	 * a descriptor to an imsg (parent.c's setup_peer_send(),
+	 * setup_search_peer_send() and IMSG_LISTENER_SESSION_INIT are the
+	 * only five such call sites).
+	 *
+	 * An earlier version of this comment kept both promises on the theory
+	 * that an imsgbuf_allow_fdpass() channel uses sendmsg(2)/recvmsg(2)
+	 * for all of its traffic. It does -- but that is not what the two
+	 * promises gate. SYS_sendmsg and SYS_recvmsg are PLEDGE_STDIO
+	 * (sys/kern/kern_pledge.c); "sendfd"/"recvfd" are checked in
+	 * unp_internalize()/unp_externalize() (sys/kern/uipc_usrreq.c), which
+	 * the kernel reaches only when SCM_RIGHTS is actually attached to the
+	 * message. A plain imsg with fd == -1 needs neither.
+	 *
+	 * If that reasoning is wrong, pledge(2) does not degrade: a violation
+	 * is an uncatchable SIGABRT with a core dump, and this line reverts.
+	 */
 #ifdef __OpenBSD__
-	if (pledge("stdio rpath recvfd sendfd", NULL) == -1)
+	if (pledge("stdio rpath", NULL) == -1)
 		fatal("pledge");
 #endif
 
@@ -159,15 +300,29 @@ auth_dispatch(int fd, short event, void *arg)
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			log_warnx("listener closed channel");
-			event_del(&iev->ev);
-			return;
+			/*
+			 * SS7: this process was spawned (parent.c's
+			 * spawn_connection()) to serve exactly this one
+			 * connection's listener-worker and will never serve
+			 * another -- exit now rather than sit in
+			 * event_dispatch() forever with nothing left to do.
+			 * parent.c's reap_child() already documents this
+			 * exact expectation ("left to notice its own peer
+			 * channel EOF and exit on its own") and already
+			 * treats an auth-worker exit as the ordinary,
+			 * expected end of a session, not something to warn
+			 * about. Matches store.c's store_shutdown() for the
+			 * same reason on that per-session worker.
+			 */
+			log_debug("auth-worker: listener closed channel, "
+			    "exiting");
+			exit(0);
 		}
 	}
 
 	for (;;) {
-		if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n == 0)
 			break;
 
@@ -183,6 +338,15 @@ auth_dispatch(int fd, short event, void *arg)
 			/* imsg_get_data() guarantees size, not NUL termination, force it */
 			req.username[sizeof(req.username) - 1] = '\0';
 			req.password[sizeof(req.password) - 1] = '\0';
+
+			if (auth_session_already_resolved(req.session_id)) {
+				log_warnx("session %u: IMSG_AUTH_REQUEST for a "
+				    "session already successfully authenticated, "
+				    "refusing (SS6.2)", req.session_id);
+				explicit_bzero(req.password, sizeof(req.password));
+				break;
+			}
+
 			memset(&res, 0, sizeof(res));
 			res.session_id = req.session_id;
 			auth_verify(&req, &res);
@@ -193,11 +357,12 @@ auth_dispatch(int fd, short event, void *arg)
 			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);
 
 			if (res.ok) {
 				struct imsg_auth_cred	 cred;
 
+				auth_session_mark_resolved(res.session_id);
+
 				memset(&cred, 0, sizeof(cred));
 				cred.session_id = res.session_id;
 				cred.uid = res.uid;
@@ -213,7 +378,6 @@ auth_dispatch(int fd, short event, void *arg)
 				    IMSG_AUTH_CRED, 0, 0, -1, &cred,
 				    sizeof(cred)) == -1)
 					log_warn("imsg_compose IMSG_AUTH_CRED");
-				imsgev_add(&iev_parent);
 			}
 			break;
 		}
@@ -224,8 +388,7 @@ auth_dispatch(int fd, short event, void *arg)
 		}
 		imsg_free(&imsg);
 	}
-	/* unconditional re-arm: imsgev_init() is EV_READ not EV_PERSIST, so a pure EV_WRITE call would let it lapse */
-	imsgev_add(iev);
+	imsgev_rearm_read(iev);
 	(void)fd;
 }
 
@@ -253,8 +416,8 @@ auth_dispatch_parent(int fd, short event, void *arg)
 	}
 
 	for (;;) {
-		if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n == 0)
 			break;
 
@@ -262,10 +425,29 @@ auth_dispatch_parent(int fd, short event, void *arg)
 		    imsg_get_type(&imsg));
 		imsg_free(&imsg);
 	}
-	imsgev_add(iev);
+	imsgev_rearm_read(iev);
 	(void)fd;
 }
 
+/*
+ * Usernames come off the network, so they reach syslog only through this:
+ * anything outside printable ASCII becomes '?'.  listener.c is meant to
+ * reject a bare CR/LF in a command line, but auth is a separate process and
+ * does not get to assume that held.
+ */
+static void
+auth_safe_name(const char *in, char *out, size_t outsize)
+{
+	size_t	 i;
+
+	for (i = 0; i + 1 < outsize && in[i] != '\0'; i++) {
+		unsigned char	 c = (unsigned char)in[i];
+
+		out[i] = (c >= 0x20 && c < 0x7f) ? (char)c : '?';
+	}
+	out[i] = '\0';
+}
+
 /* always calls crypt_checkpass() with hash == NULL on unknown username, to avoid timing leaks */
 static void
 auth_verify(struct imsg_auth_request *req, struct imsg_auth_result *res)
@@ -273,7 +455,26 @@ auth_verify(struct imsg_auth_request *req, struct imsg
 	struct cred_entry	 ce;
 	const char		*hash = NULL;
 	int			 found;
+	char			 safename[AUTH_USERNAME_MAX];
 
+	/*
+	 * Budget spent: refuse without spending a bcrypt. Deliberately
+	 * before cred_lookup(), so a client past the cap cannot even make
+	 * this process re-read and re-scan the credential file. Logged and
+	 * reported exactly like any other failure -- the client learns
+	 * nothing it did not already know.
+	 */
+	if (auth_failures >= AUTH_MAX_TRIES) {
+		char	 overname[AUTH_USERNAME_MAX];
+
+		res->ok = 0;
+		auth_safe_name(req->username, overname, sizeof(overname));
+		log_info("session %u: authentication failed for \"%s\" "
+		    "(over AUTH_MAX_TRIES, not checked)", req->session_id,
+		    overname);
+		return;
+	}
+
 	found = (cred_lookup(cred_file_basename, req->username, &ce) == 0);
 	if (found)
 		hash = ce.passwordhash;
@@ -286,8 +487,27 @@ auth_verify(struct imsg_auth_request *req, struct imsg
 		res->gid = ce.gid;
 	} else {
 		res->ok = 0;
+		auth_failures++;
 	}
 
+	/*
+	 * Nothing used to record an authentication outcome at all, so a
+	 * password-guessing run left no trace in the logs and there was
+	 * nothing for pf(4)/fail2ban-style tooling to key on. log_info() is
+	 * not gated on verbosity (see log.c), so these are always emitted.
+	 * The failure line deliberately does not distinguish "no such user"
+	 * from "wrong password" -- that would hand back the same enumeration
+	 * oracle crypt_checkpass(NULL) exists to close.
+	 */
+	auth_safe_name(req->username, safename, sizeof(safename));
+	if (res->ok)
+		log_info("session %u: authentication succeeded for \"%s\" "
+		    "(uid %u)", req->session_id, safename,
+		    (unsigned)res->uid);
+	else
+		log_info("session %u: authentication failed for \"%s\"",
+		    req->session_id, safename);
+
 	explicit_bzero(&ce, sizeof(ce));
 }
 
@@ -304,12 +524,57 @@ cred_lookup(const char *path, const char *username, st
 		return (-1);
 	}
 
+	/*
+	 * parent.c refuses to load the TLS private key unless it is
+	 * root-owned and no looser than 0740; this file holds every user's
+	 * bcrypt hash and had no such check. A flat text file people edit
+	 * by hand very easily ends up 0644, at which point any local user
+	 * can take the hashes away and attack them offline.
+	 *
+	 * Deliberately permissive about ownership and group-read, so the
+	 * usual "root:_imapauth 0640" and "_imapauth 0400" layouts both
+	 * pass; only world access and group-write are refused. Fails
+	 * closed: a credential store with the wrong mode stops logins
+	 * rather than serving them, and says so loudly.
+	 */
+	{
+		struct stat	 st;
+
+		if (fstat(fileno(fp), &st) == -1) {
+			log_warn("fstat %s", path);
+			fclose(fp);
+			return (-1);
+		}
+		if (st.st_mode & (S_IROTH | S_IWOTH | S_IXOTH | S_IWGRP)) {
+			log_warnx("%s: insecure permissions (mode %04o), must "
+			    "not be world-accessible or group-writable; "
+			    "refusing all authentication until this is fixed",
+			    path, (unsigned)(st.st_mode & 07777));
+			fclose(fp);
+			return (-1);
+		}
+	}
+
 	while (fgets(line, sizeof(line), fp) != NULL) {
-		char	*p = line;
-		char	*fields[5];
-		int	 i;
-		char	*ep;
+		char		*p = line;
+		char		*fields[5];
+		int		 i;
+		char		*ep;
+		unsigned long	 ulval;
 
+		/*
+		 * fgets(3) splits an over-long line, and the tail would then be
+		 * parsed as its own entry -- a phantom credential rather than an
+		 * error. Refuse to read the file at all rather than guess.
+		 */
+		if (strchr(line, '\n') == NULL &&
+		    strlen(line) == sizeof(line) - 1) {
+			log_warnx("%s: over-long line, refusing to parse the "
+			    "credential file", path);
+			explicit_bzero(line, sizeof(line));
+			fclose(fp);
+			return (-1);
+		}
 		line[strcspn(line, "\n")] = '\0';
 		if (line[0] == '\0' || line[0] == '#')
 			continue;
@@ -334,13 +599,54 @@ cred_lookup(const char *path, const char *username, st
 		    strlcpy(out->passwordhash, fields[1],
 		    sizeof(out->passwordhash)) >= sizeof(out->passwordhash))
 			continue;
+		/*
+		 * crypt_checkpass(3) returns SUCCESS when the stored hash
+		 * and the supplied password are both empty
+		 * (lib/libc/crypt/cryptutil.c's "empty password" case), so a
+		 * blank second field is not a disabled account -- it is a
+		 * login with an empty password. Require a bcrypt hash, for
+		 * the same reason as the uid/gid checks below: the
+		 * credential file should not be able to express this in the
+		 * first place.
+		 *
+		 * Only the empty case needs catching. Any other non-bcrypt
+		 * value ("!", "*", a legacy crypt string, garbage) already
+		 * falls through crypt_checkpass()'s own "$2" test to its
+		 * fake: label, which burns a bcrypt for timing and fails.
+		 *
+		 * "continue" rather than a distinct error, matching every
+		 * other malformed-entry case here: the client must not be
+		 * able to tell this apart from "no such user", or the
+		 * enumeration oracle crypt_checkpass(NULL) exists to close
+		 * comes back through the error text.
+		 *
+		 * To disable an account, use imapduser -d, which removes the
+		 * line.
+		 */
+		if (fields[1][0] != '$' || fields[1][1] != '2')
+			continue;
+		/*
+		 * strtoul(3) accepts a leading "-", so "-1" would arrive here as
+		 * 0xffffffff, and "0" is root. parent.c refuses uid/gid 0 at the
+		 * spawn boundary, which is the check that matters -- but the
+		 * credential file should not be able to express either in the
+		 * first place, and the wrap case is caught nowhere else.
+		 */
+		if (fields[2][0] < '0' || fields[2][0] > '9' ||
+		    fields[3][0] < '0' || fields[3][0] > '9')
+			continue;
 		errno = 0;
-		out->uid = (uid_t)strtoul(fields[2], &ep, 10);
-		if (*ep != '\0' || errno != 0)
+		ulval = strtoul(fields[2], &ep, 10);
+		if (*ep != '\0' || errno != 0 || ulval == 0 ||
+		    ulval >= (unsigned long)(uid_t)-1)
 			continue;
-		out->gid = (gid_t)strtoul(fields[3], &ep, 10);
-		if (*ep != '\0' || errno != 0)
+		out->uid = (uid_t)ulval;
+		errno = 0;
+		ulval = strtoul(fields[3], &ep, 10);
+		if (*ep != '\0' || errno != 0 || ulval == 0 ||
+		    ulval >= (unsigned long)(gid_t)-1)
 			continue;
+		out->gid = (gid_t)ulval;
 		if (strlcpy(out->maildir, fields[4], sizeof(out->maildir)) >=
 		    sizeof(out->maildir))
 			continue;
@@ -348,6 +654,13 @@ cred_lookup(const char *path, const char *username, st
 		break;
 	}
 
+	/*
+	 * line[] held the raw credential record -- username, bcrypt hash,
+	 * uid, gid, maildir -- for every entry scanned. auth_verify()
+	 * scrubs its struct cred_entry and auth_dispatch() scrubs the
+	 * password; this buffer was the one left behind.
+	 */
+	explicit_bzero(line, sizeof(line));
 	fclose(fp);
 	return (found ? 0 : -1);
 }
blob - fa4f85eeb4c4c347226d0d001e2580ec44713e2e
blob + a83bf2678a4738a483467710477fd2c2d2e0eb29
--- src/auth_cmd.c
+++ src/auth_cmd.c
@@ -211,12 +211,27 @@ sasl_plain_finish(struct session *s, const char *tag, 
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
+	/*
+	 * SS7: this connection's auth-worker may not exist -- parent.c's
+	 * spawn_connection() tolerates that fork failing independently
+	 * of the listener-worker's own. listener_main()'s boot-drain
+	 * loop leaves iev_auth.ibuf.fd at -1 in that case rather than
+	 * wiring it to a real peer (see listener.c's globals-block
+	 * comment on iev_auth). Fail gracefully instead of composing to
+	 * an unwired imsgev.
+	 */
+	if (iev_auth.ibuf.fd == -1) {
+		session_reply(s, tag, "NO", "[UNAVAILABLE] authentication "
+		    "temporarily unavailable");
+		explicit_bzero(&req, sizeof(req));
+		return (1);
+	}
+
 	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() already copied req, safe to scrub our stack copy */
 	explicit_bzero(&req, sizeof(req));
 
@@ -243,6 +258,7 @@ int
 session_handle_idle_continuation(struct session *s, const char *line)
 {
 	s->idling = 0;
+	session_idle_poll_disarm(s);
 
 	if (strcasecmp(line, "DONE") != 0) {
 		session_reply(s, s->pending_tag, "BAD",
@@ -294,6 +310,9 @@ cmd_authenticate(struct session *s, const char *tag, c
 	}
 
 	if (initial != NULL) {	/* RFC 9051 SS6.2.2 initial-resp: finishes in one round trip */
+		/* `initial` is the base64 of the cleartext password and points
+		 * into s->inbuf; have the reader scrub it once consumed. */
+		s->scrub_inbuf = 1;
 		return sasl_plain_finish(s, tag, initial, 1);
 	}
 
blob - 51f65325e114d9eab73fcbd8cff51ff4da44736c
blob + 4ece4d36298f7a478bc532382f97e19b0132e581
--- src/envelope.c
+++ src/envelope.c
@@ -40,7 +40,9 @@ int
 envbuf_append(char *buf, size_t bufsize, size_t *outlen, const char *data,
     size_t datalen)
 {
-	if (*outlen + datalen > bufsize)
+	/* subtract rather than add: "*outlen + datalen" wraps if datalen is
+	 * ever close to SIZE_MAX, and the check would then pass */
+	if (*outlen > bufsize || datalen > bufsize - *outlen)
 		return (-1);
 	memcpy(buf + *outlen, data, datalen);
 	*outlen += datalen;
@@ -59,21 +61,37 @@ int
 envbuf_append_nstring(char *buf, size_t bufsize, size_t *outlen,
     const char *val, size_t vallen)
 {
+	size_t	 save = *outlen;
 	size_t	 i;
 
 	if (val == NULL)
 		return (envbuf_append_str(buf, bufsize, outlen, "NIL"));
 
 	if (envbuf_append(buf, bufsize, outlen, "\"", 1) == -1)
-		return (-1);
+		goto fail;
 	for (i = 0; i < vallen; i++) {
-		if ((val[i] == '"' || val[i] == '\\') &&
+		char	 c = val[i];
+
+		/* RFC 9051 SS4.3: a quoted string is TEXT-CHAR only, which
+		 * excludes NUL, CR and LF; substitute rather than reject so
+		 * one odd byte in a header doesn't drop the whole field */
+		if (c == '\0' || c == '\r' || c == '\n')
+			c = ' ';
+		if ((c == '"' || c == '\\') &&
 		    envbuf_append(buf, bufsize, outlen, "\\", 1) == -1)
-			return (-1);
-		if (envbuf_append(buf, bufsize, outlen, &val[i], 1) == -1)
-			return (-1);
+			goto fail;
+		if (envbuf_append(buf, bufsize, outlen, &c, 1) == -1)
+			goto fail;
 	}
-	return (envbuf_append(buf, bufsize, outlen, "\"", 1));
+	if (envbuf_append(buf, bufsize, outlen, "\"", 1) == -1)
+		goto fail;
+	return (0);
+
+fail:
+	/* all-or-nothing: a partial append leaves an unterminated quoted
+	 * string in the caller's buffer */
+	*outlen = save;
+	return (-1);
 }
 
 /* Formats one RFC 5322 mailbox as an IMAP address tuple (RFC 9051 SS9); no group syntax, addr-adl always NIL. */
@@ -200,6 +218,9 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 		mailboxlen -= 2;
 	}
 
+	if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, "(", 1) == -1)
+		return (-1);
+
 	if (name != NULL) {
 		size_t	 j;
 
@@ -237,12 +258,13 @@ envbuf_append_one_address(char *buf, size_t bufsize, s
 	if (envbuf_append_nstring(addrbuf, sizeof(addrbuf), &addrlen, host,
 	    hostlen) == -1)
 		return (-1);
+	if (envbuf_append(addrbuf, sizeof(addrbuf), &addrlen, ")", 1) == -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));
+	/* one atomic append: either the whole "(...)" tuple lands in the
+	 * caller's buffer or none of it does (envbuf_append() leaves
+	 * *outlen untouched on failure) */
+	return (envbuf_append(buf, bufsize, outlen, addrbuf, addrlen));
 }
 
 /* Formats an RFC 5322 address-list as "(" 1*address ")", or NIL if none parse (RFC 9051 SS7.5.2); splits on top-level commas only. */
@@ -295,13 +317,14 @@ envbuf_append_address_list(char *buf, size_t bufsize, 
 			tok_len--;
 
 		if (tok_len > 0) {
+			/* a malformed or non-fitting address leaves buf/outlen
+			 * untouched -- envbuf_append_one_address() builds the
+			 * whole "(...)" tuple locally before its one atomic
+			 * append into buf -- so skipping it and continuing is
+			 * safe */
 			if (envbuf_append_one_address(buf, bufsize, outlen,
 			    val + tok_start, tok_len) == 0)
 				any = 1;
-			else if (*outlen > bufsize) {
-				return (-1);	/* can't happen; envbuf_append() never overruns bufsize */
-			}
-			/* malformed address: buf/outlen untouched on failure (addrbuf only flushed atomically) */
 		}
 	}
 
blob - c30aafc310af2638e430368765831fed1d627e96
blob + 3d9bc13cb2e5fed06610f3907760529a6f0c3fce
--- src/fetch_cmd.c
+++ src/fetch_cmd.c
@@ -60,10 +60,16 @@ parse_nz_number(const char *str, uint32_t *out)
 	return (0);
 }
 
-/* RFC 9051 SS9 seq-range; "*" unresolved here, carried via lo_star/hi_star to store.c; backwards literal range swapped */
-int
-parse_seq_range(const char *tok, uint32_t *lo, uint32_t *hi, int *lo_star,
-    int *hi_star)
+/*
+ * Parses one range token -- no ':'-split ambiguity beyond the existing
+ * single colon, no comma -- into r. This used to be parse_seq_range()'s
+ * entire body, factored out so parse_sequence_set() below can reuse it
+ * once per comma-separated segment instead of duplicating it (parse_
+ * seq_range() itself is gone now -- QRESYNC known-uids, its last
+ * caller, moved to parse_sequence_set() directly; see mailbox_cmd.c).
+ */
+static int
+parse_one_seq_range(const char *tok, struct seq_range *r)
 {
 	char		 buf[32];
 	char		*colon;
@@ -74,8 +80,8 @@ parse_seq_range(const char *tok, uint32_t *lo, uint32_
 	if (strlcpy(buf, tok, sizeof(buf)) >= sizeof(buf))
 		return (-1);
 
-	*lo_star = *hi_star = 0;
-	*lo = *hi = 0;
+	r->lo_is_star = r->hi_is_star = 0;
+	r->lo = r->hi = 0;
 
 	if ((colon = strchr(buf, ':')) != NULL) {
 		*colon = '\0';
@@ -87,25 +93,82 @@ parse_seq_range(const char *tok, uint32_t *lo, uint32_
 	}
 
 	if (strcmp(loside, "*") == 0)
-		*lo_star = 1;
-	else if (parse_nz_number(loside, lo) == -1)
+		r->lo_is_star = 1;
+	else if (parse_nz_number(loside, &r->lo) == -1)
 		return (-1);
 
 	if (strcmp(hiside, "*") == 0)
-		*hi_star = 1;
-	else if (parse_nz_number(hiside, hi) == -1)
+		r->hi_is_star = 1;
+	else if (parse_nz_number(hiside, &r->hi) == -1)
 		return (-1);
 
-	if (!*lo_star && !*hi_star && *lo > *hi) {
-		uint32_t	 tmp = *lo;
+	if (!r->lo_is_star && !r->hi_is_star && r->lo > r->hi) {
+		uint32_t	 tmp = r->lo;
 
-		*lo = *hi;
-		*hi = tmp;
+		r->lo = r->hi;
+		r->hi = tmp;
 	}
 
 	return (0);
 }
 
+/*
+ * RFC 9051 SS9 sequence-set: (seq-number/seq-range) *("," seq-number/
+ * seq-range) -- what every parse_seq_range() call site used to reject a
+ * comma for, rather than actually parse. Splits text on top-level commas
+ * (no nesting or quoting in this grammar, so a plain scan is exact) and
+ * parses each segment with parse_one_seq_range() above. Writes up to
+ * SEQSET_MAX_RANGES entries to ranges[] and the count to *nranges;
+ * returns -1 (with *errmsg set) if any segment is malformed, empty, or
+ * there are more segments than SEQSET_MAX_RANGES allows.
+ */
+int
+parse_sequence_set(const char *text, struct seq_range ranges[SEQSET_MAX_RANGES],
+    uint32_t *nranges, const char **errmsg)
+{
+	const char	*p;
+	uint32_t	 n = 0;
+
+	if (text == NULL || *text == '\0') {
+		*errmsg = "empty sequence set";
+		return (-1);
+	}
+
+	for (p = text; ; ) {
+		const char	*start = p;
+		char		 tok[32];
+		size_t		 len;
+
+		while (*p != '\0' && *p != ',')
+			p++;
+		len = (size_t)(p - start);
+		if (len == 0 || len >= sizeof(tok)) {
+			*errmsg = "invalid sequence set";
+			return (-1);
+		}
+		if (n >= SEQSET_MAX_RANGES) {
+			*errmsg = "sequence set has too many comma-separated "
+			    "ranges";
+			return (-1);
+		}
+		memcpy(tok, start, len);
+		tok[len] = '\0';
+
+		if (parse_one_seq_range(tok, &ranges[n]) == -1) {
+			*errmsg = "invalid sequence set";
+			return (-1);
+		}
+		n++;
+
+		if (*p == '\0')
+			break;
+		p++;	/* skip ',' */
+	}
+
+	*nranges = n;
+	return (0);
+}
+
 /* like strtok_r(str, " ", &savep), but space isn't a delimiter inside an unclosed '[' or '(' (RFC 9051 SS9 header-list) */
 static char *
 fetch_att_tok(char *str, char **savep)
@@ -266,6 +329,97 @@ parse_partial_suffix(const char *s, int *has_partial_o
 	return (0);
 }
 
+/* generic BODY.PEEK[...] tok (already known not to be HEADER.FIELDS): [], [TEXT], or [<section-part>], optional <<start.count>> (SS6.4.5); updates *attrs_inout/section_part_out/partial-range out-params. Returns 1 on success, 0 if silently degraded (*degraded_out set, same lenient skip as other unsupported forms), -1 on a hard parse error (*errmsg set). */
+static int
+parse_body_peek_section_tok(const char *tok, uint32_t *attrs_inout,
+    char *section_part_out, size_t section_part_outsize,
+    int *has_partial_out, uint32_t *partial_start_out,
+    uint32_t *partial_count_out, int *degraded_out, const char **errmsg)
+{
+	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_out = 1;
+		return (0);
+	}
+	if ((size_t)(close - bracket_start) >= sizeof(inner)) {
+		*degraded_out = 1;
+		return (0);
+	}
+	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_out, partial_start_out,
+	    partial_count_out) == -1) {
+		*errmsg = "malformed <partial> range";
+		return (-1);
+	}
+
+	if (inner[0] == '\0') {
+		*attrs_inout |= MBOX_FETCH_BODY_WHOLE;
+	} else if (strcasecmp(inner, "TEXT") == 0) {
+		*attrs_inout |= MBOX_FETCH_BODY_TEXT;
+	} else if (section_part_valid(inner)) {
+		*attrs_inout |= MBOX_FETCH_BODY_PART;
+		if (strlcpy(section_part_out, inner, section_part_outsize) >=
+		    section_part_outsize) {
+			*attrs_inout &= ~MBOX_FETCH_BODY_PART;
+			*degraded_out = 1;
+			return (0);
+		}
+	} else {
+		*degraded_out = 1;	/* recognized shape, unsupported section (e.g. "2.1.TEXT") */
+		return (0);
+	}
+
+	return (1);
+}
+
+/* BODY.PEEK[HEADER.FIELDS...] tok: extracts the bracket body, dedupes a second HEADER.FIELDS item, delegates to parse_header_fields_att(). Returns 1 on success (*attrs_inout and the header_fields_*_out params updated), 0 if this token should be silently ignored (a duplicate), -1 on a hard parse error (*errmsg set). */
+static int
+parse_body_peek_header_fields_tok(const char *tok, uint32_t *attrs_inout,
+    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, const char **errmsg)
+{
+	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_inout & MBOX_FETCH_HEADER_FIELDS)
+		return (0);	/* 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_inout |= MBOX_FETCH_HEADER_FIELDS;
+	return (1);
+}
+
 /* RFC 9051 SS6.4.5 fetch-att + ALL/FULL/FAST macros; unsupported items silently skipped (*degraded_out=1) unless all are, then -2/NO */
 int
 parse_fetch_atts(char *spec, uint32_t *attrs_out, int *degraded_out,
@@ -341,86 +495,18 @@ parse_fetch_atts(char *spec, uint32_t *attrs_out, int 
 		} else if (strncasecmp(tok, "BODY.PEEK[", strlen("BODY.PEEK[")) ==
 		    0 && strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
 		    strlen("BODY.PEEK[HEADER.FIELDS")) != 0) {
-			/* every other BODY.PEEK[...] shape: [], [TEXT], or [<section-part>], optional <<start.count>> (SS6.4.5) */
-			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 well-bracketed, same lenient skip as other unsupported forms */
-				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";
+			if (parse_body_peek_section_tok(tok, &attrs,
+			    section_part_out, section_part_outsize,
+			    &has_partial, &partial_start, &partial_count,
+			    &degraded, errmsg) == -1)
 				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, unsupported section (e.g. "2.1.TEXT") */
-				continue;
-			}
 		} else if (strncasecmp(tok, "BODY.PEEK[HEADER.FIELDS",
 		    strlen("BODY.PEEK[HEADER.FIELDS")) == 0) {
-			/* prefix-matched (field-name list varies); a second HEADER.FIELDS item is silently ignored */
-			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,
+			if (parse_body_peek_header_fields_tok(tok, &attrs,
 			    header_fields_not_out, header_fields_out,
-			    header_fields_outsize) == -1) {
-				*errmsg = "malformed HEADER.FIELDS section";
+			    header_fields_outsize, header_fields_label_out,
+			    header_fields_label_outsize, errmsg) == -1)
 				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) {
 			attrs |= MBOX_FETCH_ENVELOPE;	/* RFC 9051 SS7.5.2; no .PEEK variant, no \Seen side effect */
 		} else if (strcasecmp(tok, "BODY") == 0 ||
@@ -747,9 +833,24 @@ parse_fetch_modifiers(char *modspec, struct imsg_mbox_
 				    "mod-sequence value";
 				return (-1);
 			}
+			/*
+			 * RFC 7162 SS7: chgsince-fetch-mod takes a
+			 * mod-sequence-value, "1*DIGIT ... (1 <= n <=
+			 * 9,223,372,036,854,775,807)". strtoull(3) accepts a
+			 * leading sign, so "-1" would otherwise arrive as
+			 * ULLONG_MAX with errno untouched and match nothing,
+			 * silently. Same first-character-is-a-digit guard
+			 * auth.c, index.c and listener.c's literal parser use.
+			 */
+			if (*valtok < '0' || *valtok > '9') {
+				*errmsg = "invalid CHANGEDSINCE mod-sequence";
+				return (-1);
+			}
 			errno = 0;
 			req->changedsince = strtoull(valtok, &ep, 10);
-			if (*ep != '\0' || errno != 0) {
+			if (*ep != '\0' || errno != 0 ||
+			    req->changedsince == 0 ||
+			    req->changedsince > MODSEQ_MAX) {
 				*errmsg = "invalid CHANGEDSINCE mod-sequence";
 				return (-1);
 			}
@@ -791,8 +892,9 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	struct imsg_mbox_fetch	 req;
 	const char		*seqtok;
 	char			*attspec, *modspec;
-	uint32_t		 lo, hi, attrs;
-	int			 lo_star, hi_star, rc, want_vanished = 0, degraded;
+	struct seq_range	 ranges[SEQSET_MAX_RANGES];
+	uint32_t		 nranges, attrs;
+	int			 rc, want_vanished = 0, degraded;
 	int			 header_fields_not = 0;
 	char			 header_fields[HEADER_FIELDS_MAX];
 	char			 header_fields_label[HEADER_FIELDS_LABEL_MAX];
@@ -822,18 +924,11 @@ fetch_dispatch(struct session *s, const char *tag, cha
 		args++;
 	attspec = args;
 
-	if (strchr(seqtok, ',') != NULL) {
-		session_reply(s, tag, "BAD",
-		    "comma-separated sequence sets not supported "
-		    "issue separate FETCH commands");
+	if (parse_sequence_set(seqtok, ranges, &nranges, &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
 		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,
@@ -950,10 +1045,7 @@ fetch_dispatch(struct session *s, const char *tag, cha
 		return (1);
 	}
 
-	req.seq_lo = lo;
-	req.seq_hi = hi;
-	req.lo_is_star = lo_star;
-	req.hi_is_star = hi_star;
+	req.nranges = nranges;
 
 	/* RFC 7162 SS3.1: MODSEQ fetch-att and CHANGEDSINCE modifier are both CONDSTORE-enabling */
 	if (req.attrs & MBOX_FETCH_MODSEQ)
@@ -968,10 +1060,12 @@ fetch_dispatch(struct session *s, const char *tag, cha
 	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);
+	if (!send_mbox_request(s, IMSG_MBOX_FETCH, cmdname, "IMSG_MBOX_FETCH",
+	    &req, sizeof(req), ranges, nranges, sizeof(struct seq_range))) {
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		s->state = SESSION_SELECTED;
+		return (1);
+	}
 
 	return (1);
 }
blob - f0352b50e26676e1d971d79906d320fb43b27b6a
blob + ec0bf31d729d076828fe70cb87dd5dd50c2dd6a8
--- src/imapd.8
+++ src/imapd.8
@@ -3,7 +3,7 @@
 .\" 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 $
+.Dd $Mdocdate: September 6 2026 $
 .Dt IMAPD 8
 .Os
 .Sh NAME
@@ -31,7 +31,7 @@ re-executes unprivileged
 and
 .Em auth
 children over
-.Xr imsg 3
+.Xr imsg_init 3
 control channels; a
 .Em store
 child is forked per authenticated session, chroots into the mail
@@ -49,6 +49,15 @@ and port 993
 .Pq implicit TLS, per RFC 8314 ,
 both via
 .Xr tls_init 3 .
+Those are the defaults, used when
+.Pa /etc/imapd.conf
+contains no
+.Ic listen
+directive at all.
+A file that contains any
+.Ic listen
+directive selects listeners as well as configuring them: only the
+listeners it names are bound.
 Authentication is
 .Li AUTH=PLAIN
 only, and is refused before TLS is established;
@@ -253,6 +262,22 @@ a second
 .Ic listen
 line naming a different address is a configuration error.
 .Pp
+A
+.Ic listen
+directive also selects which listeners run.
+If the file contains no
+.Ic listen
+directive, both listeners are bound on their default ports.
+If it contains any, only the listeners it names are bound: a file whose
+only
+.Ic listen
+directive is
+.Pp
+.Dl listen on * tls port 993
+.Pp
+serves implicit TLS alone, with nothing on port 143, which is the
+deployment RFC 8314 Section 3 asks for.
+.Pp
 .Ar address
 must be a literal IPv4 address, a literal IPv6 address,
 .Ql ::
@@ -267,7 +292,9 @@ Hostnames are not accepted: resolving one would add a 
 .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
+Since
+.Ox Ns 's
+IPv6 sockets are always IPv6-only
 .Pq no v4-mapped-address dual binding , unlike Linux ,
 .Ql *
 binds two sockets per listener, one
@@ -281,6 +308,31 @@ Mail spool root, chrooted into by the
 child.
 Defaults to
 .Pa /var/mail/imapd .
+.Ar path
+and everything directly under it
+.Pq one entry per mail user's maildir
+must be owned by
+.Sy root
+and not writable by mail users.
+Each user's maildir path under
+.Ar path
+is validated lexically only; if it names a symlink, the
+.Em store
+child's
+.Xr chroot 2
+follows it.
+That is contained:
+.Xr chroot 2
+resolves the target inside itself, and
+.Xr unveil 2
+in the
+.Em store
+child narrows the view further.
+A mail user able to create a symlink at their own maildir path would
+nonetheless choose that child's starting directory within the spool.
+The ownership requirement above is therefore a deployment assumption
+.Nm
+does not enforce in software.
 .It Ic credentials Ar path
 Credentials file (see below).
 Defaults to
@@ -294,6 +346,95 @@ 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 idle poll Ar seconds
+How often a session in
+.Li IDLE
+rechecks its selected mailbox.
+.Nm
+does not receive an event when mail arrives, so
+.Li IDLE
+is served by polling: every
+.Ar seconds ,
+the session asks its
+.Em store
+child whether anything has changed, and reports new messages and
+expunges if so.
+New mail is therefore announced within one interval rather than
+instantly, whether it was delivered by an external
+.Xr smtpd 8
+or by another IMAP session.
+.Pp
+Polling is cheap by design.
+The
+.Em store
+child first compares the modification times of the mailbox directory
+and its
+.Pa new
+subdirectory against the previous look; if neither has moved it answers
+immediately, taking no lock and reading nothing.
+Only a mailbox that has actually been touched costs an index read, and
+only one holding an unindexed delivery costs an exclusive lock.
+.Pp
+.Ar seconds
+must be between 1 and 300 inclusive, or 0 to disable polling
+altogether.
+With polling disabled a session in
+.Li IDLE
+is told nothing until it sends
+.Li DONE ,
+which is rarely what an operator wants.
+Defaults to 5.
+.It Ic startups begin Ar count Ic rate Ar percent Ic full Ar count
+Admission control on concurrent connections that have not yet
+authenticated, using
+.Xr sshd_config 5 Ns 's
+MaxStartups algorithm; see that manual for the canonical description.
+.Pp
+Below
+.Ic begin
+unauthenticated connections,
+.Nm
+accepts every new connection.
+Between
+.Ic begin
+and
+.Ic full
+it refuses new connections with a probability rising linearly from
+.Ic rate
+percent at
+.Ic begin
+to 100 percent at
+.Ic full .
+At or above
+.Ic full
+every new connection is refused.
+A connection stops counting the moment it authenticates, so the limit
+bounds unfinished logins rather than established sessions.
+.Pp
+Both counts must be between 0 and 1000000 inclusive,
+.Ar percent
+between 0 and 100 inclusive, and
+.Ic full
+must not be smaller than
+.Ic begin ;
+anything else is a configuration error.
+Setting
+.Ic full
+to 0 disables the throttle entirely and accepts unconditionally,
+which is the only way to turn it off:
+.Ic begin
+and
+.Ic rate
+have no such special value.
+Defaults to
+.Ic begin
+10
+.Ic rate
+30
+.Ic full
+100, matching
+.Xr sshd_config 5 Ns 's
+own default of 10:30:100.
 .It Ic attachment max Ar bytes
 Largest message
 .Nm
@@ -363,6 +504,31 @@ The
 child
 .Xr chroot 2 Ns s
 into this directory before dropping privileges.
+.It Pa imapd.uidvalidity
+Per-user record, at the root of each maildir, of the highest
+.Li UIDVALIDITY
+ever issued to that user.
+A mailbox that is created, or recreated after a
+.Li DELETE ,
+is given a value greater than every value in this file rather than the
+current time alone, so that a mailbox recreated quickly cannot be handed
+a
+.Li UIDVALIDITY
+it has used before.
+RFC 9051 Section 2.3.1.1 requires that; a bare timestamp only
+approximates it, because two mailboxes created in the same second get
+the same value.
+.Pp
+The file is created on demand, holds one decimal number, and is its own
+lock.
+It is removed with the maildir and needs no separate administration.
+Note that
+.Xr imapduser 8 Fl d
+deliberately leaves a maildir in place, so an account removed and
+re-added keeps its history here, which is the desired behaviour;
+removing a maildir by hand and recreating it resets the record, and a
+client holding a cache from before that point could in principle be
+misled.
 .El
 .Sh NETWORK
 .Nm
@@ -377,9 +543,12 @@ by default; these, along with the listen address, are 
 .Pa /etc/imapd.conf
 (or the file named by
 .Fl f )
-at startup, falling back to defaults for any
+at startup.
+A file containing no
 .Ic listen
-directive the file omits.
+directive leaves both listeners on those defaults; a file containing any
+.Ic listen
+directive binds only the listeners it names.
 IPv6 and dual-stack binding are available but not the default; see the
 .Ic listen on
 directive under FILES above.
@@ -388,6 +557,7 @@ directive under FILES above.
 .Xr imsg_init 3 ,
 .Xr tls_init 3 ,
 .Xr httpd.conf 5 ,
+.Xr sshd_config 5 ,
 .Xr httpd 8 ,
 .Xr imapduser 8 ,
 .Xr smtpd 8
@@ -408,10 +578,27 @@ directive under FILES above.
 .%R RFC 7162
 .%T IMAP Extensions: Quick Flag Changes Resynchronization (CONDSTORE) and Quick Mailbox Resynchronization (QRESYNC)
 .Re
+.Pp
+.Rs
+.%A F. Yergeau
+.%D November 2003
+.%R RFC 3629
+.%T UTF-8, a transformation format of ISO 10646
+.Re
+.Pp
+.Rs
+.%A J. Klensin
+.%A M. Padlipsky
+.%D March 2008
+.%R RFC 5198
+.%T Unicode Format for Network Interchange
+.Re
 .Sh HISTORY
 .Nm
 is a from-scratch IMAP server written for the OpenIMAPD project, in
-the OpenBSD privilege-separation tradition of
+the
+.Ox
+privilege-separation tradition of
 .Xr smtpd 8 .
 .Sh CAVEATS
 This implementation is under active development.
@@ -420,3 +607,87 @@ This implementation is under active development.
 and shared or multi-user mailboxes
 .Pq no Li ACL support
 are deliberately left out.
+.Pp
+Mailbox names are required to be well-formed UTF-8.
+RFC 9051 Section 5.1 encodes them in Net-Unicode
+.Pq RFC 5198 ,
+and requires a server to prohibit the creation of 8-bit names that do not
+comply.
+.Nm
+enforces three of Net-Unicode's requirements and not the other three, which
+is worth stating plainly rather than leaving to be discovered:
+.Bl -bullet -offset indent -compact
+.It
+UTF-8 well-formedness per RFC 3629 is enforced.
+Overlong encodings, UTF-16 surrogates and anything above U+10FFFF are
+refused.
+.It
+The C1 controls U+0080 to U+009F are refused, as Net-Unicode requires.
+C0 and DEL were already refused, which RFC 9051 Section 5.1 permits.
+.It
+U+FEFF is refused anywhere in a name.
+Net-Unicode forbids it only at the beginning;
+.Nm
+is deliberately stricter, because a zero-width no-break space inside a name
+produces two mailboxes no user can tell apart.
+.It
+Normalization to NFC is
+.Em not
+performed and
+.Em not
+required.
+Two canonically equivalent spellings of the same name, such as U+00E9 and
+U+0065 U+0301, are therefore two distinct mailboxes with distinct
+.Li UIDVALIDITY
+values.
+A client that normalizes differently from another client used on the same
+account will see both.
+.It
+Names are
+.Em not
+checked against a Unicode version, so a name containing an unassigned code
+point is accepted.
+Net-Unicode forbids that, and honouring it would require a Unicode character
+database inside the daemon.
+.El
+.Pp
+A name that fails these checks is refused with
+.Li NO [CANNOT]
+by
+.Li CREATE ,
+.Li RENAME
+and
+.Li COPY Ns / Ns Li MOVE ,
+and reported as nonexistent by the commands that only ask whether a mailbox
+is there.
+A directory whose name fails them is skipped by
+.Li LIST
+and cannot be selected; the first such skip in a connection is logged, naming
+the directory.
+This last case can only arise for a mailbox created before these checks
+existed, or created outside
+.Nm
+altogether.
+.Pp
+RFC 9051 Section 6.3.5 does not say what becomes of a session that
+.Li DELETE Ns s
+the mailbox it currently has selected, and states no condition under which
+such a
+.Li DELETE
+must be refused.
+.Nm
+allows it and returns the session to the authenticated state, as
+.Li CLOSE
+does: the mailbox no longer exists, so nothing is selected.
+A client that issues
+.Li FETCH ,
+.Li STORE ,
+.Li SEARCH ,
+.Li COPY ,
+.Li MOVE
+or
+.Li EXPUNGE
+afterwards is refused until it selects a mailbox again, rather than being
+served
+.Li INBOX
+under the deleted mailbox's name.
blob - 990a13c9f6bb93fc272d9a2c6c0f30821042744d
blob + 3488e7c9336f34c6c5d5a011a449644f91959cfa
--- src/imapd.conf.example
+++ src/imapd.conf.example
@@ -58,3 +58,23 @@ listen on 0.0.0.0 tls port 993
 # imapd.h for the full rationale). Uncomment and adjust if your mail
 # routinely carries larger attachments than that.
 attachment max 41943040
+
+# How often a session in IDLE rechecks its selected mailbox. imapd does
+# not get an event when mail arrives, so IDLE is served by polling: new
+# mail is announced within one interval, whether it was delivered by an
+# external MTA or by another IMAP session. Most polls cost two stat(2)
+# calls and no lock, so a short interval is cheap. Must be 1-300 seconds,
+# or 0 to disable pushing entirely (an IDLEing session is then told
+# nothing until it sends DONE). Defaults to 5.
+#idle poll 5
+
+# Admission-control throttle on concurrent, not-yet-authenticated
+# connections, modeled on sshd_config(5)'s MaxStartups (see that
+# man page for the canonical description of this algorithm).
+# Below "begin" open connections, every new connection is
+# accepted normally. Between "begin" and "full", new connections
+# are refused with linearly increasing probability, starting at
+# "rate" percent at "begin" and reaching 100% at "full". At or
+# above "full", every new connection is refused outright.
+# Defaults to sshd_config(5)'s own default, 10:30:100.
+#startups begin 10 rate 30 full 100
blob - 7ebe46f03770374d5b2bb7631a129c716bc566cb
blob + 0cf2479b94bc7d44cefac9c27ab52e1c0ccfd64e
--- src/imapd.h
+++ src/imapd.h
@@ -24,12 +24,14 @@
 #include <sys/types.h>
 #include <sys/cdefs.h>		/* __dead */
 #include <sys/queue.h>
+#include <sys/socket.h>	/* struct sockaddr_storage, socklen_t --
+				 * imsg_listener_session_init below */
 
 #include <event.h>
 #include <imsg.h>
 #include <stdint.h>
 
-#define IMAPD_VERSION	"0.1.1"
+#define IMAPD_VERSION	"0.1.3"
 
 /*
  * Process roles, selected at exec time via "-x <role>". See main.c.
@@ -38,7 +40,11 @@ enum openimap_proc_type {
 	PROC_PARENT,
 	PROC_LISTENER,
 	PROC_AUTH,
-	PROC_STORE
+	PROC_STORE,
+	PROC_KEYMGR,
+	PROC_SEARCH		/* SS8: per-connection SEARCH-grammar
+				 * parsing oracle, docs/openimap-tls-privsep-
+				 * design.md SS8.1 */
 };
 
 /*
@@ -51,14 +57,28 @@ enum imsg_type {
 	IMSG_SETUP_PEER,
 	IMSG_SETUP_DONE,
 
-	/* parent -> listener, at boot */
-	IMSG_LISTENER_SOCKET_CLEARTEXT,	/* one bound, listening fd for the
-					 * cleartext/STARTTLS port */
-	IMSG_LISTENER_SOCKET_TLS,	/* same, for the implicit-TLS port */
-	IMSG_TLS_CERT,
-	IMSG_TLS_KEY,
-	IMSG_LISTENER_INIT,
+	IMSG_TLS_CERT,			/* parent -> new listener-worker, once,
+					 * during its own per-connection boot
+					 * sequence (part of the same handshake
+					 * as IMSG_LISTENER_SESSION_INIT below);
+					 * parent -> keymgr, at boot AND on every
+					 * SIGHUP reload (keymgr.c is still the
+					 * one long-lived process that needs a
+					 * live reload path, see its header
+					 * comment). The certificate is public,
+					 * so every recipient just gets its own
+					 * copy. */
 
+	/*
+	 * parent -> new listener-worker, once, immediately after
+	 * fork -- SS7's replicated-listener model has parent doing
+	 * the accept() itself (see parent.c's header comment) and
+	 * handing off one already-accepted connection (fd-passed
+	 * alongside this payload) instead of a long-lived listener
+	 * accept()ing from bound sockets handed to it at boot.
+	 */
+	IMSG_LISTENER_SESSION_INIT,
+
 	/* parent -> auth, at boot */
 	IMSG_AUTH_INIT,
 
@@ -66,18 +86,61 @@ enum imsg_type {
 	IMSG_AUTH_REQUEST,
 	IMSG_AUTH_RESULT,
 
+	/*
+	 * parent -> new listener-worker AND parent -> new search-
+	 * oracle-worker, once each, immediately after fork -- SS8's
+	 * narrow per-connection SEARCH-parsing oracle (docs/openimap-
+	 * tls-privsep-design.md SS8.1). Kept distinct from
+	 * IMSG_SETUP_PEER rather than reusing its id field:
+	 * listener_main()'s boot-drain loop already uses id as a
+	 * hard binary discriminator (0 for the auth peer, nonzero/
+	 * session_id for the keymgr peer), and a third peer with no
+	 * unambiguous id to claim is cleaner as its own type than a
+	 * guessed sentinel value.
+	 */
+	IMSG_SETUP_SEARCH_PEER,
+
+	/*
+	 * listener -> search-oracle: already-buffered SEARCH argument
+	 * text (post RETURN/CHARSET; the highest-risk grammar only,
+	 * see SS8.1), sent as raw trailing bytes with no fixed
+	 * struct, same technique as IMSG_TLS_CERT. search-oracle ->
+	 * listener: struct imsg_search_parse_result below, echoing
+	 * parse_search_key_list()'s own (rc, errmsg) contract
+	 * (search_cmd.c). At most one of these round-trips is ever
+	 * in flight per session -- listener.c's session_is_busy()/
+	 * cmd_queue pipelining already makes a second SEARCH wait,
+	 * not race, so no correlation id is needed.
+	 */
+	IMSG_SEARCH_PARSE_REQUEST,
+	IMSG_SEARCH_PARSE_RESULT,
+
+	/* parent -> keymgr, at boot and on SIGHUP reload: the real TLS
+	 * private key (IMSG_TLS_CERT above carries the matching
+	 * certificate). See docs/openimap-tls-privsep-design.md SS5. */
+	IMSG_KEYMGR_INIT,
+
+	/* listener <-> keymgr: a private-key operation, forwarded
+	 * synchronously from listener's OpenSSL RSA_METHOD/EC_KEY_METHOD
+	 * engine override (see listener.c) to keymgr and back. Each
+	 * reply reuses the same type as its request, correlated by the
+	 * imsg id field, mirroring smtpd's ca.c IMSG_CA_* convention. */
+	IMSG_KEYMGR_RSA_PRIVENC,
+	IMSG_KEYMGR_RSA_PRIVDEC,
+	IMSG_KEYMGR_ECDSA_SIGN,
+
 	/* auth -> parent, per successful login */
 	IMSG_AUTH_CRED,
 
 	/* per-session store spawn */
 	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_SELECT,		/* EXAMINE too: it is a SELECT with the
+					 * request's "readonly" field set, see
+					 * select_or_examine() in mailbox_cmd.c */
 	IMSG_MBOX_SELECTED,
 	IMSG_MBOX_FETCH,
 	IMSG_MBOX_FETCH_META,
@@ -108,7 +171,6 @@ enum imsg_type {
 	IMSG_MBOX_DELETE,
 	IMSG_MBOX_RENAME,
 	IMSG_MBOX_RESULT,
-	IMSG_MBOX_UNSOLICITED,
 
 	/*
 	 * RFC 7162 (CONDSTORE/QRESYNC) additions
@@ -153,8 +215,18 @@ 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 */
+	/*
+	 * A port of 0 means "this listener is not configured, do not bind
+	 * it". parse.y seeds both with their defaults and clears the one a
+	 * config did not ask for -- but only when the config named at least
+	 * one "listen" line, so a config with none still gets both, as it
+	 * always has. Before this existed, parse.y recorded which listeners
+	 * were named in file-static variables that never reached this struct,
+	 * so parent.c bound both unconditionally and "listen on * tls port
+	 * 993" alone still served cleartext on 143.
+	 */
+	uint16_t port_cleartext;	/* 143, STARTTLS; 0 = not configured */
+	uint16_t port_implicit_tls;	/* 993, RFC 8314; 0 = not configured */
 	char	 spool_root[1024];	/* mail spool root, store's chroot */
 	char	 cred_file[1024];	/* auth's credential file, one
 					 * line per user, format
@@ -163,6 +235,23 @@ struct openimap_config {
 	char	 tls_cert_file[1024];
 	char	 tls_key_file[1024];
 	uint32_t bodystructure_read_max; /* "attachment max" directive */
+
+	/* SS7's "startups begin/rate/full" directive; see IMSG_
+	 * LISTENER_MAXSTARTUPS's enum comment. Defaults (10/30/100)
+	 * set by config_load(), matching sshd_config(5)'s own
+	 * default of "10:30:100". */
+	uint32_t max_startups_begin;
+	uint32_t max_startups_rate;	/* percent, 0-100 */
+	uint32_t max_startups_full;
+
+	/*
+	 * "idle poll" directive: how often an IDLEing session asks its store
+	 * child whether the selected mailbox has changed. 0 disables polling
+	 * entirely, which restores the pre-poll behaviour -- an IDLEing
+	 * session then sees nothing until it sends DONE. See listener.c's
+	 * session_idle_poll() and index.c's idle_probe_unchanged().
+	 */
+	uint32_t idle_poll_secs;
 };
 
 /*
@@ -175,20 +264,76 @@ struct openimap_config {
 /*
  * Boot-time config-delivery payloads.
  */
-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 */
+/*
+ * IMSG_LISTENER_SESSION_INIT's payload; see its enum comment.
+ * The accepted client fd itself rides as the imsg's fd-pass,
+ * not a field here. remote_ss/remote_sslen are the raw
+ * sockaddr accept(2) filled in for parent, carried as-is
+ * rather than pre-formatted, so the listener-worker keeps
+ * doing its own getnameinfo() formatting into struct
+ * session's remote_addr, same as listener_start_session()
+ * always has (listener.c).
+ */
+struct imsg_listener_session_init {
+	uint32_t		session_id;
+	int			implicit_tls;
+	struct sockaddr_storage	remote_ss;
+	socklen_t		remote_sslen;
+	/*
+	 * Carried per connection rather than read from a config the
+	 * listener-worker does not have: under SS7 this process is spawned
+	 * fresh per connection, so a SIGHUP that changes "idle poll" reaches
+	 * every later connection with no reload machinery of its own.
+	 */
+	uint32_t		idle_poll_secs;
 };
 
 struct imsg_auth_init {
 	char		cred_file[1024];
 };
 
+/*
+ * IMSG_KEYMGR_RSA_PRIVENC / IMSG_KEYMGR_RSA_PRIVDEC / IMSG_KEYMGR_
+ * ECDSA_SIGN (listener -> keymgr, request; keymgr -> listener,
+ * reply, same imsg type both ways, correlated by the imsg id
+ * field). Mirrors smtpd's ca.c IMSG_CA_RSA_PRIVENC/_PRIVDEC/_ECDSA_
+ * SIGN payload shape (request id, pubkey hash, input bytes, target
+ * length/padding mode; result length + output bytes), adapted to
+ * imapd's own "fixed header + trailing raw bytes on one imsg"
+ * convention (imsg_get_buf()+imsg_get_len(), see imsg_mbox_append
+ * below) instead of smtpd's m_* message-abstraction macros.
+ *
+ * hash is libtls's tls_cert_pubkey_hash() format ("SHA256:" plus
+ * lowercase hex of the certificate's DER SubjectPublicKeyInfo
+ * digest), computed independently by keymgr.c's keymgr_pubkey_
+ * hash() from the certificate it holds; a request whose hash
+ * doesn't match is refused. padding is an RSA_PKCS1_PADDING-style
+ * OpenSSL padding constant, meaningful only for the two RSA
+ * operations, ignored for ECDSA_SIGN.
+ *
+ * KEYMGR_DATA_MAX (1024 bytes) covers both directions: an RSA
+ * to/from buffer sized to RSA_size() (1024 bytes exactly covers an
+ * 8192-bit RSA key, comfortably past any realistic configuration)
+ * and an ECDSA digest/signature, both far smaller in practice.
+ */
+#define KEYMGR_HASH_MAX	80	/* "SHA256:" + 64 hex chars + NUL, generous */
+#define KEYMGR_DATA_MAX	1024
+
+struct imsg_keymgr_sign_request {
+	char		hash[KEYMGR_HASH_MAX];
+	uint32_t	padding;	/* RSA padding mode; ignored for ECDSA */
+	uint32_t	fromlen;	/* trailing input bytes, <= KEYMGR_DATA_MAX */
+};
+
+struct imsg_keymgr_sign_reply {
+	int		ok;	/* 0 = refused/failed; listener's engine callback
+				 * returns this straight to OpenSSL, which fails
+				 * that one RSA/EC operation, same as any other
+				 * engine failure -- see keymgr.c's header comment
+				 * on why this is a reply, not a fatalx() */
+	uint32_t	tolen;	/* trailing output bytes, meaningful only if ok */
+};
+
 struct imsg_auth_request {
 	uint32_t	session_id;
 	char		username[AUTH_USERNAME_MAX];
@@ -267,10 +412,13 @@ struct imsg_mbox_select {
 	int		qresync_has_uids; /* 0 = client omitted known-uids;
 					 * store.c then defaults to the full
 					 * UID range (SS3.2.5.1) */
-	uint32_t	qresync_uid_lo;	/* known-uids range, single range only
-					 * (no comma lists); ignored if
+	uint32_t	qresync_nranges; /* count of the trailing struct
+					 * seq_range array (RFC 9051 SS9
+					 * sequence-set; "*" is forbidden in
+					 * known-uids per SS3.2.5.1, already
+					 * rejected by parse_qresync_group());
+					 * ignored (and 0) if
 					 * !qresync_has_uids */
-	uint32_t	qresync_uid_hi;
 };
 
 struct imsg_mbox_selected {
@@ -430,8 +578,15 @@ struct imsg_mbox_select_vanished {
 /*
  * Cap on the dotted-numeric section-part string (struct imsg_mbox_fetch's
  * section_part below). Sized for MIME_MAX_DEPTH (10) levels x 2 digits
- * plus dots: 29 worst case; 40 leaves headroom. listener.c rejects (BAD)
- * an oversized section-part rather than truncating it.
+ * plus dots: 29 worst case; 40 leaves headroom.
+ *
+ * An oversized section-part is never truncated. fetch_cmd.c's
+ * parse_body_peek_section_tok() DROPS that one fetch-att and sets its
+ * degraded flag -- the same lenient skip every other unsupported
+ * BODY[...] form gets -- so the response simply omits that item, and the
+ * client only sees a NO if every item it asked for was dropped. (This
+ * comment previously said listener.c rejects an oversized section-part
+ * with BAD; it does not, and never did.)
  */
 #define SECTION_PART_MAX	40
 
@@ -499,6 +654,18 @@ struct imsg_mbox_select_vanished {
 #define BODYSTRUCTURE_MAX	12000
 
 /*
+ * "idle poll" bounds. The default is short because a poll is cheap: the store
+ * child answers an unchanged mailbox with two stat(2) calls and no lock (see
+ * index.c's idle_probe_unchanged()), so the cost of a tighter interval is
+ * two syscalls and one small imsg round trip per idling session.
+ * IDLE_POLL_MAX is a sanity bound, not a protocol limit -- RFC 2177 lets a
+ * client hold an IDLE for 29 minutes, and a poll slower than a few minutes
+ * would make IDLE indistinguishable from the broken behaviour this replaced.
+ */
+#define IDLE_POLL_DEFAULT	5	/* seconds */
+#define IDLE_POLL_MAX		300	/* seconds; 0 disables polling */
+
+/*
  * Cap on raw on-disk bytes build_bodystructure() (via read_message_
  * body()) will read while deriving a message's MIME structure.
  * Independent from APPEND_LITERAL_MAX: mail delivered by an external
@@ -519,15 +686,42 @@ struct imsg_mbox_select_vanished {
  */
 #define BODYSTRUCTURE_READ_DEFAULT	41943040
 
+/*
+ * RFC 9051 SS9 sequence-set: (seq-number/seq-range) *("," seq-number/
+ * seq-range) -- one comma-separated range. "*" ("the last message") is
+ * carried unresolved via lo_is_star/hi_is_star; only the store process
+ * knows the live value (highest sequence number or UID in use) to
+ * resolve it against. A full sequence-set travels to the store process
+ * as an imsg request's trailing variable-length array of these (struct
+ * seq_range ranges[nranges]), the same pattern already used below for
+ * IMSG_MBOX_SEARCH's search_node array.
+ */
+struct seq_range {
+	uint32_t	lo;	/* 1-based, inclusive; ignored if lo_is_star */
+	uint32_t	hi;	/* 1-based, inclusive; ignored if hi_is_star */
+	int		lo_is_star;
+	int		hi_is_star;
+};
+
+/*
+ * Bounds a sequence-set's comma-separated range count, both on the wire
+ * (so a struct seq_range trailing array can't grow an imsg past
+ * MAX_IMSGSIZE, 16384 -- see imsgev.c) and for the store side's own
+ * per-range membership check. 500 ranges is 8000 bytes of trailing
+ * array, well under budget alongside any of this file's imsg_mbox_*
+ * request headers; a client whose sequence-set has more comma segments
+ * than fit in listener.h's 8192-byte SESSION_INBUF_MAX command line
+ * already can't reach this cap in practice.
+ */
+#define SEQSET_MAX_RANGES	500
+
 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;	/* "*" resolves against store's live
-					 * message count, not listener's
-					 * possibly-stale SELECT-time count */
+	uint32_t	nranges;	/* count of the trailing struct
+					 * seq_range array (RFC 9051 SS9
+					 * sequence-set); "*" resolves
+					 * against store's live message
+					 * count, not listener's possibly-
+					 * stale SELECT-time count */
 	uint32_t	attrs;		/* bitmask of MBOX_FETCH_* above */
 
 	/* RFC 7162 SS3.1.4.1 CHANGEDSINCE; has_changedsince distinguishes
@@ -535,8 +729,9 @@ struct imsg_mbox_fetch {
 	int		has_changedsince;
 	uint64_t	changedsince;
 
-	/* by_uid: RFC 9051 SS6.4.9 UID FETCH, resolve seq_lo/seq_hi
-	 * against UID space. listener.c separately forces MBOX_FETCH_UID
+	/* by_uid: RFC 9051 SS6.4.9 UID FETCH, resolve the trailing
+	 * sequence-set ranges against UID space rather than sequence-
+	 * number space. listener.c separately forces MBOX_FETCH_UID
 	 * into attrs whenever this is set.
 	 *
 	 * want_vanished: RFC 7162 SS3.2.6 VANISHED modifier (only legal
@@ -757,10 +952,13 @@ struct imsg_mbox_result {
 #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;
+	uint32_t	nranges;	/* count of the trailing struct
+					 * seq_range array (RFC 9051 SS9
+					 * sequence-set), same header-plus-
+					 * trailing-array shape as
+					 * imsg_mbox_fetch above; always >= 1,
+					 * STORE always requires a
+					 * sequence-set */
 	int		mode;		/* MBOX_STORE_* above */
 	int		silent;		/* 1 = ".SILENT", suppress the
 					 * untagged FETCH per message */
@@ -813,17 +1011,17 @@ struct imsg_mbox_store_modified {
 struct imsg_mbox_expunge {
 	int		silent;	/* 1 for CLOSE, 0 for a real EXPUNGE command */
 
-	/* RFC 9051 SS6.4.9's UID EXPUNGE form: only \Deleted messages whose
-	 * UID is in [seq_lo, seq_hi] are removed. Never set together with
-	 * silent=1 (no "UID CLOSE"). seq_lo/seq_hi/lo_is_star/hi_is_star
-	 * mirror imsg_mbox_fetch's naming; meaningless when !by_uid (plain
-	 * EXPUNGE takes no arguments).
+	/* RFC 9051 SS6.4.9's UID EXPUNGE form: only \Deleted messages in
+	 * the trailing struct seq_range array (nranges entries, same
+	 * header-plus-trailing-array shape as imsg_mbox_fetch above) are
+	 * removed. Never set together with silent=1 (no "UID CLOSE").
+	 * Meaningless when !by_uid (plain EXPUNGE/CLOSE take no
+	 * arguments) -- nranges is 0 and there's no trailing data in
+	 * that case, unlike every other sequence-set-bearing imsg here,
+	 * which always require nranges >= 1.
 	 */
 	int		by_uid;
-	uint32_t	seq_lo;
-	uint32_t	seq_hi;
-	int		lo_is_star;
-	int		hi_is_star;
+	uint32_t	nranges;
 };
 
 struct imsg_mbox_expunged {
@@ -853,10 +1051,12 @@ struct imsg_mbox_expunged {
  */
 struct imsg_mbox_copy {
 	int		by_uid;
-	uint32_t	seq_lo;
-	uint32_t	seq_hi;
-	int		lo_is_star;
-	int		hi_is_star;
+	uint32_t	nranges;	/* count of the trailing struct
+					 * seq_range array, same header-plus-
+					 * trailing-array shape as
+					 * imsg_mbox_fetch above; always >= 1,
+					 * COPY/MOVE always require a
+					 * sequence-set */
 	char		destname[MBOX_NAME_MAX];
 };
 
@@ -934,7 +1134,7 @@ struct imsg_mbox_appended {
  * directly as COUNT if requested).
  *
  * RFC 9051 SS6.4.4 SEARCH's search-key grammar nests arbitrarily, so it
- * can't be a single fixed-size struct. listener.c's parse_search_key()/
+ * can't be a single fixed-size struct. search_cmd.c's parse_search_key()/
  * parse_search_key_list() compile the whole search-program into a flat
  * postfix array of struct search_node, sent as variable-length trailing
  * data after a small fixed header, same imsg_get_buf()/imsg_get_len()
@@ -1005,6 +1205,47 @@ struct imsg_mbox_search {
 					 * in this imsg's trailing data */
 };
 
+/*
+ * IMSG_SEARCH_PARSE_REQUEST's raw trailing bytes (already-
+ * buffered SEARCH argument text, post RETURN/CHARSET) are sized
+ * against this rather than left unbounded: generously bigger
+ * than any single argument list could legitimately be, since
+ * the whole command line it was sliced from is already capped
+ * at listener.h's 8192-byte SESSION_INBUF_MAX.
+ */
+#define SEARCH_ORACLE_ARGS_MAX		8192
+
+/*
+ * IMSG_SEARCH_PARSE_RESULT (search-oracle -> listener): reply to
+ * IMSG_SEARCH_PARSE_REQUEST, echoing parse_search_key_list()'s own
+ * (rc, errmsg) contract (search_cmd.c) -- rc == 0: nnodes valid,
+ * struct search_node[nnodes] trails, same wire shape
+ * imsg_mbox_search above already uses; errmsg unused. rc == -1:
+ * BAD, errmsg set, nnodes/trailing data unused. rc == -2: NO,
+ * errmsg set, same. Every errmsg parse_search_key_list() and its
+ * helpers produce is a static string literal (search_cmd.c has no
+ * runtime-formatted SEARCH parse error), so
+ * SEARCH_ORACLE_ERRMSG_MAX only needs to cover the longest one,
+ * with headroom. uses_modseq mirrors struct search_parse_ctx's
+ * own field of the same name (search_cmd.c, opaque to every
+ * caller outside that file) -- RFC 7162 SS3.1: a SEARCH
+ * including the MODSEQ data item is a CONDSTORE-enabling
+ * command. Valid only when rc == 0, same as nnodes; a
+ * rejected parse never reaches search_dispatch()'s CONDSTORE
+ * check.
+ */
+#define SEARCH_ORACLE_ERRMSG_MAX	128
+struct imsg_search_parse_result {
+	int		rc;		/* 0 ok, -1 BAD, -2 NO */
+	uint32_t	nnodes;		/* valid when rc == 0; struct
+					 * search_node[nnodes] trails, same
+					 * technique as imsg_mbox_search above */
+	int		uses_modseq;	/* valid when rc == 0, see this
+					 * struct's own comment */
+	char		errmsg[SEARCH_ORACLE_ERRMSG_MAX]; /* valid when
+					 * rc != 0 */
+};
+
 struct imsg_mbox_search_match {
 	uint32_t	seqno;
 	uint32_t	uid;
@@ -1024,13 +1265,26 @@ struct imsg_mbox_search_match {
  * message, ascending UID order) / IMSG_MBOX_IDLE_REFRESHED (store ->
  * listener, terminal).
  *
- * Used two ways by listener.c: once, synchronously after "+ idling", to
- * seed s->idle_known_uids with a baseline; and again whenever
- * session_notify_idle_peers() (triggered by another session's
- * EXPUNGE/APPEND/MOVE) asks an idling session to recheck. Both reuse the
- * same shape; store.c just reports current state via the same
- * refresh_index() helper handle_mbox_select() uses, so an idle-refresh
- * is as fresh as a fresh SELECT.
+ * Used by listener.c first synchronously after "+ idling", to seed
+ * s->idle_known_uids with a baseline, and then once per "idle poll"
+ * interval for as long as the session stays in IDLE. store.c reports
+ * current state via the same refresh_index() helper handle_mbox_select()
+ * uses, so an idle-refresh is as fresh as a fresh SELECT -- including
+ * mail an external MTA has just delivered into new/, which refresh_index()
+ * indexes on the way past.
+ *
+ * The poll is what makes IDLE push at all. It replaced
+ * session_notify_idle_peers(), which asked OTHER sessions in this
+ * process's "sessions" list to recheck after an EXPUNGE/APPEND/MOVE: under
+ * the replicated-listener model each listener process owns exactly one
+ * session, so that loop always skipped its only element and an IDLEing
+ * session was never told about anything -- not another session's changes
+ * and not new mail either. A poll covers both, and needs no notification
+ * path between processes at all.
+ *
+ * Most polls cost two stat(2) calls and no lock: see index.c's
+ * idle_probe_unchanged(), and the "unchanged" flag in struct
+ * imsg_mbox_idle_refreshed above.
  */
 struct imsg_mbox_idle_uid {
 	uint32_t	uid;
@@ -1038,6 +1292,16 @@ struct imsg_mbox_idle_uid {
 
 struct imsg_mbox_idle_refreshed {
 	int		ok;
+	/*
+	 * Set when the store child's cheap probe found neither the mailbox
+	 * directory nor new/ touched since the last look, in which case NO
+	 * IMSG_MBOX_IDLE_UID messages preceded this one and every field
+	 * below is left zero and is meaningless. The listener MUST treat
+	 * this as "nothing to do" before it diffs: diffing the resulting
+	 * empty list against the baseline would report every message in the
+	 * mailbox as expunged.
+	 */
+	int		unchanged;
 	uint32_t	exists;
 	uint32_t	uidvalidity;
 	uint32_t	uidnext;
@@ -1100,23 +1364,54 @@ __dead void	 parent_main(const char *, int, char *[],
 
 /* listener.c / auth.c / store.c take no struct openimap_config *, each
  * gets exactly the config it needs over its fd-3 channel instead:
- * IMSG_LISTENER_INIT, IMSG_AUTH_INIT, IMSG_STORE_INIT respectively.
+ * IMSG_LISTENER_SESSION_INIT, IMSG_AUTH_INIT, IMSG_STORE_INIT respectively.
+ * search_oracle.c needs no config at all -- see its own comment.
  */
 __dead void	 listener_main(void);
 __dead void	 auth_main(void);
 __dead void	 store_main(void);
+__dead void	 keymgr_main(void);
+__dead void	 search_oracle_main(void);
 
+/*
+ * search_oracle.c's one entry point into search_cmd.c's otherwise-
+ * private struct search_parse_ctx, see search_oracle_parse()'s own
+ * comment (search_cmd.c). Declared here, not in listener.h, on
+ * purpose: search_oracle.c is a separate role, not part of listener.h's
+ * listener.c/auth_cmd.c/mailbox_cmd.c/append_cmd.c/fetch_cmd.c/
+ * search_cmd.c/store_cmd.c/store_ipc.c family, and pulling that whole
+ * header in for one prototype is what caused search_oracle.c's own
+ * file-scope `static struct imsgev iev_parent` to collide with
+ * listener.h's unrelated `extern struct imsgev iev_parent` (listener's
+ * own long-lived fd-3 channel) -- same identifier, incompatible
+ * linkage, a real build failure on premio. search_cmd.c already
+ * includes this header too, so its own view of the prototype is
+ * unchanged by the move.
+ */
+int	 search_oracle_parse(char *, struct search_node *, uint32_t *,
+	    int *, char *, size_t);
+
 /* imsg helpers shared by all roles. The "handler" passed to
  * imsgev_init() is the libevent callback, expected to run its own
- * imsgbuf_read()/imsg_get() loop (parent_dispatch_child() is the
- * reference shape).
+ * imsgbuf_read()/imsgbuf_get() loop (parent_dispatch_child() is the
+ * reference shape), and to end with imsgev_rearm_read().
  */
+void		 imsgev_ibuf_init(struct imsgbuf *, int);
 void		 imsgev_init(struct imsgev *, int,
 		    void (*)(int, short, void *), void *);
 void		 imsgev_init_from_ibuf(struct imsgev *, const struct imsgbuf *,
 		    void (*)(int, short, void *), void *);
+/* You do NOT need to call this after an imsg_compose(). imsgev_init() installs
+ * imsgev_on_compose() as the channel's imsg close callback, so libutil arms
+ * EV_WRITE from inside imsg_close() on every queueing path. This is now only
+ * for the rare caller that must arm a channel it did not just compose on. */
 void		 imsgev_add(struct imsgev *);
 
+/* Same work as imsgev_add(), deliberately under a different name: this is
+ * the one a dispatch handler MUST call before returning. See its definition
+ * in imsgev.c for why the two are spelled apart. */
+void		 imsgev_rearm_read(struct imsgev *);
+
 /* Boot-time setup-loop helpers: a freshly exec'd child blocks reading
  * fd 3 for zero or more IMSG_SETUP_PEER messages, then IMSG_SETUP_DONE,
  * and acks. */
blob - 556721d06f0b054a849cf2b37d31b5a2697799e4
blob + 2aa0bb8c8aa051fa5f41d67e9576286e411585cf
--- src/imsgev.c
+++ src/imsgev.c
@@ -26,6 +26,17 @@
 /*
  * imsgev.c, shared wrapper around imsgbuf + event(3), used by
  * parent.c, listener.c, auth.c, and store.c.
+ *
+ * IMSG API VERSION. This tree calls the current libutil imsg interface --
+ * imsgbuf_init()/imsgbuf_read()/imsgbuf_write()/imsgbuf_flush()/
+ * imsgbuf_clear() and imsgbuf_get() -- and not the older imsg_init()/
+ * imsg_read()/imsg_get() spellings. That is not cosmetic: OpenBSD kept
+ * imsg_get() for a while as a compatibility wrapper (it called
+ * imsgbuf_get() and translated a success into a byte count) and has since
+ * removed it, so a build against current headers fails to link on that
+ * symbol alone. imsgbuf_get() returns 1 for a message, 0 for none and -1 on
+ * error; every call site in this tree tests only the 0 and -1 cases, which
+ * the wrapper passed through unchanged, so the switch was exact.
  */
 
 #include <sys/types.h>
@@ -37,23 +48,95 @@
 #include "imapd.h"
 #include "log.h"
 
+/*
+ * The one place an imsgbuf gets its imapd-wide settings. Every channel in
+ * the daemon goes through here, including the five that each role builds by
+ * hand on fd 3 before the event loop exists -- they used to open-code two of
+ * these three lines and omit the third, which left the two ends of the
+ * parent<->child channel disagreeing about the size limit.
+ *
+ * On imsgbuf_set_maxsize(3): its argument is the maximum PAYLOAD, not the
+ * maximum message. It adds IMSG_HEADER_SIZE before storing (libutil's
+ * imsgbuf_set_maxsize()), and imsgbuf_init(3) has already installed
+ * MAX_IMSGSIZE as the whole-message limit. So this call RAISES the limit by
+ * IMSG_HEADER_SIZE rather than clamping it -- which is fine, but it means
+ * every channel has to make the same call or the ends differ by 16 bytes.
+ *
+ * On imsgbuf_allow_fdpass(3): the parent fd-passes on each of these channels
+ * at spawn time (setup_peer_send(), IMSG_LISTENER_SESSION_INIT), so every
+ * channel needs it. Note that the parent is the ONLY process that ever
+ * attaches a descriptor to an imsg -- see the pledge comments in auth.c,
+ * keymgr.c, listener.c, search_oracle.c and store.c.
+ */
 void
+imsgev_ibuf_init(struct imsgbuf *ibuf, int fd)
+{
+	if (imsgbuf_init(ibuf, fd) == -1)
+		fatal("imsgbuf_init");
+	if (imsgbuf_set_maxsize(ibuf, MAX_IMSGSIZE) == -1)
+		fatal("imsgbuf_set_maxsize");
+	imsgbuf_allow_fdpass(ibuf);
+}
+
+/*
+ * Arm EV_WRITE for a channel that has just queued a message.
+ *
+ * libutil calls this from imsg_close() (imsg.c:397-399), which is the funnel
+ * every queueing path goes through: imsg_compose(), imsg_composev() and
+ * imsg_forward() all end there. So it fires once per message queued, on every
+ * path, and no call site can forget it. It replaces the 36 hand-written
+ * "imsgev_add() after every imsg_compose()" pairings this tree used to carry.
+ *
+ * imsgbuf_set_close_callback(3) and imsgbuf_set_userdata(3) arrived in
+ * OpenBSD commit 348f1fc0836b (2026-09-04) -- the same commit that removed
+ * imsg_get() -- explicitly "to replace the bad imsgev wrappers in various
+ * deamons".
+ *
+ * On the early return: a single FETCH can queue hundreds of messages, and
+ * imsgev_add() is event_del() + event_set() + event_add(). Once EV_WRITE is
+ * armed the rest of a batch has nothing to do. event_pending(3) is asked
+ * rather than iev->events because it reports what libevent actually holds and
+ * so cannot go stale; smtpd uses the same idiom (usr.sbin/smtpd/control.c:362).
+ * Auditing every early return in all 13 dispatch handlers found them all to be
+ * teardown paths, so iev->events would in fact have been safe here -- this is
+ * belt and braces, not a fix for a known hole.
+ */
+static void
+imsgev_on_compose(struct imsgbuf *ibuf, void *arg)
+{
+	struct imsgev	*iev = arg;
+
+	(void)ibuf;
+
+	if (event_pending(&iev->ev, EV_WRITE, NULL))
+		return;
+	imsgev_add(iev);
+}
+
+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);
+	imsgev_ibuf_init(&iev->ibuf, fd);
 
-	/* every channel fd-passes something at some point; allow it always */
-	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);
+
+	/*
+	 * After event_set()/event_add(), because the callback touches iev->ev,
+	 * and after imsgev_ibuf_init(), because imsgbuf_init() memset()s the
+	 * whole imsgbuf and would wipe both of these. Deliberately NOT done in
+	 * imsgev_ibuf_init() itself: the five roles call that on fd 3 before
+	 * any event loop exists and then compose synchronously through
+	 * setup_recv_done_and_ack(), where a callback would reach an event that
+	 * has never been event_set().
+	 */
+	imsgbuf_set_userdata(&iev->ibuf, iev);
+	imsgbuf_set_close_callback(&iev->ibuf, imsgev_on_compose);
 }
 
 /* like imsgev_init(), but copies an already-init'd *ibuf instead of re-init'ing (would discard buffered bytes) */
@@ -70,6 +153,10 @@ imsgev_init_from_ibuf(struct imsgev *iev, const struct
 	event_set(&iev->ev, iev->ibuf.fd, iev->events, iev->handler,
 	    iev->data);
 	event_add(&iev->ev, NULL);
+
+	/* see imsgev_init(); the struct copy above carried ibuf3's NULLs */
+	imsgbuf_set_userdata(&iev->ibuf, iev);
+	imsgbuf_set_close_callback(&iev->ibuf, imsgev_on_compose);
 }
 
 /* re-arm after imsg_compose(); adds EV_WRITE if output is queued. Call at the end of any compose path. */
@@ -86,7 +173,36 @@ imsgev_add(struct imsgev *iev)
 	event_add(&iev->ev, NULL);
 }
 
-/* blocks for one IMSG_SETUP_PEER, returns its fd-passed fd; imsg_get() checked before imsgbuf_read() to avoid coalesced-message stalls */
+/*
+ * Re-arm a channel's read event at the end of its libevent dispatch handler.
+ *
+ * imsgev_init() arms EV_READ without EV_PERSIST, so libevent drops the event
+ * once it has fired. Every dispatch handler in this daemon must therefore
+ * re-arm before returning, or that channel is never read again -- a hang
+ * rather than a crash, and not one any single test names.
+ *
+ * This is the SAME work as imsgev_add(), on purpose, under a second name.
+ * imsgev_add()'s other job -- arming EV_WRITE after an imsg_compose() -- now
+ * belongs to imsgev_on_compose(), which libutil calls from imsg_close(). The
+ * two jobs used to be spelled identically at 49 call sites, which is what made
+ * "delete the 36 the callback replaces" a change no reviewer could check by
+ * reading the diff. Naming them apart is what made that diff legible; it is
+ * not a behaviour change, and delegating rather than duplicating keeps it from
+ * becoming one.
+ *
+ * Note that it still arms EV_WRITE when output is queued, because a handler
+ * that composed a reply and is now returning needs exactly that.
+ *
+ * See docs/Opus-5-security-review-2/Opus-5-DESIGN-imsgev-retirement.md
+ * sections 3 and 6.
+ */
+void
+imsgev_rearm_read(struct imsgev *iev)
+{
+	imsgev_add(iev);
+}
+
+/* blocks for one IMSG_SETUP_PEER, returns its fd-passed fd; imsgbuf_get() checked before imsgbuf_read() to avoid coalesced-message stalls */
 int
 setup_recv_one_peer(struct imsgbuf *ibuf3)
 {
@@ -95,8 +211,8 @@ setup_recv_one_peer(struct imsgbuf *ibuf3)
 	int		 fd;
 
 	for (;;) {
-		if ((n = imsg_get(ibuf3, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(ibuf3, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n != 0)
 			break;
 		if ((n = imsgbuf_read(ibuf3)) == -1)
@@ -116,7 +232,7 @@ setup_recv_one_peer(struct imsgbuf *ibuf3)
 	return (fd);
 }
 
-/* blocks for IMSG_SETUP_DONE, then sends one back as an ack (see setup_recv_one_peer() re: imsg_get() ordering) */
+/* blocks for IMSG_SETUP_DONE, then sends one back as an ack (see setup_recv_one_peer() re: imsgbuf_get() ordering) */
 void
 setup_recv_done_and_ack(struct imsgbuf *ibuf3)
 {
@@ -124,8 +240,8 @@ setup_recv_done_and_ack(struct imsgbuf *ibuf3)
 	ssize_t		 n;
 
 	for (;;) {
-		if ((n = imsg_get(ibuf3, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(ibuf3, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n != 0)
 			break;
 		if ((n = imsgbuf_read(ibuf3)) == -1)
blob - 6d496e3ee37c7d87346833e35d43c6e96ce2e0d4
blob + c7edf6f62c43e5467c67983c0fd8ca81f21dc298
--- src/index.c
+++ src/index.c
@@ -36,7 +36,56 @@
 #include "log.h"
 #include "store_internal.h"
 
+/*
+ * The index is a colon-delimited, line-oriented text file, so nothing
+ * written into one of its fields may contain ':', CR or LF -- an LF
+ * especially, since it would make the next index_save() emit a second,
+ * fully attacker-chosen physical line. index_append() has always
+ * enforced this for the basename it writes; the sites that need a
+ * keywords field bypass index_append() and hand-build the line, so the
+ * rule lives here where every one of them can share it.
+ */
+/*
+ * RFC 7162 SS7: a mod-sequence is a "Positive unsigned 63-bit integer
+ * (1 <= n <= 9,223,372,036,854,775,807)". Values read back from the index
+ * are bounded by it, as the client-facing parsers in mailbox_cmd.c,
+ * fetch_cmd.c and store_cmd.c bound the ones read off the wire.
+ */
+#define INDEX_MODSEQ_MAX	INT64_MAX
+
 int
+index_field_valid(const char *field)
+{
+	return (field != NULL && strpbrk(field, ":\r\n") == NULL);
+}
+
+/*
+ * A basename read back OUT of the index is concatenated into "new/%s" /
+ * "cur/%s" and handed to open(2)/stat(2)/rename(2) (mime.c, mbox_store.c).
+ * unveil(2) stops such a path leaving the maildir, but nothing stops it
+ * moving around inside it, so the format's rules are enforced on load as
+ * well as on the write side that already refuses these.
+ */
+int
+index_basename_valid(const char *basename)
+{
+	const unsigned char	*p;
+
+	/* strictly stronger than index_field_valid(): whatever is unsafe to
+	 * write into a line is also unsafe to paste into a path */
+	if (!index_field_valid(basename))
+		return (0);
+	/* excludes "", ".", "..", and dotfiles in one test */
+	if (basename[0] == '\0' || basename[0] == '.')
+		return (0);
+	for (p = (const unsigned char *)basename; *p != '\0'; p++) {
+		if (*p == '/' || *p < 0x20 || *p == 0x7f)
+			return (0);
+	}
+	return (1);
+}
+
+int
 index_load(int fd, struct mbox_index *idx)
 {
 	FILE	*fp;
@@ -61,6 +110,28 @@ index_load(int fd, struct mbox_index *idx)
 	}
 
 	while (fgets(line, sizeof(line), fp) != NULL) {
+		/*
+		 * fgets(3) silently splits a line longer than the buffer,
+		 * and the remainder is then parsed as its own record. A
+		 * filled buffer with no '\n' in it is ambiguous by itself
+		 * -- it's also what a LEGAL maximum-length line looks like,
+		 * since its own trailing '\n' doesn't fit in this read.
+		 * Peek at the next byte to tell them apart: the line's own
+		 * '\n' (or EOF, the last line in a file missing its final
+		 * newline) means this was exactly one record; anything else
+		 * means fgets(3) really did split it.
+		 */
+		if (strchr(line, '\n') == NULL &&
+		    strlen(line) == sizeof(line) - 1) {
+			int	c = fgetc(fp);
+
+			if (c != EOF && c != '\n') {
+				log_warnx("session %u: over-long line in %s, "
+				    "refusing to parse the index", session_id,
+				    STORE_INDEX_NAME);
+				goto fail;
+			}
+		}
 		line[strcspn(line, "\n")] = '\0';
 		if (line[0] == '\0')
 			continue;
@@ -72,42 +143,65 @@ index_load(int fd, struct mbox_index *idx)
 			if ((colon = strchr(line, ':')) == NULL) {
 				log_warnx("session %u: malformed index "
 				    "header: %s", session_id, line);
-				fclose(fp);
-				return (-1);
+				goto fail;
 			}
 			*colon = '\0';
+			/*
+			 * Each of the three header fields is digits or
+			 * nothing, for the reason index_parse_line() states
+			 * below for the UID field: strtoul(3) and strtoull(3)
+			 * accept leading whitespace and a sign, so "-1:1:1"
+			 * would load a UIDVALIDITY of 4294967295 and " 5:1:1"
+			 * a UIDVALIDITY of 5, neither of which this format can
+			 * express. Same guard auth.c, listener.c and the three
+			 * mod-sequence parsers use.
+			 */
+			if (line[0] < '0' || line[0] > '9') {
+				log_warnx("session %u: malformed "
+				    "UIDVALIDITY: %s", session_id, line);
+				goto fail;
+			}
 			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);
+				goto fail;
 			}
 
 			/* RFC 7162: optional third field HIGHESTMODSEQ; NULL means older two-field header, defaults to 1 */
 			if ((colon2 = strchr(colon + 1, ':')) != NULL)
 				*colon2 = '\0';
 
+			if (colon[1] < '0' || colon[1] > '9') {
+				log_warnx("session %u: malformed UIDNEXT: %s",
+				    session_id, colon + 1);
+				goto fail;
+			}
 			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);
+				goto fail;
 			}
 
 			if (colon2 != NULL) {
+				if (colon2[1] < '0' || colon2[1] > '9') {
+					log_warnx("session %u: malformed "
+					    "HIGHESTMODSEQ: %s", session_id,
+					    colon2 + 1);
+					goto fail;
+				}
 				errno = 0;
 				idx->highestmodseq = strtoull(colon2 + 1, &ep,
 				    10);
-				if (*ep != '\0' || errno != 0) {
+				if (*ep != '\0' || errno != 0 ||
+				    idx->highestmodseq > INDEX_MODSEQ_MAX) {
 					log_warnx("session %u: malformed "
 					    "HIGHESTMODSEQ: %s", session_id,
 					    colon2 + 1);
-					fclose(fp);
-					return (-1);
+					goto fail;
 				}
 			} else
 				idx->highestmodseq = 1;
@@ -122,33 +216,40 @@ index_load(int fd, struct mbox_index *idx)
 			if (newlines == NULL) {
 				log_warn("session %u: reallocarray index",
 				    session_id);
-				fclose(fp);
-				return (-1);
+				goto fail;
 			}
 			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);
+			goto fail;
 		}
 		idx->nlines++;
 	}
 	if (ferror(fp)) {
 		log_warn("session %u: fgets %s", session_id, STORE_INDEX_NAME);
-		fclose(fp);
-		return (-1);
+		goto fail;
 	}
 	fclose(fp);
 
 	if (first) {
-		idx->uidvalidity = (uint32_t)time(NULL);
+		idx->uidvalidity = uidvalidity_next();
 		idx->uidnext = 1;
 		idx->highestmodseq = 1;
+		idx->fresh = 1;		/* caller must persist; see the field */
 	}
 
 	return (0);
+
+fail:
+	/* idx may hold a partial set of already-allocated lines at this
+	 * point; index_free() is a safe no-op if it doesn't. Every caller
+	 * (refresh_index() included) documents/relies on "-1 means idx is
+	 * already freed" -- this is what makes that true. */
+	index_free(idx);
+	fclose(fp);
+	return (-1);
 }
 
 /* Parses one "UID:basename:keywords[:MODSEQ]" index line; returns -1 (logged) on a corrupt line, caller skips it. */
@@ -160,9 +261,19 @@ index_parse_line(const char *line, struct index_rec *r
 
 	memset(rec, 0, sizeof(*rec));
 
+	/*
+	 * strtoul(3) accepts leading whitespace and a sign, so ":x:y:1"
+	 * would parse as UID 0 and "-1:x:y:1" as UID 4294967295. The field
+	 * is digits or nothing.
+	 */
+	if (line[0] < '0' || line[0] > '9') {
+		log_warnx("session %u: corrupt index line (UID field is not "
+		    "a decimal number)", session_id);
+		return (-1);
+	}
 	errno = 0;
 	rec->uid = (uint32_t)strtoul(line, &ep, 10);
-	if (*ep != ':') {
+	if (*ep != ':' || errno != 0) {
 		log_warnx("session %u: corrupt index line: %s", session_id,
 		    line);
 		return (-1);
@@ -180,6 +291,20 @@ index_parse_line(const char *line, struct index_rec *r
 	}
 	memcpy(rec->basename, p, (size_t)(q - p));
 	rec->basename[q - p] = '\0';
+	/*
+	 * About to be pasted into "new/%s" / "cur/%s" and passed to
+	 * open(2)/stat(2)/rename(2) by every caller. Refuse traversal,
+	 * hidden names and control bytes here rather than relying on
+	 * unveil(2) to catch the ones that would leave the maildir -- it
+	 * does nothing about the ones that stay inside it. Logged by UID,
+	 * not by basename: the basename is exactly the untrusted text that
+	 * should not reach syslog raw.
+	 */
+	if (!index_basename_valid(rec->basename)) {
+		log_warnx("session %u: refusing index line with unsafe "
+		    "basename (UID %u)", session_id, rec->uid);
+		return (-1);
+	}
 
 	p = q + 1;
 	if ((r = strchr(p, ':')) != NULL) {
@@ -192,9 +317,25 @@ index_parse_line(const char *line, struct index_rec *r
 		memcpy(rec->keywords, p, (size_t)(r - p));
 		rec->keywords[r - p] = '\0';
 
+		/*
+		 * Same rule as the UID field above, which this function has
+		 * always enforced -- the MODSEQ field twelve lines down did
+		 * not get it. RFC 7162 SS7 bounds a mod-sequence at
+		 * 9,223,372,036,854,775,807, and strtoull(3)'s sign handling
+		 * would otherwise turn "-1" into 18446744073709551615 with
+		 * errno untouched, a value that then flows into CHANGEDSINCE
+		 * and UNCHANGEDSINCE comparisons and out to the client as a
+		 * MODSEQ FETCH item.
+		 */
+		if (r[1] < '0' || r[1] > '9') {
+			log_warnx("session %u: malformed per-message MODSEQ "
+			    "in index line: %s", session_id, line);
+			return (-1);
+		}
 		errno = 0;
 		rec->modseq = strtoull(r + 1, &ep, 10);
-		if (*ep != '\0' || errno != 0) {
+		if (*ep != '\0' || errno != 0 ||
+		    rec->modseq > INDEX_MODSEQ_MAX) {
 			log_warnx("session %u: malformed per-message MODSEQ "
 			    "in index line: %s", session_id, line);
 			return (-1);
@@ -232,6 +373,104 @@ index_max_uid(struct mbox_index *idx)
 	return (v);
 }
 
+/*
+ * Resolves every "*" in a parsed sequence-set (an array of struct
+ * seq_range, e.g. from IMSG_MBOX_FETCH's trailing array) against max --
+ * the store's live index_max_uid() for a by-UID request, idx->nlines
+ * for a sequence-number request, same values handle_mbox_fetch() and
+ * friends have always resolved "*" against.
+ *
+ * RFC 9051 SS9: a seq-range is unordered ("the first sequence number
+ * may be smaller or larger than the second"). parse_one_seq_range()
+ * (fetch_cmd.c) already swaps a backwards LITERAL range at parse time,
+ * but skips the swap when either side is "*", since only this function
+ * knows what "*" resolves to -- so e.g. "5:*" on a 2-message mailbox
+ * arrives here as lo=5, hi=2 and is swapped to lo=2, hi=5 below, same
+ * as mbox_copy.c's COPY/MOVE handling has always done for this case
+ * (formerly duplicated there, now centralized here so every caller,
+ * including FETCH/STORE/UID EXPUNGE, gets the same RFC-correct
+ * behavior for a backwards "*"-involving range).
+ *
+ * The swap runs before the clamps below: lo is then clamped up to at
+ * least 1, and hi is additionally clamped down to max when clamp_hi is
+ * set, matching this codebase's existing per-mode behavior (sequence
+ * numbers can never legitimately exceed idx->nlines, but an explicit
+ * (non-"*") UID above the highest UID in use is left alone rather than
+ * clamped, since it's simply a range that won't match anything past
+ * the last message). Every range is kept, even one still degenerate
+ * after the swap and clamps (possible only when max itself is 0, i.e.
+ * an empty mailbox, e.g. "*:*" resolving to lo=1 hi=0 after the lo<1
+ * clamp) -- seqset_contains() below correctly treats lo > hi as "never
+ * matches", and every caller's own scan is bounded by the same empty
+ * idx->nlines/no-UIDs-in-use condition, so this can't cause an
+ * incorrect match, only a harmless unmatchable entry. Ranges are not
+ * merged or sorted -- SEQSET_MAX_RANGES already bounds the count, so
+ * letting seqset_contains() below do one full pass per resolved range
+ * at each membership test is cheap enough not to be worth a merge
+ * step. Returns the number of ranges written to resolved[] (always
+ * nranges; unlike before this function grew the swap, no range is
+ * ever dropped).
+ */
+uint32_t
+seqset_resolve(const struct seq_range *ranges, uint32_t nranges,
+    uint32_t max, int clamp_hi, struct seq_range resolved[SEQSET_MAX_RANGES])
+{
+	uint32_t	i, n = 0, lo, hi;
+
+	for (i = 0; i < nranges; i++) {
+		lo = ranges[i].lo_is_star ? max : ranges[i].lo;
+		hi = ranges[i].hi_is_star ? max : ranges[i].hi;
+		if (lo > hi) {
+			uint32_t	tmp = lo;
+
+			lo = hi;
+			hi = tmp;
+		}
+		if (lo < 1)
+			lo = 1;
+		if (clamp_hi && hi > max)
+			hi = max;
+		resolved[n].lo = lo;
+		resolved[n].hi = hi;
+		resolved[n].lo_is_star = resolved[n].hi_is_star = 0;
+		n++;
+	}
+	return (n);
+}
+
+/* True if val falls in any of the nresolved [lo, hi] pairs from seqset_resolve() above. */
+int
+seqset_contains(const struct seq_range *resolved, uint32_t nresolved,
+    uint32_t val)
+{
+	uint32_t	i;
+
+	for (i = 0; i < nresolved; i++) {
+		if (val >= resolved[i].lo && val <= resolved[i].hi)
+			return (1);
+	}
+	return (0);
+}
+
+/*
+ * Highest hi across all resolved ranges (0 if nresolved == 0), for an
+ * early-exit bound on an ascending scan of idx->lines: once the loop's
+ * position/UID exceeds this, no later line can match any range, same
+ * early "break" every one of these loops already had for a single
+ * range's hi.
+ */
+uint32_t
+seqset_max_hi(const struct seq_range *resolved, uint32_t nresolved)
+{
+	uint32_t	i, max = 0;
+
+	for (i = 0; i < nresolved; i++) {
+		if (resolved[i].hi > max)
+			max = resolved[i].hi;
+	}
+	return (max);
+}
+
 /* Reports every UID in [lo, hi] absent from idx as IMSG_MBOX_SELECT_VANISHED ranges; RFC 7162 SS3.2.6 VANISHED modifier. */
 void
 send_vanished_range(const struct mbox_index *idx, uint32_t lo, uint32_t hi,
@@ -265,6 +504,16 @@ send_vanished_range(const struct mbox_index *idx, uint
 				    "IMSG_MBOX_SELECT_VANISHED", session_id);
 		}
 
+		/*
+		 * A UID of UINT32_MAX would wrap want to 0, after which the
+		 * tail check below is trivially true and this function emits
+		 * a VANISHED (EARLIER) range covering the whole UID space --
+		 * telling a QRESYNC client that every message in the mailbox
+		 * is gone. Stop instead: there is nothing above this UID to
+		 * report.
+		 */
+		if (rec.uid == UINT32_MAX)
+			return;
 		want = rec.uid + 1;
 	}
 
@@ -312,6 +561,25 @@ index_append(struct mbox_index *idx, uint32_t uid, con
 	char	line[STORE_INDEX_LINE_MAX];
 	int	len;
 
+	/*
+	 * RFC 9051 SS9 makes a uniqueid an nz-number, so UID 0 is not a UID.
+	 * Callers assign from idx->uidnext and increment it afterwards, with
+	 * no ceiling anywhere, so an exhausted uidnext wraps to 0 and the
+	 * appends after that silently REUSE UIDs still in the mailbox --
+	 * which SS2.3.1.1 forbids outright, and which breaks
+	 * index_max_uid()'s ascending-order assumption and every "*"
+	 * resolution built on it. Refusing here turns that into a logged
+	 * failure at the first append past the end. The RFC's actual answer
+	 * to running out of UIDs is to change UIDVALIDITY, which needs
+	 * persistent state this daemon does not keep yet; see the index.c
+	 * review's finding #1.
+	 */
+	if (uid == 0) {
+		log_warnx("session %u: refusing index entry with UID 0 "
+		    "(uidnext exhausted or index header corrupt)", session_id);
+		return (-1);
+	}
+
 	/* defense in depth: refuse a basename containing ':' or newline (refresh_index() already pre-skips these) */
 	if (strpbrk(basename, ":\r\n") != NULL) {
 		log_warnx("session %u: refusing index entry with unsafe "
@@ -391,6 +659,18 @@ index_save(const struct mbox_index *idx)
 			return (-1);
 		}
 	}
+	if (fflush(fp) != 0) {
+		log_warn("session %u: fflush %s", session_id,
+		    STORE_INDEX_TMP_NAME);
+		fclose(fp);
+		return (-1);
+	}
+	if (fsync(fileno(fp)) == -1) {
+		log_warn("session %u: fsync %s", session_id,
+		    STORE_INDEX_TMP_NAME);
+		fclose(fp);
+		return (-1);
+	}
 	if (fclose(fp) != 0) {
 		log_warn("session %u: fclose %s", session_id,
 		    STORE_INDEX_TMP_NAME);
@@ -402,6 +682,21 @@ index_save(const struct mbox_index *idx)
 		    STORE_INDEX_TMP_NAME, STORE_INDEX_NAME);
 		return (-1);
 	}
+
+	/* and the directory entry the rename(2) just repointed */
+	{
+		int	dfd;
+
+		if ((dfd = open(".", O_RDONLY | O_DIRECTORY)) == -1)
+			log_warn("session %u: open . for fsync (continuing)",
+			    session_id);
+		else {
+			if (fsync(dfd) == -1)
+				log_warn("session %u: fsync . (continuing)",
+				    session_id);
+			close(dfd);
+		}
+	}
 	return (0);
 }
 
@@ -420,47 +715,62 @@ index_free(struct mbox_index *idx)
 /* RFC 7162 SS3.2.5.1 QRESYNC resync: streams VANISHED ranges then FETCH_META for messages with modseq > qresync_modseq. */
 void
 qresync_send_resync(const struct imsg_mbox_select *req,
-    const struct mbox_index *idx, struct imsgev *iev)
+    const struct seq_range *ranges, uint32_t nranges,
+    struct mbox_index *idx, struct imsgev *iev)
 {
-	uint32_t	uid_lo, uid_hi, want, i;
+	struct seq_range	resolved[SEQSET_MAX_RANGES];
+	uint32_t		nresolved, max_hi, i;
 
 	if (req->qresync_has_uids) {
-		uid_lo = req->qresync_uid_lo;
-		uid_hi = req->qresync_uid_hi;
+		/*
+		 * known-uids is a full RFC 9051 SS9 sequence-set
+		 * (SS3.2.5.1), resolved/swapped/clamped the same way as
+		 * every other UID-space consumer. "*" is forbidden in
+		 * known-uids and already rejected by mailbox_cmd.c's
+		 * parse_qresync_group(), so the max passed here is never
+		 * actually consulted -- kept only for the same calling
+		 * convention every other by-UID seqset_resolve() caller
+		 * uses. clamp_hi is 0: a known UID above the highest one
+		 * currently in use is exactly the case RFC 7162 SS3.2.5.1
+		 * wants reported VANISHED, not silently dropped.
+		 */
+		nresolved = seqset_resolve(ranges, nranges,
+		    index_max_uid(idx), 0, resolved);
 	} else {
 		/* SS3.2.5.1: no known-uids list acts as "1:<uidnext-1>", or empty if uidnext == 1 (never assigned) */
 		if (idx->uidnext <= 1)
 			return;
-		uid_lo = 1;
-		uid_hi = idx->uidnext - 1;
+		resolved[0].lo = 1;
+		resolved[0].hi = idx->uidnext - 1;
+		resolved[0].lo_is_star = resolved[0].hi_is_star = 0;
+		nresolved = 1;
 	}
-	if (uid_hi < uid_lo)
-		return;		/* empty requested range, nothing to do */
 
-	/* single forward pass; want tracks the lowest UID not yet accounted for, a gap before it means vanished */
-	want = uid_lo;
-	for (i = 0; i < idx->nlines && want <= uid_hi; i++) {
+	/*
+	 * RFC 7162 SS3.2.6: VANISHED (EARLIER) MUST precede FETCH in the
+	 * response stream; guaranteed not by send order here but by
+	 * store_ipc.c's session_handle_mbox_selected(), which buffers
+	 * both kinds separately while SESSION_SELECTING and flushes all
+	 * VANISHED ranges before any FETCH -- the same two-pass split
+	 * handle_mbox_fetch() already uses for its own FETCH ...
+	 * (VANISHED) modifier, and send_vanished_range() itself is the
+	 * exact same helper that call site uses, one resolved range at a
+	 * time.
+	 */
+	for (i = 0; i < nresolved; i++)
+		send_vanished_range(idx, resolved[i].lo, resolved[i].hi, iev);
+
+	max_hi = seqset_max_hi(resolved, nresolved);
+	for (i = 0; i < idx->nlines; i++) {
 		struct index_rec	rec;
 
 		if (index_parse_line(idx->lines[i], &rec) == -1)
 			continue;	/* corrupt line, already logged, not reported either way */
-		if (rec.uid < want)
-			continue;	/* below the requested range, or already accounted for */
-		if (rec.uid > uid_hi)
+		if (rec.uid > max_hi)
 			break;
+		if (!seqset_contains(resolved, nresolved, rec.uid))
+			continue;
 
-		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];
@@ -481,39 +791,232 @@ qresync_send_resync(const struct imsg_mbox_select *req
 				    "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 index (fd already flock(2)'d LOCK_EX) and indexes any new/ files not yet known; on failure idx is already index_free()'d. */
+/*
+ * Takes the mailbox's index lock (op is LOCK_EX or LOCK_SH) and opens the
+ * index under it, filling *il. Returns 0, or -1 with nothing held.
+ *
+ * The order matters and is the whole point: the lock is taken on
+ * STORE_INDEX_LOCK_NAME, whose inode is stable, and the index is opened only
+ * afterwards, so the descriptor cannot refer to an inode that a concurrent
+ * index_save() has already renamed away. See STORE_INDEX_LOCK_NAME's comment
+ * in store_internal.h for what went wrong when the lock was taken on the
+ * index itself.
+ *
+ * Callers release with index_lock_release(), which is idempotent.
+ */
 int
-refresh_index(struct mbox_index *idx, int fd)
+index_lock_acquire(struct index_lock *il, int op)
 {
+	il->lockfd = -1;
+	il->fd = -1;
+
+	if ((il->lockfd = open(STORE_INDEX_LOCK_NAME, O_RDWR | O_CREAT,
+	    0600)) == -1) {
+		log_warn("session %u: open %s", session_id,
+		    STORE_INDEX_LOCK_NAME);
+		return (-1);
+	}
+	if (flock(il->lockfd, op) == -1) {
+		log_warn("session %u: flock %s", session_id,
+		    STORE_INDEX_LOCK_NAME);
+		close(il->lockfd);
+		il->lockfd = -1;
+		return (-1);
+	}
+	if ((il->fd = open(STORE_INDEX_NAME, O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s", session_id, STORE_INDEX_NAME);
+		flock(il->lockfd, LOCK_UN);
+		close(il->lockfd);
+		il->lockfd = -1;
+		return (-1);
+	}
+	return (0);
+}
+
+/* Drops whatever index_lock_acquire() took; safe to call twice, and safe on an INDEX_LOCK_INIT struct that was never acquired. */
+void
+index_lock_release(struct index_lock *il)
+{
+	if (il->fd != -1) {
+		close(il->fd);
+		il->fd = -1;
+	}
+	/* lock last, so no other process can take it while our index fd is open */
+	if (il->lockfd != -1) {
+		flock(il->lockfd, LOCK_UN);
+		close(il->lockfd);
+		il->lockfd = -1;
+	}
+}
+
+/*
+ * Issues a UIDVALIDITY for a mailbox that has none, and records it.
+ *
+ * RFC 9051 SS2.3.1.1 requires that a mailbox which loses its UIDs be given a
+ * UIDVALIDITY greater than the one it had before, and recommends a timestamp
+ * on the grounds that this "ensures that the value is unique and always
+ * increases". Two calls inside one second break that promise, and a client
+ * cannot detect it: an unchanged UIDVALIDITY is exactly how the protocol says
+ * "your cache is still good".
+ *
+ * So the timestamp is kept as a floor, not as the answer: the value returned
+ * is the later of the clock and one past the highest value this user has ever
+ * been issued, and the new high-water mark is written back before returning.
+ * The record lives at the maildir root (STORE_UIDVALIDITY_NAME) because the
+ * mailbox's own state is gone after DELETE -- that is what DELETE means -- so
+ * the memory has to outlive it.
+ *
+ * Called from index_load() with the mailbox's index lock already held. See
+ * STORE_UIDVALIDITY_NAME's comment for why this lock is safe to nest there,
+ * and for the rule that keeps it that way.
+ *
+ * Every failure degrades to the old behaviour -- a bare timestamp -- rather
+ * than failing the operation the caller was performing: a mailbox that opens
+ * with a slightly weaker UIDVALIDITY guarantee is better than a mailbox that
+ * will not open. One case deliberately does NOT write back: a file that
+ * exists and holds something unparseable is left exactly as it is, because
+ * overwriting it would replace a floor we failed to read with a lower one,
+ * which is the single worst thing this function could do.
+ */
+uint32_t
+uidvalidity_next(void)
+{
+	const char	*path;
+	char		 buf[32];
+	struct stat	 st;
+	time_t		 now;
+	char		*ep;
+	unsigned long	 parsed;
+	uint32_t	 floor = 0, val;
+	ssize_t		 n;
+	int		 fd, writeback = 1;
+
+	/*
+	 * The namespace is flat -- select_mailbox_dir() leaves a mailbox with
+	 * a single chdir("..") -- so the root is either the cwd (INBOX) or
+	 * exactly one level up.
+	 */
+	path = current_mailbox_dir[0] == '\0' ?
+	    STORE_UIDVALIDITY_NAME : "../" STORE_UIDVALIDITY_NAME;
+
+	now = time(NULL);
+	val = (now > 0 && (uintmax_t)now <= UINT32_MAX) ? (uint32_t)now : 0;
+
+	if ((fd = open(path, O_RDWR | O_CREAT, 0600)) == -1) {
+		log_warn("session %u: open %s (UIDVALIDITY floor); falling "
+		    "back to a bare timestamp", session_id, path);
+		return (val != 0 ? val : 1);
+	}
+	if (flock(fd, LOCK_EX) == -1) {
+		log_warn("session %u: flock %s (UIDVALIDITY floor); falling "
+		    "back to a bare timestamp", session_id, path);
+		close(fd);
+		return (val != 0 ? val : 1);
+	}
+
+	/*
+	 * A zero-length file is the ordinary just-created case, not damage:
+	 * floor 0 is correct for it. Anything present but unreadable is
+	 * damage, and is left alone.
+	 */
+	if (fstat(fd, &st) == 0 && st.st_size > 0) {
+		if ((n = read(fd, buf, sizeof(buf) - 1)) <= 0) {
+			log_warn("session %u: read %s (UIDVALIDITY floor)",
+			    session_id, path);
+			writeback = 0;
+		} else {
+			buf[n] = '\0';
+			buf[strcspn(buf, "\r\n")] = '\0';
+			/* same digit guard as the index header: strtoul(3)
+			 * accepts a leading sign, so "-1" would read as
+			 * 4294967295 and pin the floor at its ceiling */
+			errno = 0;
+			parsed = strtoul(buf, &ep, 10);
+			if (buf[0] < '0' || buf[0] > '9' || *ep != '\0' ||
+			    errno != 0 || parsed > UINT32_MAX) {
+				log_warnx("session %u: %s holds no usable "
+				    "UIDVALIDITY floor (%s); leaving it alone "
+				    "and falling back to a bare timestamp",
+				    session_id, path, buf);
+				writeback = 0;
+			} else
+				floor = (uint32_t)parsed;
+		}
+	}
+
+	if (writeback) {
+		if (val <= floor) {
+			if (floor == UINT32_MAX) {
+				/* 4 billion issued values, or a clock past
+				 * 2106: nothing greater is representable. */
+				log_warnx("session %u: UIDVALIDITY floor is "
+				    "exhausted (%u); reusing it", session_id,
+				    floor);
+				val = UINT32_MAX;
+				writeback = 0;
+			} else
+				val = floor + 1;
+		}
+	} else if (val == 0)
+		val = 1;		/* unusable clock and unusable floor */
+
+	if (writeback) {
+		n = snprintf(buf, sizeof(buf), "%u\n", val);
+		if (n < 0 || (size_t)n >= sizeof(buf) ||
+		    lseek(fd, 0, SEEK_SET) == -1 ||
+		    ftruncate(fd, 0) == -1 ||
+		    write(fd, buf, (size_t)n) != n)
+			log_warn("session %u: could not record the "
+			    "UIDVALIDITY floor in %s; the value issued now "
+			    "may be issued again", session_id, path);
+	}
+
+	/*
+	 * A successful floor consultation is otherwise silent, and a
+	 * UIDVALIDITY that is quietly wrong looks exactly like one that is
+	 * right -- the client cannot tell, which is the whole reason this
+	 * function exists. At -v this says what was read and what was issued.
+	 */
+	log_debug("session %u: UIDVALIDITY: floor %u in %s -> issued %u%s",
+	    session_id, floor, path, val,
+	    writeback ? "" : " (floor NOT updated)");
+
+	close(fd);			/* releases the flock(2) */
+	return (val);
+}
+
+/*
+ * Walks new/ for maildir deliveries the index does not know about yet.
+ *
+ * mutate == 0 answers only "is there at least one?", stops at the first, and
+ * touches neither idx nor the filesystem -- so it is safe under a SHARED
+ * index lock. That is the question handle_mbox_idle_refresh()'s poll asks
+ * every few seconds, and the answer is almost always no.
+ *
+ * mutate == 1 indexes every one it finds, which is what refresh_index() wants
+ * and which needs the exclusive lock its callers hold.
+ *
+ * Returns 1 if anything was found (mutate == 1: added), 0 if not, -1 on error
+ * -- and on error with mutate set, idx has already been index_free()'d, which
+ * is refresh_index()'s long-standing contract with ITS callers.
+ */
+static int
+index_scan_new(struct mbox_index *idx, int mutate)
+{
 	DIR		*dp;
 	struct dirent	*de;
+	int		 added = 0;
 
-	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 */
 		log_warn("session %u: opendir new", session_id);
-		index_free(idx);
+		if (mutate)
+			index_free(idx);
 		return (-1);
 	}
 	while ((de = readdir(dp)) != NULL) {
@@ -521,22 +1024,57 @@ refresh_index(struct mbox_index *idx, int fd)
 			continue;	/* ".", "..", and dotfiles, maildir delivery never creates the latter */
 		/* never index a filename with ':' or newline, would corrupt the index line format */
 		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);
+			/*
+			 * Logged only on the mutating pass. The read-only one
+			 * runs once per poll interval for the whole life of an
+			 * IDLE, and one badly-named file would otherwise fill
+			 * the log with the same line forever.
+			 */
+			if (mutate)
+				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 (!mutate) {
+			closedir(dp);
+			return (1);	/* one is enough to answer the question */
+		}
 		if (index_append(idx, idx->uidnext, de->d_name) == -1) {
 			closedir(dp);
 			index_free(idx);
 			return (-1);
 		}
 		idx->uidnext++;
+		added = 1;
 	}
 	closedir(dp);
+	return (added);
+}
 
+/* Loads the index (fd already flock(2)'d LOCK_EX) and indexes any new/ files not yet known; on failure idx is already index_free()'d. */
+int
+refresh_index(struct mbox_index *idx, int fd)
+{
+	int	added;
+
+	if (index_load(fd, idx) == -1)
+		return (-1);
+
+	if ((added = index_scan_new(idx, 1)) == -1)
+		return (-1);	/* index_scan_new() has already freed idx */
+
+	/*
+	 * Nothing new: don't pay index_save()'s cost for a no-op refresh --
+	 * unless the header itself is new, in which case the cost is the
+	 * point. A freshly invented UIDVALIDITY that is never written down is
+	 * invented again, differently, on the next call.
+	 */
+	if (!added && !idx->fresh)
+		return (0);
+
 	if (index_save(idx) == -1) {
 		index_free(idx);
 		return (-1);
@@ -544,6 +1082,85 @@ refresh_index(struct mbox_index *idx, int fd)
 	return (0);
 }
 
+/*
+ * Cheap change probe for handle_mbox_idle_refresh().
+ *
+ * Two stat(2) calls, no lock and no read, answering "can anything possibly
+ * have changed since the last look?". Both directories are needed and neither
+ * is redundant:
+ *
+ *   "."    every mutation imapd itself makes ends in index_save(), which
+ *          creates imapd.index.tmp and rename(2)s it over imapd.index. Both
+ *          are operations on this directory, so its mtime moves for APPEND,
+ *          STORE, EXPUNGE, COPY and MOVE alike.
+ *   "new"  an external MTA's delivery lands a file here and touches nothing
+ *          else until something indexes it, so "." alone would miss the one
+ *          case IDLE exists for.
+ *
+ * The sample is taken and stored BEFORE the caller does any work, on purpose.
+ * Recording it afterwards would be tidier -- our own index_save() moves "."'s
+ * mtime, so a real change costs one extra no-op refresh on the following poll
+ * -- but it would also record a state that was never reported, and a write
+ * that landed between the work and the sample would then be invisible for
+ * good. One redundant refresh is a much better bug than a lost notification.
+ *
+ * The residual hole is timestamp granularity: two changes in the same
+ * nanosecond, either side of a sample, are indistinguishable. On OpenBSD's
+ * nanosecond st_mtim that is theoretical, and it is written down here rather
+ * than left to be rediscovered.
+ */
+static struct {
+	int		 valid;
+	ino_t		 dir_ino;
+	ino_t		 new_ino;
+	struct timespec	 dir_mtim;
+	struct timespec	 new_mtim;
+} idle_probe;
+
+/* Forces the next probe to report a change; call whenever cwd changes mailbox. */
+void
+idle_probe_reset(void)
+{
+	idle_probe.valid = 0;
+}
+
+static int
+tspec_eq(const struct timespec *a, const struct timespec *b)
+{
+	return (a->tv_sec == b->tv_sec && a->tv_nsec == b->tv_nsec);
+}
+
+/* 1 = nothing can have changed since the last call; 0 = look properly. */
+static int
+idle_probe_unchanged(void)
+{
+	struct stat	 dst, nst;
+	int		 same;
+
+	if (stat(".", &dst) == -1) {
+		/* cannot tell, so do not claim to know: fall through to the
+		 * full refresh, which will report the failure properly. */
+		idle_probe.valid = 0;
+		return (0);
+	}
+	if (stat("new", &nst) == -1)
+		memset(&nst, 0, sizeof(nst));	/* absent new/ is a stable state */
+
+	same = idle_probe.valid &&
+	    dst.st_ino == idle_probe.dir_ino &&
+	    nst.st_ino == idle_probe.new_ino &&
+	    tspec_eq(&dst.st_mtim, &idle_probe.dir_mtim) &&
+	    tspec_eq(&nst.st_mtim, &idle_probe.new_mtim);
+
+	idle_probe.valid = 1;
+	idle_probe.dir_ino = dst.st_ino;
+	idle_probe.new_ino = nst.st_ino;
+	idle_probe.dir_mtim = dst.st_mtim;
+	idle_probe.new_mtim = nst.st_mtim;
+
+	return (same);
+}
+
 /* RFC 9051 SS6.3.4-SS6.3.6/SS6.3.9; re-validated here independently of listener.c's client-side check (privsep defense in depth). */
 void
 handle_mbox_idle_refresh(struct imsgev *iev)
@@ -551,26 +1168,76 @@ 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;
+	struct index_lock		 il = INDEX_LOCK_INIT;
 	size_t				 i;
+	int				 pending;
 	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);
+	/*
+	 * Cheapest question first. On an untouched mailbox this is the whole
+	 * of the work: no lock, no index read, and no IMSG_MBOX_IDLE_UID
+	 * stream -- which matters because that stream is one message per
+	 * message in the mailbox, and the poll runs every few seconds.
+	 */
+	if (idle_probe_unchanged()) {
+		reply.ok = 1;
+		reply.unchanged = 1;
+		/*
+		 * The whole mechanism is otherwise silent, and a poll that
+		 * has quietly stopped firing is indistinguishable from a
+		 * healthy daemon -- which is exactly how the dead
+		 * cross-session push survived unnoticed. At -v these two
+		 * lines make the poll observable: one per interval while
+		 * nothing happens, the other when there is real work.
+		 */
+		log_debug("session %u: idle refresh: unchanged (probe: no "
+		    "change to . or new/)", session_id);
 		goto send;
 	}
-	if (flock(fd, LOCK_EX) == -1) {
-		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
-		close(fd);
+
+	/*
+	 * Something moved, so the index must be read -- but reading is all
+	 * the common case needs, so take the SHARED lock and escalate only if
+	 * new/ actually holds a delivery that has to be written into the
+	 * index.
+	 */
+	if (index_lock_acquire(&il, LOCK_SH) == -1)
 		goto send;
+	if (index_load(il.fd, &idx) == -1) {
+		index_lock_release(&il);
+		goto send;
 	}
-	if (refresh_index(&idx, fd) == -1) {
-		flock(fd, LOCK_UN);
-		close(fd);
+	if ((pending = index_scan_new(&idx, 0)) == -1) {
+		index_free(&idx);
+		index_lock_release(&il);
 		goto send;
 	}
+	/*
+	 * idx.fresh joins pending here: index_load() has just invented a
+	 * UIDVALIDITY for a mailbox that had no index, and writing it down
+	 * needs the exclusive lock as much as indexing a delivery does.
+	 */
+	if (pending || idx.fresh) {
+		/*
+		 * Drop the shared lock and redo the whole thing exclusively.
+		 * Deliberately not an in-place flock(2) upgrade: that
+		 * conversion is not atomic, so another process can slip in
+		 * between the two states and what was read under the shared
+		 * lock cannot be trusted afterwards. Re-reading under
+		 * LOCK_EX costs one extra index_load() on the rare path that
+		 * had real work to do anyway.
+		 */
+		index_free(&idx);
+		index_lock_release(&il);
+		if (index_lock_acquire(&il, LOCK_EX) == -1)
+			goto send;
+		if (refresh_index(&idx, il.fd) == -1) {
+			index_lock_release(&il);
+			goto send;
+		}
+	}
 
 	for (i = 0; i < idx.nlines; i++) {
 		if (index_parse_line(idx.lines[i], &rec) == -1)
@@ -589,14 +1256,17 @@ handle_mbox_idle_refresh(struct imsgev *iev)
 	reply.uidnext = idx.uidnext;
 	reply.highestmodseq = idx.highestmodseq;
 
+	log_debug("session %u: idle refresh: streamed %zu uid(s), "
+	    "highestmodseq %llu%s", session_id, idx.nlines,
+	    (unsigned long long)idx.highestmodseq,
+	    pending ? " (escalated to LOCK_EX, indexed new delivery)" : "");
+
 	index_free(&idx);
-	flock(fd, LOCK_UN);
-	close(fd);
+	index_lock_release(&il);
 
 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);
 }
blob - c4aa587b33bd334d3a01ebbe554cc1e058b1c0f4
blob + 690803c7796434b4318d87e9ef1ca9a9282726d0
--- src/listener.c
+++ src/listener.c
@@ -1,6 +1,22 @@
 /*
  * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ * Copyright (c) 2014 Reyk Floeter <reyk@openbsd.org>
+ * Copyright (c) 2012 Gilles Chehade <gilles@poolp.org>
  *
+ * The RSA_METHOD/EC_KEY_METHOD engine override below (keymgr_engine_
+ * init() and everything it installs) adapts smtpd's ca.c
+ * (rsa_engine_init()/ecdsa_engine_init()/rsae_priv_enc()/rsae_priv_
+ * dec()/ecdsae_do_sign(), ca.c:289-558) to imapd's own imsg
+ * conventions: the OpenSSL API shape leaves little room for
+ * independent structure, and this project's own docs/LICENSE-AUDIT.md
+ * precedent (log.c, imsgev.c) already treats a borrow this close as
+ * needing the original author's copyright even where the
+ * implementation differs; see docs/openimap-tls-privsep-design.md
+ * SS5.4 and SS10.1. keymgr_use_fake_private_key()'s two-line call
+ * shape is lifted from smtpd's smtp.c:187-193 (Gilles Chehade,
+ * Pierre-Yves Ritschard, Jacek Masiulaniec); see that function's own
+ * comment.
+ *
  * 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.
@@ -14,7 +30,7 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* listener.c, protocol/network process: client sockets, IMAP dispatch, TLS. */
+/* listener.c, protocol/network process: client sockets, IMAP dispatch, TLS. Real TLS private-key operations are forwarded to keymgr(8); see keymgr_engine_init() below and docs/openimap-tls-privsep-design.md SS5. */
 
 #include <sys/types.h>
 #include <sys/queue.h>
@@ -26,12 +42,19 @@
 #include <ctype.h>
 #include <errno.h>
 #include <event.h>
+#include <fcntl.h>
 #include <grp.h>
 #include <imsg.h>
+#include <netdb.h>
 #include <poll.h>
 #include <pwd.h>
 #include <resolv.h>
 #include <stdarg.h>
+#include <openssl/bn.h>
+#include <openssl/ec.h>
+#include <openssl/ecdsa.h>
+#include <openssl/rsa.h>
+
 #include <stdint.h>
 #include <stdio.h>
 #include <stdlib.h>
@@ -45,30 +68,37 @@
 #include "listener.h"
 
 struct session_list	 sessions = TAILQ_HEAD_INITIALIZER(sessions);
-static uint32_t		 next_session_id = 1;
 
-struct imsgev	 iev_auth;	/* channel to the AUTH process */
+struct imsgev	 iev_auth;	/* channel to the AUTH process; .ibuf.fd == -1
+				 * if this connection's auth-worker spawn failed
+				 * (see listener_main()'s boot-drain comment) --
+				 * auth_cmd.c's sasl_plain_finish() checks this
+				 * before sending IMSG_AUTH_REQUEST */
+struct imsgev	 iev_search;	/* channel to the search-oracle process (SS8.1);
+				 * .ibuf.fd == -1 if this connection's oracle
+				 * spawn failed (same boot-drain comment as
+				 * iev_auth above) -- search_cmd.c's
+				 * search_dispatch() checks this before sending
+				 * IMSG_SEARCH_PARSE_REQUEST */
 struct imsgev	 iev_parent;	/* fd 3, alive for the process's lifetime */
 
-/* n_cleartext_fd/n_tls_fd say how many of each array's slots are 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];
+/*
+ * Channel to the keymgr process: real TLS private-key operations are
+ * forwarded here synchronously, from inside OpenSSL's RSA_METHOD/
+ * EC_KEY_METHOD callbacks (keymgr_engine_init() below), never through
+ * the normal event-driven imsgev dispatch -- nothing unsolicited ever
+ * arrives on this channel, so it's a plain struct imsgbuf, not a
+ * struct imsgev; see keymgr_forward_rsa()/keymgr_forward_ecdsa().
+ */
+static struct imsgbuf	 keymgr_ibuf;
 
 static struct tls_config	*listener_tls_config;
 struct tls		*listener_tls_ctx;	/* NULL if TLS setup failed, degrades to no-TLS, not fatal */
+uint32_t		 listener_idle_poll_secs = IDLE_POLL_DEFAULT;	/* overwritten from IMSG_LISTENER_SESSION_INIT below */
 
-/* Matches parent.c's send_tls_certs() read buffer size. */
+/* Matches parent.c's send_tls_cert() read buffer size. */
 #define TLS_CERT_MAX	8192
-#define TLS_KEY_MAX	8192
 
-/* SIGHUP reload staging; listener_reload_tls() fires once both flags are set. */
-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;
-
 struct imap_cmd_entry {
 	const char	*name;
 	unsigned int	 states;	/* bitmask of 1U << SESSION_* */
@@ -81,7 +111,8 @@ struct imap_cmd_entry {
 	 (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_SEARCH_PARSING) | (1U << SESSION_SEARCHING) | \
+	 (1U << SESSION_STATUSING) | \
 	 (1U << SESSION_COPYING) | (1U << SESSION_CREATING) | \
 	 (1U << SESSION_DELETING) | (1U << SESSION_RENAMING) | \
 	 (1U << SESSION_LISTING))
@@ -130,36 +161,374 @@ static const struct imap_cmd_entry imap_cmds[] = {
 };
 #define NUM_IMAP_CMDS	(sizeof(imap_cmds) / sizeof(imap_cmds[0]))
 
+/*
+ * tls_config_use_fake_private_key() is internal to lib/libtls, not
+ * declared in the installed tls.h -- confirmed directly (checked
+ * lib/libtls/tls.h, the only public libtls header in openbsd_source/):
+ * neither it nor tls_config_set_sign_cb() (tls_internal.h) appears
+ * there. smtpd's smtp.c (smtp.c:54) forward-declares it the same way;
+ * see docs/openimap-tls-privsep-design.md SS9 on what depending on an
+ * unexported, no-compatibility-promise libtls internal costs.
+ */
+void	tls_config_use_fake_private_key(struct tls_config *);
+
+/*
+ * SS5.4/SS10.2's fake-key call shape, exactly as smtp.c:187-193 uses it
+ * (Gilles Chehade, Pierre-Yves Ritschard, Jacek Masiulaniec -- see this
+ * file's header comment): tls_config_use_fake_private_key() installs
+ * libtls's placeholder key, which keymgr_engine_init()'s RSA_METHOD/
+ * EC_KEY_METHOD override below then intercepts every operation on;
+ * tls_config_set_keypair_mem() supplies the real certificate with a NULL
+ * key. Wrapped in one function so the tls_config-building if/else-if
+ * chains in listener_main() (one call per branch) don't need
+ * restructuring around two calls.
+ */
+static int
+keymgr_set_fake_keypair(struct tls_config *config, const char *cert_buf,
+    size_t cert_len)
+{
+	tls_config_use_fake_private_key(config);
+	return tls_config_set_keypair_mem(config, (const uint8_t *)cert_buf,
+	    cert_len, NULL, 0);
+}
+
+/*
+ * RSA/ECDSA privsep engine: installed once, process-wide, so every RSA/
+ * EC private-key operation OpenSSL performs against this process's
+ * "fake" key -- inside a TLS handshake, on whatever connection happens
+ * to be negotiating at the time -- is intercepted here and forwarded to
+ * keymgr over keymgr_ibuf instead. Adapted from smtpd's ca.c
+ * (ca.c:289-558, Reyk Floeter, Gilles Chehade -- see this file's header
+ * comment); ca.c's own m_*()-based imsg framing (smtpd's message-
+ * abstraction layer) is replaced with imapd's native "fixed header +
+ * trailing raw bytes on one imsg" convention, and the imsg id field
+ * imapd already uses elsewhere (e.g. IMSG_SETUP_PEER's session id) takes
+ * the place of ca.c's own separate reqid bookkeeping.
+ */
+
+static const RSA_METHOD	*keymgr_rsa_default;
+static RSA_METHOD		*keymgr_rsae_method;
+static const EC_KEY_METHOD	*keymgr_ecdsa_default;
+static EC_KEY_METHOD		*keymgr_ecdsae_method;
+
+/*
+ * Blocks reading keymgr_ibuf directly, bypassing the event loop --
+ * called synchronously from inside an OpenSSL RSA_METHOD callback, which
+ * cannot itself be deferred to the normal libevent dispatch. Unlike
+ * ca.c's rsae_send_imsg() (ca.c:293-369), there's no "some other imsg is
+ * queued up, hand it to the normal dispatcher" branch: nothing besides
+ * these three request/reply pairs is ever multiplexed on the listener
+ * <->keymgr channel, keymgr never sends anything unsolicited.
+ */
+static int
+keymgr_forward_rsa(uint32_t type, const char *hash, const unsigned char *from,
+    int fromlen, unsigned char *to, size_t tosize, int padding)
+{
+	struct imsg_keymgr_sign_request	 req;
+	struct imsg_keymgr_sign_reply		 rep;
+	unsigned char	 combined[sizeof(req) + KEYMGR_DATA_MAX];
+	struct imsg	 imsg;
+	static uint32_t	 reqid;
+	uint32_t	 id;
+	ssize_t		 n;
+	int		 done, ret;
+
+	if (fromlen < 0 || (size_t)fromlen > KEYMGR_DATA_MAX) {
+		log_warnx("keymgr_forward_rsa: %d bytes over KEYMGR_DATA_MAX",
+		    fromlen);
+		return (0);
+	}
+
+	memset(&req, 0, sizeof(req));
+	if (strlcpy(req.hash, hash, sizeof(req.hash)) >= sizeof(req.hash)) {
+		log_warnx("keymgr_forward_rsa: pubkey hash too long");
+		return (0);
+	}
+	req.padding = (uint32_t)padding;
+	req.fromlen = (uint32_t)fromlen;
+
+	memcpy(combined, &req, sizeof(req));
+	memcpy(combined + sizeof(req), from, (size_t)fromlen);
+
+	id = ++reqid;
+	if (imsg_compose(&keymgr_ibuf, type, id, 0, -1, combined,
+	    sizeof(req) + (size_t)fromlen) == -1) {
+		log_warnx("keymgr_forward_rsa: imsg_compose");
+		return (0);
+	}
+	if (imsgbuf_flush(&keymgr_ibuf) == -1)
+		fatal("keymgr_forward_rsa: imsgbuf_flush");
+
+	ret = 0;
+	done = 0;
+	while (!done) {
+		if ((n = imsgbuf_get(&keymgr_ibuf, &imsg)) == -1)
+			fatal("keymgr_forward_rsa: imsg_get");
+		if (n == 0) {
+			if ((n = imsgbuf_read(&keymgr_ibuf)) == -1)
+				fatal("keymgr_forward_rsa: imsgbuf_read");
+			if (n == 0)
+				fatalx("keymgr_forward_rsa: keymgr closed "
+				    "channel");
+			continue;
+		}
+		if (imsg_get_type(&imsg) != type ||
+		    imsg_get_id(&imsg) != id) {
+			log_warnx("keymgr_forward_rsa: unexpected reply "
+			    "type %u id %u (wanted %u/%u)",
+			    imsg_get_type(&imsg), imsg_get_id(&imsg), type,
+			    id);
+			imsg_free(&imsg);
+			continue;
+		}
+		if (imsg_get_buf(&imsg, &rep, sizeof(rep)) == -1) {
+			log_warnx("keymgr_forward_rsa: bad reply header");
+			imsg_free(&imsg);
+			break;
+		}
+		/*
+		 * tosize, not KEYMGR_DATA_MAX: `to` is OpenSSL's own
+		 * output buffer, which the caller sized RSA_size(rsa)
+		 * -- 256 bytes for a 2048-bit key, where KEYMGR_DATA_MAX
+		 * is 1024, sized for an 8192-bit one. Bounding by the
+		 * wire cap rather than by the buffer we were actually
+		 * handed would let a longer-than-expected reply overrun
+		 * it. smtpd's ca.c sends RSA_size() across for exactly
+		 * this reason (ca.c:319, m_add_size()); this restores
+		 * that bound on the receiving side.
+		 */
+		if (rep.ok && rep.tolen <= tosize &&
+		    imsg_get_len(&imsg) == rep.tolen) {
+			if (imsg_get_buf(&imsg, to, rep.tolen) == -1)
+				log_warnx("keymgr_forward_rsa: bad reply "
+				    "data");
+			else
+				ret = (int)rep.tolen;
+		}
+		imsg_free(&imsg);
+		done = 1;
+	}
+
+	return (ret);
+}
+
+static ECDSA_SIG *
+keymgr_forward_ecdsa(const char *hash, const unsigned char *dgst,
+    int dgst_len)
+{
+	struct imsg_keymgr_sign_request	 req;
+	struct imsg_keymgr_sign_reply		 rep;
+	unsigned char	 combined[sizeof(req) + KEYMGR_DATA_MAX];
+	unsigned char	 sigbuf[KEYMGR_DATA_MAX];
+	struct imsg	 imsg;
+	ECDSA_SIG	*sig = NULL;
+	static uint32_t	 reqid;
+	uint32_t	 id;
+	ssize_t		 n;
+	int		 done;
+	const unsigned char *sigp;
+
+	if (dgst_len < 0 || (size_t)dgst_len > KEYMGR_DATA_MAX) {
+		log_warnx("keymgr_forward_ecdsa: %d bytes over "
+		    "KEYMGR_DATA_MAX", dgst_len);
+		return (NULL);
+	}
+
+	memset(&req, 0, sizeof(req));
+	if (strlcpy(req.hash, hash, sizeof(req.hash)) >= sizeof(req.hash)) {
+		log_warnx("keymgr_forward_ecdsa: pubkey hash too long");
+		return (NULL);
+	}
+	req.fromlen = (uint32_t)dgst_len;
+
+	memcpy(combined, &req, sizeof(req));
+	memcpy(combined + sizeof(req), dgst, (size_t)dgst_len);
+
+	id = ++reqid;
+	if (imsg_compose(&keymgr_ibuf, IMSG_KEYMGR_ECDSA_SIGN, id, 0, -1,
+	    combined, sizeof(req) + (size_t)dgst_len) == -1) {
+		log_warnx("keymgr_forward_ecdsa: imsg_compose");
+		return (NULL);
+	}
+	if (imsgbuf_flush(&keymgr_ibuf) == -1)
+		fatal("keymgr_forward_ecdsa: imsgbuf_flush");
+
+	done = 0;
+	while (!done) {
+		if ((n = imsgbuf_get(&keymgr_ibuf, &imsg)) == -1)
+			fatal("keymgr_forward_ecdsa: imsg_get");
+		if (n == 0) {
+			if ((n = imsgbuf_read(&keymgr_ibuf)) == -1)
+				fatal("keymgr_forward_ecdsa: imsgbuf_read");
+			if (n == 0)
+				fatalx("keymgr_forward_ecdsa: keymgr closed "
+				    "channel");
+			continue;
+		}
+		if (imsg_get_type(&imsg) != IMSG_KEYMGR_ECDSA_SIGN ||
+		    imsg_get_id(&imsg) != id) {
+			log_warnx("keymgr_forward_ecdsa: unexpected reply "
+			    "type %u id %u (wanted %u/%u)",
+			    imsg_get_type(&imsg), imsg_get_id(&imsg),
+			    IMSG_KEYMGR_ECDSA_SIGN, id);
+			imsg_free(&imsg);
+			continue;
+		}
+		if (imsg_get_buf(&imsg, &rep, sizeof(rep)) == -1) {
+			log_warnx("keymgr_forward_ecdsa: bad reply header");
+			imsg_free(&imsg);
+			break;
+		}
+		if (rep.ok && rep.tolen <= KEYMGR_DATA_MAX &&
+		    imsg_get_len(&imsg) == rep.tolen) {
+			if (imsg_get_buf(&imsg, sigbuf, rep.tolen) == -1)
+				log_warnx("keymgr_forward_ecdsa: bad reply "
+				    "data");
+			else {
+				sigp = sigbuf;
+				d2i_ECDSA_SIG(&sig, &sigp, (long)rep.tolen);
+			}
+		}
+		imsg_free(&imsg);
+		done = 1;
+	}
+
+	return (sig);
+}
+
+static int
+keymgr_rsa_priv_enc(int flen, const unsigned char *from, unsigned char *to,
+    RSA *rsa, int padding)
+{
+	char	*hash;
+
+	if ((hash = RSA_get_ex_data(rsa, 0)) != NULL)
+		return (keymgr_forward_rsa(IMSG_KEYMGR_RSA_PRIVENC, hash,
+		    from, flen, to, (size_t)RSA_size(rsa), padding));
+	return (RSA_meth_get_priv_enc(keymgr_rsa_default)(flen, from, to,
+	    rsa, padding));
+}
+
+static int
+keymgr_rsa_priv_dec(int flen, const unsigned char *from, unsigned char *to,
+    RSA *rsa, int padding)
+{
+	char	*hash;
+
+	if ((hash = RSA_get_ex_data(rsa, 0)) != NULL)
+		return (keymgr_forward_rsa(IMSG_KEYMGR_RSA_PRIVDEC, hash,
+		    from, flen, to, (size_t)RSA_size(rsa), padding));
+	return (RSA_meth_get_priv_dec(keymgr_rsa_default)(flen, from, to,
+	    rsa, padding));
+}
+
+static ECDSA_SIG *
+keymgr_ecdsa_do_sign(const unsigned char *dgst, int dgst_len,
+    const BIGNUM *inv, const BIGNUM *rp, EC_KEY *eckey)
+{
+	ECDSA_SIG *(*psign_sig)(const unsigned char *, int, const BIGNUM *,
+	    const BIGNUM *, EC_KEY *);
+	char	*hash;
+
+	if ((hash = EC_KEY_get_ex_data(eckey, 0)) != NULL)
+		return (keymgr_forward_ecdsa(hash, dgst, dgst_len));
+	EC_KEY_METHOD_get_sign(keymgr_ecdsa_default, NULL, NULL, &psign_sig);
+	return (psign_sig(dgst, dgst_len, inv, rp, eckey));
+}
+
+static void
+keymgr_rsa_engine_init(void)
+{
+	if ((keymgr_rsa_default = RSA_get_default_method()) == NULL)
+		fatalx("keymgr_rsa_engine_init: RSA_get_default_method");
+
+	if ((keymgr_rsae_method = RSA_meth_dup(keymgr_rsa_default)) == NULL)
+		fatalx("keymgr_rsa_engine_init: RSA_meth_dup");
+
+	RSA_meth_set_priv_enc(keymgr_rsae_method, keymgr_rsa_priv_enc);
+	RSA_meth_set_priv_dec(keymgr_rsae_method, keymgr_rsa_priv_dec);
+
+	RSA_meth_set_flags(keymgr_rsae_method,
+	    RSA_meth_get_flags(keymgr_rsa_default) | RSA_METHOD_FLAG_NO_CHECK);
+	RSA_meth_set0_app_data(keymgr_rsae_method,
+	    RSA_meth_get0_app_data(keymgr_rsa_default));
+
+	RSA_set_default_method(keymgr_rsae_method);
+}
+
+static void
+keymgr_ecdsa_engine_init(void)
+{
+	int (*sign)(int, const unsigned char *, int, unsigned char *,
+	    unsigned int *, const BIGNUM *, const BIGNUM *, EC_KEY *);
+	int (*sign_setup)(EC_KEY *, BN_CTX *, BIGNUM **, BIGNUM **);
+
+	if ((keymgr_ecdsa_default = EC_KEY_get_default_method()) == NULL)
+		fatalx("keymgr_ecdsa_engine_init: EC_KEY_get_default_method");
+
+	if ((keymgr_ecdsae_method = EC_KEY_METHOD_new(keymgr_ecdsa_default))
+	    == NULL)
+		fatalx("keymgr_ecdsa_engine_init: EC_KEY_METHOD_new");
+
+	EC_KEY_METHOD_get_sign(keymgr_ecdsa_default, &sign, &sign_setup,
+	    NULL);
+	EC_KEY_METHOD_set_sign(keymgr_ecdsae_method, sign, sign_setup,
+	    keymgr_ecdsa_do_sign);
+
+	EC_KEY_set_default_method(keymgr_ecdsae_method);
+}
+
+/* Installs both engine overrides; call exactly once, before any tls_config touches a key -- listener_main() calls this right before its TLS setup block, mirroring ca_engine_init()'s call from smtpd's dispatcher() (dispatcher.c:135). */
+static void
+keymgr_engine_init(void)
+{
+	keymgr_rsa_engine_init();
+	keymgr_ecdsa_engine_init();
+}
+
+/* Builds and starts this process's one and only session; defined below, forward-declared here since listener_main() calls it. */
+static void	 listener_start_session(uint32_t, int, int,
+		    const struct sockaddr_storage *, socklen_t);
+
 __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;
+	struct imsgbuf				 ibuf3;
+	struct passwd				*pw;
+	int					 auth_peer_fd = -1, keymgr_peer_fd = -1;
+	int					 search_peer_fd = -1;	/* SS8.1 */
+	struct imsg				 imsg;
+	struct imsg_listener_session_init	 sinit;
+	ssize_t					 n;
+	char		 cert_buf[TLS_CERT_MAX];
+	size_t		 cert_len = 0;
+	int		 client_fd = -1;
+	int		 got_cert = 0, got_session_init = 0, got_keymgr_peer = 0;
 
-	memset(&init, 0, sizeof(init));
+	memset(&sinit, 0, sizeof(sinit));
 
-	if (imsgbuf_init(&ibuf3, 3) == -1)
-		fatal("imsgbuf_init");
-	imsgbuf_allow_fdpass(&ibuf3);	/* receives fd-passed socket/peer messages below */
+	/* fd-passing is allowed on this channel: it receives fd-passed peer/session messages below; see imsgev_ibuf_init()'s own comment */
+	imsgev_ibuf_init(&ibuf3, 3);
 
-	/* boot-time handshake: one peer (auth), then SETUP_DONE+ack. */
-	peer_fd = setup_recv_one_peer(&ibuf3);
-	setup_recv_done_and_ack(&ibuf3);
-
-	/* Socket/cert/key/init arrive on fd 3 in any order; read synchronously before event_set(). */
-	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");
+	/*
+	 * SS7: this process is spawned fresh per connection (parent.c's
+	 * spawn_connection()), not once at daemon boot, so the peer
+	 * handshake is folded into the same synchronous drain loop as
+	 * the rest of boot below rather than kept as separate blocking
+	 * setup_recv_one_peer() calls. The auth peer in particular may
+	 * never arrive at all -- spawn_connection() skips wiring one
+	 * when the auth-worker itself failed to fork -- so it can't be
+	 * a fixed, blocking "read exactly one" step the way it was when
+	 * a listener process's whole boot depended on both peers
+	 * existing. IMSG_SETUP_PEER is told apart by id: 0 for the auth
+	 * peer, session_id (always >= 1) for the keymgr peer, matching
+	 * parent.c's own setup_peer_send() calls. There is no
+	 * IMSG_SETUP_DONE/ack step either -- see parent.c's header
+	 * comment for why spawn_connection() doesn't use one.
+	 */
+	while (!got_cert || !got_session_init || !got_keymgr_peer) {
+		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n == 0) {
 			if ((n = imsgbuf_read(&ibuf3)) == -1)
 				fatal("imsgbuf_read");
@@ -169,19 +538,64 @@ listener_main(void)
 			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);
+		case IMSG_SETUP_PEER: {
+			uint32_t	 id = imsg_get_id(&imsg);
+			int		 peer_fd = imsg_get_fd(&imsg);
+
+			if (peer_fd == -1) {
+				log_warnx("listener: IMSG_SETUP_PEER "
+				    "carried no fd");
+				break;
+			}
+			/*
+			 * parent sends each of these exactly once, so a
+			 * repeat cannot happen today -- but this loop runs
+			 * before pledge(2) and before the privilege drop,
+			 * which makes it the one place where "trust the
+			 * parent" is doing the most work. State the
+			 * invariant rather than silently overwriting a
+			 * descriptor. imsg_get_fd(3) has already passed
+			 * responsibility for peer_fd to us (imsg_init(3):
+			 * only UNCLAIMED descriptors are closed by
+			 * imsg_free()), so the duplicate must be closed
+			 * here.
+			 */
+			if (id == 0) {
+				if (auth_peer_fd != -1) {
+					log_warnx("listener: duplicate auth "
+					    "IMSG_SETUP_PEER, ignoring");
+					close(peer_fd);
+					break;
+				}
+				auth_peer_fd = peer_fd;
+			} else {
+				if (keymgr_peer_fd != -1) {
+					log_warnx("listener: duplicate keymgr "
+					    "IMSG_SETUP_PEER, ignoring");
+					close(peer_fd);
+					break;
+				}
+				keymgr_peer_fd = peer_fd;
+				got_keymgr_peer = 1;
+			}
 			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);
+		}
+		case IMSG_SETUP_SEARCH_PEER: {
+			int	peer_fd = imsg_get_fd(&imsg);
+
+			/* SS8.1: optional, like the auth peer above -- not gated by the while() condition, spawn_connection() may not have wired one at all */
+			if (peer_fd == -1)
+				log_warnx("listener: IMSG_SETUP_SEARCH_PEER "
+				    "carried no fd");
+			else if (search_peer_fd != -1) {
+				/* already claimed above, so close it here */
+				log_warnx("listener: duplicate "
+				    "IMSG_SETUP_SEARCH_PEER, ignoring");
+				close(peer_fd);
+			} else
+				search_peer_fd = peer_fd;
 			break;
+		}
 		case IMSG_TLS_CERT:
 			cert_len = imsg_get_len(&imsg);
 			if (cert_len > sizeof(cert_buf)) {
@@ -196,27 +610,25 @@ listener_main(void)
 			}
 			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:
-			if (imsg_get_data(&imsg, &init, sizeof(init))
+		case IMSG_LISTENER_SESSION_INIT:
+			if (imsg_get_data(&imsg, &sinit, sizeof(sinit))
 			    == -1) {
-				log_warnx("bad IMSG_LISTENER_INIT");
+				log_warnx("bad IMSG_LISTENER_SESSION_INIT");
 				break;
 			}
-			got_init = 1;
+			/*
+			 * Refuse before claiming, unlike the two peer
+			 * cases above: an fd left unclaimed on this imsg
+			 * is closed by imsg_free() below (imsg_init(3)),
+			 * so there is nothing to clean up by hand.
+			 */
+			if (client_fd != -1) {
+				log_warnx("listener: duplicate "
+				    "IMSG_LISTENER_SESSION_INIT, ignoring");
+				break;
+			}
+			client_fd = imsg_get_fd(&imsg);
+			got_session_init = 1;
 			break;
 		default:
 			log_debug("listener boot: unhandled %d",
@@ -225,15 +637,10 @@ listener_main(void)
 		}
 		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 (client_fd == -1)
+		fatalx("listener: IMSG_LISTENER_SESSION_INIT carried no "
+		    "client fd");
 
 	if ((pw = getpwnam("_imapd")) == NULL)
 		fatalx("getpwnam _imapd: no such user "
@@ -250,10 +657,13 @@ listener_main(void)
 	    setresuid(pw->pw_uid, pw->pw_uid, pw->pw_uid) == -1)
 		fatal("cannot drop privileges to _imapd");
 
+	/* Installs the process-wide RSA_METHOD/EC_KEY_METHOD override before any tls_config touches a key. */
+	keymgr_engine_init();
+
 	/* Failure here isn't fatal, degrades to no-TLS, checked via listener_tls_ctx == NULL below. */
-	if (cert_len == 0 || key_len == 0) {
-		log_warnx("listener: no TLS cert/key received, TLS "
-		    "disabled for this run");
+	if (cert_len == 0) {
+		log_warnx("listener: no TLS cert received, TLS "
+		    "disabled for this session");
 	} 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) {
@@ -268,9 +678,8 @@ listener_main(void)
 		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) {
+	} else if (keymgr_set_fake_keypair(listener_tls_config, cert_buf,
+	    cert_len) != 0) {
 		log_warnx("listener: tls_config_set_keypair_mem: %s, "
 		    "TLS disabled", tls_config_error(listener_tls_config));
 		tls_free(listener_tls_ctx);
@@ -287,31 +696,99 @@ listener_main(void)
 		listener_tls_config = NULL;
 	} else {
 		tls_config_clear_keys(listener_tls_config);
-		log_info("listener: TLS configured");
+		log_info("TLS configured (private-key operations "
+		    "forwarded to keymgr)");
 	}
-	explicit_bzero(key_buf, sizeof(key_buf));
 
 	event_init();
 
-	imsgev_init(&iev_auth, peer_fd, listener_dispatch_auth, NULL);
+	/*
+	 * auth_peer_fd may be -1 (no auth-worker for this connection --
+	 * see this function's header comment); iev_auth.ibuf.fd is left
+	 * at -1 in that case (its default zero-init would be fd 0, a
+	 * real, misleading fd), and auth_cmd.c's sasl_plain_finish()
+	 * checks that before ever composing to it.
+	 */
+	if (auth_peer_fd != -1)
+		imsgev_init(&iev_auth, auth_peer_fd, listener_dispatch_auth,
+		    NULL);
+	else {
+		iev_auth.ibuf.fd = -1;
+		log_warnx("session %u: no auth-worker was spawned for this "
+		    "connection, AUTHENTICATE/LOGIN will fail until a new "
+		    "connection gets one", sinit.session_id);
+	}
 
+	/*
+	 * SS8.1: search_peer_fd may be -1 (no search-oracle for this
+	 * connection -- parent.c's spawn_connection() tolerates that fork
+	 * failing independently of listener/auth's own); iev_search.ibuf.fd
+	 * is left at -1 in that case, and search_cmd.c's search_dispatch()
+	 * checks that before ever composing to it, mirroring iev_auth just
+	 * above.
+	 */
+	if (search_peer_fd != -1)
+		imsgev_init(&iev_search, search_peer_fd, listener_dispatch_search,
+		    NULL);
+	else {
+		iev_search.ibuf.fd = -1;
+		log_warnx("session %u: no search-oracle was spawned for this "
+		    "connection, SEARCH will fail until a new connection gets "
+		    "one", sinit.session_id);
+	}
+
+	if (imsgbuf_init(&keymgr_ibuf, keymgr_peer_fd) == -1)
+		fatal("imsgbuf_init keymgr");
+	imsgbuf_set_maxsize(&keymgr_ibuf, MAX_IMSGSIZE);
+
 	/* Reuses fd 3's populated ibuf3, a fresh imsgbuf_init() would drop buffered bytes. */
 	imsgev_init_from_ibuf(&iev_parent, &ibuf3, listener_dispatch_parent,
 	    NULL);
 
-	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);
-	}
+	/*
+	 * This process's one and only session, built from what boot just
+	 * drained above -- see listener_start_session()'s own comment.
+	 * Must run after event_init()/the TLS setup block above:
+	 * session_tls_start()/session_arm_client_read() register
+	 * libevent events, and an implicit-TLS session needs
+	 * listener_tls_ctx already set.
+	 */
+	listener_idle_poll_secs = sinit.idle_poll_secs;
 
+	listener_start_session(sinit.session_id, client_fd,
+	    sinit.implicit_tls, &sinit.remote_ss, sinit.remote_sslen);
+
+	/*
+	 * SS8.1 finding: this process makes no socket(2)/connect(2)/
+	 * bind(2)/listen(2)/accept(2) call anywhere any more -- parent.c's
+	 * spawn_connection() owns every one of those now (SS7), and this
+	 * process only ever inherits an already-accepted client_fd over
+	 * IMSG_LISTENER_SESSION_INIT. The one remaining candidate for
+	 * needing "inet" is listener_start_session()'s getnameinfo(3) call
+	 * just above, formatting s->remote_addr -- but NI_NUMERICHOST|
+	 * NI_NUMERICSERV means it never touches the resolver or the
+	 * network, just formats already-numeric address bytes already in
+	 * hand (see that call's own comment). Dropping "inet" here on that
+	 * basis; if this turns out wrong, pledge(2) will kill the process
+	 * on its next getnameinfo(3) call and this line reverts.
+	 *
+	 * "recvfd" stays: this process receives a descriptor after this line,
+	 * the store child's peer fd, arriving as IMSG_SETUP_PEER on the fd 3
+	 * channel once parent.c's store-fork handshake completes
+	 * (listener_dispatch_parent()'s own case below).
+	 *
+	 * "sendfd" goes: this process never attaches a descriptor to an imsg.
+	 * The parent is the only one in the tree that does
+	 * (setup_peer_send(), setup_search_peer_send(),
+	 * IMSG_LISTENER_SESSION_INIT -- five call sites, all in parent.c).
+	 * SYS_sendmsg is PLEDGE_STDIO (sys/kern/kern_pledge.c); "sendfd" is
+	 * checked in unp_internalize() (sys/kern/uipc_usrreq.c), which the
+	 * kernel reaches only when SCM_RIGHTS is actually attached, so an
+	 * imsgbuf_allow_fdpass() channel carrying only fd == -1 messages does
+	 * not need the promise.
+	 */
 #ifdef __OpenBSD__
-	if (pledge("stdio recvfd sendfd inet", NULL) == -1)
+	if (pledge("stdio recvfd", NULL) == -1)
 		fatal("pledge");
 #endif
 
@@ -319,34 +796,75 @@ listener_main(void)
 	fatalx("listener: exited event loop");
 }
 
-/* RFC 8314: on the implicit-TLS port the greeting waits for the handshake (s->pending_greeting). */
-void
-listener_accept(int fd, short event, void *arg)
+/*
+ * Builds and starts this process's one and only session, from the
+ * IMSG_LISTENER_SESSION_INIT payload listener_main() drained at boot
+ * (SS7: parent.c's spawn_connection() forks one listener-worker per
+ * accepted connection instead of a single long-lived listener
+ * accept()ing every one itself; MaxStartups admission control moved
+ * with it, checked in parent.c's parent_accept() before this process
+ * even exists -- see parent.c's own count_startups()/
+ * startups_should_drop(), moved there verbatim from what used to
+ * live in this file). Does the same work the old accept()-driven
+ * listener_accept() did once accept(2) returned: construct struct
+ * session, format remote_addr, log, and either begin the TLS
+ * handshake or send the plaintext greeting -- just once, since
+ * there's only ever one connection for this process to serve.
+ */
+static void
+listener_start_session(uint32_t session_id, int client_fd, int implicit_tls,
+    const struct sockaddr_storage *ss, socklen_t sslen)
 {
-	struct sockaddr_storage	 ss;
-	socklen_t		 sslen = sizeof(ss);
-	int			 client_fd;
-	struct session		*s;
+	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;
+		/*
+		 * SS7: nothing else will ever run in this process -- it
+		 * exists to serve this one session, and this is the only
+		 * call to this function. Returning would park it in
+		 * event_dispatch() forever with no client, holding a
+		 * parent-side open_session entry that counts against
+		 * MaxStartups for the rest of the daemon's uptime.
+		 * session_teardown()'s exit(0) is unreachable from here
+		 * (there is no session to tear down), so exit directly;
+		 * the implicit-TLS branch just below reaches the same
+		 * outcome through session_teardown().
+		 */
+		exit(1);
 	}
-	s->id = next_session_id++;
+	session_idle_poll_init(s);	/* before anything can tear s down */
+	s->id = session_id;
 	s->client_fd = client_fd;
 	s->state = SESSION_NOT_AUTH;
-	s->implicit_tls = (arg != (void *)0);	/* (void *)1 == port-993 listener */
+	s->implicit_tls = implicit_tls;
 	TAILQ_INSERT_TAIL(&sessions, s, entry);
 
-	log_debug("session %u: accepted (%s)", s->id,
+	{
+		char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
+
+		/*
+		 * NI_NUMERIC*: no resolver call, no network I/O of any
+		 * kind -- pure formatting of the already-numeric address
+		 * bytes in ss/sslen. This is the fact listener_main()'s
+		 * pledge() comment (SS8.1) rests dropping "inet" on; if
+		 * that turns out wrong, this call is where pledge(2)
+		 * would kill the process.
+		 */
+		if (getnameinfo((const struct sockaddr *)ss, sslen, hbuf,
+		    sizeof(hbuf), sbuf, sizeof(sbuf),
+		    NI_NUMERICHOST | NI_NUMERICSERV) == 0)
+			snprintf(s->remote_addr, sizeof(s->remote_addr),
+			    ss->ss_family == AF_INET6 ? "[%s]:%s" : "%s:%s",
+			    hbuf, sbuf);
+		else
+			strlcpy(s->remote_addr, "?", sizeof(s->remote_addr));
+	}
+
+	log_debug("session %u: accepted from %s (%s)", s->id,
+	    s->remote_addr,
 	    s->implicit_tls ? "implicit TLS" : "cleartext/STARTTLS");
 
 	if (s->implicit_tls) {
@@ -435,8 +953,8 @@ session_tls_handshake(int fd, short event, void *arg)
 		return;
 	}
 
-	log_warnx("session %u: tls_handshake: %s", s->id,
-	    tls_error(s->tls_ctx));
+	log_warnx("session %u: tls_handshake: %s (peer %s)", s->id,
+	    tls_error(s->tls_ctx), s->remote_addr);
 	session_teardown(s);
 }
 
@@ -453,6 +971,70 @@ session_send_greeting(struct session *s)
 static int	session_is_busy(const struct session *);
 static int	session_enqueue_cmd(struct session *, const char *);
 
+/* RFC 9051 SS4.3 hard cap on a non-synchronizing literal. */
+#define IMAP_NONSYNC_LITERAL_MAX	4096
+
+/*
+ * True if `line` (CRLF already stripped) ends in a NON-synchronizing
+ * literal announcement, "{n+}" (RFC 9051 SS4.3). Those octets are already
+ * in flight when the line arrives -- unlike a synchronizing "{n}", whose
+ * octets only follow our "+" continuation -- so the reader has to account
+ * for them no matter what becomes of the command that announced them.
+ * Octet count returned in *lenp; 0 is a legal announcement ("{0+}").
+ */
+static int
+line_nonsync_literal(const char *line, uint64_t *lenp)
+{
+	const char		*open, *stop;
+	char			 digits[24], *end;
+	size_t			 len, dlen;
+	unsigned long long	 v;
+
+	*lenp = 0;
+	len = strlen(line);
+	if (len < 4 || line[len - 1] != '}' || line[len - 2] != '+')
+		return (0);
+	stop = &line[len - 2];		/* one past the last digit */
+	if ((open = memrchr(line, '{', len)) == NULL || open + 1 >= stop)
+		return (0);
+	open++;
+	dlen = (size_t)(stop - open);
+	if (dlen >= sizeof(digits) || *open < '0' || *open > '9')
+		return (0);		/* also rejects strtoull(3)'s sign/space forms */
+	memcpy(digits, open, dlen);
+	digits[dlen] = '\0';
+
+	errno = 0;
+	v = strtoull(digits, &end, 10);
+	if (*end != '\0' || errno == ERANGE)
+		return (0);
+	*lenp = (uint64_t)v;
+	return (1);
+}
+
+/*
+ * RFC 9051 SS9: tag = 1*<ASTRING-CHAR except "+">, i.e. no CTLs, no SP, no
+ * 8-bit, and none of ( ) { % * DQUOTE backslash. Only the length was
+ * checked before, so any other byte reached session_reply()'s "%s %s %s"
+ * verbatim; a tag of "+" in particular turns our own reply into what the
+ * client reads as a command continuation request.
+ */
+static int
+tag_is_valid(const char *tag)
+{
+	const unsigned char	*p;
+
+	if (*tag == '\0')
+		return (0);
+	for (p = (const unsigned char *)tag; *p != '\0'; p++) {
+		if (*p <= 0x20 || *p >= 0x7f)
+			return (0);
+		if (strchr("(){%*\"\\+", (int)*p) != NULL)
+			return (0);
+	}
+	return (1);
+}
+
 /* Splits s->inbuf into CRLF lines (bare LF isn't one, RFC 9051 SS2.2); session_handle_line() can free *s* (LOGOUT). */
 void
 session_dispatch_client(int fd, short event, void *arg)
@@ -460,12 +1042,23 @@ session_dispatch_client(int fd, short event, void *arg
 	struct session	*s = arg;
 	ssize_t		 n;
 	char		*crlf;
+	size_t		 tls_want = 0;	/* bytes offered to tls_read(); see the
+					 * re-arm at the end of this function.
+					 * Not "want": the literal-assembly loop
+					 * below has its own uint64_t want, and
+					 * shadowing it draws -Wshadow. */
 
 	(void)event;
 
+	if (s->write_failed) {
+		/* A prior session_write() couldn't finish; see listener.h. */
+		session_teardown(s);
+		return;
+	}
+
 	if (s->tls_active) {
-		n = tls_read(s->tls_ctx, s->inbuf + s->inbuflen,
-		    sizeof(s->inbuf) - s->inbuflen);
+		tls_want = sizeof(s->inbuf) - s->inbuflen;
+		n = tls_read(s->tls_ctx, s->inbuf + s->inbuflen, tls_want);
 		if (n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT) {
 			/* tls_read() can want to write (renegotiation); re-arm one-shot for the direction it needs. */
 			event_del(&s->client_ev);
@@ -486,6 +1079,10 @@ session_dispatch_client(int fd, short event, void *arg
 		n = read(fd, s->inbuf + s->inbuflen,
 		    sizeof(s->inbuf) - s->inbuflen);
 		if (n == -1) {
+			/* client_fd is O_NONBLOCK since parent.c's parent_accept(). */
+			if (errno == EINTR || errno == EAGAIN ||
+			    errno == EWOULDBLOCK)
+				return;
 			log_warn("session %u: read", s->id);
 			session_teardown(s);
 			return;
@@ -500,9 +1097,50 @@ session_dispatch_client(int fd, short event, void *arg
 	s->inbuflen += (size_t)n;
 
 	for (;;) {
-		size_t	consumed;
-		int	alive;
+		uint64_t	nonsync_len;
+		size_t		consumed, linelen;
+		int		alive;
 
+		/*
+		 * Octets of a non-synchronizing literal whose command was
+		 * refused (bad syntax, over the size cap, arrived while the
+		 * session was busy, or simply wasn't APPEND). They are on
+		 * the wire regardless, so swallow them rather than let the
+		 * message body be parsed as further IMAP commands.
+		 */
+		if (s->literal_discard > 0) {
+			uint64_t	take;
+
+			take = (uint64_t)s->inbuflen < s->literal_discard ?
+			    (uint64_t)s->inbuflen : s->literal_discard;
+			if (take > 0) {
+				memmove(s->inbuf, s->inbuf + take,
+				    s->inbuflen - (size_t)take);
+				s->inbuflen -= (size_t)take;
+				s->literal_discard -= take;
+			}
+			if (s->literal_discard > 0)
+				break;	/* need more data */
+			/*
+			 * The command line that announced this literal
+			 * still ends in CRLF (RFC 9051 SS9). Nothing
+			 * downstream wants it, and leaving it makes the
+			 * line parser below see a zero-length line and
+			 * answer "* BAD Empty command line" after every
+			 * refused literal. Deliberately tolerant: if what
+			 * follows is not CRLF then the command had more
+			 * arguments after the literal, and the line parser
+			 * should still get a crack at them.
+			 */
+			if (s->inbuflen >= 2 && s->inbuf[0] == '\r' &&
+			    s->inbuf[1] == '\n') {
+				memmove(s->inbuf, s->inbuf + 2,
+				    s->inbuflen - 2);
+				s->inbuflen -= 2;
+			}
+			continue;
+		}
+
 		/* RFC 9051 SS4.3 literal in flight; checked before CRLF search since raw octets can contain CRLF. */
 		if (s->literal_pending) {
 			uint64_t	want, take;
@@ -548,17 +1186,81 @@ session_dispatch_client(int fd, short event, void *arg
 		if (crlf == NULL)
 			break;
 
+		linelen = (size_t)(crlf - s->inbuf);
+		consumed = linelen + 2;
+
+		/*
+		 * RFC 9051 SS2.2/SS9: CR and LF appear in a command only as the
+		 * CRLF terminator, and NUL is not an ASTRING-CHAR. This is the
+		 * one chokepoint where that can be enforced, and enforcing it
+		 * here is what keeps every downstream site that echoes client
+		 * text back (FETCH's BODY[<label>], error text, the tag itself)
+		 * from being able to emit a line break into the response
+		 * stream. A NUL additionally made the C-string parsers below
+		 * silently ignore the rest of the line. No safe resync point,
+		 * so close, as the post-literal CRLF check already does.
+		 */
+		if (memchr(s->inbuf, '\r', linelen) != NULL ||
+		    memchr(s->inbuf, '\n', linelen) != NULL ||
+		    memchr(s->inbuf, '\0', linelen) != NULL) {
+			static const char bad[] = "* BAD bare CR, LF or NUL "
+			    "in command line, closing connection\r\n";
+
+			log_warnx("session %u: bare CR/LF/NUL in command "
+			    "line, closing", s->id);
+			session_write(s, bad, sizeof(bad) - 1);
+			session_teardown(s);
+			return;
+		}
+
 		*crlf = '\0';
-		consumed = (size_t)(crlf - s->inbuf) + 2;
 
+		/*
+		 * Note a trailing "{n+}" before dispatching: its octets follow
+		 * immediately on the wire, so they have to be accounted for
+		 * even when the command is rejected or deferred. Over RFC
+		 * 9051 SS4.3's 4096 the client is already out of spec and we
+		 * have no idea how much to skip, so close instead of guessing.
+		 */
+		nonsync_len = 0;
+		if (line_nonsync_literal(s->inbuf, &nonsync_len) &&
+		    nonsync_len > IMAP_NONSYNC_LITERAL_MAX) {
+			static const char bad[] = "* BAD non-synchronizing "
+			    "literal exceeds 4096 octets, closing "
+			    "connection\r\n";
+
+			log_warnx("session %u: oversized non-synchronizing "
+			    "literal (%llu), closing", s->id,
+			    (unsigned long long)nonsync_len);
+			session_write(s, bad, sizeof(bad) - 1);
+			session_teardown(s);
+			return;
+		}
+
 		/* SASL continuation (auth_cont) and IDLE's "DONE" (idling) route around the tag/name/args parser and the pipeline queue below. */
 		if (s->auth_cont) {
+			/* the line is the user's password in base64, see below */
+			s->scrub_inbuf = 1;
 			alive = session_handle_auth_continuation(s, s->inbuf);
 		} else if (s->idling) {
 			alive = session_handle_idle_continuation(s, s->inbuf);
 		} else if (session_is_busy(s)) {
-			/* A prior async command hasn't replied yet; queue rather than reject (RFC 9051 SS5.5 pipelining). */
-			if (!session_enqueue_cmd(s, s->inbuf)) {
+			/*
+			 * A prior async command hasn't replied yet; queue rather
+			 * than reject (RFC 9051 SS5.5 pipelining). A command
+			 * carrying a non-synchronizing literal can't be queued:
+			 * its octets are arriving now, but cmd_append() would
+			 * only enter literal-read mode when the line is finally
+			 * dequeued, by which point the body would already have
+			 * been parsed as commands. Refuse it and swallow.
+			 */
+			if (nonsync_len > 0) {
+				session_reply(s, "*", "BAD",
+				    "non-synchronizing literal not accepted on "
+				    "a pipelined command, retry with a "
+				    "synchronizing literal");
+				alive = 1;
+			} else if (!session_enqueue_cmd(s, s->inbuf)) {
 				static const char bad[] = "* BAD too many "
 				    "pipelined commands, closing connection"
 				    "\r\n";
@@ -568,18 +1270,32 @@ session_dispatch_client(int fd, short event, void *arg
 				session_write(s, bad, sizeof(bad) - 1);
 				session_teardown(s);
 				return;
-			}
-			alive = 1;
+			} else
+				alive = 1;
 		} else {
 			alive = session_handle_line(s, s->inbuf);
 		}
 		if (alive == 0)
 			return;	/* s was torn down (LOGOUT), do not touch */
 
+		/*
+		 * Anything cmd_append() did not take over (it sets literal_
+		 * pending) gets swallowed rather than parsed.
+		 */
+		if (nonsync_len > 0 && !s->literal_pending)
+			s->literal_discard = nonsync_len;
+
 		/* cmd_starttls() zeroes inbuflen to discard pipelined plaintext, clamp to avoid underflow. */
 		if (consumed > s->inbuflen)
 			consumed = s->inbuflen;
 
+		/* Don't leave a SASL response (base64 of the cleartext password)
+		 * sitting in a long-lived heap buffer after it's been consumed. */
+		if (s->scrub_inbuf) {
+			explicit_bzero(s->inbuf, consumed);
+			s->scrub_inbuf = 0;
+		}
+
 		memmove(s->inbuf, s->inbuf + consumed, s->inbuflen - consumed);
 		s->inbuflen -= consumed;
 	}
@@ -593,10 +1309,48 @@ session_dispatch_client(int fd, short event, void *arg
 		/* was a raw write(2), wrong on a TLS session, bytes would land unencrypted on the wire */
 		session_write(s, bad, sizeof(bad) - 1);
 		session_teardown(s);
+		return;
 	}
+
+	/*
+	 * tls_read() is a single SSL_read(), and SSL_read() returns at
+	 * most what one TLS record holds. A record carries up to 16384
+	 * bytes of plaintext while inbuf is SESSION_INBUF_MAX (8192), so
+	 * a client that puts more than that in one record leaves the
+	 * remainder inside libtls -- NOT in the socket. client_ev is a
+	 * level-triggered EV_READ registration on the socket fd, so it
+	 * would never fire for those bytes: a client waiting on a reply
+	 * to a command sitting in them hangs forever, and (with no
+	 * inactivity timeout) so does its MaxStartups slot. The
+	 * cleartext path has no equivalent problem -- there the
+	 * remainder stays in the kernel socket buffer, which a
+	 * level-triggered event does see.
+	 *
+	 * So if the read returned everything we had room for, assume
+	 * more may be behind it and re-queue this callback.
+	 * event_active() schedules it for the next turn of the loop
+	 * rather than recursing. ncalls must be 1, not 0:
+	 * event_process_active()'s "while (ncalls)" skips the callback
+	 * entirely at 0 (lib/libevent/event.c), which is why libevent's
+	 * own backends all pass 1.
+	 *
+	 * This terminates: each extra pass consumes real buffered
+	 * plaintext, and once libtls has none left tls_read() returns
+	 * TLS_WANT_POLLIN, which is not > 0.
+	 */
+	if (s->tls_active && n > 0 && (size_t)n == tls_want &&
+	    s->inbuflen < sizeof(s->inbuf))
+		event_active(&s->client_ev, EV_READ, 1);
 }
 
-/* Blocking write(2)/tls_write(): never tears s down on failure. */
+/*
+ * Blocking write(2)/tls_write(): retries EAGAIN/TLS_WANT_POLL* via poll(2),
+ * bounded by SESSION_WRITE_POLL_TIMEOUT_MS below.  On an unrecoverable
+ * error or a timeout it does NOT tear s down itself -- it only records the
+ * failure in s->write_failed, which session_dispatch_client() checks the
+ * next time this session's fd becomes readable.  See the s->write_failed
+ * comment in listener.h.
+ */
 #define SESSION_WRITE_POLL_TIMEOUT_MS	5000
 
 void
@@ -604,9 +1358,11 @@ session_write(struct session *s, const char *buf, size
 {
 	size_t	sent = 0;
 
+	if (s->write_failed)
+		return;
+
 	/*
-	 * Permanent outbound-traffic diagnostic, kept deliberately (not the
-	 * leftover it started as during Canary/Airmail debugging). Gated on
+	 * Permanent outbound-traffic diagnostic, kept deliberately. Gated on
 	 * log_getverbose() rather than relying on log_debug()'s own internal
 	 * check, since building/scrubbing dbuf below is real work this
 	 * function would otherwise do on every single write regardless of
@@ -654,11 +1410,13 @@ session_write(struct session *s, const char *buf, size
 						log_warnx("session %u: write: "
 						    "timed out or poll error",
 						    s->id);
+						s->write_failed = 1;
 						return;
 					}
 					continue;
 				}
 				log_warn("session %u: write", s->id);
+				s->write_failed = 1;
 				return;
 			}
 			sent += (size_t)n;
@@ -679,11 +1437,13 @@ session_write(struct session *s, const char *buf, size
 			if (pret == -1) {
 				log_warn("session %u: poll (tls_write retry)",
 				    s->id);
+				s->write_failed = 1;
 				return;
 			}
 			if (pret == 0) {
 				log_warnx("session %u: tls_write: timed out "
 				    "waiting for socket", s->id);
+				s->write_failed = 1;
 				return;
 			}
 			continue;
@@ -691,6 +1451,7 @@ session_write(struct session *s, const char *buf, size
 		if (n == -1) {
 			log_warnx("session %u: tls_write: %s", s->id,
 			    tls_error(s->tls_ctx));
+			s->write_failed = 1;
 			return;
 		}
 		sent += (size_t)n;
@@ -738,6 +1499,43 @@ session_untagged(struct session *s, const char *text)
 	session_write(s, buf, (size_t)len);
 }
 
+/* Shared client-composing send for the SELECT/FETCH/STORE/EXPUNGE/SEARCH/COPY/MOVE call sites' "fixed request struct + N elemsize-sized trailing elements" imsg shape to s->store_iev; malloc failure and imsg_compose failure are both reported back; the caller replies NO and restores s->state. */
+int
+send_mbox_request(struct session *s, int imsg_type, const char *what,
+    const char *imsgname, const void *req, size_t reqlen, const void *elems,
+    uint32_t nelems, size_t elemsize)
+{
+	size_t	 bodylen = (size_t)nelems * elemsize;
+	char	*combined;
+
+	if ((combined = malloc(reqlen + bodylen)) == NULL) {
+		log_warn("session %u: malloc %s imsg buffer", s->id, what);
+		return (0);
+	}
+	memcpy(combined, req, reqlen);
+	if (bodylen > 0)
+		memcpy(combined + reqlen, elems, bodylen);
+
+	/*
+	 * A compose failure used to be logged and reported as success, so the
+	 * caller left s->state at the busy value it had just set and nothing
+	 * was in flight to move it back: session_is_busy() then blocked every
+	 * further command while the client waited for a tagged reply that
+	 * would never be sent. Every one of this function's call sites already
+	 * handles a 0 return by replying NO and restoring s->state, so
+	 * reporting the failure here fixes SELECT, FETCH, STORE, COPY/MOVE,
+	 * EXPUNGE and SEARCH without touching any of them.
+	 */
+	if (imsg_compose(&s->store_iev->ibuf, imsg_type, 0, 0, -1, combined,
+	    reqlen + bodylen) == -1) {
+		log_warn("session %u: imsg_compose %s", s->id, imsgname);
+		free(combined);
+		return (0);
+	}
+	free(combined);
+	return (1);
+}
+
 /* RFC 7162 SS3.1: marks the session CONDSTORE-aware; emits an unsolicited HIGHESTMODSEQ OK if selected. */
 void
 session_condstore_enable(struct session *s)
@@ -809,6 +1607,7 @@ session_is_busy(const struct session *s)
 	case SESSION_STORING:
 	case SESSION_EXPUNGING:
 	case SESSION_APPENDING:
+	case SESSION_SEARCH_PARSING:
 	case SESSION_SEARCHING:
 	case SESSION_STATUSING:
 	case SESSION_COPYING:
@@ -868,18 +1667,38 @@ 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) {
+		log_debug("session %u: <<< (empty)", s->id);
 		session_reply(s, "*", "BAD", "Empty command line");
 		return (1);
 	}
 
+	/*
+	 * AUTHENTICATE's optional initial response is the base64 of the
+	 * user's cleartext password (RFC 4616 SS2), and a client that
+	 * ignores LOGINDISABLED puts it in the clear on LOGIN. Log the
+	 * command, never its arguments. (parse_command_line() has already
+	 * split `line` in place, so it is rebuilt from the pieces here.)
+	 */
+	if (name != NULL && (strcasecmp(name, "AUTHENTICATE") == 0 ||
+	    strcasecmp(name, "LOGIN") == 0))
+		log_debug("session %u: <<< %s %s <redacted>", s->id, tag,
+		    name);
+	else
+		log_debug("session %u: <<< %s%s%s%s%s", s->id, tag,
+		    name != NULL ? " " : "", name != NULL ? name : "",
+		    args != NULL ? " " : "", args != NULL ? args : "");
+
 	/* Checked once here, not per strlcpy(3) site, an overlong tag must not be echoed back truncated. */
 	if (strlen(tag) >= IMAP_TAG_MAX) {
 		session_reply(s, "*", "BAD", "Tag too long");
 		return (1);
 	}
+	/* Replied to with "*", never with the tag: see tag_is_valid(). */
+	if (!tag_is_valid(tag)) {
+		session_reply(s, "*", "BAD", "Invalid tag");
+		return (1);
+	}
 	if (name == NULL) {
 		session_reply(s, tag, "BAD", "Missing command");
 		return (1);
@@ -910,7 +1729,7 @@ listener_dispatch_auth(int fd, short event, void *arg)
 	struct imsg	 imsg;
 	ssize_t		 n;
 
-	/* Without this, a queued imsg_compose() never flushes and imsgev_add() busy-loops on EV_WRITE. */
+	/* Without this, a queued imsg_compose() never flushes and the EV_WRITE arm imsgev_on_compose() installed busy-loops. */
 	if (event & EV_WRITE) {
 		if (imsgbuf_write(&iev->ibuf) == -1)
 			fatal("imsgbuf_write");
@@ -920,15 +1739,32 @@ listener_dispatch_auth(int fd, short event, void *arg)
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
 			fatal("imsgbuf_read");
 		if (n == 0) {
-			log_warnx("auth closed channel");
+			/*
+			 * SS7: this session's auth-worker may die while the
+			 * session itself lives on (already authenticated, so
+			 * it no longer needs auth at all; or never got this
+			 * far and just won't be able to AUTHENTICATE again --
+			 * auth_cmd.c's sasl_plain_finish() fails that
+			 * gracefully once iev_auth.ibuf.fd reads -1, the same
+			 * sentinel listener_main() sets when no auth-worker
+			 * was spawned in the first place). Close and mark it
+			 * dead rather than just event_del(), or a later
+			 * AUTHENTICATE would silently compose onto a fd
+			 * nothing reads any more and the client would hang
+			 * waiting for a reply that never comes.
+			 */
+			log_warnx("auth-worker closed channel");
 			event_del(&iev->ev);
+			close(iev->ibuf.fd);
+			imsgbuf_clear(&iev->ibuf);
+			iev->ibuf.fd = -1;
 			return;
 		}
 	}
 
 	for (;;) {
-		if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n == 0)
 			break;
 
@@ -953,7 +1789,6 @@ listener_dispatch_auth(int fd, short event, void *arg)
 				break;
 			}
 			
-			s->uid = res.uid;	/* session_notify_idle_peers() groups by this */
 			s->state = SESSION_STORE_PENDING;
 			break;
 		}
@@ -964,76 +1799,167 @@ listener_dispatch_auth(int fd, short event, void *arg)
 		}
 		imsg_free(&imsg);
 	}
-	imsgev_add(iev);	/* re-arm for the next event */
+	imsgev_rearm_read(iev);
 	(void)fd;
 }
 
-/* Rebuilds the TLS ctx/config for SIGHUP reload; a failure leaves the old, working pair untouched. */
+/*
+ * SEARCH-ORACLE channel (SS8.1): the per-connection search-oracle's one
+ * reply, IMSG_SEARCH_PARSE_RESULT, to whatever IMSG_SEARCH_PARSE_REQUEST
+ * search_dispatch() (search_cmd.c) last sent it. At most one such round
+ * trip is ever in flight for this process's one session (see imapd.h's
+ * IMSG_SEARCH_PARSE_REQUEST comment), and this process serves exactly
+ * one session for its whole life (SS7) -- TAILQ_FIRST(&sessions), looked
+ * up once here, is unambiguously it; iev_auth's IMSG_AUTH_RESULT case
+ * looks itself up by session_id instead, but that field predates SS7 and
+ * is kept there for parity with auth.c's own imsg shape, not because
+ * there's any real ambiguity left to resolve.
+ */
 void
-listener_reload_tls(const char *cert_buf, size_t cert_len,
-    const char *key_buf, size_t key_len)
+listener_dispatch_search(int fd, short event, void *arg)
 {
-	struct tls		*new_ctx;
-	struct tls_config	*new_config;
-	struct tls		*old_ctx;
-	struct tls_config	*old_config;
+	struct imsgev	*iev = arg;
+	struct session	*s = TAILQ_FIRST(&sessions);
+	struct imsg	 imsg;
+	ssize_t		 n;
 
-	if (cert_len == 0 || key_len == 0) {
-		log_warnx("listener: SIGHUP reload: empty cert or key, "
-		    "keeping previous TLS configuration");
-		return;
+	if (event & EV_WRITE) {
+		if (imsgbuf_write(&iev->ibuf) == -1)
+			fatal("imsgbuf_write");
 	}
 
-	if ((new_config = tls_config_new()) == NULL) {
-		log_warnx("listener: SIGHUP reload: tls_config_new failed, "
-		    "keeping previous TLS configuration");
-		return;
+	if (event & EV_READ) {
+		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
+			fatal("imsgbuf_read");
+		if (n == 0) {
+			/*
+			 * SS8.1: this session's search-oracle may die while the
+			 * session itself lives on, same fail-soft shape as
+			 * listener_dispatch_auth()'s own channel-EOF handling
+			 * just above (see its comment) -- a SEARCH already in
+			 * flight gets a synthesized NO below instead of hanging
+			 * forever; a later SEARCH fails fast via
+			 * search_dispatch()'s iev_search.ibuf.fd == -1 check,
+			 * the same sentinel used when no oracle was ever spawned
+			 * in the first place.
+			 */
+			log_warnx("search-oracle closed channel");
+			event_del(&iev->ev);
+			close(iev->ibuf.fd);
+			imsgbuf_clear(&iev->ibuf);
+			iev->ibuf.fd = -1;
+
+			if (s != NULL && s->state == SESSION_SEARCH_PARSING) {
+				s->state = SESSION_SELECTED;
+				session_reply(s, s->pending_tag, "NO",
+				    "[UNAVAILABLE] search temporarily "
+				    "unavailable");
+				if (!session_dequeue_next(s))
+					return; /* s torn down by a queued LOGOUT */
+			}
+			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);
+	for (;;) {
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
+		if (n == 0)
+			break;
 
-	log_info("listener: SIGHUP reload: TLS configuration reloaded");
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_SEARCH_PARSE_RESULT: {
+			struct imsg_search_parse_result	 res;
+			struct search_node			*nodes = NULL;
+			size_t					 bodylen;
+
+			if (s == NULL || s->state != SESSION_SEARCH_PARSING) {
+				log_debug("IMSG_SEARCH_PARSE_RESULT with no "
+				    "SEARCH awaiting one, ignored");
+				break;
+			}
+
+			if (imsg_get_buf(&imsg, &res, sizeof(res)) == -1) {
+				log_warnx("bad IMSG_SEARCH_PARSE_RESULT "
+				    "(header)");
+				s->state = SESSION_SELECTED;
+				session_reply(s, s->pending_tag, "NO",
+				    "[SERVERBUG] internal error");
+				break;
+			}
+			/*
+			 * imsg_get_buf() guarantees size, not NUL
+			 * termination -- and search_dispatch_finish()
+			 * hands this field straight to session_reply(),
+			 * i.e. straight to the client, where snprintf("%s")
+			 * would read on past res into this stack frame.
+			 * The sender is the least-trusted process in the
+			 * system by design (SS8.1's whole premise), so its
+			 * framing is exactly what must not be taken on
+			 * trust. Same rule as auth.c's inbound username/
+			 * password, parent.c's maildir and keymgr.c's hash.
+			 */
+			res.errmsg[sizeof(res.errmsg) - 1] = '\0';
+
+			if (res.rc == 0) {
+				bodylen = imsg_get_len(&imsg);
+				if (res.nnodes > SEARCH_PROGRAM_MAX_NODES ||
+				    bodylen != (size_t)res.nnodes *
+				    sizeof(struct search_node)) {
+					log_warnx("bad "
+					    "IMSG_SEARCH_PARSE_RESULT (nnodes "
+					    "%u, %zu trailing bytes)", res.nnodes,
+					    bodylen);
+					s->state = SESSION_SELECTED;
+					session_reply(s, s->pending_tag, "NO",
+					    "[SERVERBUG] internal error");
+					break;
+				}
+				if (bodylen > 0) {
+					if ((nodes = malloc(bodylen)) == NULL) {
+						log_warn("malloc SEARCH nodes");
+						s->state = SESSION_SELECTED;
+						session_reply(s, s->pending_tag,
+						    "NO", "[SERVERBUG] internal "
+						    "error");
+						break;
+					}
+					if (imsg_get_buf(&imsg, nodes, bodylen)
+					    == -1) {
+						log_warnx("bad "
+						    "IMSG_SEARCH_PARSE_RESULT "
+						    "(nodes)");
+						free(nodes);
+						s->state = SESSION_SELECTED;
+						session_reply(s, s->pending_tag,
+						    "NO", "[SERVERBUG] internal "
+						    "error");
+						break;
+					}
+				}
+			}
+
+			search_dispatch_finish(s, &res, nodes);
+			free(nodes);
+			break;
+		}
+		default:
+			log_debug("listener_dispatch_search: unhandled %d",
+			    imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	imsgev_rearm_read(iev);
+	(void)fd;
+
+	if (s != NULL) {
+		if (!session_dequeue_next(s))
+			return; /* s torn down by a queued LOGOUT */
+	}
 }
 
-/* PARENT channel (fd 3): IMSG_STORE_FORK (spawn failure), IMSG_SETUP_PEER (spawn success, imsg_get_id() has session id), and SIGHUP's IMSG_TLS_CERT/KEY. */
+/* PARENT channel (fd 3): IMSG_STORE_FORK (spawn failure) and IMSG_SETUP_PEER (spawn success, imsg_get_id() has session id). No SIGHUP-driven reload here any more (SS7): this process is spawned fresh per connection and gets its own current TLS cert once at spawn time (IMSG_TLS_CERT, in listener_main()'s boot-drain loop), so it never lives long enough to need one -- see parent.c's header comment. */
 void
 listener_dispatch_parent(int fd, short event, void *arg)
 {
@@ -1058,8 +1984,8 @@ listener_dispatch_parent(int fd, short event, void *ar
 	}
 
 	for (;;) {
-		if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n == 0)
 			break;
 
@@ -1111,56 +2037,6 @@ listener_dispatch_parent(int fd, short event, void *ar
 			    "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));
@@ -1168,7 +2044,7 @@ listener_dispatch_parent(int fd, short event, void *ar
 		}
 		imsg_free(&imsg);
 	}
-	imsgev_add(iev);	/* re-arm for the next event */
+	imsgev_rearm_read(iev);
 	(void)fd;
 }
 
@@ -1201,6 +2077,8 @@ session_teardown(struct session *s)
 		free(s->store_iev);
 	}
 
+	session_idle_poll_disarm(s);
+
 	if (s->client_ev_added)
 		event_del(&s->client_ev);
 
@@ -1228,12 +2106,34 @@ session_teardown(struct session *s)
 	free(s->pending_body_buf);
 	free(s->pending_envelope_buf);
 	free(s->pending_bodystructure_buf);
+	free(s->idle_known_uids);
+	free(s->idle_incoming_uids);
 
 	/* Any commands pipelined behind the one in flight when this session was torn down. */
 	while (s->cmd_queue_n > 0)
 		free(s->cmd_queue[--s->cmd_queue_n]);
 
 	TAILQ_REMOVE(&sessions, s, entry);
-	log_debug("session %u: closed", s->id);
+	log_debug("session %u: closed (peer %s)", s->id, s->remote_addr);
+
+	/* inbuf can still hold a base64 SASL response; don't hand it back
+	 * to the allocator intact. */
+	explicit_bzero(s, sizeof(*s));
 	free(s);
+
+	/*
+	 * SS7: this process was spawned (parent.c's spawn_connection())
+	 * to serve exactly this one session and will never serve another
+	 * -- listener_start_session() is called exactly once, from
+	 * listener_main()'s boot sequence. Without this, the process
+	 * would sit in event_dispatch() forever with nothing left to do,
+	 * leaking one process per finished connection for the rest of
+	 * the daemon's uptime; parent.c's reap_child() already treats a
+	 * listener-worker exit as the ordinary, expected end of a
+	 * session (see its own comment), not something to warn about.
+	 * Matches store.c's store_shutdown() for the same reason on
+	 * that per-session worker.
+	 */
+	log_debug("listener-worker: session closed, exiting");
+	exit(0);
 }
blob - /dev/null
blob + f7ebcb8722030c44e1c797cbe9f9048f4d8e536a (mode 644)
--- /dev/null
+++ src/keymgr.c
@@ -0,0 +1,910 @@
+/*
+ * Copyright (c) 2026 David Williams <dhw@openimapd.dev>
+ * Copyright (c) 2014 Reyk Floeter <reyk@openbsd.org>
+ * Copyright (c) 2012 Gilles Chehade <gilles@poolp.org>
+ * Copyright (c) 2008 Pierre-Yves Ritschard <pyr@openbsd.org>
+ * Copyright (c) 2008 Reyk Floeter <reyk@openbsd.org>
+ *
+ * This file's overall architecture -- a dedicated process holding the
+ * real TLS private key, a "fake private key" installed in the
+ * TLS-terminating process instead, and a process-wide OpenSSL
+ * RSA_METHOD/EC_KEY_METHOD engine override that forwards every
+ * private-key operation here as a synchronous imsg round-trip -- is
+ * smtpd's ca.c (Reyk Floeter, Gilles Chehade), ported to imapd's own
+ * imsg/privsep conventions rather than copied verbatim; see
+ * docs/openimap-tls-privsep-design.md SS10.1 and SS5.5. The RSA_METHOD/
+ * EC_KEY_METHOD engine-override code itself (the code this file's
+ * request/reply pair answers) lives in listener.c, not here -- see that
+ * file's header comment for the matching attribution.
+ *
+ * keymgr_pubkey_hash() below additionally replicates, byte-for-byte,
+ * smtpd's ssl.c hash_x509()/ssl_pubkey_hash() algorithm (Pierre-Yves
+ * Ritschard, Reyk Floeter, Gilles Chehade): SHA256 of the certificate's
+ * DER-encoded SubjectPublicKeyInfo, formatted "SHA256:" plus lowercase
+ * hex. That exact format is what libtls's own (unexported)
+ * tls_cert_pubkey_hash() tags onto the fake key's OpenSSL ex_data slot 0
+ * at TLS-config time (lib/libtls/tls.c, confirmed by direct reading, not
+ * assumed) -- this process's key lookup only works if it derives the
+ * identical string from the same certificate, so the exact byte shape
+ * of hash_x509() is load-bearing here, not just a convenient precedent.
+ *
+ * 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.
+ */
+
+/*
+ * keymgr.c, TLS private-key isolation process: holds the real RSA/EC
+ * private key; listener.c gets libtls's "fake private key" instead and
+ * forwards every sign/decrypt operation here over imsg. See
+ * docs/openimap-tls-privsep-design.md SS5 for the full design.
+ *
+ * Deliberately diverges from ca.c's fatalx()-on-bad-request style for
+ * anything content-dependent: imapd's parent.c does not auto-restart a
+ * dead listener/auth/keymgr child (see parent.c's reap_child()), it only
+ * logs and tells the operator to "rcctl restart imapd" -- a crashed
+ * keymgr would be worse than smtpd's ca dying, since every future TLS
+ * handshake needs it. This file follows the same fatal/graceful split
+ * every other role in this tree already uses (compare auth_main()'s
+ * fatalx()-on-bad-INIT-framing vs. auth_verify()'s graceful "wrong
+ * password" reply, or listener_main()'s own "no TLS cert/key received"
+ * warning instead of a fatalx()): boot-time *plumbing* failures (no
+ * peer, a parent that closes the channel mid-handshake, getpwnam/
+ * chroot/privilege-drop failing) are still fatalx() -- there is no
+ * sensible degraded mode for those. Boot-time *content* failures (an
+ * unparseable cert or key) and any later per-request failure (unknown
+ * hash, a key that won't do the requested operation) are logged and
+ * degrade instead: keymgr keeps running with no usable key (or its last
+ * known-good one), and answers every signing request with a plain
+ * failure until a good SIGHUP reload arrives, exactly mirroring how
+ * listener.c already treats its own "no TLS cert/key received" case as
+ * non-fatal.
+ */
+
+#include <sys/types.h>
+#include <sys/queue.h>
+
+#include <openssl/bio.h>
+#include <openssl/ec.h>
+#include <openssl/ecdsa.h>
+#include <openssl/evp.h>
+#include <openssl/pem.h>
+#include <openssl/rsa.h>
+#include <openssl/x509.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"
+
+/*
+ * Matches parent.c's send_tls_cert()/send_keymgr_key() read buffer size
+ * (8192), same reasoning as listener.c's own TLS_CERT_MAX -- kept as a
+ * separate local constant rather than a shared imapd.h macro, same as
+ * parent.c's own unnamed 8192 and listener.c's TLS_CERT_MAX/TLS_KEY_MAX
+ * today; nothing outside this file needs to agree on the exact value,
+ * only that it's large enough for parent.c's own read buffer.
+ */
+#define KEYMGR_CERT_MAX	8192
+#define KEYMGR_KEY_MAX	8192
+
+/*
+ * SS7: keymgr is the one remaining boot-time, daemon-lifetime
+ * singleton (this file's header comment) but now serves every live
+ * connection's listener-worker, not just one -- each gets its own
+ * peer entry, wired in as parent.c's spawn_connection() forks it
+ * (keymgr_dispatch_parent()'s own IMSG_SETUP_PEER case below), torn
+ * down independently when that one listener-worker's channel closes
+ * (keymgr_dispatch_listener()). Named (not anonymous) so a forward
+ * declaration isn't needed above keymgr_dispatch_parent()'s use of
+ * the type -- same reasoning as listener.h's own struct session_list.
+ */
+struct keymgr_peer {
+	uint32_t		 session_id;
+	struct imsgev		 iev;
+	TAILQ_ENTRY(keymgr_peer) entry;
+};
+TAILQ_HEAD(keymgr_peer_list, keymgr_peer);
+static struct keymgr_peer_list	 keymgr_peers =
+	    TAILQ_HEAD_INITIALIZER(keymgr_peers);
+
+static struct imsgev	 iev_parent;	/* fd 3, alive for the process's lifetime */
+
+/* SIGHUP reload staging; keymgr_dispatch_parent() fires once both flags are set -- same pattern listener.c used for its own now-removed cert/key reload gating. */
+static char	 reload_cert_buf[KEYMGR_CERT_MAX], reload_key_buf[KEYMGR_KEY_MAX];
+static size_t	 reload_cert_len, reload_key_len;
+static int	 reload_got_cert, reload_got_key;
+
+/* The currently-loaded real key and its libtls-compatible pubkey hash; NULL/empty iff no usable key has ever loaded successfully. */
+static EVP_PKEY	*keymgr_pkey;
+static char	 keymgr_hash[KEYMGR_HASH_MAX];
+
+/* SS6.1's explicit permission gate: true once the boot-time cert+key pair has been processed at all (even if it was rejected as unusable) -- distinct from keymgr_pkey being non-NULL, which tracks whether a *usable* key is currently loaded. A signing request arriving before this is set is refused outright, not merely "refused because no key is loaded yet", so the gate is checkable on its own rather than an incidental side effect of message ordering. */
+static int	 keymgr_got_init;
+
+static int	 keymgr_load(const char *, size_t, const char *, size_t);
+static int	 keymgr_pubkey_hash(X509 *, char *, size_t);
+static void	 keymgr_dispatch_listener(int, short, void *);
+static void	 keymgr_peer_teardown(struct keymgr_peer *);
+static void	 keymgr_dispatch_parent(int, short, void *);
+static void	 keymgr_handle_rsa(struct imsgev *, struct imsg *, uint32_t,
+		    uint32_t);
+static void	 keymgr_handle_ecdsa(struct imsgev *, struct imsg *,
+		    uint32_t);
+static void	 keymgr_reply(struct imsgev *, uint32_t, uint32_t, int,
+		    const void *, size_t);
+static int	 keymgr_recv_trailing(struct imsg *, uint32_t,
+		    unsigned char *, size_t);
+
+__dead void
+keymgr_main(void)
+{
+	struct imsgbuf	 ibuf3;
+	struct passwd	*pw;
+	struct imsg	 imsg;
+	ssize_t		 n;
+	char		 cert_buf[KEYMGR_CERT_MAX], key_buf[KEYMGR_KEY_MAX];
+	size_t		 cert_len = 0, key_len = 0;
+	int		 got_cert = 0, got_key = 0;
+
+	/* fd-passing is allowed on this channel for the fd-passed IMSG_SETUP_PEER peer fds; see imsgev_ibuf_init()'s own comment */
+	imsgev_ibuf_init(&ibuf3, 3);
+
+	/*
+	 * IMSG_TLS_CERT (cert bytes) and IMSG_KEYMGR_INIT (key bytes) must
+	 * both be read before the peer handshake -- same "config before
+	 * anything else" ordering auth_main() already uses for
+	 * IMSG_AUTH_INIT (auth.c's comment: "must be read first"), so that
+	 * keymgr_got_init/keymgr_pkey are in their final boot-time state
+	 * before listener's peer channel can possibly send a signing
+	 * request. Two separate imsg types rather than one combined
+	 * payload deliberately mirrors parent.c's send_tls_cert()/
+	 * send_keymgr_key() split (see parent.c's send_keymgr_init()
+	 * comment) rather than packing cert+key into a single imsg, which
+	 * would leave uncomfortably little headroom under MAX_IMSGSIZE
+	 * (16384) once both are near their own 8192-byte caps.
+	 */
+	while (!got_cert || !got_key) {
+		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
+			fatal("imsgbuf_get");
+		if (n == 0) {
+			if ((n = imsgbuf_read(&ibuf3)) == -1)
+				fatal("imsgbuf_read");
+			if (n == 0)
+				fatalx("parent closed channel "
+				    "before INIT");
+			continue;
+		}
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_TLS_CERT:
+			cert_len = imsg_get_len(&imsg);
+			if (cert_len > sizeof(cert_buf)) {
+				log_warnx("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_KEYMGR_INIT:
+			key_len = imsg_get_len(&imsg);
+			if (key_len > sizeof(key_buf)) {
+				log_warnx("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_KEYMGR_INIT");
+				key_len = 0;
+			}
+			got_key = 1;
+			break;
+		default:
+			log_debug("keymgr boot: unhandled %d",
+			    imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+
+	/* Content failure here is not fatal, see this file's header comment. */
+	if (cert_len == 0 || key_len == 0)
+		log_warnx("no usable TLS cert/key at boot, TLS "
+		    "private-key operations will fail until the next "
+		    "SIGHUP reload supplies valid material");
+	else if (keymgr_load(cert_buf, cert_len, key_buf, key_len) == -1)
+		log_warnx("failed to load TLS cert/key at boot "
+		    "(see above), TLS private-key operations will fail "
+		    "until the next SIGHUP reload supplies valid material");
+	explicit_bzero(key_buf, sizeof(key_buf));
+	keymgr_got_init = 1;
+
+	/* keymgr's own daemon-user identity; SS5.5's chosen new account, following imapd's per-role convention (_imapd for listener, _imapauth for auth) over smtpd's literal SMTPD_USER reuse. */
+	if ((pw = getpwnam("_imapkey")) == NULL)
+		fatalx("getpwnam _imapkey: no such user "
+		    "(expected, not yet provisioned by an install script)");
+
+	/* No filesystem access needed at all -- the key arrives over imsg from parent, never touches disk in this process. */
+	if (chroot("/var/empty") == -1)
+		fatal("chroot /var/empty");
+	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 _imapkey");
+
+	/*
+	 * SS7: unlike listener/auth-worker, keymgr stays the one
+	 * boot-time, daemon-lifetime child (this file's header comment)
+	 * -- but no peer is wired to it at boot any more. parent.c's
+	 * boot sequence sends it only IMSG_SETUP_DONE, with no preceding
+	 * IMSG_SETUP_PEER (see parent.c's own boot comment on why); its
+	 * first, and every later, listener-worker peer instead arrives
+	 * post-boot over this same fd 3 channel, as an ordinary
+	 * IMSG_SETUP_PEER per spawn_connection() call, handled by
+	 * keymgr_dispatch_parent()'s own case below -- not here.
+	 */
+	setup_recv_done_and_ack(&ibuf3);
+
+	event_init();
+
+	imsgev_init_from_ibuf(&iev_parent, &ibuf3, keymgr_dispatch_parent,
+	    NULL);
+
+#ifdef __OpenBSD__
+	/*
+	 * recvfd, and only recvfd. Unlike every other child, keymgr keeps
+	 * receiving descriptors for its whole life: it is the one
+	 * daemon-lifetime singleton, and spawn_connection() sends it a fresh
+	 * IMSG_SETUP_PEER per accepted connection (parent.c), handled by
+	 * keymgr_dispatch_parent()'s own case below.
+	 *
+	 * No sendfd. keymgr never attaches a descriptor to an imsg -- the
+	 * parent is the only process in the tree that does. An earlier
+	 * version of this comment claimed sendfd was needed "for
+	 * imsg_compose()'s reply path"; it is not. SYS_sendmsg is
+	 * PLEDGE_STDIO (sys/kern/kern_pledge.c), and "sendfd" is checked in
+	 * unp_internalize() (sys/kern/uipc_usrreq.c), which the kernel
+	 * reaches only when SCM_RIGHTS is actually attached. keymgr_reply()
+	 * composes with fd == -1.
+	 *
+	 * No rpath either: keymgr touches no filesystem at all (SS5.5), which
+	 * is why this is auth.c's promise minus rpath.
+	 */
+	if (pledge("stdio recvfd", NULL) == -1)
+		fatal("pledge");
+#endif
+
+	event_dispatch();
+	fatalx("exited event loop");
+}
+
+/*
+ * Replicates smtpd's ssl.c hash_x509() byte-for-byte (see this file's
+ * header comment for why the exact format is load-bearing, not
+ * cosmetic): SHA256 digest of the certificate's DER SubjectPublicKeyInfo,
+ * formatted "SHA256:" followed by lowercase hex.
+ */
+static int
+keymgr_pubkey_hash(X509 *cert, char *hash, size_t hashlen)
+{
+	static const char	hex[] = "0123456789abcdef";
+	/*
+	 * unsigned char/unsigned int, not smtpd hash_x509()'s char/int:
+	 * X509_pubkey_digest(3) takes "unsigned char *md, unsigned int
+	 * *len" (x509.h), and the signed spellings draw a -Wpointer-sign
+	 * and an incompatible-pointer diagnostic under this Makefile's
+	 * -Wall. The emitted string is unchanged -- with an unsigned
+	 * digest, digest[i] >> 4 is already the high nibble in 0..15, so
+	 * the & 0x0f below becomes redundant rather than wrong, and it is
+	 * kept so this stays visibly the same algorithm as hash_x509().
+	 */
+	unsigned char		digest[EVP_MAX_MD_SIZE];
+	size_t			off;
+	unsigned int		dlen, i;
+
+	if (X509_pubkey_digest(cert, EVP_sha256(), digest, &dlen) != 1)
+		return (-1);
+	if (hashlen < 2 * (size_t)dlen + sizeof("SHA256:"))
+		return (-1);
+
+	off = strlcpy(hash, "SHA256:", hashlen);
+	for (i = 0; i < dlen; i++) {
+		hash[off++] = hex[(digest[i] >> 4) & 0x0f];
+		hash[off++] = hex[digest[i] & 0x0f];
+	}
+	hash[off] = '\0';
+	return (0);
+}
+
+/*
+ * Parses a cert+key pair and, only if both parse successfully, replaces
+ * the currently-loaded key. Deliberately parses the new pair fully
+ * before touching the old one, unlike a literal "free the old EVP_PKEY/
+ * hash, then install the new one": a rejected or malformed SIGHUP
+ * reload (a typo'd path, a half-written file mid-rotation on the
+ * operator's side) should leave keymgr still answering with the last
+ * known-good key, not with none at all. This is not sourced against
+ * smtpd's ca.c, whose own dict_check()/dict_xset() reload behavior this
+ * project's design document already flags as unconfirmed either way
+ * (docs/openimap-tls-privsep-design.md SS5.5) -- it's a small,
+ * self-contained imapd choice, not a smtpd port.
+ *
+ * cert_buf/key_buf are not retained past this call; only the derived
+ * EVP_PKEY and hash string are kept. Callers are responsible for
+ * scrubbing their own copy of key_buf once this returns.
+ */
+static int
+keymgr_load(const char *cert_buf, size_t cert_len, const char *key_buf,
+    size_t key_len)
+{
+	BIO		*cert_bio = NULL, *key_bio = NULL;
+	X509		*cert = NULL;
+	EVP_PKEY	*pkey = NULL;
+	char		 hash[KEYMGR_HASH_MAX];
+
+	if (cert_len == 0 || key_len == 0) {
+		log_warnx("empty cert or key, not (re)loading");
+		return (-1);
+	}
+
+	if ((cert_bio = BIO_new_mem_buf(cert_buf, (int)cert_len)) == NULL) {
+		log_warnx("BIO_new_mem_buf (cert) failed");
+		goto fail;
+	}
+	if ((cert = PEM_read_bio_X509(cert_bio, NULL, NULL, NULL)) == NULL) {
+		log_warnx("PEM_read_bio_X509 failed");
+		goto fail;
+	}
+	if (keymgr_pubkey_hash(cert, hash, sizeof(hash)) == -1) {
+		log_warnx("could not hash certificate pubkey");
+		goto fail;
+	}
+
+	if ((key_bio = BIO_new_mem_buf(key_buf, (int)key_len)) == NULL) {
+		log_warnx("BIO_new_mem_buf (key) failed");
+		goto fail;
+	}
+	if ((pkey = PEM_read_bio_PrivateKey(key_bio, NULL, NULL, NULL))
+	    == NULL) {
+		log_warnx("PEM_read_bio_PrivateKey failed");
+		goto fail;
+	}
+
+	/*
+	 * Both halves parsing is not the same as them belonging together.
+	 * A rotation that replaced the certificate but not the key (or the
+	 * reverse) yields exactly that: two files that each parse cleanly
+	 * and do not correspond. It is the most common way a rotation goes
+	 * wrong, and it is precisely the case this function's
+	 * parse-both-before-swapping design exists to survive -- so check
+	 * it here, where both objects are in hand, and let the goto below
+	 * keep the last known-good pair.
+	 *
+	 * The per-request hash check in keymgr_handle_rsa()/_ecdsa()
+	 * cannot catch this: it compares the request's certificate hash
+	 * against this process's certificate hash, never the certificate
+	 * against the key.
+	 */
+	if (X509_check_private_key(cert, pkey) != 1) {
+		log_warnx("certificate and private key do not match, not "
+		    "(re)loading");
+		goto fail;
+	}
+
+	/* Both parsed; only now touch the live state. */
+	if (keymgr_pkey != NULL)
+		EVP_PKEY_free(keymgr_pkey);
+	keymgr_pkey = pkey;
+	pkey = NULL;
+	/* strlcpy, not memcpy of sizeof(): keymgr_pubkey_hash() writes 72
+	 * of KEYMGR_HASH_MAX's 80 bytes, and copying the rest would drag
+	 * eight uninitialised stack bytes into a static. */
+	(void)strlcpy(keymgr_hash, hash, sizeof(keymgr_hash));
+
+	log_info("TLS key loaded (%s)", keymgr_hash);
+
+	BIO_free(cert_bio);
+	BIO_free(key_bio);
+	X509_free(cert);
+	return (0);
+
+fail:
+	if (cert_bio != NULL)
+		BIO_free(cert_bio);
+	if (key_bio != NULL)
+		BIO_free(key_bio);
+	if (cert != NULL)
+		X509_free(cert);
+	if (pkey != NULL)
+		EVP_PKEY_free(pkey);
+	return (-1);
+}
+
+/* PARENT channel (fd 3): SIGHUP reload's IMSG_TLS_CERT/IMSG_KEYMGR_INIT pair (same paired-flags shape listener.c used for its own now-removed cert/key reload gating), plus IMSG_SETUP_PEER wiring in a fresh listener-worker's peer (SS7: one per parent.c's spawn_connection() call, imsg_get_id() carries session_id -- see listener.c's own IMSG_SETUP_PEER (store) case for the same pattern). */
+static void
+keymgr_dispatch_parent(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) {
+			/*
+			 * The parent is gone, so this process is done -- the
+			 * same answer keymgr_main() already gives to the
+			 * identical event before the event loop starts
+			 * ("parent closed channel before INIT"), and what
+			 * keymgr_dispatch_listener()'s comment below has
+			 * always said this channel means.
+			 *
+			 * It used to event_del() and return, which left
+			 * keymgr serving its existing listener peers until
+			 * the last one closed. That protected nothing:
+			 * keymgr is reached only through libtls's
+			 * private-key callbacks, which fire during the TLS
+			 * handshake, so an established session never asks it
+			 * for anything again. What it cost was a process
+			 * holding the TLS private key outliving its
+			 * supervisor for as long as one client kept a
+			 * connection open -- and IDLE means days -- while an
+			 * "rcctl restart imapd" brought up a second keymgr
+			 * with the same key.
+			 *
+			 * log_warnx, not log_debug: unlike auth-worker's and
+			 * search-oracle's peer EOF, this is not the ordinary
+			 * end of anything. An orderly shutdown SIGTERMs
+			 * keymgr (parent.c's sigterm_handler()), so reaching
+			 * here means the parent died without running it --
+			 * a crash, a SIGKILL, the OOM killer. An operator
+			 * should see that without -v.
+			 *
+			 * exit(0), not fatalx: keymgr has not failed. It is
+			 * ending because the process it exists to serve
+			 * ended. fatalx would log "fatal:" at LOG_CRIT and
+			 * exit 1, which misreports an orderly response to
+			 * someone else's death -- and there is no parent
+			 * left to read the status anyway.
+			 */
+			log_warnx("parent closed channel, exiting");
+			exit(0);
+		}
+	}
+
+	for (;;) {
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
+		if (n == 0)
+			break;
+
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_SETUP_PEER: {
+			uint32_t		 sess_id = imsg_get_id(&imsg);
+			int			 peer_fd = imsg_get_fd(&imsg);
+			struct keymgr_peer	*kp;
+
+			if (peer_fd == -1) {
+				log_warnx("IMSG_SETUP_PEER carried no fd");
+				break;
+			}
+			if ((kp = calloc(1, sizeof(*kp))) == NULL) {
+				log_warn("calloc");
+				close(peer_fd);
+				break;
+			}
+			kp->session_id = sess_id;
+			imsgev_init(&kp->iev, peer_fd, keymgr_dispatch_listener,
+			    kp);
+			TAILQ_INSERT_TAIL(&keymgr_peers, kp, entry);
+			log_debug("session %u: listener peer wired", sess_id);
+			break;
+		}
+		case IMSG_TLS_CERT: {
+			size_t	 len = imsg_get_len(&imsg);
+
+			if (len > sizeof(reload_cert_buf)) {
+				log_warnx("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) {
+				if (keymgr_load(reload_cert_buf,
+				    reload_cert_len, reload_key_buf,
+				    reload_key_len) == -1)
+					log_warnx("SIGHUP reload: "
+					    "keeping previous key, see "
+					    "above");
+				explicit_bzero(reload_key_buf,
+				    sizeof(reload_key_buf));
+				reload_got_cert = reload_got_key = 0;
+			}
+			break;
+		}
+		case IMSG_KEYMGR_INIT: {
+			size_t	 len = imsg_get_len(&imsg);
+
+			if (len > sizeof(reload_key_buf)) {
+				log_warnx("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_KEYMGR_INIT "
+				    "(reload)");
+				len = 0;
+			}
+			reload_key_len = len;
+			reload_got_key = 1;
+			if (reload_got_cert && reload_got_key) {
+				if (keymgr_load(reload_cert_buf,
+				    reload_cert_len, reload_key_buf,
+				    reload_key_len) == -1)
+					log_warnx("SIGHUP reload: "
+					    "keeping previous key, see "
+					    "above");
+				explicit_bzero(reload_key_buf,
+				    sizeof(reload_key_buf));
+				reload_got_cert = reload_got_key = 0;
+			}
+			break;
+		}
+		default:
+			log_debug("keymgr_dispatch_parent: unhandled %d",
+			    imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	imsgev_rearm_read(iev);
+	(void)fd;
+}
+
+/*
+ * LISTENER channel: the three signing/decrypt request types
+ * (SS5.2/SS6.1). SS7: arg is this peer's owning struct keymgr_peer,
+ * not the bare struct imsgev directly (imsgev_init()'s own arg,
+ * set when keymgr_dispatch_parent()'s IMSG_SETUP_PEER case wires a
+ * new listener-worker in) -- needed on the EOF path below to know
+ * which one of possibly many live peers just went away.
+ */
+static void
+keymgr_dispatch_listener(int fd, short event, void *arg)
+{
+	struct keymgr_peer	*kp = arg;
+	struct imsgev		*iev = &kp->iev;
+	struct imsg		 imsg;
+	ssize_t			 n;
+
+	/*
+	 * A transport failure on ONE listener-worker's channel drops that
+	 * peer, it does not end this process. This is the same
+	 * plumbing-vs-degrade split this file's header comment already
+	 * describes, applied to the per-peer channel: keymgr is the one
+	 * daemon-lifetime singleton and parent.c's reap_child()
+	 * deliberately does not restart it, so fatal()ing here would turn
+	 * one connection's worker dying at the wrong moment (a crash, a
+	 * SIGKILL, the shutdown race) into "no TLS for the whole daemon
+	 * until an operator runs rcctl restart". EPIPE from a peer that
+	 * went away with a reply still queued is exactly that case --
+	 * keymgr_reply() composes and returns, so replies really do sit
+	 * queued for the event loop to write.
+	 *
+	 * parent.c's store_child_dispatch() is the in-tree precedent for
+	 * this shape. keymgr_dispatch_parent() stays strict: that is the
+	 * fd-3 channel, and a keymgr that has lost its parent has no
+	 * future. Its transport errors fatal() and, since 2026-09-05, its
+	 * EOF exits too -- that last path used to drain instead, which is
+	 * the one place this sentence was describing something the code
+	 * did not do.
+	 */
+	if (event & EV_WRITE) {
+		if (imsgbuf_write(&iev->ibuf) == -1) {
+			log_warnx("session %u: write error on listener "
+			    "channel, dropping this peer", kp->session_id);
+			keymgr_peer_teardown(kp);
+			return;
+		}
+	}
+	if (event & EV_READ) {
+		if ((n = imsgbuf_read(&iev->ibuf)) == -1) {
+			log_warnx("session %u: read error on listener "
+			    "channel, dropping this peer", kp->session_id);
+			keymgr_peer_teardown(kp);
+			return;
+		}
+		if (n == 0) {
+			log_debug("session %u: listener closed channel",
+			    kp->session_id);
+			keymgr_peer_teardown(kp);
+			return;
+		}
+	}
+
+	for (;;) {
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
+		if (n == 0)
+			break;
+
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_KEYMGR_RSA_PRIVENC:
+		case IMSG_KEYMGR_RSA_PRIVDEC:
+			keymgr_handle_rsa(iev, &imsg, imsg_get_type(&imsg),
+			    imsg_get_id(&imsg));
+			break;
+		case IMSG_KEYMGR_ECDSA_SIGN:
+			keymgr_handle_ecdsa(iev, &imsg, imsg_get_id(&imsg));
+			break;
+		default:
+			log_debug("keymgr_dispatch_listener: unhandled %d",
+			    imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	imsgev_rearm_read(iev);
+	(void)fd;
+}
+
+/*
+ * Drops one listener-worker peer: unregisters its event, closes and
+ * clears its channel, unlinks it and frees it. Shared by every exit in
+ * keymgr_dispatch_listener() -- EOF and transport error alike -- which
+ * is the point: those are the same underlying event (that worker is
+ * gone) arriving through two code paths, and they must not have two
+ * different outcomes.
+ *
+ * imsgbuf_clear() is not optional: imsgbuf_init() allocates, and
+ * close(2) alone would leak it, one allocation per connection for the
+ * life of the daemon.
+ */
+static void
+keymgr_peer_teardown(struct keymgr_peer *kp)
+{
+	event_del(&kp->iev.ev);
+	close(kp->iev.ibuf.fd);
+	imsgbuf_clear(&kp->iev.ibuf);
+	TAILQ_REMOVE(&keymgr_peers, kp, entry);
+	free(kp);
+}
+
+/* Bound-checked read of this imsg's trailing raw bytes into a caller-supplied fixed buffer; same "fixed header + trailing raw bytes on one imsg" shape as store.c's recv_trailing_array()/imapd.h's imsg_mbox_append, without the malloc since KEYMGR_DATA_MAX is a small fixed cap rather than message-dependent. */
+static int
+keymgr_recv_trailing(struct imsg *imsg, uint32_t len, unsigned char *buf,
+    size_t bufsize)
+{
+	size_t	 bodylen = imsg_get_len(imsg);
+
+	if (len > bufsize || bodylen != (size_t)len) {
+		log_warnx("trailing data length mismatch (header "
+		    "says %u, imsg has %zu bytes, buffer holds %zu)", len,
+		    bodylen, bufsize);
+		return (0);
+	}
+	if (bodylen == 0)
+		return (1);
+	if (imsg_get_buf(imsg, buf, bodylen) == -1) {
+		log_warnx("bad trailing data");
+		return (0);
+	}
+	return (1);
+}
+
+/* Composes a struct imsg_keymgr_sign_reply plus its trailing output bytes, reusing the SAME imsg type as the request (correlated by id) -- matches ca_imsg()'s own convention of replying on imsg->hdr.type rather than a distinct reply type. */
+static void
+keymgr_reply(struct imsgev *iev, uint32_t type, uint32_t id, int ok,
+    const void *to, size_t tolen)
+{
+	struct imsg_keymgr_sign_reply	 rep;
+	unsigned char			 combined[sizeof(rep) + KEYMGR_DATA_MAX];
+
+	/*
+	 * Every caller bounds its own result today (both handlers check
+	 * RSA_size()/ECDSA_size() against their output buffer before
+	 * operating), but combined[] is a fixed stack buffer in the
+	 * key-holding process and this function should not depend on that
+	 * discipline holding forever -- keymgr_recv_trailing(), its
+	 * mirror image on the inbound side, does bound-check. A result
+	 * that does not fit is reported as a failed operation, which is
+	 * what struct imsg_keymgr_sign_reply's ok field is for.
+	 */
+	if (ok && tolen > KEYMGR_DATA_MAX) {
+		log_warnx("keymgr_reply: %zu-byte result exceeds "
+		    "KEYMGR_DATA_MAX (%d), refusing", tolen,
+		    KEYMGR_DATA_MAX);
+		ok = 0;
+	}
+
+	memset(&rep, 0, sizeof(rep));
+	rep.ok = ok;
+	rep.tolen = ok ? (uint32_t)tolen : 0;
+
+	memcpy(combined, &rep, sizeof(rep));
+	if (ok && tolen > 0)
+		memcpy(combined + sizeof(rep), to, tolen);
+
+	if (imsg_compose(&iev->ibuf, type, id, 0, -1, combined,
+	    sizeof(rep) + (ok ? tolen : 0)) == -1)
+		log_warn("imsg_compose reply");
+
+	/*
+	 * imsg_compose() has copied it. On an RSA_PRIVDEC this buffer
+	 * held the session's decrypted premaster secret; the file scrubs
+	 * the key material it is given (key_buf, reload_key_buf) and the
+	 * output of the operations it performs belongs in the same rule.
+	 */
+	explicit_bzero(combined, sizeof(combined));
+}
+
+/* IMSG_KEYMGR_RSA_PRIVENC / IMSG_KEYMGR_RSA_PRIVDEC: mirrors ca_imsg()'s RSA_private_encrypt()/RSA_private_decrypt() dispatch (ca.c:216-253), on imapd's own single-key state (SS5.5) rather than ca.c's hash-keyed dict. */
+static void
+keymgr_handle_rsa(struct imsgev *iev, struct imsg *imsg, uint32_t type,
+    uint32_t id)
+{
+	struct imsg_keymgr_sign_request	 req;
+	unsigned char				 from[KEYMGR_DATA_MAX];
+	unsigned char				 to[KEYMGR_DATA_MAX];
+	RSA					*rsa = NULL;
+	int					 ret = 0;
+	const char				*opname = type ==
+	    IMSG_KEYMGR_RSA_PRIVENC ? "RSA_PRIVENC" : "RSA_PRIVDEC";
+
+	if (imsg_get_buf(imsg, &req, sizeof(req)) == -1) {
+		log_warnx("bad %s request (header)", opname);
+		keymgr_reply(iev, type, id, 0, NULL, 0);
+		return;
+	}
+	/* imsg_get_buf() guarantees size, not NUL termination, force it (same reasoning as auth.c's inbound username/password fields). */
+	req.hash[sizeof(req.hash) - 1] = '\0';
+
+	if (!keymgr_recv_trailing(imsg, req.fromlen, from, sizeof(from))) {
+		keymgr_reply(iev, type, id, 0, NULL, 0);
+		return;
+	}
+
+	if (!keymgr_got_init) {
+		log_warnx("%s request before IMSG_KEYMGR_INIT "
+		    "completed, refusing (SS6.1)", opname);
+		keymgr_reply(iev, type, id, 0, NULL, 0);
+		return;
+	}
+	if (keymgr_pkey == NULL || strcmp(req.hash, keymgr_hash) != 0) {
+		log_warnx("%s request for unrecognized key hash, "
+		    "refusing", opname);
+		keymgr_reply(iev, type, id, 0, NULL, 0);
+		return;
+	}
+
+	if ((rsa = EVP_PKEY_get1_RSA(keymgr_pkey)) == NULL) {
+		log_warnx("%s: loaded key is not RSA", opname);
+		keymgr_reply(iev, type, id, 0, NULL, 0);
+		return;
+	}
+	if ((size_t)RSA_size(rsa) > sizeof(to)) {
+		log_warnx("%s: RSA key too large (%d > %zu)",
+		    opname, RSA_size(rsa), sizeof(to));
+		RSA_free(rsa);
+		keymgr_reply(iev, type, id, 0, NULL, 0);
+		return;
+	}
+
+	if (type == IMSG_KEYMGR_RSA_PRIVENC)
+		ret = RSA_private_encrypt((int)req.fromlen, from, to, rsa,
+		    (int)req.padding);
+	else
+		ret = RSA_private_decrypt((int)req.fromlen, from, to, rsa,
+		    (int)req.padding);
+	RSA_free(rsa);
+
+	if (ret <= 0) {
+		log_warnx("%s failed", opname);
+		keymgr_reply(iev, type, id, 0, NULL, 0);
+		explicit_bzero(to, sizeof(to));
+		return;
+	}
+	keymgr_reply(iev, type, id, 1, to, (size_t)ret);
+	/* RSA_PRIVDEC's output is the session's decrypted premaster
+	 * secret; do not leave it on this process's stack. */
+	explicit_bzero(to, sizeof(to));
+}
+
+/* IMSG_KEYMGR_ECDSA_SIGN: mirrors ca_imsg()'s ECDSA_sign() dispatch (ca.c:255-279). */
+static void
+keymgr_handle_ecdsa(struct imsgev *iev, struct imsg *imsg, uint32_t id)
+{
+	struct imsg_keymgr_sign_request	 req;
+	unsigned char				 dgst[KEYMGR_DATA_MAX];
+	unsigned char				 sig[KEYMGR_DATA_MAX];
+	EC_KEY					*ec = NULL;
+	unsigned int				 siglen;
+	int					 ret;
+
+	if (imsg_get_buf(imsg, &req, sizeof(req)) == -1) {
+		log_warnx("bad ECDSA_SIGN request (header)");
+		keymgr_reply(iev, IMSG_KEYMGR_ECDSA_SIGN, id, 0, NULL, 0);
+		return;
+	}
+	req.hash[sizeof(req.hash) - 1] = '\0';
+
+	if (!keymgr_recv_trailing(imsg, req.fromlen, dgst, sizeof(dgst))) {
+		keymgr_reply(iev, IMSG_KEYMGR_ECDSA_SIGN, id, 0, NULL, 0);
+		return;
+	}
+
+	if (!keymgr_got_init) {
+		log_warnx("ECDSA_SIGN request before "
+		    "IMSG_KEYMGR_INIT completed, refusing (SS6.1)");
+		keymgr_reply(iev, IMSG_KEYMGR_ECDSA_SIGN, id, 0, NULL, 0);
+		return;
+	}
+	if (keymgr_pkey == NULL || strcmp(req.hash, keymgr_hash) != 0) {
+		log_warnx("ECDSA_SIGN request for unrecognized key "
+		    "hash, refusing");
+		keymgr_reply(iev, IMSG_KEYMGR_ECDSA_SIGN, id, 0, NULL, 0);
+		return;
+	}
+
+	if ((ec = EVP_PKEY_get1_EC_KEY(keymgr_pkey)) == NULL) {
+		log_warnx("ECDSA_SIGN: loaded key is not EC");
+		keymgr_reply(iev, IMSG_KEYMGR_ECDSA_SIGN, id, 0, NULL, 0);
+		return;
+	}
+	if ((size_t)ECDSA_size(ec) > sizeof(sig)) {
+		log_warnx("ECDSA_SIGN: signature too large "
+		    "(%d > %zu)", ECDSA_size(ec), sizeof(sig));
+		EC_KEY_free(ec);
+		keymgr_reply(iev, IMSG_KEYMGR_ECDSA_SIGN, id, 0, NULL, 0);
+		return;
+	}
+
+	siglen = (unsigned int)sizeof(sig);
+	ret = ECDSA_sign(0, dgst, (int)req.fromlen, sig, &siglen, ec);
+	EC_KEY_free(ec);
+
+	if (ret != 1) {
+		log_warnx("ECDSA_sign failed");
+		keymgr_reply(iev, IMSG_KEYMGR_ECDSA_SIGN, id, 0, NULL, 0);
+		explicit_bzero(sig, sizeof(sig));
+		return;
+	}
+	keymgr_reply(iev, IMSG_KEYMGR_ECDSA_SIGN, id, 1, sig, siglen);
+	/* Same rule as keymgr_handle_rsa()'s output buffer. */
+	explicit_bzero(sig, sizeof(sig));
+}
blob - d646dbe0c9832601d042c2b1c7c8a0123ace2182
blob + 8af7ee8bc99acd2fa6ff0402d695548150e97705
--- src/listener.h
+++ src/listener.h
@@ -51,6 +51,16 @@ enum session_state {
 				 * from the client-literal-read phase
 				 * (s->literal_pending) that precedes it --
 				 * this covers only the store round trip. */
+	SESSION_SEARCH_PARSING, /* SS8.1: IMSG_SEARCH_PARSE_REQUEST sent
+				 * to the per-connection search-oracle,
+				 * awaiting IMSG_SEARCH_PARSE_RESULT --
+				 * precedes SESSION_SEARCHING below, a SEARCH
+				 * is now a two-hop async round trip (oracle
+				 * parse, then store execute), not one.
+				 * Handled by listener_dispatch_search()
+				 * (listener.c), which calls
+				 * search_dispatch_finish() (search_cmd.c)
+				 * once the oracle replies. */
 	SESSION_SEARCHING,	/* IMSG_MBOX_SEARCH sent, awaiting the
 				 * IMSG_MBOX_SEARCH_MATCH stream + terminal
 				 * IMSG_MBOX_RESULT; handled by
@@ -121,6 +131,24 @@ enum session_state {
 #define HEADER_FIELDS_LABEL_MAX	288
 
 /*
+ * Bound on one mailbox name rendered as an RFC 9051 SS4.3 quoted string by
+ * quote_mailbox(): two surrounding DQUOTEs, a NUL, and a backslash before
+ * every byte in the worst case, where every byte of a MBOX_NAME_MAX-1
+ * name is a quoted-special.
+ */
+#define MBOX_QUOTED_MAX		((2 * MBOX_NAME_MAX) + 3)
+
+/*
+ * RFC 7162 SS7's ceiling on a mod-sequence: "Positive unsigned 63-bit
+ * integer (mod-sequence) (1 <= n <= 9,223,372,036,854,775,807)". The
+ * three client-facing mod-sequence parsers (QRESYNC's select-param,
+ * CHANGEDSINCE, UNCHANGEDSINCE) bound their strtoull(3) result by this;
+ * mod-sequence-value additionally forbids 0, mod-sequence-valzer allows
+ * it, and each parser enforces its own case.
+ */
+#define MODSEQ_MAX		INT64_MAX
+
+/*
  * RFC 9051 SS6.4.4 SEARCH result options understood as ESEARCH return
  * items (SS7.3.4). SAVE ("$", SS6.4.4.1) is recognized but rejected
  * with a flagged NO. Shared here since store_cmd.c's
@@ -141,6 +169,11 @@ struct vanished_range {
 struct session {
 	uint32_t		 id;
 	int			 client_fd;
+	char			 remote_addr[64]; /* numeric "host:port" (or
+					  * "[host]:port" for IPv6), set once
+					  * at spawn time in listener_start_session();
+					  * NI_NUMERICHOST|NI_NUMERICSERV only, so
+					  * no resolver/DNS pledge needed */
 	struct event		 client_ev;
 	enum session_state	 state;
 	int			 implicit_tls;	/* accepted on port 993, per
@@ -151,9 +184,18 @@ struct session {
 						 * resolves */
 	int			 client_ev_added; /* client_ev is only safe to
 						   * event_del() once this is
-						   * set, see listener_accept()'s
+						   * set, see listener_start_session()'s
 						   * implicit-TLS early-teardown
 						   * path */
+	int			 write_failed;	/* set by session_write() on an
+						 * unrecoverable write error or
+						 * a SESSION_WRITE_POLL_TIMEOUT_MS
+						 * timeout; checked at the top
+						 * of session_dispatch_client(),
+						 * which tears the session down
+						 * rather than read (and then
+						 * silently fail to answer)
+						 * another command */
 	int			 tls_active;	/* 1 once a real TLS handshake
 						 * (implicit-TLS or STARTTLS)
 						 * has completed; gates
@@ -168,6 +210,10 @@ struct session {
 	char			 inbuf[SESSION_INBUF_MAX];
 	size_t			 inbuflen;	/* bytes of unparsed input
 						 * currently in inbuf */
+	int			 scrub_inbuf;	/* 1 when the line currently being
+						 * consumed carried SASL credentials
+						 * and must be explicit_bzero(3)'d out
+						 * of inbuf once dispatched */
 	int			 auth_cont;	/* 1 while the next raw client
 						 * line is a SASL continuation
 						 * response, not a fresh tagged
@@ -193,12 +239,6 @@ struct session {
 	uint32_t		 cmd_queue_n;	/* count of valid entries in
 						 * cmd_queue, 0..SESSION_CMD_
 						 * QUEUE_MAX */
-	uid_t			 uid;		/* set once from the auth
-						 * result; lets session_notify_
-						 * idle_peers() find this
-						 * session's other concurrent
-						 * sessions (same user) without
-						 * a second auth round trip */
 	char			 pending_tag[IMAP_TAG_MAX]; /* copy of whichever
 						 * command's tag is waiting on an
 						 * async imsg round trip, the
@@ -329,6 +369,14 @@ struct session {
 						 * starts from */
 	uint64_t		 literal_remaining; /* bytes of the literal
 						 * still needed */
+	uint64_t		 literal_discard; /* octets of a NON-synchronizing
+						 * literal (RFC 9051 SS4.3) whose
+						 * command was refused or deferred:
+						 * already on the wire, so they are
+						 * swallowed by session_dispatch_
+						 * client() rather than parsed as
+						 * further commands. Mutually
+						 * exclusive with literal_pending. */
 	char			 append_mailbox[MBOX_NAME_MAX]; /* APPEND's
 						 * arguments, parsed by
 						 * cmd_append() before the literal
@@ -387,15 +435,25 @@ struct session {
 						 * rather than LIST, picks the
 						 * untagged keyword and tagged
 						 * completion text */
-	char			 rename_oldname[MBOX_NAME_MAX]; /* RENAME's
-						 * arguments, stashed so
+	char			 mbox_op_name[MBOX_NAME_MAX]; /* the mailbox
+						 * the in-flight CREATE/DELETE/
+						 * RENAME names (RENAME's
+						 * source), stashed so
 						 * session_finish_mbox_op() can
-						 * compare oldname against s->
-						 * selected_mailbox: if this
-						 * session had the renamed
-						 * mailbox selected, it needs to
-						 * follow along to newname */
-	char			 rename_newname[MBOX_NAME_MAX];
+						 * compare it against s->
+						 * selected_mailbox when the
+						 * store child's terminal reply
+						 * arrives: if this session had
+						 * that mailbox selected, RENAME
+						 * follows it to rename_newname
+						 * and DELETE deselects. Shared
+						 * across the three commands on
+						 * mbox_op_prev_state's
+						 * reasoning -- only one is ever
+						 * in flight at a time. */
+	char			 rename_newname[MBOX_NAME_MAX]; /* RENAME's
+						 * destination; its source is
+						 * mbox_op_name above */
 	uint32_t		 search_return_opts; /* SEARCH_RETURN_* bitmask
 						 * for the in-flight SEARCH --
 						 * which of MIN/MAX/ALL/COUNT to
@@ -482,9 +540,9 @@ struct session {
 	 * RFC 9051 SS6.3.13 (IDLE) EXISTS and EXPUNGE only.
 	 * idle_known_uids is this session's cached, ordered snapshot of
 	 * which UIDs existed as of the last authoritative view, populated
-	 * from an IMSG_MBOX_IDLE_REFRESH round trip (the baseline one after
-	 * "+ idling", or a later one from session_notify_idle_peers() when
-	 * another session with the same uid mutates the mailbox).
+	 * from an IMSG_MBOX_IDLE_REFRESH round trip -- the baseline one after
+	 * "+ idling", then one per "idle poll" interval from
+	 * session_idle_poll() for as long as the session stays in IDLE.
 	 * idle_baseline_valid gates against diffing before the first one
 	 * lands. Diffing this array against a freshly streamed
 	 * IMSG_MBOX_IDLE_UID list lets session_handle_idle_refreshed()
@@ -503,6 +561,14 @@ struct session {
 	int			 idle_refresh_pending;
 	int			 idle_refresh_again;
 
+	/*
+	 * The IDLE poll timer. evtimer_set() once in listener_start_session()
+	 * so that evtimer_del() is always safe, then armed only between
+	 * "+ idling" and DONE -- an evtimer is one-shot, so session_idle_poll()
+	 * re-arms itself on every firing.
+	 */
+	struct event		 idle_ev;
+
 	/* Accumulates the freshly streamed IMSG_MBOX_IDLE_UID list for an
 	 * in-flight refresh, a separate array from idle_known_uids,
 	 * since session_handle_idle_refreshed() needs both old and new
@@ -512,6 +578,16 @@ struct session {
 	uint32_t		 idle_incoming_n;
 	uint32_t		 idle_incoming_cap;
 
+	/* Set when session_handle_idle_uid() had to drop a UID because the
+	 * accumulator would not grow. A SHORT list is worse than no list:
+	 * diffed against idle_known_uids it turns every dropped UID into an
+	 * untagged EXPUNGE for a message that still exists, and the short
+	 * list then becomes the new baseline, so the error never washes
+	 * out. session_handle_idle_refreshed() therefore discards the whole
+	 * refresh when this is set, exactly as it does for !res->ok.
+	 */
+	int			 idle_alloc_failed;
+
 	/*
 	 * Accumulates VANISHED (EARLIER) ranges streamed via zero or more
 	 * IMSG_MBOX_SELECT_VANISHED during an in-flight QRESYNC SELECT
@@ -536,6 +612,15 @@ struct session {
 	uint32_t		 qresync_nfetches;
 	uint32_t		 qresync_fetches_cap;
 
+	/* Set when either QRESYNC resync accumulator above had to drop an
+	 * entry. Both are held back until the terminal IMSG_MBOX_SELECTED,
+	 * so nothing has reached the client yet and the SELECT can still be
+	 * failed -- which is the only safe answer, since completing it
+	 * would advertise a fresh HIGHESTMODSEQ the client adopts as its
+	 * new sync anchor, permanently hiding whatever was dropped.
+	 */
+	int			 qresync_alloc_failed;
+
 	/*
 	 * COPY/MOVE (RFC 9051 SS6.4.7/SS6.4.8), SESSION_COPYING. Two
 	 * parallel growable arrays accumulate each IMSG_MBOX_COPY_MAPPING
@@ -603,18 +688,19 @@ TAILQ_HEAD(session_list, session);
  */
 extern struct session_list	 sessions;
 extern struct imsgev		 iev_auth;
+extern struct imsgev		 iev_search;	/* SS8.1, see listener.c's own declaration */
 extern struct imsgev		 iev_parent;
 extern struct tls		*listener_tls_ctx;
+/* "idle poll" seconds, from IMSG_LISTENER_SESSION_INIT; 0 disables polling */
+extern uint32_t			 listener_idle_poll_secs;
 
 /* Cross-file entry points: forward declarations for listener.c (core) +
  * auth_cmd.c + mailbox_cmd.c + append_cmd.c + fetch_cmd.c + search_cmd.c
  * + store_cmd.c + store_ipc.c.
  */
- void	 listener_accept(int, short, void *);
  void	 listener_dispatch_auth(int, short, void *);
+ void	 listener_dispatch_search(int, short, void *);
  void	 listener_dispatch_parent(int, short, void *);
- void	 listener_reload_tls(const char *, size_t, const char *,
-		    size_t);
  void	 session_dispatch_client(int, short, void *);
  void	 session_store_dispatch(int, short, void *);
  void	 session_handle_mbox_selected(struct session *,
@@ -630,7 +716,7 @@ extern struct tls		*listener_tls_ctx;
  void	 session_handle_mbox_result(struct session *,
 		    struct imsg_mbox_result *);
  int	 session_request_expunge(struct session *, const char *,
-		    int, int, uint32_t, uint32_t, int, int);
+		    int, int, const struct seq_range *, uint32_t);
  int	 session_finish_append(struct session *);
  void	 session_handle_mbox_appended(struct session *,
 		    const struct imsg_mbox_appended *);
@@ -641,8 +727,9 @@ extern struct tls		*listener_tls_ctx;
 extern const char	*fetch_month_names[12];
  void	 format_internaldate(int64_t, char *, size_t);
  int	 parse_nz_number(const char *, uint32_t *);
- int	 parse_seq_range(const char *, uint32_t *, uint32_t *,
-		    int *, int *);
+ int	 parse_sequence_set(const char *,
+		    struct seq_range[SEQSET_MAX_RANGES], uint32_t *,
+		    const char **);
  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 **);
@@ -665,6 +752,15 @@ struct search_parse_ctx;
  int	 parse_search_key_list(char **, struct search_parse_ctx *,
 		    const char **, int);
  int	 parse_search_return_opts(char **, uint32_t *, const char **);
+/* search_oracle_parse()'s prototype lives in imapd.h, not here --
+ * search_oracle.c (its only caller outside search_cmd.c) is a
+ * separate role, not part of this header's listener.c/auth_cmd.c/
+ * mailbox_cmd.c/append_cmd.c/fetch_cmd.c/search_cmd.c/store_cmd.c/
+ * store_ipc.c family, and must not pull in this header's other
+ * declarations (iev_auth, iev_search, iev_parent, sessions, struct
+ * session itself) -- see imapd.h's copy for why. search_cmd.c (this
+ * function's actual definition) already includes imapd.h too, so
+ * moving the prototype there changes nothing it sees. */
  void	 session_handle_mbox_search_match(struct session *,
 		    struct imsg_mbox_search_match *);
  void	 session_finish_search(struct session *,
@@ -692,13 +788,17 @@ struct search_parse_ctx;
  void	 session_handle_idle_refreshed(struct session *,
 		    const struct imsg_mbox_idle_refreshed *);
  void	 session_request_idle_refresh(struct session *);
+ void	 session_idle_poll_init(struct session *);
+ void	 session_idle_poll_arm(struct session *);
+ void	 session_idle_poll_disarm(struct session *);
  void	 session_push_idle_expunges(struct session *,
 		    const uint32_t *, uint32_t, const uint32_t *, uint32_t);
- void	 session_notify_idle_peers(const struct session *);
  int	 session_handle_idle_continuation(struct session *, const char *);
  int	 parse_select_params(char *, struct imsg_mbox_select *,
-		    const struct session *, int *, const char **);
+		    const struct session *, struct seq_range[SEQSET_MAX_RANGES],
+		    uint32_t *, int *, const char **);
  int	 parse_qresync_group(char *, struct imsg_mbox_select *,
+		    struct seq_range[SEQSET_MAX_RANGES], uint32_t *,
 		    const char **);
  char	*split_trailing_modifiers(char *);
  int	 parse_fetch_modifiers(char *, struct imsg_mbox_fetch *,
@@ -710,6 +810,12 @@ struct search_parse_ctx;
  int	 fetch_dispatch(struct session *, const char *, char *, int);
  int	 store_do(struct session *, const char *, char *, int);
  int	 search_dispatch(struct session *, const char *, char *, int);
+/* SS8.1: completes search_dispatch() once listener_dispatch_search()
+ * (listener.c) gets this session's IMSG_SEARCH_PARSE_RESULT; see
+ * search_dispatch_finish()'s own comment (search_cmd.c). */
+ void	 search_dispatch_finish(struct session *,
+		    const struct imsg_search_parse_result *,
+		    struct search_node *);
  int	 uid_expunge_dispatch(struct session *, const char *, const char *);
  void	 session_handle_fetch_vanished(struct session *,
 		    const struct imsg_mbox_select_vanished *);
@@ -719,8 +825,12 @@ struct search_parse_ctx;
 		    int, int);
  int	 listener_mailbox_name_valid(const char *);
  int	 listener_mailbox_name_is_inbox(const char *);
+ int	 listener_reject_bad_utf8(struct session *, const char *, const char *);
  int	 list_pattern_match(const char *, const char *, int);
- int	 parse_list_token(char **, char *, size_t, const char **);
+ int	 parse_mailbox_name(char **, char *, size_t, const char **);
+ int	 parse_list_pattern(char **, char *, size_t, const char **);
+ int	 quote_mailbox(char *, size_t, const char *);
+ void	 session_reset_idle_baseline(struct session *);
  void	 session_finish_mbox_op(struct session *,
 		    const struct imsg_mbox_result *);
  void	 session_finish_list(struct session *,
@@ -740,6 +850,8 @@ struct search_parse_ctx;
  void	 session_reply(struct session *, const char *, const char *,
 		    const char *);
  void	 session_untagged(struct session *, const char *);
+ int	 send_mbox_request(struct session *, int, const char *, const char *,
+		    const void *, size_t, const void *, uint32_t, size_t);
  int	 parse_command_line(char *, char **, char **, char **);
  int	 session_handle_line(struct session *, char *);
  int	 session_dequeue_next(struct session *);
blob - faf6403faeed92cc39bf3f8af63c7b7d54a31e53
blob + 62a7715c6161a70f8736f966a553b3dd561d7889
--- src/log.c
+++ src/log.c
@@ -43,6 +43,83 @@ static int	 log_foreground = 1;
 static int	 log_verbose = 0;
 static char	 log_procname[32] = "imapd";
 
+/*
+ * Neutralises control characters for the -d path below. Returns a
+ * malloc(3)'d copy of `s`, or NULL.
+ *
+ * WHY THIS EXISTS, AND WHY ONLY HERE
+ *
+ * Plenty of what this daemon logs comes from a client and has been through
+ * no validation at all -- listener.c's "<<<" echo is the whole command line,
+ * verbatim, before authentication -- so an unauthenticated peer can put
+ * arbitrary bytes into a log message. Written straight to a terminal those
+ * bytes are escape sequences: clear the screen, move the cursor back over
+ * lines that have already scrolled past, retitle the window.
+ *
+ * The syslog path needs none of this. OpenBSD's syslogd(8) already runs
+ * every byte of every message it receives through vis(3) before writing it
+ * anywhere (usr.sbin/syslogd/syslogd.c, printline()), so a backgrounded
+ * imapd was never exposed. The gap was only ever -d, where vlog() writes to
+ * stderr and the reader is a person watching a live daemon -- which is
+ * exactly when what is on screen ought to be trustworthy.
+ *
+ * Doing it here rather than at each log site is the point. There are around
+ * ninety call sites whose arguments carry client- or filesystem-derived
+ * text, and escaping at each one would be ninety edits plus a rule everyone
+ * has to remember forever. This is a property of the output channel, so it
+ * belongs at the output channel.
+ *
+ * WHAT IS ENCODED
+ *
+ * C0 (0x00-0x1f) and DEL as vis(3)'s caret form, and the C1 controls
+ * U+0080-U+009F -- two bytes, 0xc2 0x80..0xc2 0x9f, in the UTF-8 this
+ * daemon speaks -- as its meta form. The notation matches vis(3) so that a
+ * -d trace and a syslog line describe a control byte the same way.
+ *
+ * Everything else, including all other bytes >= 0x80, is passed through.
+ * That is a deliberate divergence from syslogd, which octal-escapes them:
+ * the threat is control characters, no UTF-8 sequence can contain a C0
+ * byte, and a mailbox name that reads as "日本" rather than
+ * "\346\227\245\346\234\254" is worth keeping in the one output a human
+ * reads live.
+ *
+ * A backslash is NOT escaped, so the encoding is one-way. That is
+ * deliberate and it is the same call syslogd makes by passing VIS_NOSLASH:
+ * every flag name in this protocol starts with one ("\Seen", "\Deleted"),
+ * and doubling all of them would cost more legibility than the ambiguity
+ * costs correctness. Nothing reads this output back.
+ */
+static char *
+log_escape_ctl(const char *s)
+{
+	const unsigned char	*p = (const unsigned char *)s;
+	char			*out, *q;
+
+	/* worst case is 2 bytes out per byte in: C0 -> "^X", C1 -> "M-^X" */
+	if ((out = malloc((2 * strlen(s)) + 1)) == NULL)
+		return (NULL);
+
+	for (q = out; *p != '\0'; p++) {
+		if (p[0] == 0xc2 && p[1] >= 0x80 && p[1] <= 0x9f) {
+			/* a C1 control, which some terminals still honour */
+			*q++ = 'M';
+			*q++ = '-';
+			*q++ = '^';
+			*q++ = (char)((p[1] & 0x1f) + '@');
+			p++;
+		} else if (*p < 0x20) {
+			*q++ = '^';
+			*q++ = (char)(*p + '@');
+		} else if (*p == 0x7f) {
+			*q++ = '^';
+			*q++ = '?';
+		} else
+			*q++ = (char)*p;
+	}
+	*q = '\0';
+	return (out);
+}
+
 void
 log_init(int foreground, int verbose)
 {
@@ -78,19 +155,36 @@ log_getverbose(void)
 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);
-		}
+		char	*msg = NULL, *safe = NULL;
+
+		/*
+		 * Formatted first, then escaped: the untrusted text arrives
+		 * through `ap`, so there is nothing to neutralise until the
+		 * message has been built. vasprintf(3) rather than a fixed
+		 * buffer because the "<<<" echo carries a whole IMAP command
+		 * line and truncating it would lose the part worth reading.
+		 *
+		 * On allocation failure, say so rather than falling back to
+		 * an unescaped write -- a path that only runs when memory is
+		 * gone is exactly the one nobody would notice going raw.
+		 */
+		if (vasprintf(&msg, fmt, ap) == -1)
+			msg = NULL;
+		if (msg != NULL)
+			safe = log_escape_ctl(msg);
+		if (safe != NULL)
+			fprintf(stderr, "%s: %s\n", log_procname, safe);
+		else
+			fprintf(stderr, "%s: (message dropped: out of "
+			    "memory while logging)\n", log_procname);
+		free(safe);
+		free(msg);
 		fflush(stderr);
 	} else
+		/* no escaping here: syslogd(8) vis(3)-encodes everything it receives */
 		vsyslog(pri, fmt, ap);
 
 	errno = saved_errno;
@@ -111,15 +205,34 @@ log_warn(const char *emsg, ...)
 {
 	char	*nfmt;
 	va_list	 ap;
+	int	 saved_errno = errno;
 
-	/* best-effort in appending strerror(errno) after the format */
+	/*
+	 * saved_errno, not a bare errno, for two reasons. The obvious one is
+	 * that asprintf(3), vfprintf(3) and free(3) may each leave errno set,
+	 * and a caller is entitled to read errno after log_warn() returns --
+	 * so it is restored on the way out, the way the shared OpenBSD idiom
+	 * this file carries has always done it. The subtler one is the
+	 * asprintf failure path below, which logs strerror() a second time
+	 * from a point where errno is no longer the caller's.
+	 */
 	if (emsg == NULL)
-		logit(LOG_ERR, "%s", strerror(errno));
+		logit(LOG_ERR, "%s", strerror(saved_errno));
 	else {
-		if (asprintf(&nfmt, "%s: %s", emsg, strerror(errno)) == -1) {
+		/* best-effort in appending strerror() after the format */
+		if (asprintf(&nfmt, "%s: %s", emsg,
+		    strerror(saved_errno)) == -1) {
+			/*
+			 * Out of memory: log the caller's message without the
+			 * errno text, then the errno text on its own line, so
+			 * the reason is not lost just because asprintf() was
+			 * the thing that failed.
+			 */
 			va_start(ap, emsg);
 			vlog(LOG_ERR, emsg, ap);
 			va_end(ap);
+			logit(LOG_ERR, "%s", strerror(saved_errno));
+			errno = saved_errno;
 			return;
 		}
 		va_start(ap, emsg);
@@ -127,6 +240,8 @@ log_warn(const char *emsg, ...)
 		va_end(ap);
 		free(nfmt);
 	}
+
+	errno = saved_errno;
 }
 
 void
blob - 1b18e20d63c7e39c3f55c43dedf0da5e1ef134c8
blob + c6f31ba72dc92219094f4139283a07a05a77c726
--- src/mailbox_cmd.c
+++ src/mailbox_cmd.c
@@ -38,9 +38,11 @@
 #include "imapd.h"
 #include "log.h"
 #include "listener.h"
+#include "utf8.h"
 
 int
 parse_qresync_group(char *inner, struct imsg_mbox_select *req,
+    struct seq_range ranges[SEQSET_MAX_RANGES], uint32_t *nranges,
     const char **errmsg)
 {
 	char		*p;
@@ -77,9 +79,23 @@ parse_qresync_group(char *inner, struct imsg_mbox_sele
 		*errmsg = "QRESYNC requires uidvalidity and mod-sequence";
 		return (-1);
 	}
+	/*
+	 * RFC 7162 SS7: mod-sequence-value is "1*DIGIT ... (1 <= n <=
+	 * 9,223,372,036,854,775,807)". strtoull(3) accepts a leading sign,
+	 * so "-1" would arrive here as ULLONG_MAX with errno untouched --
+	 * the same trap auth.c, index.c and listener.c's literal parser
+	 * each guard with a first-character-is-a-digit test. Enforced here
+	 * too, along with the grammar's nonzero requirement and its 63-bit
+	 * ceiling.
+	 */
+	if (*tok < '0' || *tok > '9') {
+		*errmsg = "invalid QRESYNC mod-sequence";
+		return (-1);
+	}
 	errno = 0;
 	modseq = strtoull(tok, &ep, 10);
-	if (*ep != '\0' || errno != 0) {
+	if (*ep != '\0' || errno != 0 || modseq == 0 ||
+	    modseq > MODSEQ_MAX) {
 		*errmsg = "invalid QRESYNC mod-sequence";
 		return (-1);
 	}
@@ -105,23 +121,27 @@ parse_qresync_group(char *inner, struct imsg_mbox_sele
 		*p = '\0';
 		p++;
 	}
-	if (strchr(tok, ',') != NULL) {
-		*errmsg = "comma-separated known-uids not supported";
-		return (-1);
-	}
 	{
-		uint32_t	lo, hi;
-		int		lo_star, hi_star;
+		uint32_t	i;
 
-		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)";
+		/*
+		 * known-uids is a full RFC 9051 SS9 sequence-set
+		 * (SS3.2.5.1's grammar is "known-uids = sequence-set"),
+		 * same as every other sequence-set consumer now parses;
+		 * only the "*" restriction below is specific to this call
+		 * site.
+		 */
+		if (parse_sequence_set(tok, ranges, nranges, errmsg) == -1)
 			return (-1);
+		for (i = 0; i < *nranges; i++) {
+			if (ranges[i].lo_is_star || ranges[i].hi_is_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;
+		req->qresync_nranges = *nranges;
 	}
 
 	while (*p == ' ')
@@ -166,7 +186,8 @@ parse_qresync_group(char *inner, struct imsg_mbox_sele
 /* SELECT/EXAMINE select-params (RFC 4466 + RFC 7162 SS3.1.8/SS3.2.5 CONDSTORE/QRESYNC); p modified in place */
 int
 parse_select_params(char *p, struct imsg_mbox_select *req,
-    const struct session *s, int *want_condstore, const char **errmsg)
+    const struct session *s, struct seq_range ranges[SEQSET_MAX_RANGES],
+    uint32_t *nranges, int *want_condstore, const char **errmsg)
 {
 	*want_condstore = 0;
 
@@ -220,7 +241,8 @@ parse_select_params(char *p, struct imsg_mbox_select *
 			}
 			*end = '\0';
 
-			if (parse_qresync_group(q + 1, req, errmsg) == -1)
+			if (parse_qresync_group(q + 1, req, ranges, nranges,
+			    errmsg) == -1)
 				return (-1);
 
 			req->qresync = 1;
@@ -241,10 +263,13 @@ static int
 select_or_examine(struct session *s, const char *tag, char *args, int readonly)
 {
 	struct imsg_mbox_select	 req;
+	char				 mailbox[MBOX_NAME_MAX];
 	char				*params, *p;
-	size_t				 len;
+	const char			*errmsg = NULL;
 	int				 want_condstore = 0;
 	const char			*cmdname = readonly ? "EXAMINE" : "SELECT";
+	struct seq_range		 ranges[SEQSET_MAX_RANGES];
+	uint32_t			 nranges = 0;
 
 	if (args == NULL) {
 		char	text[40];
@@ -255,47 +280,27 @@ select_or_examine(struct session *s, const char *tag, 
 		return (1);
 	}
 
+	/*
+	 * This used to scan for the argument's end and then, separately,
+	 * strip quotes only if the name both began and ended with one. The
+	 * two halves could disagree: SELECT "unterminated found no closing
+	 * quote, so the strip declined to fire and the name reached the store
+	 * with its leading DQUOTE still attached, coming back as "no such
+	 * mailbox" while CREATE rejected the identical input at parse.
+	 */
 	p = args;
-	if (*p == '"') {
-		p++;
-		while (*p != '\0' && *p != '"')
-			p++;
-		if (*p == '"')
-			p++;
-	} else {
-		while (*p != '\0' && *p != ' ')
-			p++;
-	}
-	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);
+	if (parse_mailbox_name(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
 		return (1);
 	}
+	while (*p == ' ')
+		p++;
+	params = (*p != '\0') ? p : NULL;
 
-	len = strlen(args);
-	if (len >= 2 && args[0] == '"' && args[len - 1] == '"') {
-		args[len - 1] = '\0';
-		args++;
-		len -= 2;
-	}
-	if (len == 0) {
+	if (mailbox[0] == '\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) {
 		/* should already be wired here, ST_AUTH requires SESSION_AUTHENTICATED/SELECTED */
@@ -306,7 +311,7 @@ select_or_examine(struct session *s, const char *tag, 
 	}
 
 	memset(&req, 0, sizeof(req));
-	if (strlcpy(req.mailbox, args, sizeof(req.mailbox)) >=
+	if (strlcpy(req.mailbox, mailbox, sizeof(req.mailbox)) >=
 	    sizeof(req.mailbox)) {
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
@@ -315,7 +320,6 @@ select_or_examine(struct session *s, const char *tag, 
 
 	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",
@@ -324,8 +328,8 @@ select_or_examine(struct session *s, const char *tag, 
 		}
 		params[plen - 1] = '\0';
 
-		if (parse_select_params(params + 1, &req, s, &want_condstore,
-		    &errmsg) == -1) {
+		if (parse_select_params(params + 1, &req, s, ranges, &nranges,
+		    &want_condstore, &errmsg) == -1) {
 			session_reply(s, tag, "BAD", errmsg);
 			return (1);
 		}
@@ -335,24 +339,33 @@ select_or_examine(struct session *s, const char *tag, 
 	if (want_condstore)
 		s->condstore_enabled = 1;
 
-	/* RFC 9051 SS6.3.2: SELECT auto-deselects any current mailbox with untagged OK [CLOSED] */
+	/*
+	 * RFC 9051 SS6.3.2: SELECT auto-deselects any current mailbox with
+	 * untagged OK [CLOSED]. The IDLE snapshot describes the mailbox being
+	 * deselected, so it goes with it -- a stale one would be diffed
+	 * against the NEW mailbox's UID list on the next IDLE and report its
+	 * messages as expunged.
+	 */
 	if (s->state == SESSION_SELECTED)
 		session_untagged(s, "OK [CLOSED] Previous mailbox is now closed");
+	session_reset_idle_baseline(s);
 
 	if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag) ||
-	    strlcpy(s->selected_mailbox, args, sizeof(s->selected_mailbox)) >=
-	    sizeof(s->selected_mailbox)) {
+	    strlcpy(s->selected_mailbox, mailbox,
+	    sizeof(s->selected_mailbox)) >= sizeof(s->selected_mailbox)) {
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
 	s->state = SESSION_SELECTING;
 	s->mbox_readonly = readonly;
 
-	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);
+	if (!send_mbox_request(s, IMSG_MBOX_SELECT, cmdname, "IMSG_MBOX_SELECT",
+	    &req, sizeof(req), ranges, nranges, sizeof(struct seq_range))) {
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		s->state = SESSION_AUTHENTICATED;
+		return (1);
+	}
 
 	return (1);
 }
@@ -371,7 +384,16 @@ cmd_examine(struct session *s, const char *tag, char *
 	return select_or_examine(s, tag, args, 1);
 }
 
-/* listener.c's own copy of store.c's mailbox_name_valid(), for a fast BAD/NO with no store round trip */
+/*
+ * listener.c's own copy of store.c's mailbox_name_valid(), for a fast
+ * BAD/NO with no store round trip. store.c re-validates every mailbox
+ * name it is sent and is the authority; this copy exists only to save
+ * the round trip, so the two MUST stay in step -- it had drifted, missing
+ * store.c's rejection of "." / ".." and of the on-disk index filenames.
+ * The two index names are open-coded here because their authoritative
+ * definitions (STORE_INDEX_NAME, STORE_INDEX_TMP_NAME) live in
+ * store_internal.h, which the listener deliberately does not include.
+ */
 int
 listener_mailbox_name_valid(const char *name)
 {
@@ -380,6 +402,11 @@ listener_mailbox_name_valid(const char *name)
 	len = strlen(name);
 	if (len == 0 || len >= MBOX_NAME_MAX)
 		return (0);
+
+	/* RFC 9051 SS5.1, the same predicate store.c uses; see utf8.c */
+	if (!utf8_mailbox_ok(name))
+		return (0);
+
 	for (i = 0; i < len; i++) {
 		unsigned char	c = (unsigned char)name[i];
 
@@ -393,6 +420,15 @@ listener_mailbox_name_valid(const char *name)
 	    strcmp(name, "cur") == 0)
 		return (0);
 
+	/* reject "." and ".." (DELETE "." would destroy INBOX) and the on-disk index filenames */
+	if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0)
+		return (0);
+	if (strcmp(name, "imapd.index") == 0 ||
+	    strcmp(name, "imapd.index.tmp") == 0 ||
+	    strcmp(name, "imapd.index.lock") == 0 ||
+	    strcmp(name, "imapd.uidvalidity") == 0)
+		return (0);
+
 	return (1);
 }
 
@@ -403,6 +439,42 @@ listener_mailbox_name_is_inbox(const char *name)
 	return (strcasecmp(name, "INBOX") == 0);
 }
 
+/*
+ * The shared refusal for a mailbox name that is not valid UTF-8, worded
+ * identically at every command that asks the server to accept a NEW name:
+ * CREATE, RENAME's destination, and COPY/MOVE's target.
+ *
+ * Commands that ask whether an EXISTING name is there -- DELETE, RENAME's
+ * source, STATUS -- keep their RFC 5530 NONEXISTENT answer instead, because
+ * for those it is the true and complete story: a name this server refuses to
+ * create can never have existed here. SELECT and APPEND validate no name on
+ * this side at all and reach store.c's mailbox_name_valid(), which now
+ * carries the same check and answers "no such mailbox" -- also true.
+ *
+ * NO rather than BAD, though BAD is defensible. SS9's ASTRING-CHAR is 7-bit
+ * and QUOTED-CHAR's only 8-bit alternatives are UTF8-2/3/4, so a name that is
+ * not well-formed UTF-8 is outside the grammar and BAD would fit. But SS6.3.4
+ * names NO as CREATE's failure code -- "create failure: can't create mailbox
+ * with that name" -- in the same paragraph that requires this rejection, and
+ * RFC 5530 SS3's CANNOT ("the operation attempted cannot be performed for
+ * reasons that are permanent") says the part that matters to a client, which
+ * TRYCREATE would get exactly backwards: do not retry by creating it.
+ *
+ * Returns 1 if it replied and the caller should stop, 0 if the name is fine.
+ */
+int
+listener_reject_bad_utf8(struct session *s, const char *tag, const char *name)
+{
+	if (utf8_mailbox_ok(name))
+		return (0);
+
+	log_debug("session %u: mailbox name is not valid UTF-8, refusing "
+	    "(RFC 9051 SS5.1)", s->id);
+	session_reply(s, tag, "NO",
+	    "[CANNOT] mailbox name is not valid UTF-8");
+	return (1);
+}
+
 /* RFC 9051 SS6.3.4 CREATE; "already exists" is store.c's call (mkdir(2) EEXIST, handle_mbox_create()) */
 int
 cmd_create(struct session *s, const char *tag, char *args)
@@ -418,7 +490,7 @@ cmd_create(struct session *s, const char *tag, char *a
 	}
 
 	p = args;
-	if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+	if (parse_mailbox_name(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
 		session_reply(s, tag, "BAD", errmsg);
 		return (1);
 	}
@@ -427,11 +499,30 @@ cmd_create(struct session *s, const char *tag, char *a
 		session_reply(s, tag, "NO", "[CANNOT] cannot create INBOX");
 		return (1);
 	}
+	if (listener_reject_bad_utf8(s, tag, mailbox))
+		return (1);
 	if (!listener_mailbox_name_valid(mailbox)) {
 		session_reply(s, tag, "BAD", "invalid mailbox name");
 		return (1);
 	}
 
+	/*
+	 * SS9 gives CREATE no arguments after the mailbox name, and
+	 * copy_move_dispatch() (store_cmd.c) already refuses trailing
+	 * garbage -- this was the fifth way the mailbox-argument handling
+	 * disagreed with itself. Silently ignoring the tail is how
+	 * `CREATE "a"b` used to succeed while naming only `a`. (If
+	 * CREATE-SPECIAL-USE, RFC 6154, is ever added, its "(USE (\Archive))"
+	 * goes here rather than through this check.)
+	 */
+	while (*p == ' ')
+		p++;
+	if (*p != '\0') {
+		session_reply(s, tag, "BAD",
+		    "trailing garbage after mailbox name");
+		return (1);
+	}
+
 	if (s->store_iev == NULL) {
 		log_warnx("session %u: CREATE with no store channel wired",
 		    s->id);
@@ -442,6 +533,8 @@ cmd_create(struct session *s, const char *tag, char *a
 	memset(&req, 0, sizeof(req));
 	if (strlcpy(req.mailbox, mailbox, sizeof(req.mailbox)) >=
 	    sizeof(req.mailbox) ||
+	    strlcpy(s->mbox_op_name, mailbox, sizeof(s->mbox_op_name)) >=
+	    sizeof(s->mbox_op_name) ||
 	    strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag)) {
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
@@ -450,10 +543,21 @@ cmd_create(struct session *s, const char *tag, char *a
 	s->mbox_op_prev_state = s->state;
 	s->state = SESSION_CREATING;
 
+	/*
+	 * A failed compose used to log and fall through with s->state left
+	 * at SESSION_CREATING -- session_is_busy() (listener.c) then blocks
+	 * every further command while nothing is in flight to move the
+	 * state back, so the client waits for a tagged reply that will
+	 * never be sent. Same fail-soft shape listener_dispatch_search()
+	 * uses for a dead search-oracle: put the session back and answer.
+	 */
 	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_CREATE, 0, 0, -1,
-	    &req, sizeof(req)) == -1)
+	    &req, sizeof(req)) == -1) {
 		log_warn("session %u: imsg_compose IMSG_MBOX_CREATE", s->id);
-	imsgev_add(s->store_iev);
+		s->state = s->mbox_op_prev_state;
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
 
 	return (1);
 }
@@ -473,7 +577,7 @@ cmd_delete(struct session *s, const char *tag, char *a
 	}
 
 	p = args;
-	if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+	if (parse_mailbox_name(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
 		session_reply(s, tag, "BAD", errmsg);
 		return (1);
 	}
@@ -488,6 +592,23 @@ cmd_delete(struct session *s, const char *tag, char *a
 		return (1);
 	}
 
+	/*
+	 * SS9 gives DELETE no arguments after the mailbox name, and
+	 * copy_move_dispatch() (store_cmd.c) already refuses trailing
+	 * garbage -- this was the fifth way the mailbox-argument handling
+	 * disagreed with itself. Silently ignoring the tail is how
+	 * `DELETE "a"b` used to succeed while naming only `a`. (If
+	 * CREATE-SPECIAL-USE, RFC 6154, is ever added, its "(USE (\Archive))"
+	 * goes here rather than through this check.)
+	 */
+	while (*p == ' ')
+		p++;
+	if (*p != '\0') {
+		session_reply(s, tag, "BAD",
+		    "trailing garbage after mailbox name");
+		return (1);
+	}
+
 	if (s->store_iev == NULL) {
 		log_warnx("session %u: DELETE with no store channel wired",
 		    s->id);
@@ -498,6 +619,8 @@ cmd_delete(struct session *s, const char *tag, char *a
 	memset(&req, 0, sizeof(req));
 	if (strlcpy(req.mailbox, mailbox, sizeof(req.mailbox)) >=
 	    sizeof(req.mailbox) ||
+	    strlcpy(s->mbox_op_name, mailbox, sizeof(s->mbox_op_name)) >=
+	    sizeof(s->mbox_op_name) ||
 	    strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag)) {
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
@@ -506,10 +629,14 @@ cmd_delete(struct session *s, const char *tag, char *a
 	s->mbox_op_prev_state = s->state;
 	s->state = SESSION_DELETING;
 
+	/* see cmd_create()'s comment on this failure path */
 	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_DELETE, 0, 0, -1,
-	    &req, sizeof(req)) == -1)
+	    &req, sizeof(req)) == -1) {
 		log_warn("session %u: imsg_compose IMSG_MBOX_DELETE", s->id);
-	imsgev_add(s->store_iev);
+		s->state = s->mbox_op_prev_state;
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
 
 	return (1);
 }
@@ -531,11 +658,11 @@ cmd_rename(struct session *s, const char *tag, char *a
 	}
 
 	p = args;
-	if (parse_list_token(&p, oldname, sizeof(oldname), &errmsg) == -1) {
+	if (parse_mailbox_name(&p, oldname, sizeof(oldname), &errmsg) == -1) {
 		session_reply(s, tag, "BAD", errmsg);
 		return (1);
 	}
-	if (parse_list_token(&p, newname, sizeof(newname), &errmsg) == -1) {
+	if (parse_mailbox_name(&p, newname, sizeof(newname), &errmsg) == -1) {
 		session_reply(s, tag, "BAD", errmsg);
 		return (1);
 	}
@@ -549,11 +676,30 @@ cmd_rename(struct session *s, const char *tag, char *a
 		session_reply(s, tag, "NO", "[NONEXISTENT] no such mailbox");
 		return (1);
 	}
+	if (listener_reject_bad_utf8(s, tag, newname))
+		return (1);
 	if (listener_mailbox_name_is_inbox(newname) || !listener_mailbox_name_valid(newname)) {
 		session_reply(s, tag, "BAD", "invalid mailbox name");
 		return (1);
 	}
 
+	/*
+	 * SS9 gives RENAME no arguments after the mailbox name, and
+	 * copy_move_dispatch() (store_cmd.c) already refuses trailing
+	 * garbage -- this was the fifth way the mailbox-argument handling
+	 * disagreed with itself. Silently ignoring the tail is how
+	 * `RENAME "a"b` used to succeed while naming only `a`. (If
+	 * CREATE-SPECIAL-USE, RFC 6154, is ever added, its "(USE (\Archive))"
+	 * goes here rather than through this check.)
+	 */
+	while (*p == ' ')
+		p++;
+	if (*p != '\0') {
+		session_reply(s, tag, "BAD",
+		    "trailing garbage after mailbox name");
+		return (1);
+	}
+
 	if (s->store_iev == NULL) {
 		log_warnx("session %u: RENAME with no store channel wired",
 		    s->id);
@@ -568,8 +714,8 @@ cmd_rename(struct session *s, const char *tag, char *a
 	    sizeof(req.newname) ||
 	    strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag) ||
-	    strlcpy(s->rename_oldname, oldname, sizeof(s->rename_oldname)) >=
-	    sizeof(s->rename_oldname) ||
+	    strlcpy(s->mbox_op_name, oldname, sizeof(s->mbox_op_name)) >=
+	    sizeof(s->mbox_op_name) ||
 	    strlcpy(s->rename_newname, newname, sizeof(s->rename_newname)) >=
 	    sizeof(s->rename_newname)) {
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
@@ -578,10 +724,14 @@ cmd_rename(struct session *s, const char *tag, char *a
 	s->mbox_op_prev_state = s->state;
 	s->state = SESSION_RENAMING;
 
+	/* see cmd_create()'s comment on this failure path */
 	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_RENAME, 0, 0, -1,
-	    &req, sizeof(req)) == -1)
+	    &req, sizeof(req)) == -1) {
 		log_warn("session %u: imsg_compose IMSG_MBOX_RENAME", s->id);
-	imsgev_add(s->store_iev);
+		s->state = s->mbox_op_prev_state;
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
 
 	return (1);
 }
@@ -637,56 +787,236 @@ list_pattern_match(const char *pat, const char *name, 
 	return (*p == '\0');
 }
 
-/* pulls one list-mailbox-shaped token (RFC 9051 SS9) off *pp; "" is a legal zero-length token for LIST */
+/*
+ * Renders one mailbox name as an RFC 9051 SS4.3 quoted string, INCLUDING
+ * the surrounding DQUOTEs, escaping the two quoted-specials:
+ *
+ *   quoted          = DQUOTE *QUOTED-CHAR DQUOTE
+ *   QUOTED-CHAR     = <any TEXT-CHAR except quoted-specials> /
+ *                     "\" quoted-specials / UTF8-2 / UTF8-3 / UTF8-4
+ *   quoted-specials = DQUOTE / "\"
+ *
+ * The response builders used to substitute the name into "\"%s\"" raw, so
+ * a mailbox whose name contained a DQUOTE ended the quoted string early
+ * and one ending in a backslash escaped its own closing quote -- either
+ * way the client's parser loses the response boundary. Names like that
+ * are reachable: nothing on the input side rejects the characters, and a
+ * name can also arrive on disk without passing through IMAP at all.
+ *
+ * out must be MBOX_QUOTED_MAX bytes for a MBOX_NAME_MAX-bounded name.
+ * Returns 0, or -1 if the name did not fit -- in which case out still
+ * holds a well-formed but shorter quoted string, the same all-or-nothing
+ * discipline envbuf_append_nstring() keeps for ENVELOPE nstrings.
+ */
 int
-parse_list_token(char **pp, char *out, size_t outsize, const char **errmsg)
+quote_mailbox(char *out, size_t outsize, const char *name)
 {
+	size_t	i, o = 0;
+
+	if (outsize < 3) {		/* DQUOTE DQUOTE NUL */
+		if (outsize > 0)
+			out[0] = '\0';
+		return (-1);
+	}
+
+	out[o++] = '"';
+	for (i = 0; name[i] != '\0'; i++) {
+		char	c = name[i];
+		size_t	need;
+
+		/*
+		 * SS4.3's TEXT-CHAR excludes NUL, CR and LF; substitute
+		 * rather than drop, the same choice envbuf_append_nstring()
+		 * makes. Both mailbox_name_valid()s already refuse bytes
+		 * below 0x20, so this is a boundary guard, not a live path.
+		 */
+		if (c == '\r' || c == '\n')
+			c = ' ';
+
+		need = (c == '"' || c == '\\') ? 2 : 1;
+		/* need bytes, plus the closing DQUOTE, plus the NUL */
+		if (o + need + 2 > outsize) {
+			out[o++] = '"';
+			out[o] = '\0';
+			return (-1);
+		}
+		if (need == 2)
+			out[o++] = '\\';
+		out[o++] = c;
+	}
+	out[o++] = '"';
+	out[o] = '\0';
+	return (0);
+}
+
+/*
+ * SS9's ATOM-CHAR, for the two productions this file has to tell apart:
+ *
+ *   ATOM-CHAR       = <any CHAR except atom-specials>
+ *   atom-specials   = "(" / ")" / "{" / SP / CTL / list-wildcards /
+ *                     quoted-specials / resp-specials
+ *   ASTRING-CHAR    = ATOM-CHAR / resp-specials
+ *   list-char       = ATOM-CHAR / list-wildcards / resp-specials
+ *
+ * Both productions add resp-specials ("]") back, so "]" is fine either way.
+ * The ONLY difference is list-wildcards: "%" and "*" are legal unquoted in a
+ * LIST pattern and not in a mailbox name. Getting that backwards breaks
+ * LIST "" *, which is the most common command in IMAP, so it is the one thing
+ * `wildcards` decides.
+ *
+ * DELIBERATELY LENIENT ABOUT 8-BIT. Core ABNF's CHAR is %x01-7F, so strictly
+ * an unquoted atom cannot carry UTF-8 at all and a non-ASCII mailbox name
+ * must be quoted. We accept it unquoted anyway. The atom-specials are refused
+ * because each of them actively corrupts something -- DQUOTE and backslash
+ * break the server's own quoted-string responses, "{" is a literal
+ * announcement, "(" and ")" and SP break argument splitting, "%" and "*" are
+ * wildcards -- whereas a bare UTF-8 byte harms nothing and some clients emit
+ * it. utf8_mailbox_ok() still judges the bytes afterwards. RFC 9051 SS5.1's
+ * own client guidance names only the atom-specials as requiring quoting.
+ */
+static int
+atom_char_ok(unsigned char c, int wildcards)
+{
+	if (c <= 0x20 || c == 0x7f)		/* CTL and SP */
+		return (0);
+	if (c == '(' || c == ')' || c == '{' || c == '"' || c == '\\')
+		return (0);
+	if ((c == '%' || c == '*') && !wildcards)
+		return (0);
+	return (1);
+}
+
+/*
+ * The one mailbox-argument parser. Pulls a single SS9 astring (or
+ * list-mailbox, when `wildcards`) off *pp, DECODED -- quoted-string escapes
+ * resolved -- into out, and advances *pp past it.
+ *
+ * It replaced three hand-written copies: parse_list_token(), the mailbox half
+ * of parse_append_args() (append_cmd.c), and nine lines inline in
+ * select_or_examine(). They disagreed with each other about unterminated
+ * quotes, empty arguments, over-length names and "", and all three shared the
+ * two defects that mattered:
+ *
+ *   - none of them undid quote_mailbox()'s escaping, so the server could not
+ *     read back a name it had itself just written: CREATE "a\"b" created a
+ *     mailbox called  a\  and LIST then advertised a name SELECT could not
+ *     find;
+ *   - none of them enforced atom-specials, so CREATE a"b succeeded and every
+ *     later LIST response carried a quoted string that ended early.
+ *
+ * LITERALS ARE REFUSED, NOT PARSED. SS9 allows one here (mailbox = "INBOX" /
+ * astring, astring = 1*ASTRING-CHAR / string, string = quoted / literal), and
+ * supporting one would mean resuming command parsing after the octets arrive
+ * -- the listener's literal machinery is terminal by construction
+ * (line_nonsync_literal() matches only a trailing "{n+}", and literal_pending
+ * has exactly one setter, in cmd_append()). Until that exists, refusing is the
+ * honest answer and the safe one: before this, "CREATE {5}" was read as an
+ * atom and created a directory literally named "{5}" while the client sat
+ * waiting for a "+" continuation that was never coming.
+ *
+ * It does NOT validate the name. Whether these bytes are an acceptable
+ * mailbox is a separate question, asked by listener_mailbox_name_valid() and
+ * again by store.c's mailbox_name_valid() on the far side of the imsg
+ * boundary. Merging the two would undo that double-check.
+ *
+ * Returns 0, or -1 with *errmsg set to text fit for a tagged BAD.
+ */
+static int
+parse_mailbox_arg(char **pp, char *out, size_t outsize, int wildcards,
+    const char **errmsg)
+{
 	char	*p = *pp;
+	size_t	 o = 0;
 
 	while (*p == ' ')
 		p++;
 
 	if (*p == '\0') {
-		*errmsg = "LIST requires two arguments";
+		*errmsg = wildcards ? "missing LIST pattern" :
+		    "missing mailbox name";
 		return (-1);
 	}
 
+	if (*p == '{') {
+		*errmsg = "literals are not supported in a mailbox name; "
+		    "send it as a quoted string";
+		return (-1);
+	}
+
 	if (*p == '"') {
-		const char	*start = p + 1;
-		char		*end = strchr(start, '"');
-		size_t		 len;
-
-		if (end == NULL) {
-			*errmsg = "unterminated quoted string";
-			return (-1);
+		p++;
+		for (;;) {
+			if (*p == '\0') {
+				*errmsg = "unterminated quoted string";
+				return (-1);
+			}
+			if (*p == '"') {
+				p++;
+				break;
+			}
+			if (*p == '\\') {
+				/* SS9 QUOTED-CHAR escapes exactly the two quoted-specials, nothing else */
+				p++;
+				if (*p == '\0') {
+					/* a trailing backslash: the string ran out, not a bad escape */
+					*errmsg = "unterminated quoted string";
+					return (-1);
+				}
+				if (*p != '"' && *p != '\\') {
+					*errmsg = "only \\\" and \\\\ may be "
+					    "escaped in a quoted string";
+					return (-1);
+				}
+			}
+			if (o + 1 >= outsize) {
+				*errmsg = "mailbox name too long";
+				return (-1);
+			}
+			out[o++] = *p++;
 		}
-		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 {
-		const 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);
+		while (*p != '\0' && *p != ' ') {
+			if (!atom_char_ok((unsigned char)*p, wildcards)) {
+				*errmsg = wildcards ?
+				    "invalid character in LIST pattern; send "
+				    "it as a quoted string" :
+				    "invalid character in an unquoted mailbox "
+				    "name; send it as a quoted string";
+				return (-1);
+			}
+			if (o + 1 >= outsize) {
+				*errmsg = "mailbox name too long";
+				return (-1);
+			}
+			out[o++] = *p++;
 		}
-		memcpy(out, start, len);
-		out[len] = '\0';
 	}
 
+	out[o] = '\0';
 	*pp = p;
 	return (0);
 }
 
+/*
+ * The two grammars, named at the call site rather than hidden behind a
+ * boolean. RFC 9051 SS9: list = "LIST" [SP list-select-opts] SP mailbox SP
+ * mbox-or-pat -- so LIST's REFERENCE is a plain mailbox and only its PATTERN
+ * may carry wildcards unquoted. Every other command taking a mailbox
+ * (create/delete/rename/select/examine/status/copy/move/append) uses the
+ * strict one.
+ */
+int
+parse_mailbox_name(char **pp, char *out, size_t outsize, const char **errmsg)
+{
+	return (parse_mailbox_arg(pp, out, outsize, 0, errmsg));
+}
+
+int
+parse_list_pattern(char **pp, char *out, size_t outsize, const char **errmsg)
+{
+	return (parse_mailbox_arg(pp, out, outsize, 1, errmsg));
+}
+
 /* RFC 9051 SS6.3.9 basic syntax only, no list-select/return-opts; LSUB shares this, is_lsub just varies output */
 static int
 list_dispatch(struct session *s, const char *tag, char *args, int is_lsub)
@@ -719,7 +1049,8 @@ list_dispatch(struct session *s, const char *tag, char
 		return (1);
 	}
 
-	if (parse_list_token(&p, reference, sizeof(reference), &errmsg) ==
+	/* SS9: list's reference is a plain `mailbox`; only mbox-or-pat below takes wildcards */
+	if (parse_mailbox_name(&p, reference, sizeof(reference), &errmsg) ==
 	    -1) {
 		session_reply(s, tag, "BAD", errmsg);
 		return (1);
@@ -737,7 +1068,7 @@ list_dispatch(struct session *s, const char *tag, char
 		return (1);
 	}
 
-	if (parse_list_token(&p, pattern, sizeof(pattern), &errmsg) == -1) {
+	if (parse_list_pattern(&p, pattern, sizeof(pattern), &errmsg) == -1) {
 		session_reply(s, tag, "BAD", errmsg);
 		return (1);
 	}
@@ -777,8 +1108,22 @@ list_dispatch(struct session *s, const char *tag, char
 
 	/* SS6.3.9: unaccepted pattern MUST be silently ignored; INBOX answered synchronously, no store round trip */
 	if (list_pattern_match(canon, "INBOX", 1)) {
-		/* "()", SS7.3.1 makes every attribute optional; INBOX has no children and is selectable */
-		snprintf(text, sizeof(text), "%s () \"/\" INBOX", kw);
+		/*
+		 * "()", SS7.3.1 makes every attribute optional; INBOX has no
+		 * children and is selectable.
+		 *
+		 * Quoted, though bare INBOX is a legal atom and SS9's
+		 * mailbox rule names it explicitly ("INBOX" / astring), so
+		 * either spelling parses to the same name. This line is
+		 * answered here in the listener without a store round trip,
+		 * so it is the one mailbox name that never passes through
+		 * quote_mailbox() -- and the result was a server that spelled
+		 * INBOX two different ways in one session, bare here and
+		 * quoted in SELECT's own LIST line (store_ipc.c). Nothing was
+		 * wrong with either, but a server should name a mailbox the
+		 * same way every time it names it.
+		 */
+		snprintf(text, sizeof(text), "%s () \"/\" \"INBOX\"", kw);
 		session_untagged(s, text);
 	}
 
@@ -801,10 +1146,14 @@ list_dispatch(struct session *s, const char *tag, char
 	s->mbox_op_prev_state = s->state;
 	s->state = SESSION_LISTING;
 
+	/* see cmd_create()'s comment on this failure path */
 	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_LIST, 0, 0, -1,
-	    NULL, 0) == -1)
+	    NULL, 0) == -1) {
 		log_warn("session %u: imsg_compose IMSG_MBOX_LIST", s->id);
-	imsgev_add(s->store_iev);
+		s->state = s->mbox_op_prev_state;
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
 
 	return (1);
 }
@@ -852,7 +1201,7 @@ cmd_status(struct session *s, const char *tag, char *a
 	}
 
 	p = args;
-	if (parse_list_token(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
+	if (parse_mailbox_name(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
 		session_reply(s, tag, "BAD", errmsg);
 		return (1);
 	}
@@ -940,10 +1289,14 @@ cmd_status(struct session *s, const char *tag, char *a
 	s->status_prev_state = s->state;
 	s->state = SESSION_STATUSING;
 
+	/* see cmd_create()'s comment on this failure path */
 	if (imsg_compose(&s->store_iev->ibuf, IMSG_MBOX_STATUS, 0, 0, -1,
-	    &req, sizeof(req)) == -1)
+	    &req, sizeof(req)) == -1) {
 		log_warn("session %u: imsg_compose IMSG_MBOX_STATUS", s->id);
-	imsgev_add(s->store_iev);
+		s->state = s->status_prev_state;
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		return (1);
+	}
 
 	return (1);
 }
blob - e023cd32c16c4ccb8bd55c98e948d355ee0047e7
blob + c3ffa39ac8f46be40627a05f38e6af585447814a
--- src/main.c
+++ src/main.c
@@ -37,6 +37,8 @@ static const struct {
 	{ "listener",	PROC_LISTENER },
 	{ "auth",	PROC_AUTH },
 	{ "store",	PROC_STORE },
+	{ "keymgr",	PROC_KEYMGR },
+	{ "search",	PROC_SEARCH },
 };
 
 const char *
@@ -51,6 +53,10 @@ log_procname(enum openimap_proc_type type)
 		return "auth";
 	case PROC_STORE:
 		return "store";
+	case PROC_KEYMGR:
+		return "keymgr";
+	case PROC_SEARCH:
+		return "search";
 	default:
 		return "?";
 	}
@@ -59,8 +65,13 @@ log_procname(enum openimap_proc_type type)
 __dead static void
 usage(void)
 {
+	/*
+	 * -x is deliberately absent, matching imapd.8's SYNOPSIS: it names
+	 * the role a re-exec'd child takes and is not an operator-facing
+	 * option (see imapd.8's DESCRIPTION, which says so in those words).
+	 */
 	fprintf(stderr,
-	    "usage: %s [-dVv] [-D macro=value] [-f config] [-x role]\n",
+	    "usage: %s [-dVv] [-D macro=value] [-f file]\n",
 	    getprogname());
 	exit(1);
 }
@@ -127,8 +138,17 @@ main(int argc, char *argv[])
 			usage();
 	}
 
-	log_init(debug, verbose);
+	/*
+	 * log_procinit() BEFORE log_init(), not after. log_init() hands
+	 * log_procname's address to openlog(3), which stores the pointer
+	 * rather than a copy (libc's openlog_r(): data->log_tag = ident) and
+	 * dereferences it when each message is formatted. Setting the role
+	 * name first means openlog(3) is given a buffer that already says
+	 * "listener"/"auth"/... and log.c never mutates a string syslog(3)
+	 * is holding.
+	 */
 	log_procinit(log_procname(role));
+	log_init(debug, verbose);
 
 	/* re-exec'd children get their config slice over fd 3 (IMSG_*_INIT), so *_main() takes no conf arg */
 	switch (role) {
@@ -146,6 +166,12 @@ main(int argc, char *argv[])
 	case PROC_STORE:
 		store_main();
 		/* NOTREACHED */
+	case PROC_KEYMGR:
+		keymgr_main();
+		/* NOTREACHED */
+	case PROC_SEARCH:
+		search_oracle_main();
+		/* NOTREACHED */
 	}
 
 	fatalx("unhandled role %d", role);
blob - ccd5b0e54b123e49096895d2a72789ba83ac746e
blob + 42996699578eecbbbd94fab1b89340132bc1404a
--- src/mbox_copy.c
+++ src/mbox_copy.c
@@ -58,26 +58,26 @@ resolve_mailbox_target(const char *name, char *target,
 
 static int
 stage_copy_messages(struct mbox_index *idx, struct imsg_mbox_copy *req,
+    const struct seq_range *ranges, uint32_t nranges,
     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;	/* bytes staged so far */
-	uint32_t		 lo, hi;
+	struct seq_range	 resolved[SEQSET_MAX_RANGES];
+	uint32_t		 nresolved, max_hi;
 
-	lo = hi = 0;
-	if (req->by_uid) {
-		uint32_t	max_uid = index_max_uid(idx);
+	/*
+	 * "*" and the backwards-range swap (RFC 9051 SS9: a seq-range is
+	 * unordered) are both handled by seqset_resolve() now: seqno-space hi is clamped to idx->nlines,
+	 * UID-space hi is left alone (an explicit UID above the highest
+	 * in use is just a range matching nothing extra, not an error).
+	 */
+	nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
+	    index_max_uid(idx) : (uint32_t)idx->nlines, !req->by_uid,
+	    resolved);
+	max_hi = seqset_max_hi(resolved, nresolved);
 
-		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;
@@ -91,15 +91,16 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 			continue;
 
 		if (req->by_uid) {
-			if (rec.uid < lo)
-				continue;
-			if (rec.uid > hi)
+			if (rec.uid > max_hi)
 				break;
+			if (!seqset_contains(resolved, nresolved, rec.uid))
+				continue;
 		} else {
-			if (i + 1 < lo)
-				continue;
-			if (i + 1 > hi)
+			if ((uint32_t)(i + 1) > max_hi)
 				break;
+			if (!seqset_contains(resolved, nresolved,
+			    (uint32_t)(i + 1)))
+				continue;
 		}
 
 		if (locate_message_file(rec.basename, &size, suffix,
@@ -133,6 +134,21 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 			    "%s, failing COPY", session_id, rec.basename);
 			goto fail;
 		}
+		/*
+		 * The index-format check used to run in commit_copy_messages()'s
+		 * second loop -- after every message file had already been
+		 * renamed into the destination, so a source record with a ':'
+		 * or a newline in its keywords failed the COPY with the whole
+		 * commit already on disk. It is knowable here, before anything
+		 * is written, which is where every other refusal in this
+		 * function lives. commit_copy_messages() keeps its own copy of
+		 * the check as defence in depth.
+		 */
+		if (!index_field_valid(cs.keywords)) {
+			log_warnx("session %u: COPY: unsafe keywords field on "
+			    "%s, failing COPY", session_id, rec.basename);
+			goto fail;
+		}
 		lp = strstr(suffix, "2,");
 		cs.sysflags = letters_to_sysflags(lp != NULL ? lp + 2 : "");
 
@@ -145,6 +161,14 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 			    "long", session_id);
 			goto fail;
 		}
+		/* same reasoning as the keywords check above; this name is
+		 * generated locally, so a failure here means a hostname or
+		 * timestamp carrying a ':' or a newline. */
+		if (!index_basename_valid(cs.basename)) {
+			log_warnx("session %u: COPY: generated basename is "
+			    "unsafe for the index, failing COPY", session_id);
+			goto fail;
+		}
 
 		if (snprintf(path, sizeof(path), "%s/%s%s",
 		    suffix[0] == '\0' ? "new" : "cur", rec.basename, suffix)
@@ -180,11 +204,27 @@ stage_copy_messages(struct mbox_index *idx, struct ims
 					free(cs.body);
 					goto fail;
 				}
-				if (n == 0)
-					break;	/* short file; copy what we got */
+				if (n == 0) {
+					/*
+					 * The file shrank between
+					 * locate_message_file()'s stat and this
+					 * read. Copying the prefix would put a
+					 * truncated message in the destination
+					 * and still answer OK; RFC 9051 SS6.4.7
+					 * makes COPY all-or-nothing, and every
+					 * other integrity failure in this
+					 * function fails the whole operation.
+					 */
+					log_warnx("session %u: COPY: %s shrank "
+					    "during staging (%zu of %zu bytes "
+					    "read), failing COPY", session_id,
+					    path, rd, cs.bodylen);
+					close(srcfd);
+					free(cs.body);
+					goto fail;
+				}
 				rd += (size_t)n;
 			}
-			cs.bodylen = rd;
 		}
 		close(srcfd);
 
@@ -226,6 +266,7 @@ commit_copy_messages(struct mbox_index *destidx, struc
     size_t nstaged, struct imsgev *iev)
 {
 	size_t	i;
+	size_t	ncommitted = 0;	/* entries whose file is already in cur/ */
 
 	for (i = 0; i < nstaged; i++) {
 		char	tmppath[300], curpath[320];
@@ -236,13 +277,13 @@ commit_copy_messages(struct mbox_index *destidx, struc
 		    staged[i].basename) >= (int)sizeof(tmppath)) {
 			log_warnx("session %u: COPY: tmp path too long",
 			    session_id);
-			return (0);
+			goto rollback;
 		}
 		if ((tmpfd = open(tmppath, O_WRONLY | O_CREAT | O_EXCL,
 		    0600)) == -1) {
 			log_warn("session %u: COPY: open %s", session_id,
 			    tmppath);
-			return (0);
+			goto rollback;
 		}
 		{
 			size_t	 written = 0;
@@ -258,7 +299,7 @@ commit_copy_messages(struct mbox_index *destidx, struc
 					    session_id, tmppath);
 					close(tmpfd);
 					unlink(tmppath);
-					return (0);
+					goto rollback;
 				}
 				written += (size_t)n;
 			}
@@ -275,22 +316,33 @@ commit_copy_messages(struct mbox_index *destidx, struc
 			log_warnx("session %u: COPY: cur path too long",
 			    session_id);
 			unlink(tmppath);
-			return (0);
+			goto rollback;
 		}
 		if (rename(tmppath, curpath) == -1) {
 			log_warn("session %u: COPY: rename %s -> %s",
 			    session_id, tmppath, curpath);
 			unlink(tmppath);
-			return (0);
+			goto rollback;
 		}
+		ncommitted = i + 1;
 	}
 
 	for (i = 0; i < nstaged; i++) {
 		uint32_t	 dest_uid = destidx->uidnext;
 
+		/* stage_copy_messages() already refused these; kept as defence
+		 * in depth, since a bad line here would corrupt the index. */
+		if (!index_basename_valid(staged[i].basename) ||
+		    !index_field_valid(staged[i].keywords)) {
+			log_warnx("session %u: COPY: unsafe field in staged "
+			    "message, refusing to update the index",
+			    session_id);
+			goto rollback;
+		}
+
 		if (index_append(destidx, dest_uid, staged[i].basename) ==
 		    -1)
-			return (0);
+			goto rollback;
 		destidx->uidnext++;
 		staged[i].dest_uid = dest_uid;
 
@@ -304,20 +356,20 @@ commit_copy_messages(struct mbox_index *destidx, struc
 			if (n < 0 || (size_t)n >= sizeof(line)) {
 				log_warnx("session %u: COPY: index line too "
 				    "long", session_id);
-				return (0);
+				goto rollback;
 			}
 			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);
+				goto rollback;
 			}
 		}
 	}
 
 	if (index_save(destidx) == -1)
-		return (0);
+		goto rollback;
 
 	for (i = 0; i < nstaged; i++) {
 		struct imsg_mbox_copy_mapping	 mapping;
@@ -332,20 +384,172 @@ commit_copy_messages(struct mbox_index *destidx, struc
 	}
 
 	return (1);
+
+rollback:
+	/*
+	 * RFC 9051 SS6.4.7: "partial copy MUST NOT be done". The destination
+	 * index is only written by the index_save() above, so a failure before
+	 * it already leaves the mailbox's OBSERVABLE state untouched --
+	 * refresh_index() adopts unknown files from new/ only, never cur/, so
+	 * an orphan here is invisible to LIST, FETCH, SEARCH and every UID.
+	 * What it is not is reclaimed: nothing ever deletes it, and a client
+	 * retrying a COPY that fails on ENOSPC leaks another set each time.
+	 * So undo the renames rather than leave them.
+	 *
+	 * A failure to unlink is logged and stepped over: this path is already
+	 * the error path, and one stubborn file should not stop the rest from
+	 * being cleaned up.
+	 */
+	while (ncommitted > 0) {
+		char	curpath[320];
+		char	letters[8];
+
+		ncommitted--;
+		sysflags_to_letters(staged[ncommitted].sysflags, letters,
+		    sizeof(letters));
+		if (snprintf(curpath, sizeof(curpath), "cur/%s:2,%s",
+		    staged[ncommitted].basename, letters) >=
+		    (int)sizeof(curpath))
+			continue;	/* couldn't have been created either */
+		if (unlink(curpath) == -1 && errno != ENOENT)
+			log_warn("session %u: COPY: rollback unlink %s",
+			    session_id, curpath);
+	}
+	return (0);
 }
 
+/* Shared COPY/MOVE prefix: resolves dest, locks the SELECTed mailbox's index (and, cross-mailbox, the dest's too, in strcmp() order to dodge AB-BA deadlock), loads both; restores cwd to `saved` on any failure it caused. */
+static int
+lock_copy_move_mailboxes(const char *what, const char *destname,
+    const char *saved, struct mbox_index *idx_a, struct mbox_index *idx_b,
+    struct mbox_index **srcidx_out, struct mbox_index **destidx_out,
+    char *desttarget, size_t desttargetlen, int *cross_mailbox_out,
+    struct index_lock *il_a, struct index_lock *il_b,
+    struct imsg_mbox_result *result)
+{
+	char	first[MBOX_NAME_MAX], second[MBOX_NAME_MAX];
+	int	first_is_dest;
+
+	if (resolve_mailbox_target(destname, desttarget, desttargetlen) ==
+	    -1) {
+		log_debug("session %u: %s %s: invalid destination mailbox "
+		    "name", session_id, what, destname);
+		result->error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+		return (0);
+	}
+	*cross_mailbox_out = (strcmp(saved, desttarget) != 0);
+
+	if (!*cross_mailbox_out) {
+		if (index_lock_acquire(il_a, LOCK_EX) == -1)
+			return (0);
+		if (index_load(il_a->fd, idx_a) == -1)
+			return (0);
+		*srcidx_out = idx_a;
+		*destidx_out = idx_a;
+		return (1);
+	}
+
+	if (strcmp(saved, desttarget) <= 0) {
+		if (strlcpy(first, saved, sizeof(first)) >= sizeof(first) ||
+		    strlcpy(second, desttarget, sizeof(second)) >=
+		    sizeof(second)) {
+			log_warnx("session %u: %s: mailbox name truncated, "
+			    "can't happen (same-size buffers)", session_id,
+			    what);
+			return (0);
+		}
+		first_is_dest = 0;
+	} else {
+		if (strlcpy(first, desttarget, sizeof(first)) >=
+		    sizeof(first) ||
+		    strlcpy(second, saved, sizeof(second)) >= sizeof(second)) {
+			log_warnx("session %u: %s: mailbox name truncated, "
+			    "can't happen (same-size buffers)", session_id,
+			    what);
+			return (0);
+		}
+		first_is_dest = 1;
+	}
+
+	if (select_mailbox_dir(first) == -1) {
+		if (first_is_dest)
+			result->error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+		else
+			log_warnx("session %u: %s: couldn't reach %s",
+			    session_id, what, first);
+		return (0);
+	}
+	if (index_lock_acquire(il_a, LOCK_EX) == -1)
+		goto restore;
+	if (index_load(il_a->fd, idx_a) == -1)
+		goto restore;
+
+	if (select_mailbox_dir(second) == -1) {
+		if (!first_is_dest)
+			result->error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+		else
+			log_warnx("session %u: %s: couldn't reach %s",
+			    session_id, what, second);
+		goto restore;
+	}
+	if (index_lock_acquire(il_b, LOCK_EX) == -1)
+		goto restore;
+	if (index_load(il_b->fd, idx_b) == -1)
+		goto restore;
+
+	*srcidx_out = first_is_dest ? idx_b : idx_a;
+	*destidx_out = first_is_dest ? idx_a : idx_b;
+
+	/* Back to source; staging (or MOVE's own dispatch) reads relative to cwd next. */
+	if (select_mailbox_dir(saved) == -1) {
+		log_warnx("session %u: %s: couldn't return to %s",
+		    session_id, what, saved);
+		goto restore;
+	}
+	return (1);
+
+restore:
+	if (select_mailbox_dir(saved) == -1)
+		log_warnx("session %u: %s: couldn't restore previously "
+		    "selected mailbox %s", session_id, what, saved);
+	return (0);
+}
+
+/* Common COPY/MOVE teardown: unlocks/closes the index fd(s), frees the index(es), and replies with IMSG_MBOX_RESULT. */
+static void
+finish_copy_move(struct mbox_index *idx_a, struct mbox_index *idx_b,
+    struct index_lock *il_a, struct index_lock *il_b, int ok,
+    struct imsg_mbox_result *result, struct imsgev *iev)
+{
+	/* both are idempotent, and safe on an INDEX_LOCK_INIT struct that
+	 * lock_copy_move_mailboxes() never got as far as acquiring */
+	index_lock_release(il_a);
+	index_lock_release(il_b);
+
+	if (result->error != MBOX_OP_ERR_NO_SUCH_MAILBOX)
+		result->error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
+	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);
+}
+
 /* RFC 9051 SS6.4.7 COPY; if dest != SELECTed mailbox, both indexes are locked in strcmp() name order to avoid AB-BA deadlock. */
 
 void
-handle_mbox_copy(struct imsg_mbox_copy *req, struct imsgev *iev)
+handle_mbox_copy(struct imsg_mbox_copy *req, const struct seq_range *ranges,
+    uint32_t nranges, 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;
+	struct index_lock		 il_a = INDEX_LOCK_INIT;
+	struct index_lock		 il_b = INDEX_LOCK_INIT;
 	int				 ok = 1;
 	char				 saved[MBOX_NAME_MAX];
 	char				 desttarget[MBOX_NAME_MAX];
@@ -363,136 +567,15 @@ handle_mbox_copy(struct imsg_mbox_copy *req, struct im
 		goto done;
 	}
 
-	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.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+	if (!lock_copy_move_mailboxes("COPY", req->destname, saved, &idx_a,
+	    &idx_b, &srcidx, &destidx, desttarget, sizeof(desttarget),
+	    &cross_mailbox, &il_a, &il_b, &result)) {
 		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) {
-			if (strlcpy(first, saved, sizeof(first)) >=
-			    sizeof(first) ||
-			    strlcpy(second, desttarget, sizeof(second)) >=
-			    sizeof(second)) {
-				log_warnx("session %u: COPY: mailbox name "
-				    "truncated, can't happen (same-size "
-				    "buffers)", session_id);
-				ok = 0;
-				goto done;
-			}
-			first_is_dest = 0;
-		} else {
-			if (strlcpy(first, desttarget, sizeof(first)) >=
-			    sizeof(first) ||
-			    strlcpy(second, saved, sizeof(second)) >=
-			    sizeof(second)) {
-				log_warnx("session %u: COPY: mailbox name "
-				    "truncated, can't happen (same-size "
-				    "buffers)", session_id);
-				ok = 0;
-				goto done;
-			}
-			first_is_dest = 1;
-		}
-
-		if (select_mailbox_dir(first) == -1) {
-			if (first_is_dest)
-				result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
-			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.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
-			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)) {
+	if (!stage_copy_messages(srcidx, req, ranges, nranges, &staged,
+	    &nstaged)) {
 		ok = 0;
 		goto restore_saved;
 	}
@@ -528,32 +611,45 @@ done:
 		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);
+	finish_copy_move(&idx_a, &idx_b, &il_a, &il_b, ok, &result, iev);
+}
 
-	if (result.error != MBOX_OP_ERR_NO_SUCH_MAILBOX)
-		result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
-	index_free(&idx_a);
-	index_free(&idx_b);
+/*
+ * Repairs move_same_mailbox()'s in-place compaction when it has to abandon
+ * partway, and returns the new write index.
+ *
+ * Mid-compaction the array is not something index_free() can walk. Slots
+ * [0, out) hold the kept pointers; slots [out, dropped] hold STALE DUPLICATES
+ * of pointers that are either still live at [0, out) or have already been
+ * passed to free(); and idx->nlines still records the length the array had
+ * before compaction started. index_free() frees every slot in
+ * [0, idx->nlines), so it would free those duplicates a second time.
+ *
+ * `dropped` is the index whose line the caller has just freed. Sliding the
+ * not-yet-visited tail (dropped + 1 onward) down over the stale region and
+ * publishing the resulting length leaves the array holding exactly one live,
+ * distinct pointer per slot -- no double free, and no leak of the entries the
+ * loop never reached. The caller is on its failure path and never calls
+ * index_save(), so the on-disk index is untouched either way; what this
+ * guarantees is that index_free() can run.
+ */
+static size_t
+compaction_bail(struct mbox_index *idx, size_t dropped, size_t out)
+{
+	size_t	j;
 
-	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);
+	for (j = dropped + 1; j < idx->nlines; j++)
+		idx->lines[out++] = idx->lines[j];
+	idx->nlines = out;
+	return (out);
 }
 
 /* MOVE within same mailbox: range membership below must test "in" (read pos), not "out" (compaction write index), "out" under-consumed the range. */
 
 static int
-move_same_mailbox(struct imsg_mbox_copy *req, struct mbox_index *idx,
-    uint32_t *nmoved_out, struct imsgev *iev)
+move_same_mailbox(struct imsg_mbox_copy *req, const struct seq_range *ranges,
+    uint32_t nranges, struct mbox_index *idx, uint32_t *nmoved_out,
+    struct imsgev *iev)
 {
 	struct moved {
 		uint32_t	old_uid;
@@ -563,26 +659,22 @@ move_same_mailbox(struct imsg_mbox_copy *req, struct m
 		char		keywords[512];
 	};
 
-	struct moved	*moved = NULL;
-	size_t		 nmoved = 0, movedcap = 0, in, out, i;
-	uint32_t	 lo, hi;
-	int		 ok = 1;
+	struct moved		*moved = NULL;
+	size_t			 nmoved = 0, movedcap = 0, in, out, i;
+	struct seq_range	 resolved[SEQSET_MAX_RANGES];
+	uint32_t		 nresolved;
+	int			 ok = 1;
 
 	*nmoved_out = 0;
 
-	lo = hi = 0;
-	if (req->by_uid) {
-		uint32_t	max_uid = index_max_uid(idx);
+	/*
+	 * "*" and the backwards-range swap (RFC 9051 SS9: a seq-range is
+	 * unordered) are both handled by seqset_resolve() now.
+	 */
+	nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
+	    index_max_uid(idx) : (uint32_t)idx->nlines, !req->by_uid,
+	    resolved);
 
-		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;
@@ -594,10 +686,11 @@ move_same_mailbox(struct imsg_mbox_copy *req, struct m
 		}
 
 		if (req->by_uid)
-			matched = (rec.uid >= lo && rec.uid <= hi);
+			matched = seqset_contains(resolved, nresolved,
+			    rec.uid);
 		else
-			matched = ((uint32_t)(in + 1) >= lo &&
-			    (uint32_t)(in + 1) <= hi);
+			matched = seqset_contains(resolved, nresolved,
+			    (uint32_t)(in + 1));
 
 		if (!matched) {
 			idx->lines[out++] = idx->lines[in];
@@ -614,6 +707,7 @@ move_same_mailbox(struct imsg_mbox_copy *req, struct m
 				log_warn("session %u: MOVE: reallocarray "
 				    "moved", session_id);
 				free(idx->lines[in]);
+				out = compaction_bail(idx, in, out);
 				ok = 0;
 				goto done;
 			}
@@ -632,6 +726,7 @@ move_same_mailbox(struct imsg_mbox_copy *req, struct m
 			    "long for %s, failing MOVE", session_id,
 			    rec.basename);
 			free(idx->lines[in]);
+			out = compaction_bail(idx, in, out);
 			ok = 0;
 			goto done;
 		}
@@ -644,6 +739,15 @@ move_same_mailbox(struct imsg_mbox_copy *req, struct m
 	for (i = 0; i < nmoved; i++) {
 		uint32_t	 dest_uid = idx->uidnext;
 
+		if (!index_basename_valid(moved[i].basename) ||
+		    !index_field_valid(moved[i].keywords)) {
+			log_warnx("session %u: MOVE: unsafe field in moved "
+			    "message, refusing to update the index",
+			    session_id);
+			ok = 0;
+			goto done;
+		}
+
 		if (index_append(idx, dest_uid, moved[i].basename) == -1) {
 			ok = 0;
 			goto done;
@@ -714,9 +818,10 @@ done:
 /* Cross-mailbox MOVE (SS6.4.8): dest commit completes before source removal, so a crash can duplicate a message but never lose one. */
 
 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)
+move_cross_mailbox(struct imsg_mbox_copy *req, const struct seq_range *ranges,
+    uint32_t nranges, 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;
@@ -724,7 +829,8 @@ move_cross_mailbox(struct imsg_mbox_copy *req, struct 
 
 	*nmoved_out = 0;
 
-	if (!stage_copy_messages(srcidx, req, &staged, &nstaged))
+	if (!stage_copy_messages(srcidx, req, ranges, nranges, &staged,
+	    &nstaged))
 		return (0);
 
 	if (nstaged == 0)
@@ -827,14 +933,15 @@ cleanup:
 /* RFC 9051 SS6.4.8 MOVE dispatcher: locks index file(s) like handle_mbox_copy(), then hands off to the same/cross-mailbox helpers above. */
 
 void
-handle_mbox_move(struct imsg_mbox_copy *req, struct imsgev *iev)
+handle_mbox_move(struct imsg_mbox_copy *req, const struct seq_range *ranges,
+    uint32_t nranges, 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;
+	struct index_lock		 il_a = INDEX_LOCK_INIT;
+	struct index_lock		 il_b = INDEX_LOCK_INIT;
 	int				 ok = 1;
 	char				 saved[MBOX_NAME_MAX];
 	char				 desttarget[MBOX_NAME_MAX];
@@ -852,172 +959,33 @@ handle_mbox_move(struct imsg_mbox_copy *req, struct im
 		goto done;
 	}
 
-	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.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
+	if (!lock_copy_move_mailboxes("MOVE", req->destname, saved, &idx_a,
+	    &idx_b, &srcidx, &destidx, desttarget, sizeof(desttarget),
+	    &cross_mailbox, &il_a, &il_b, &result)) {
 		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);
+		if (!move_same_mailbox(req, ranges, nranges, srcidx, &nmoved,
+		    iev))
 			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) {
-			if (strlcpy(first, saved, sizeof(first)) >=
-			    sizeof(first) ||
-			    strlcpy(second, desttarget, sizeof(second)) >=
-			    sizeof(second)) {
-				log_warnx("session %u: MOVE: mailbox name "
-				    "truncated, can't happen (same-size "
-				    "buffers)", session_id);
-				ok = 0;
-				goto done;
-			}
-			first_is_dest = 0;
-		} else {
-			if (strlcpy(first, desttarget, sizeof(first)) >=
-			    sizeof(first) ||
-			    strlcpy(second, saved, sizeof(second)) >=
-			    sizeof(second)) {
-				log_warnx("session %u: MOVE: mailbox name "
-				    "truncated, can't happen (same-size "
-				    "buffers)", session_id);
-				ok = 0;
-				goto done;
-			}
-			first_is_dest = 1;
-		}
-
-		if (select_mailbox_dir(first) == -1) {
-			if (first_is_dest)
-				result.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
-			else
-				log_warnx("session %u: MOVE: couldn't reach "
-				    "%s", session_id, first);
+		if (!move_cross_mailbox(req, ranges, nranges, srcidx, destidx,
+		    desttarget, saved, &nmoved, iev))
 			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.error = MBOX_OP_ERR_NO_SUCH_MAILBOX;
-			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);
-
-	if (result.error != MBOX_OP_ERR_NO_SUCH_MAILBOX)
-		result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
-	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);
+	finish_copy_move(&idx_a, &idx_b, &il_a, &il_b, ok, &result, iev);
 }
blob - d861857885440699c0eaa29c21ea45c9d67a66e4
blob + d0fe034dfe684964a06008c9365052a2ef7d948c
--- src/mbox_fetch.c
+++ src/mbox_fetch.c
@@ -38,55 +38,69 @@
 #include "log.h"
 #include "store_internal.h"
 
+/*
+ * Shared compose-and-send for the four IMSG_MBOX_FETCH_{HEADER,BODY,
+ * ENVELOPE,BODYSTRUCTURE} messages handle_mbox_fetch() below sends per
+ * message.
+ */
+static void
+fetch_send_part(struct imsgev *iev, int imsg_type, const char *what,
+    const void *meta, size_t metalen, int found, const void *buf,
+    uint32_t buflen)
+{
+	char	*combined;
+	size_t	 combined_len = metalen + (found ? buflen : 0);
+
+	if ((combined = malloc(combined_len)) == NULL) {
+		log_warn("session %u: malloc %s buffer", session_id, what);
+		return;
+	}
+	memcpy(combined, meta, metalen);
+	if (found && buflen > 0)
+		memcpy((char *)combined + metalen, buf, buflen);
+	if (imsg_compose(&iev->ibuf, imsg_type, 0, 0, -1, combined,
+	    combined_len) == -1)
+		log_warn("session %u: imsg_compose %s", session_id, what);
+	free(combined);
+}
+
 void
-handle_mbox_fetch(struct imsg_mbox_fetch *req, struct imsgev *iev)
+handle_mbox_fetch(struct imsg_mbox_fetch *req, const struct seq_range *ranges,
+    uint32_t nranges, struct imsgev *iev)
 {
 	struct mbox_index	 idx;
 	struct imsg_mbox_result	 result;
-	int			 fd;
-	uint32_t		 lo, hi, i, sent = 0;
+	struct index_lock	 il = INDEX_LOCK_INIT;
+	uint32_t		 i, sent = 0;
+	struct seq_range	 resolved[SEQSET_MAX_RANGES];
+	uint32_t		 nresolved, max_hi;
 	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);
+	if (index_lock_acquire(&il, LOCK_SH) == -1) {
 		ok = 0;
 		goto done;
 	}
-	if (flock(fd, LOCK_SH) == -1) {
-		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
-		close(fd);
+	if (index_load(il.fd, &idx) == -1) {
+		index_lock_release(&il);
 		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);
+	index_lock_release(&il);
 
-	/* RFC 9051 SS6.4.9: UID FETCH's sequence-set is UIDs; lo/hi resolve against index_max_uid(), not idx.nlines */
-	if (req->by_uid) {
-		uint32_t	max_uid = index_max_uid(&idx);
+	/* RFC 9051 SS6.4.9: UID FETCH's sequence-set is UIDs; ranges resolve against index_max_uid(), not idx.nlines */
+	nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
+	    index_max_uid(&idx) : (uint32_t)idx.nlines, !req->by_uid,
+	    resolved);
+	max_hi = seqset_max_hi(resolved, nresolved);
 
-		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 (EARLIER) MUST precede FETCH responses, guaranteed by send order here */
-	if (req->by_uid && req->want_vanished)
-		send_vanished_range(&idx, lo, hi, iev);
+	if (req->by_uid && req->want_vanished) {
+		for (i = 0; i < nresolved; i++)
+			send_vanished_range(&idx, resolved[i].lo,
+			    resolved[i].hi, iev);
+	}
 
 	for (i = 1; i <= (uint32_t)idx.nlines; i++) {
 		struct imsg_mbox_fetch_meta	 meta;
@@ -100,15 +114,15 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, struct 
 
 		/* position-space (i) or UID-space (rec.uid) range check, per req->by_uid; single ascending pass */
 		if (req->by_uid) {
-			if (rec.uid < lo)
-				continue;
-			if (rec.uid > hi)
+			if (rec.uid > max_hi)
 				break;
+			if (!seqset_contains(resolved, nresolved, rec.uid))
+				continue;
 		} else {
-			if (i < lo)
-				continue;
-			if (i > hi)
+			if (i > max_hi)
 				break;
+			if (!seqset_contains(resolved, nresolved, i))
+				continue;
 		}
 
 		if (req->has_changedsince && rec.modseq <= req->changedsince)
@@ -145,8 +159,6 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, struct 
 			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));
@@ -164,25 +176,9 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, struct 
 				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);
-			}
+			fetch_send_part(iev, IMSG_MBOX_FETCH_HEADER,
+			    "IMSG_MBOX_FETCH_HEADER", &hdrmeta, sizeof(hdrmeta),
+			    hdrmeta.found, hdrbuf, hdrlen);
 			free(hdrbuf);
 		}
 
@@ -192,8 +188,6 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, struct 
 			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 &&
@@ -253,23 +247,9 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, struct 
 			}
 			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);
-			}
+			fetch_send_part(iev, IMSG_MBOX_FETCH_BODY,
+			    "IMSG_MBOX_FETCH_BODY", &bodymeta, sizeof(bodymeta),
+			    bodymeta.found, bodybuf, bodylen);
 			free(bodybuf);
 		}
 
@@ -278,8 +258,6 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, struct 
 			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;
@@ -289,23 +267,9 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, struct 
 				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);
-			}
+			fetch_send_part(iev, IMSG_MBOX_FETCH_ENVELOPE,
+			    "IMSG_MBOX_FETCH_ENVELOPE", &envmeta, sizeof(envmeta),
+			    envmeta.found, envbuf, envlen);
 			free(envbuf);
 		}
 
@@ -314,8 +278,6 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, struct 
 			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;
@@ -325,25 +287,9 @@ handle_mbox_fetch(struct imsg_mbox_fetch *req, struct 
 				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);
-			}
+			fetch_send_part(iev, IMSG_MBOX_FETCH_BODYSTRUCTURE,
+			    "IMSG_MBOX_FETCH_BODYSTRUCTURE", &bsmeta,
+			    sizeof(bsmeta), bsmeta.found, bsbuf, bslen);
 			free(bsbuf);
 		}
 
@@ -365,7 +311,6 @@ done:
 	    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 flag-suffix letters (e.g. "FS") back to the MBOX_FLAG_* bitmask. */
@@ -412,7 +357,7 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 {
 	struct mbox_index		 idx;
 	struct imsg_mbox_status_result	 reply;
-	int				 fd;
+	struct index_lock		 il = INDEX_LOCK_INIT;
 	DIR				*dp;
 	struct dirent			*de;
 	char				 saved[MBOX_NAME_MAX];
@@ -422,8 +367,7 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 	memset(&reply, 0, sizeof(reply));
 
 	/* RFC 9051 SS6.3.11: STATUS targets a mailbox independent of the session's selection; visit-and-restore via select_mailbox_dir() */
-	if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
-	    sizeof(saved)) {
+	if (save_current_mailbox_dir(saved, sizeof(saved)) == -1) {
 		log_warnx("session %u: STATUS: current_mailbox_dir truncated "
 		    "-- can't happen (same-size buffers)", session_id);
 		reply.error = MBOX_OP_ERR_GENERIC;
@@ -447,25 +391,28 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 	}
 	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);
+	if (index_lock_acquire(&il, LOCK_EX) == -1) {
 		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
-	if (flock(fd, LOCK_EX) == -1) {
-		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
-		close(fd);
-		reply.error = MBOX_OP_ERR_GENERIC;
-		goto send;
-	}
 
-	if (index_load(fd, &idx) == -1) {
-		flock(fd, LOCK_UN);
-		close(fd);
+	if (index_load(il.fd, &idx) == -1) {
+		index_lock_release(&il);
 		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
 
+	/*
+	 * STATUS is the one command that reaches a mailbox without SELECTing
+	 * it first, so it is the one read-only path that can be the first
+	 * thing ever to touch a mailbox. Persist the header index_load() just
+	 * invented, or the UIDVALIDITY reported here is not the one the next
+	 * caller will see.
+	 */
+	if (idx.fresh && index_save(&idx) == -1)
+		log_warnx("session %u: STATUS: could not persist the new "
+		    "index header", session_id);
+
 	dp = opendir("new");
 	if (dp == NULL) {
 		if (errno != ENOENT)
@@ -480,8 +427,7 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 			    == -1) {
 				closedir(dp);
 				index_free(&idx);
-				flock(fd, LOCK_UN);
-				close(fd);
+				index_lock_release(&il);
 				reply.error = MBOX_OP_ERR_GENERIC;
 				goto send;
 			}
@@ -492,8 +438,7 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 
 	if (index_save(&idx) == -1) {
 		index_free(&idx);
-		flock(fd, LOCK_UN);
-		close(fd);
+		index_lock_release(&il);
 		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
@@ -537,8 +482,7 @@ handle_mbox_status(struct imsg_mbox_status *req, struc
 	}
 
 	index_free(&idx);
-	flock(fd, LOCK_UN);
-	close(fd);
+	index_lock_release(&il);
 
 send:
 	/* restore whatever this session had selected before STATUS (no-op if already there) */
@@ -550,5 +494,4 @@ send:
 	    sizeof(reply)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_STATUS_RESULT",
 		    session_id);
-	imsgev_add(iev);
 }
blob - d8b7cd3d2492401da8e5323e7f7f54ab57647d1e
blob + ab1f06c627ed1f742f109973e80b724c04feb825
--- src/mbox_manage.c
+++ src/mbox_manage.c
@@ -40,11 +40,12 @@
 #include "store_internal.h"
 
 void
-handle_mbox_select(struct imsg_mbox_select *req, struct imsgev *iev)
+handle_mbox_select(struct imsg_mbox_select *req,
+    const struct seq_range *ranges, uint32_t nranges, struct imsgev *iev)
 {
 	struct mbox_index	 idx;
 	struct imsg_mbox_selected reply;
-	int			 fd;
+	struct index_lock	 il = INDEX_LOCK_INIT;
 	const char		*target;
 
 	memset(&reply, 0, sizeof(reply));
@@ -67,45 +68,36 @@ handle_mbox_select(struct imsg_mbox_select *req, struc
 		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.error = MBOX_OP_ERR_GENERIC;
-		goto send;
-	}
 	/* multiple store children per user, so this read-modify-write cycle needs cross-process mutual exclusion */
-	if (flock(fd, LOCK_EX) == -1) {
-		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
-		close(fd);
+	if (index_lock_acquire(&il, LOCK_EX) == -1) {
 		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
 
-	if (refresh_index(&idx, fd) == -1) {
-		flock(fd, LOCK_UN);
-		close(fd);
+	if (refresh_index(&idx, il.fd) == -1) {
+		index_lock_release(&il);
 		reply.error = MBOX_OP_ERR_GENERIC;
 		goto send;
 	}
 
 	reply.error = MBOX_OP_OK;
+	mailbox_selected = 1;	/* SS6.2's gate; see store_internal.h */
 	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);
+		qresync_send_resync(req, ranges, nranges, &idx, iev);
 
 	index_free(&idx);
-	flock(fd, LOCK_UN);
-	close(fd);
+	index_lock_release(&il);
 
 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);
 }
 
 int
@@ -130,6 +122,14 @@ ensure_maildir_dirs(const char *prefix)
 	return (0);
 }
 
+/* Copies current_mailbox_dir into saved[savedsize]; shared truncation check for handle_mbox_create()/_delete()/_rename()/_list() below and handle_mbox_status() (mbox_fetch.c), which all visit the maildir root (or, for STATUS, a specific mailbox) and restore the caller's prior selection on the way out. Returns 0 on success, -1 if it doesn't fit (caller logs -- can't happen, same-size buffers everywhere this is called). */
+int
+save_current_mailbox_dir(char *saved, size_t savedsize)
+{
+	return (strlcpy(saved, current_mailbox_dir, savedsize) < savedsize ?
+	    0 : -1);
+}
+
 /* IMSG_MBOX_CREATE (RFC 9051 SS6.3.4); mkdir's EEXIST here means "already exists" (required refusal), unlike ensure_maildir_dirs()'s idempotent use */
 
 void
@@ -151,8 +151,7 @@ handle_mbox_create(struct imsg_mbox_create *req, struc
 	}
 
 	/* mkdir/ensure_maildir_dirs() below need cwd at maildir root, not wherever a SELECTed mailbox left it, visit root first */
-	if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
-	    sizeof(saved)) {
+	if (save_current_mailbox_dir(saved, sizeof(saved)) == -1) {
 		log_warnx("session %u: CREATE %s: current_mailbox_dir "
 		    "truncated, can't happen (same-size buffers)",
 		    session_id, req->mailbox);
@@ -200,7 +199,6 @@ send:
 	    sizeof(result)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
 		    session_id);
-	imsgev_add(iev);
 }
 
 /* bounded removal of one flat mailbox's tmp/new/cur contents plus its index; refuses if any entry isn't a regular file; caller rmdir()s the mailbox itself */
@@ -272,16 +270,36 @@ remove_maildir_subtree(const char *prefix)
 		}
 	}
 
-	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);
+	/*
+	 * The index, its lock file, and any temp left behind by an
+	 * index_save() that was interrupted between creating the temp and
+	 * renaming it. The caller rmdir(2)s the mailbox directory next, so
+	 * every file this daemon puts there has to be named here -- the temp
+	 * was already missing, which meant a crash mid-save left a file that
+	 * made DELETE fail with ENOTEMPTY from then on.
+	 */
+	{
+		static const char *files[] = {
+			STORE_INDEX_NAME,
+			STORE_INDEX_TMP_NAME,
+			STORE_INDEX_LOCK_NAME,
+		};
+		size_t	f;
+
+		for (f = 0; f < sizeof(files) / sizeof(files[0]); f++) {
+			if (snprintf(path, sizeof(path), "%s%s", prefix,
+			    files[f]) >= (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);
+			}
+		}
 	}
-	if (unlink(path) == -1 && errno != ENOENT) {
-		log_warn("session %u: DELETE: unlink %s", session_id, path);
-		return (-1);
-	}
 
 	return (0);
 }
@@ -308,8 +326,7 @@ handle_mbox_delete(struct imsg_mbox_delete *req, struc
 	}
 
 	/* visit root first, same as handle_mbox_create() */
-	if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
-	    sizeof(saved)) {
+	if (save_current_mailbox_dir(saved, sizeof(saved)) == -1) {
 		log_warnx("session %u: DELETE %s: current_mailbox_dir "
 		    "truncated, can't happen (same-size buffers)",
 		    session_id, req->mailbox);
@@ -324,7 +341,7 @@ handle_mbox_delete(struct imsg_mbox_delete *req, struc
 	}
 	switched = 1;
 
-	if (stat(req->mailbox, &st) == -1 || !S_ISDIR(st.st_mode)) {
+	if (lstat(req->mailbox, &st) == -1 || !S_ISDIR(st.st_mode)) {
 		log_debug("session %u: DELETE %s: no such mailbox",
 		    session_id, req->mailbox);
 		/* RFC 5530 SS3 NONEXISTENT: its own worked example is exactly this */
@@ -352,15 +369,37 @@ handle_mbox_delete(struct imsg_mbox_delete *req, struc
 	result.error = MBOX_OP_OK;
 
 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 (switched) {
+		/*
+		 * Deleting this session's own selection: there is nothing to
+		 * restore to, so don't try (and don't warn -- the client
+		 * asked for exactly this). cwd is already the maildir root
+		 * and current_mailbox_dir is already "", both set by the
+		 * select_mailbox_dir("") above, so this process's own state
+		 * stays consistent; what must not survive is the SS6.2 gate.
+		 * "" means INBOX and every handler resolves its mailbox from
+		 * cwd, so leaving mailbox_selected set would let a later
+		 * FETCH/STORE/EXPUNGE operate on INBOX under a name the
+		 * client believes it just deleted. Mirrors
+		 * handle_mbox_expunge()'s CLOSE reset (mbox_store.c) and
+		 * listener's own post-DELETE transition to
+		 * SESSION_AUTHENTICATED (store_ipc.c's
+		 * session_finish_mbox_op()). Note this branch is reachable
+		 * with saved == "" only if req->mailbox were "", which
+		 * mailbox_name_is_inbox() refused above.
+		 */
+		if (result.error == MBOX_OP_OK &&
+		    strcmp(saved, req->mailbox) == 0)
+			mailbox_selected = 0;
+		else if (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); INBOX refused as source since its dir can't be renamed away (store.c's chroot assumes it's always root) */
@@ -376,6 +415,7 @@ handle_mbox_rename(struct imsg_mbox_rename *req, struc
 	memset(&result, 0, sizeof(result));
 
 	if (mailbox_name_is_inbox(req->oldname) ||
+	    mailbox_name_is_inbox(req->newname) ||
 	    !mailbox_name_valid(req->oldname) ||
 	    !mailbox_name_valid(req->newname)) {
 		log_debug("session %u: RENAME %s -> %s: invalid name(s)",
@@ -384,8 +424,7 @@ handle_mbox_rename(struct imsg_mbox_rename *req, struc
 		goto send;
 	}
 
-	if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
-	    sizeof(saved)) {
+	if (save_current_mailbox_dir(saved, sizeof(saved)) == -1) {
 		log_warnx("session %u: RENAME %s -> %s: current_mailbox_dir "
 		    "truncated, can't happen (same-size buffers)",
 		    session_id, req->oldname, req->newname);
@@ -400,7 +439,7 @@ handle_mbox_rename(struct imsg_mbox_rename *req, struc
 	}
 	switched = 1;
 
-	if (stat(req->oldname, &st) == -1 || !S_ISDIR(st.st_mode)) {
+	if (lstat(req->oldname, &st) == -1 || !S_ISDIR(st.st_mode)) {
 		log_debug("session %u: RENAME %s: no such mailbox",
 		    session_id, req->oldname);
 		/* RFC 5530 SS3 NONEXISTENT: its own worked example is a RENAME failing on a missing source */
@@ -409,7 +448,7 @@ handle_mbox_rename(struct imsg_mbox_rename *req, struc
 	}
 
 	/* SS6.3.6: error to rename to an existing name; checked explicitly rather than relying on rename(2)'s semantics */
-	if (stat(req->newname, &st) == 0 || errno != ENOENT) {
+	if (lstat(req->newname, &st) == 0 || errno != ENOENT) {
 		log_debug("session %u: RENAME %s -> %s: destination exists",
 		    session_id, req->oldname, req->newname);
 		/* RFC 5530 SS3 ALREADYEXISTS: its own worked example is this exact RENAME case */
@@ -442,7 +481,6 @@ send:
 	    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 IMSG_MBOX_LIST_ITEM per mailbox subdir, INBOX excluded (listener.c handles it); visits root first */
@@ -456,11 +494,11 @@ handle_mbox_list(struct imsgev *iev)
 	struct dirent			*de;
 	char				 saved[MBOX_NAME_MAX];
 	int				 switched = 0;
+	static int			 skip_warned;	/* see the loop below */
 
 	memset(&result, 0, sizeof(result));
 
-	if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
-	    sizeof(saved)) {
+	if (save_current_mailbox_dir(saved, sizeof(saved)) == -1) {
 		log_warnx("session %u: LIST: current_mailbox_dir truncated "
 		    "-- can't happen (same-size buffers)", session_id);
 		result.error = MBOX_OP_ERR_GENERIC;
@@ -486,15 +524,77 @@ handle_mbox_list(struct imsgev *iev)
 		if (strcmp(de->d_name, ".") == 0 ||
 		    strcmp(de->d_name, "..") == 0)
 			continue;
+
+		/*
+		 * Directory-ness FIRST, before any name check, so that the
+		 * refusal below only ever sees something that could actually
+		 * be a mailbox.
+		 *
+		 * The maildir root also holds imapd.index, imapd.index.tmp,
+		 * imapd.index.lock and imapd.uidvalidity. Only the first of
+		 * those is named in the skip list below; the other three used
+		 * to fall through to mailbox_name_valid(), which refuses them
+		 * by its reserved-name list -- correct, but for a reason that
+		 * has nothing to do with a user losing a mailbox. That was
+		 * invisible while the skip was silent. The moment it started
+		 * logging, the very first LIST reported imapd.index.lock as a
+		 * mailbox that would "not be listed or selectable", which is
+		 * true of a lock file and alarming to say. A warning that
+		 * cries wolf on every connection is worse than no warning:
+		 * it teaches the operator to skip the line that matters.
+		 *
+		 * lstat(2) here is safe before validation: d_name comes from
+		 * readdir(2) on the current directory and so cannot contain
+		 * "/", which means the lookup cannot leave cwd.
+		 */
+		if (lstat(de->d_name, &st) == -1 || !S_ISDIR(st.st_mode))
+			continue;
+
+		/*
+		 * Directories that are not mailboxes. tmp/new/cur are
+		 * INBOX's own maildir subdirectories and are the reason this
+		 * list has to survive the reordering above. STORE_INDEX_NAME
+		 * is now reachable only as a DIRECTORY so named, which imapd
+		 * will not create; skipping it quietly rather than warning
+		 * keeps a reserved name from ever being reported as lost
+		 * mail.
+		 */
 		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))
+
+		if (!mailbox_name_valid(de->d_name)) {
+			/*
+			 * Not silent, and now it cannot be a false alarm:
+			 * everything reaching here is a directory that is not
+			 * one of this daemon's own, so it is somebody's mail.
+			 * Skipping it makes that mail unreachable over IMAP
+			 * with no error the user can see -- a client is told
+			 * only that the mailbox is not there. Since the SS5.1
+			 * UTF-8 rule arrived after this server had already
+			 * been creating mailboxes, the operator's log is the
+			 * one place the reason can still be found.
+			 *
+			 * Once per store child, though, not once per LIST: a
+			 * store child serves one connection (see store.c's
+			 * per-connection fork), so this is about one line per
+			 * client session, and Apple Mail LISTs often enough
+			 * that repeating it would bury the log it is meant to
+			 * improve.
+			 */
+			if (!skip_warned) {
+				skip_warned = 1;
+				log_warnx("session %u: LIST: skipping %s: "
+				    "not a valid mailbox name (RFC 9051 "
+				    "SS5.1); it will not be listed or "
+				    "selectable. Further skips this "
+				    "connection are not logged", session_id,
+				    de->d_name);
+			}
 			continue;
-		if (stat(de->d_name, &st) == -1 || !S_ISDIR(st.st_mode))
-			continue;
+		}
 
 		memset(&item, 0, sizeof(item));
 		if (strlcpy(item.mailbox, de->d_name, sizeof(item.mailbox)) >=
@@ -523,7 +623,6 @@ send:
 	    sizeof(result)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
 		    session_id);
-	imsgev_add(iev);
 }
 
 /* IMSG_MBOX_APPEND: index is updated before the tmp/->cur/ rename, so a rename failure leaves an "indexed but missing on disk" entry, not an orphaned file */
@@ -555,7 +654,8 @@ handle_mbox_append(struct imsg_mbox_append *req, const
 {
 	struct mbox_index		 idx;
 	struct imsg_mbox_appended	 reply;
-	int				 fd = -1, tmpfd = -1, locked = 0;
+	int				 tmpfd = -1;
+	struct index_lock		 il = INDEX_LOCK_INIT;
 	char				 basename[256];
 	char				 tmppath[300], curpath[320];
 	char				 target[MBOX_NAME_MAX];
@@ -567,9 +667,8 @@ handle_mbox_append(struct imsg_mbox_append *req, const
 	memset(&idx, 0, sizeof(idx));
 	memset(&reply, 0, sizeof(reply));
 
-	/* must actually chdir into the target mailbox: index_save() always operates on "imapd.index" relative to cwd, no prefix parameter */
-	if (strlcpy(saved, current_mailbox_dir, sizeof(saved)) >=
-	    sizeof(saved)) {
+	/* must actually chdir into the target mailbox: index_lock_acquire() and index_save() both operate on "imapd.index" and its lock relative to cwd, no prefix parameter */
+	if (save_current_mailbox_dir(saved, sizeof(saved)) == -1) {
 		log_warnx("session %u: APPEND %s: current_mailbox_dir "
 		    "truncated, can't happen (same-size buffers)",
 		    session_id, req->mailbox);
@@ -577,6 +676,13 @@ handle_mbox_append(struct imsg_mbox_append *req, const
 		goto done_reply;
 	}
 
+	if (!index_field_valid(req->keywords)) {
+		log_warnx("session %u: APPEND: refusing unsafe keywords "
+		    "field", session_id);
+		reply.error = MBOX_OP_ERR_GENERIC;
+		goto done_reply;
+	}
+
 	if (mailbox_name_is_inbox(req->mailbox)) {
 		target[0] = '\0';
 	} else if (mailbox_name_valid(req->mailbox)) {
@@ -652,17 +758,10 @@ handle_mbox_append(struct imsg_mbox_append *req, const
 	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);
+	if (index_lock_acquire(&il, LOCK_EX) == -1)
 		goto done_unlink;
-	}
-	if (flock(fd, LOCK_EX) == -1) {
-		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+	if (index_load(il.fd, &idx) == -1)
 		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)
@@ -694,10 +793,7 @@ handle_mbox_append(struct imsg_mbox_append *req, const
 	reply.uidvalidity = idx.uidvalidity;
 	reply.exists = (uint32_t)idx.nlines;
 
-	flock(fd, LOCK_UN);
-	close(fd);
-	fd = -1;
-	locked = 0;
+	index_lock_release(&il);
 	index_free(&idx);
 
 	/* index committed, now the rename; index-then-rename is the safer order to fail partway through */
@@ -721,10 +817,7 @@ handle_mbox_append(struct imsg_mbox_append *req, const
 
 done_unlink:
 	unlink(tmppath);
-	if (locked)
-		flock(fd, LOCK_UN);
-	if (fd != -1)
-		close(fd);
+	index_lock_release(&il);	/* idempotent; already released on the success path above */
 	index_free(&idx);
 
 done_reply:
@@ -736,5 +829,4 @@ done_reply:
 	    sizeof(reply)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_APPENDED",
 		    session_id);
-	imsgev_add(iev);
 }
blob - 16d378de99112160ecb1631e514644ca190af35a
blob + 1854a72f0dc60c2e2f21686eb75c7b61bb93a827
--- src/mbox_search.c
+++ src/mbox_search.c
@@ -55,14 +55,56 @@ kw_list_contains(const char *list, const char *kw)
 	}
 	for (tok = strtok_r(tmp, ",", &save); tok != NULL;
 	    tok = strtok_r(NULL, ",", &save)) {
-		if (strcmp(tok, kw) == 0)
+		/* RFC 9051 SS2.3.2: flags/keywords are case-insensitive */
+		if (strcasecmp(tok, kw) == 0)
 			return (1);
 	}
 	return (0);
 }
 
-/* Computes *out by applying mode/new_kws to old_kws; RFC 9051 SS6.4.6: SET ignores old_kws entirely, unlike ADD/REMOVE. Deduplicates always. */
-void
+/* Appends tok to out (comma-joined, tracking *firstp); shared overflow-check body for merge_keywords()'s three tokenize loops below. */
+static int
+append_kw_token(char *out, size_t outsize, int *firstp, const char *tok)
+{
+	/*
+	 * This CAN happen, on the ADD path: out is MBOX_FLAGS_MAX and so are
+	 * both of merge_keywords()'s inputs, but ADD concatenates them. (The
+	 * comment here used to say "can't happen (same-size MBOX_FLAGS_MAX
+	 * buffers)", which is true of SET and REMOVE and false of ADD.)
+	 * strlcat(3) has already written its truncated prefix by the time it
+	 * reports this, so *out now ends mid-keyword; merge_keywords()'s
+	 * caller must discard it rather than store it.
+	 */
+	if (!*firstp && strlcat(out, ",", outsize) >= outsize) {
+		log_warnx("session %u: merge_keywords: keyword set does not "
+		    "fit in %zu bytes", session_id, outsize);
+		return (0);
+	}
+	if (strlcat(out, tok, outsize) >= outsize) {
+		log_warnx("session %u: merge_keywords: keyword set does not "
+		    "fit in %zu bytes", session_id, outsize);
+		return (0);
+	}
+	*firstp = 0;
+	return (1);
+}
+
+/*
+ * Computes *out by applying mode/new_kws to old_kws; RFC 9051 SS6.4.6: SET
+ * ignores old_kws entirely, unlike ADD/REMOVE. Deduplicates always.
+ *
+ * Returns 0, or -1 if the result did not fit. That can really happen on the
+ * ADD path and only there: out, old_kws and new_kws are all MBOX_FLAGS_MAX,
+ * which is enough for either INPUT but not for ADD's CONCATENATION of the two.
+ * It used to return void, and append_kw_token()'s strlcat(3) writes its
+ * truncated prefix before reporting failure -- so an over-long ADD left a
+ * keyword cut mid-token in *out ("important" becoming "impor"), which
+ * handle_mbox_store() then wrote to the index under a tagged OK, losing the
+ * message's other keywords permanently. The caller now fails the STORE
+ * instead; parse_store_flags() already takes the same line with the client's
+ * own list, rejecting rather than truncating.
+ */
+int
 merge_keywords(int mode, const char *old_kws, const char *new_kws,
     char *out, size_t outsize)
 {
@@ -79,31 +121,16 @@ merge_keywords(int mode, const char *old_kws, const ch
 			    "truncated, can't happen (same-size "
 			    "MBOX_FLAGS_MAX buffers); refusing to guess at "
 			    "the keyword set", session_id);
-			return;
+			return (-1);
 		}
 		for (tok = strtok_r(tmp, ",", &save); tok != NULL;
 		    tok = strtok_r(NULL, ",", &save)) {
 			if (kw_list_contains(new_kws, tok))
 				continue;
-			if (!first) {
-				if (strlcat(out, ",", outsize) >= outsize) {
-					log_warnx("session %u: "
-					    "merge_keywords: out truncated "
-					    "-- can't happen (same-size "
-					    "MBOX_FLAGS_MAX buffers)",
-					    session_id);
-					return;
-				}
-			}
-			if (strlcat(out, tok, outsize) >= outsize) {
-				log_warnx("session %u: merge_keywords: out "
-				    "truncated, can't happen (same-size "
-				    "MBOX_FLAGS_MAX buffers)", session_id);
-				return;
-			}
-			first = 0;
+			if (!append_kw_token(out, outsize, &first, tok))
+				return (-1);
 		}
-		return;
+		return (0);
 	}
 
 	if (mode == MBOX_STORE_ADD) {
@@ -112,27 +139,12 @@ merge_keywords(int mode, const char *old_kws, const ch
 			    "truncated, can't happen (same-size "
 			    "MBOX_FLAGS_MAX buffers); refusing to guess at "
 			    "the keyword set", session_id);
-			return;
+			return (-1);
 		}
 		for (tok = strtok_r(tmp, ",", &save); tok != NULL;
 		    tok = strtok_r(NULL, ",", &save)) {
-			if (!first) {
-				if (strlcat(out, ",", outsize) >= outsize) {
-					log_warnx("session %u: "
-					    "merge_keywords: out truncated "
-					    "-- can't happen (same-size "
-					    "MBOX_FLAGS_MAX buffers)",
-					    session_id);
-					return;
-				}
-			}
-			if (strlcat(out, tok, outsize) >= outsize) {
-				log_warnx("session %u: merge_keywords: out "
-				    "truncated, can't happen (same-size "
-				    "MBOX_FLAGS_MAX buffers)", session_id);
-				return;
-			}
-			first = 0;
+			if (!append_kw_token(out, outsize, &first, tok))
+				return (-1);
 		}
 	}
 
@@ -141,28 +153,16 @@ merge_keywords(int mode, const char *old_kws, const ch
 		log_warnx("session %u: merge_keywords: new_kws truncated, "
 		    "can't happen (same-size MBOX_FLAGS_MAX buffers); "
 		    "refusing to guess at the keyword set", session_id);
-		return;
+		return (-1);
 	}
 	for (tok = strtok_r(tmp, ",", &save); tok != NULL;
 	    tok = strtok_r(NULL, ",", &save)) {
 		if (kw_list_contains(out, tok))
 			continue;
-		if (!first) {
-			if (strlcat(out, ",", outsize) >= outsize) {
-				log_warnx("session %u: merge_keywords: out "
-				    "truncated, can't happen (same-size "
-				    "MBOX_FLAGS_MAX buffers)", session_id);
-				return;
-			}
-		}
-		if (strlcat(out, tok, outsize) >= outsize) {
-			log_warnx("session %u: merge_keywords: out truncated "
-			    "-- can't happen (same-size MBOX_FLAGS_MAX "
-			    "buffers)", session_id);
-			return;
-		}
-		first = 0;
+		if (!append_kw_token(out, outsize, &first, tok))
+			return (-1);
 	}
+	return (0);
 }
 
 /* Per-message context handed to search_eval()/search_eval_leaf() during handle_mbox_search()'s scan below. */
@@ -276,7 +276,7 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 {
 	struct mbox_index	 idx;
 	struct imsg_mbox_result	 result;
-	int			 fd;
+	struct index_lock	 il = INDEX_LOCK_INIT;
 	int			 ok = 1;
 	uint32_t		 sent = 0;
 	uint32_t		 max_uid = 0;
@@ -286,40 +286,52 @@ handle_mbox_search(struct imsg_mbox_search *req, struc
 
 	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);
+	if (index_lock_acquire(&il, LOCK_SH) == -1) {
 		ok = 0;
 		goto done;
 	}
-	if (flock(fd, LOCK_SH) == -1) {
-		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
-		close(fd);
+	if (index_load(il.fd, &idx) == -1) {
+		index_lock_release(&il);
 		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);
+	index_lock_release(&il);
 
 	max_uid = index_max_uid(&idx);	/* shared with UID FETCH/STORE/EXPUNGE's own "*" resolution */
 
+	/*
+	 * Resolve each SEQSET/UIDSET node's "*" (and, since RFC 9051 SS9
+	 * treats a seq-range as unordered, swap a backwards range) via
+	 * the same seqset_resolve() every other sequence-set consumer
+	 * (FETCH/STORE/COPY/MOVE/UID EXPUNGE) already uses, one range at
+	 * a time -- SEARCH's nodes are scattered through a postfix tree
+	 * possibly mixed with AND/OR/NOT, so they can't be batched into
+	 * one seqset_resolve() call the way a single command's whole
+	 * sequence-set can. Same clamp_hi convention as
+	 * handle_mbox_fetch()/handle_mbox_store(): SEQSET clamps hi down
+	 * to idx.nlines, UIDSET doesn't (an explicit UID above the
+	 * highest in use just matches nothing extra, not an error).
+	 */
 	for (i = 0; i < nnodes; i++) {
-		struct search_node *n = &nodes[i];
+		struct search_node	*n = &nodes[i];
+		struct seq_range	 in, out;
+		uint32_t		 max;
 
 		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;
+		max = (n->op == SEARCH_OP_SEQSET) ? (uint32_t)idx.nlines :
+		    max_uid;
+		in.lo = n->seq_lo;
+		in.hi = n->seq_hi;
+		in.lo_is_star = n->lo_is_star;
+		in.hi_is_star = n->hi_is_star;
+
+		(void)seqset_resolve(&in, 1, max, n->op == SEARCH_OP_SEQSET,
+		    &out);
+		n->seq_lo = out.lo;
+		n->seq_hi = out.hi;
+		n->lo_is_star = n->hi_is_star = 0;
 	}
 
 	for (i = 1; i <= (uint32_t)idx.nlines; i++) {
@@ -378,6 +390,5 @@ done:
 	    sizeof(result)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
 		    session_id);
-	imsgev_add(iev);
 }
 
blob - 831abb312f39b67c75e6dd453dca123db64ede3f
blob + 9857d7314f5d59282511982e74888f12a9595f4c
--- src/mbox_store.c
+++ src/mbox_store.c
@@ -40,51 +40,45 @@
 
 /* IMSG_MBOX_STORE (RFC 9051 SS6.4.6); LOCK_EX (mutates index+filename); RFC 7162 UNCHANGEDSINCE misses go out as STORE_MODIFIED and force the FETCH echo despite .SILENT; one shared modseq bump per command. */
 void
-handle_mbox_store(struct imsg_mbox_store *req, struct imsgev *iev)
+handle_mbox_store(struct imsg_mbox_store *req, const struct seq_range *ranges,
+    uint32_t nranges, struct imsgev *iev)
 {
 	struct mbox_index	 idx;
 	struct imsg_mbox_result	 result;
-	int			 fd = -1;
-	uint32_t		 lo, hi, i, sent = 0;
+	struct index_lock	 il = INDEX_LOCK_INIT;
+	uint32_t		 i, sent = 0;
+	struct seq_range	 resolved[SEQSET_MAX_RANGES];
+	uint32_t		 nresolved, max_hi;
 	uint64_t		 new_modseq;
-	int			 ok = 1, changed = 0, locked = 0;
+	int			 ok = 1, changed = 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);
+	if (!index_field_valid(req->keywords)) {
+		log_warnx("session %u: STORE: refusing unsafe keywords field",
+		    session_id);
 		ok = 0;
 		goto done;
 	}
-	if (flock(fd, LOCK_EX) == -1) {
-		log_warn("session %u: flock %s", session_id, STORE_INDEX_NAME);
+
+	if (index_lock_acquire(&il, LOCK_EX) == -1) {
 		ok = 0;
 		goto done;
 	}
-	locked = 1;
 
-	if (index_load(fd, &idx) == -1) {
+	if (index_load(il.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() */
-	if (req->by_uid) {
-		uint32_t	max_uid = index_max_uid(&idx);
+	/* RFC 9051 SS6.4.9: UID STORE's sequence-set is UID-space, same seqset_resolve() switch as handle_mbox_fetch() */
+	nresolved = seqset_resolve(ranges, nranges, req->by_uid ?
+	    index_max_uid(&idx) : (uint32_t)idx.nlines, !req->by_uid,
+	    resolved);
+	max_hi = seqset_max_hi(resolved, nresolved);
 
-		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;
@@ -103,15 +97,15 @@ handle_mbox_store(struct imsg_mbox_store *req, struct 
 
 		/* same position-space/UID-space range check as handle_mbox_fetch() */
 		if (req->by_uid) {
-			if (rec.uid < lo)
-				continue;
-			if (rec.uid > hi)
+			if (rec.uid > max_hi)
 				break;
+			if (!seqset_contains(resolved, nresolved, rec.uid))
+				continue;
 		} else {
-			if (i < lo)
-				continue;
-			if (i > hi)
+			if (i > max_hi)
 				break;
+			if (!seqset_contains(resolved, nresolved, i))
+				continue;
 		}
 
 		if (req->has_unchangedsince &&
@@ -152,8 +146,23 @@ handle_mbox_store(struct imsg_mbox_store *req, struct 
 			new_sysflags = old_sysflags & ~req->sysflags;
 			break;
 		}
-		merge_keywords(req->mode, rec.keywords, req->keywords,
-		    newkeywords, sizeof(newkeywords));
+		if (merge_keywords(req->mode, rec.keywords, req->keywords,
+		    newkeywords, sizeof(newkeywords)) == -1) {
+			/*
+			 * The merged set does not fit, and newkeywords now
+			 * holds a mid-keyword truncation. Storing it would
+			 * lose this message's other keywords permanently and
+			 * invent one it never had, under a tagged OK, so fail
+			 * the STORE instead -- RFC 9051 SS6.4.6 imposes no
+			 * all-or-nothing requirement, and a NO is the only
+			 * answer that does not corrupt the index.
+			 */
+			log_warnx("session %u: STORE: merged keyword set for "
+			    "uid %u exceeds %zu bytes, failing STORE",
+			    session_id, rec.uid, sizeof(newkeywords));
+			ok = 0;
+			goto done;
+		}
 		sysflags_to_letters(new_sysflags, newletters,
 		    sizeof(newletters));
 
@@ -228,10 +237,7 @@ handle_mbox_store(struct imsg_mbox_store *req, struct 
 	}
 
 done:
-	if (locked)
-		flock(fd, LOCK_UN);
-	if (fd != -1)
-		close(fd);
+	index_lock_release(&il);
 
 	memset(&result, 0, sizeof(result));
 	result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
@@ -242,51 +248,39 @@ done:
 	    sizeof(result)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
 		    session_id);
-	imsgev_add(iev);
 }
 
 /* IMSG_MBOX_EXPUNGE (also used, silent=1, by CLOSE); LOCK_EX; two-pointer compaction yields SS7.5.1's decremented seqnos for free; unclassifiable messages kept, not dropped. */
 void
-handle_mbox_expunge(struct imsg_mbox_expunge *req, struct imsgev *iev)
+handle_mbox_expunge(struct imsg_mbox_expunge *req,
+    const struct seq_range *ranges, uint32_t nranges, struct imsgev *iev)
 {
 	struct mbox_index	 idx;
 	struct imsg_mbox_result	 result;
-	int			 fd = -1;
+	struct index_lock	 il = INDEX_LOCK_INIT;
 	size_t			 in, out;
 	uint32_t		 sent = 0;
-	uint32_t		 uid_lo, uid_hi;
-	int			 ok = 1, changed = 0, locked = 0;
+	struct seq_range	 resolved[SEQSET_MAX_RANGES];
+	uint32_t		 nresolved = 0;
+	int			 ok = 1, changed = 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);
+	if (index_lock_acquire(&il, LOCK_EX) == -1) {
 		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) {
+	if (index_load(il.fd, &idx) == -1) {
 		ok = 0;
 		goto done;
 	}
 
-	/* UID EXPUNGE: resolve the UID range once, same index_max_uid() "*" resolution as UID FETCH/STORE */
-	uid_lo = uid_hi = 0;
-	if (req->by_uid) {
-		uint32_t	max_uid = index_max_uid(&idx);
+	/* UID EXPUNGE: resolve the UID ranges once, same seqset_resolve() "*" resolution as UID FETCH/STORE; nranges is 0 for plain EXPUNGE/CLOSE (!req->by_uid), leaving nresolved 0 and the by_uid-gated check below always false */
+	if (req->by_uid)
+		nresolved = seqset_resolve(ranges, nranges,
+		    index_max_uid(&idx), 0, resolved);
 
-		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;
@@ -316,8 +310,9 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req, str
 			continue;
 		}
 
-		/* SS6.4.9: a \Deleted message outside the UID EXPUNGE range is kept, same as "not \Deleted" above */
-		if (req->by_uid && (rec.uid < uid_lo || rec.uid > uid_hi)) {
+		/* SS6.4.9: a \Deleted message outside the UID EXPUNGE ranges is kept, same as "not \Deleted" above */
+		if (req->by_uid &&
+		    !seqset_contains(resolved, nresolved, rec.uid)) {
 			idx.lines[out++] = idx.lines[in];
 			continue;
 		}
@@ -362,11 +357,18 @@ handle_mbox_expunge(struct imsg_mbox_expunge *req, str
 	}
 
 done:
-	if (locked)
-		flock(fd, LOCK_UN);
-	if (fd != -1)
-		close(fd);
+	/*
+	 * SS6.2's gate: mirrors listener's own unconditional post-CLOSE
+	 * state transition (store_ipc.c's session_handle_mbox_result():
+	 * s->state = was_close ? SESSION_AUTHENTICATED : SESSION_SELECTED,
+	 * set before checking res->error) -- CLOSE deselects regardless of
+	 * whether the expunge itself succeeded, so this does too.
+	 */
+	if (req->silent)
+		mailbox_selected = 0;
 
+	index_lock_release(&il);
+
 	memset(&result, 0, sizeof(result));
 	result.error = ok ? MBOX_OP_OK : MBOX_OP_ERR_GENERIC;
 	result.count = sent;
@@ -376,5 +378,4 @@ done:
 	    sizeof(result)) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_RESULT",
 		    session_id);
-	imsgev_add(iev);
 }
blob - a14d666a57d963e0630fd248e8b19416d7b92a64
blob + 4e80f70a1e17498db4c0df965a2c82ca5d9de501
--- src/mime.c
+++ src/mime.c
@@ -40,6 +40,49 @@
 #include "log.h"
 #include "store_internal.h"
 
+/* Scans cur/ for the first "<basename>:*" entry (maildir's flag-suffix convention);
+* shared cur/ fallback dirent scan for locate_message_file()/open_message_file() below.
+* Writes the matched entry's "cur/<name>" path to path[]; if suffix_out != NULL,
+* also copies the matched entry's suffix there (truncation there counts as no-match,
+* same as the inline check this replaces). Returns 0 on a match, -1 on opendir failure
+* (already logged) or no match.
+*/
+static int
+scan_cur_for_basename(const char *basename, char *path, size_t pathsize,
+    char *suffix_out, size_t suffix_out_size)
+{
+	DIR			*dp;
+	const 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, pathsize, "cur/%s", de->d_name) >=
+		    (int)pathsize)
+			break;
+		if (suffix_out != NULL && strlcpy(suffix_out,
+		    de->d_name + baselen, suffix_out_size) >=
+		    suffix_out_size) {
+			log_warnx("session %u: %s: flag suffix truncated, "
+			    "refusing to report a possibly-wrong flag set",
+			    session_id, de->d_name);
+			break;
+		}
+		rv = 0;
+		break;
+	}
+	closedir(dp);
+	return (rv);
+}
+
 int
 locate_message_file(const char *basename, off_t *size_out, char *suffix_out,
     size_t suffix_out_size)
@@ -64,44 +107,18 @@ locate_message_file(const char *basename, off_t *size_
 		return (-1);
 	}
 
-	{
-		DIR			*dp;
-		const 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) {
-				if (strlcpy(suffix_out, de->d_name + baselen,
-				    suffix_out_size) >= suffix_out_size) {
-					log_warnx("session %u: %s: flag "
-					    "suffix truncated, refusing to "
-					    "report a possibly-wrong flag "
-					    "set", session_id, de->d_name);
-				} else {
-					*size_out = st.st_size;
-					rv = 0;
-				}
-			}
-			break;
-		}
-		closedir(dp);
-		return (rv);
-	}
+	if (scan_cur_for_basename(basename, path, sizeof(path), suffix_out,
+	    suffix_out_size) == -1)
+		return (-1);
+	if (stat(path, &st) == -1)
+		return (-1);
+	*size_out = st.st_size;
+	return (0);
 }
 
-/* same new/->cur/ fallback lookup as locate_message_file(), but opens the file and returns a readable fd instead of stat()'ing it */
+/* same new/->cur/ fallback lookup as locate_message_file(), but opens the file and
+* returns a readable fd instead of stat()'ing it.
+*/
 int
 open_message_file(const char *basename)
 {
@@ -121,41 +138,22 @@ open_message_file(const char *basename)
 		return (-1);
 	}
 
-	{
-		DIR			*dp;
-		const 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);
-	}
+	if (scan_cur_for_basename(basename, path, sizeof(path), NULL, 0) == -1)
+		return (-1);
+	return (open(path, O_RDONLY));
 }
 
-/* returns basename's raw RFC 5322 header block through the blank-line separator ("\r\n\r\n" or "\n\n"); -1 past FETCH_HEADER_MAX or on NUL */
+/* returns basename's raw RFC 5322 header block through the blank-line separator
+* ("\r\n\r\n" or "\n\n"); -1 past FETCH_HEADER_MAX or on NUL
+*/
 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;
+	size_t	 i, hdrend = 0, limit, sepindex = 0;
+	int	 found_sep = 0;
 
 	if ((fd = open_message_file(basename)) == -1)
 		return (-1);
@@ -163,6 +161,8 @@ read_message_header(const char *basename, char **buf_o
 	while (total < (ssize_t)sizeof(readbuf)) {
 		n = read(fd, readbuf + total, sizeof(readbuf) - total);
 		if (n == -1) {
+			if (errno == EINTR)
+				continue;	/* as mbox_copy.c's staging read does */
 			log_warn("session %u: read message header (%s)",
 			    session_id, basename);
 			close(fd);
@@ -174,71 +174,105 @@ read_message_header(const char *basename, char **buf_o
 	}
 	close(fd);
 
-	for (i = 0; i + 1 < (size_t)total; i++) {
+	if (find_header_body_split(readbuf, (size_t)total, &hdrend) == 0) {
+		sepindex = hdrend;
+		found_sep = 1;
+	}
+
+	/*
+	 * NUL check bounded to where the separator was found (or the whole
+	 * buffer, if it wasn't) -- matches the interleaved scan this replaces.
+	 *
+	 * The "i + 1 < total" term means the byte at total-1 is not examined
+	 * when limit == total, which looks like an off-by-one and is not:
+	 * limit == total only when the separator ends exactly at the end of
+	 * the buffer, and find_header_body_split() sets hdrend to i+4 past a
+	 * CRLFCRLF or i+2 past an LFLF, so that unexamined byte is always the
+	 * separator's own trailing '\n'. Never a NUL.
+	 */
+	limit = found_sep ? sepindex : (size_t)total;
+	for (i = 0; i < limit && 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) {
+	if (!found_sep || 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) {
+	if ((*buf_out = malloc(sepindex)) == NULL) {
 		log_warn("session %u: malloc message header (%s)",
 		    session_id, basename);
 		return (-1);
 	}
-	memcpy(*buf_out, readbuf, (size_t)sepindex);
+	memcpy(*buf_out, readbuf, sepindex);
 	*len_out = (uint32_t)sepindex;
 	return (0);
 }
 
-/* reads the whole message (text_only=0) or just past the header separator (text_only=1, SS6.4.5.1 TEXT); maxlen/label vary per call site */
+/* reads the whole message (text_only=0) or just past the header separator
+* (text_only=1, SS6.4.5.1 TEXT); maxlen/label vary per call site
+*/
 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;
+	char		*readbuf;
+	size_t		 readbuf_size;
+	struct stat	 st;
+	int		 fd;
+	ssize_t		 n, total = 0;
+	/*
+	 * sepindex is a size_t, not an int: it holds an offset into readbuf,
+	 * whose size comes from maxlen, a CONFIGURATION value for this
+	 * function's two main callers (bodystructure_read_max). Narrowing it
+	 * to int was safe only because parse.y happens to cap that setting at
+	 * 1GB, half of INT_MAX, with nothing in either file recording the
+	 * dependency. Past 2GB the cast would go negative, making
+	 * "readbuf + sepindex" point before the allocation and
+	 * "total - sepindex" wrap into an enormous memcpy length.
+	 */
+	size_t		 i, hdrend, sepindex = 0;
 
 	*buf_out = NULL;
 	*len_out = 0;
 
+	if ((fd = open_message_file(basename)) == -1)
+		return (-1);
+
+	/*
+	 * Size the buffer to the message, not to the configured ceiling:
+	 * this function runs once per message inside handle_mbox_fetch()'s
+	 * loop, so allocating the cap (bodystructure_read_max, up to 1GB
+	 * per parse.y) made "FETCH 1:* BODYSTRUCTURE" one huge malloc(3)+
+	 * read(2) per message regardless of actual message size. The
+	 * maxlen+1 slack is kept when the file is at or over the cap, so
+	 * the "exceeds maxlen" check below still fires.
+	 */
+	readbuf_size = maxlen + 1;
+	if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && st.st_size >= 0 &&
+	    (uint64_t)st.st_size < (uint64_t)maxlen)
+		readbuf_size = (size_t)st.st_size + 1;
+
 	if ((readbuf = malloc(readbuf_size)) == NULL) {
 		log_warn("session %u: malloc message body readbuf (%s)",
 		    session_id, basename);
+		close(fd);
 		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) {
+			if (errno == EINTR)
+				continue;	/* as mbox_copy.c's staging read does */
 			log_warn("session %u: read message body (%s)",
 			    session_id, basename);
 			close(fd);
@@ -268,30 +302,19 @@ read_message_body(const char *basename, int text_only,
 	}
 
 	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) {
+		if (find_header_body_split(readbuf, (size_t)total, &hdrend)
+		    == -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;
+		sepindex = hdrend;
 	}
+	/* else sepindex stays 0: the whole message, header included */
 
-	*len_out = (uint32_t)((size_t)total - (size_t)sepindex);
+	*len_out = (uint32_t)((size_t)total - sepindex);
 	if (*len_out > 0) {
 		if ((*buf_out = malloc(*len_out)) == NULL) {
 			log_warn("session %u: malloc message body (%s)",
@@ -306,7 +329,9 @@ read_message_body(const char *basename, int text_only,
 	return (0);
 }
 
-/* name[0..namelen) matches a space-separated name in list, ASCII-range case-insensitively (RFC 9051 SS6.4.5.1); re-tokenized each call */
+/* name[0..namelen) matches a space-separated name in list, ASCII-range case-insensitively
+* (RFC 9051 SS6.4.5.1); re-tokenized each call.
+*/
 int
 header_field_name_matches(const char *name, size_t namelen, const char *list)
 {
@@ -332,7 +357,9 @@ header_field_name_matches(const char *name, size_t nam
 	return (0);
 }
 
-/* BODY.PEEK[HEADER.FIELDS[.NOT] (fields_spec)] (RFC 9051 SS6.4.5.1); splits the raw header on RFC 5322 obs-fold lines, copies matching fields verbatim */
+/* BODY.PEEK[HEADER.FIELDS[.NOT] (fields_spec)] (RFC 9051 SS6.4.5.1); splits the raw
+* header on RFC 5322 obs-fold lines, copies matching fields verbatim.
+*/
 int
 read_message_header_fields(const char *basename, const char *fields_spec,
     int want_not, char **buf_out, uint32_t *len_out)
@@ -427,7 +454,9 @@ read_message_header_fields(const char *basename, const
 	return (0);
 }
 
-/* finds the first field named `name`, returns its *unfolded* value (RFC 5322 SS2.2.3: CRLF+WSP -> WSP kept); NIL vs "" per SS7.5.2 */
+/* finds the first field named `name`, returns its *unfolded* value
+* (RFC 5322 SS2.2.3: CRLF+WSP -> WSP kept); NIL vs "" per SS7.5.2
+*/
 int
 extract_header_field(const char *hdr, size_t hdrlen, const char *name,
     char **val_out, size_t *vallen_out)
@@ -560,6 +589,17 @@ mime_read_token_or_qstring(const char *s, size_t len, 
 				(*pos)++;
 				c = s[*pos];
 			}
+			/*
+			 * The unquoted-token branch below rejects CTLs; this
+			 * branch applied no character class at all, so a
+			 * lone CR that extract_header_field() doesn't unfold
+			 * away could ride a quoted Content-Type token or
+			 * parameter value into the BODYSTRUCTURE sent to the
+			 * client. parse_content_type() degrades to its RFC
+			 * 2045 SS5.2 default on -1.
+			 */
+			if (c == '\0' || c == '\r' || c == '\n')
+				return (-1);
 			if (outlen + 1 >= outsize)
 				return (-1);
 			out[outlen++] = c;
blob - 7d38934e0e41ad7eaf36f5e19be868d6c5f63f6a
blob + 538aad82c86d6de663a9b113ebd3a66055332c4c
--- src/parent.c
+++ src/parent.c
@@ -14,10 +14,71 @@
  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
  */
 
-/* parent.c, privileged supervisor: process management, SETUP_PEER/SETUP_DONE handshake, privilege drop. */
+/*
+ * parent.c, privileged supervisor: process management, SETUP_PEER/
+ * SETUP_DONE handshake, privilege drop, and (SS7's replicated-listener
+ * model) the accept() loop itself.
+ *
+ * docs/openimap-tls-privsep-design.md SS7: rather than one long-lived
+ * listener process accepting every connection itself, parent now owns
+ * the bound listen sockets for the daemon's whole lifetime and forks a
+ * fresh, paired listener-worker + auth-worker for each connection it
+ * accepts (spawn_connection() below). A worker crash or compromise
+ * therefore affects only its own one connection -- the blast-radius
+ * goal the design doc scoped this work for -- rather than every
+ * concurrent session, as a shared listener process crashing would.
+ * keymgr remains the one boot-time, daemon-lifetime child: unlike
+ * listener/auth it holds no per-connection state, just the loaded TLS
+ * private key, and every listener-worker needs a peer wired to it (see
+ * keymgr.c's own multi-peer rewrite).
+ *
+ * This retires three pieces of machinery that existed only because a
+ * single listener process needed to tell parent things about sessions
+ * it couldn't otherwise know:
+ *
+ *   - IMSG_SESSION_OPEN/IMSG_SESSION_CLOSE (SS6.2 target 1): parent
+ *     now mints session_id and holds each session's listener_iev/
+ *     auth_iev itself, the instant it forks the pair, so there's
+ *     nothing left for a listener-worker to announce. Teardown is
+ *     reap_child() noticing that session's listener-worker pid
+ *     exited, in place of an explicit close message.
+ *
+ *   - IMSG_LISTENER_MAXSTARTUPS: the throttle now runs here, checked
+ *     in parent_accept() before either fork happens (previously a
+ *     refused connection still cost listener a full IMSG_SESSION_OPEN
+ *     round trip; now it costs nothing beyond the accept() itself).
+ *     count_startups()/startups_should_drop() are otherwise unchanged
+ *     from their listener.c originals -- see those functions' own
+ *     comments below.
+ *
+ *   - IMSG_LISTENER_INIT/IMSG_LISTENER_SOCKET_CLEARTEXT/_TLS: parent
+ *     already binds these sockets (bind_listen_socket() below,
+ *     unchanged); it now keeps them instead of handing them off.
+ *
+ * A new IMSG_LISTENER_SESSION_INIT (imapd.h) replaces IMSG_LISTENER_
+ * INIT/SOCKET_*'s old role: parent accept()s the connection itself and
+ * fd-passes the one already-accepted client_fd to the listener-worker
+ * spawned for it, along with session_id/implicit_tls/the peer address.
+ *
+ * spawn_connection() deliberately does not use the blocking
+ * setup_done_send()/setup_recv_done_and_ack() boot handshake for the
+ * new pair's peer wiring: that handshake exists to catch a boot-time
+ * child failing to even start up, at a point where fatal()ing the
+ * whole daemon is acceptable and a one-time blocking wait is cheap.
+ * Per connection, neither property holds -- a bad connection must
+ * degrade only itself (parent_handle_store_fork()'s per-session store
+ * spawn already established this "fail the session, not the daemon"
+ * discipline below), and a blocking round-trip on every accept() would
+ * stall the whole daemon's admission rate on every new connection.
+ * Each worker's own boot-drain loop already reads a fixed, statically
+ * known set of messages before touching its own event loop regardless
+ * of any ack (see listener.c/auth.c), so no ack is needed for
+ * correctness either.
+ */
 
 #include <sys/types.h>
 #include <sys/queue.h>
+#include <sys/resource.h>
 #include <sys/socket.h>
 #include <sys/stat.h>
 #include <sys/wait.h>
@@ -43,6 +104,22 @@
 
 #define STORE_CHILD_MAX		64	/* caps concurrent store children so a fork flood can't exhaust PIDs/fds */
 
+/*
+ * Caps open_sessions tracking, same fork/flood-cap reasoning as
+ * STORE_CHILD_MAX, sized generously above it since these entries are
+ * much cheaper (no pid/fd/process beyond the paired workers already
+ * counted against the OS's own process limits) and cover every open,
+ * not-yet-authenticated connection, not just authenticated ones. In
+ * the SS7 replicated-listener model this is a secondary backstop, not
+ * the primary admission control -- count_startups()/
+ * startups_should_drop() below (SS7's MaxStartups-style throttle) is
+ * what actually caps concurrent connections; this only bounds what
+ * this specific tracking structure will hold if the throttle is
+ * disabled or misconfigured. Past the cap, spawn_connection() refuses
+ * the connection outright rather than forking untracked workers.
+ */
+#define OPEN_SESSION_MAX	4096
+
 struct child {
 	pid_t				 pid;
 	enum openimap_proc_type	 type;
@@ -61,13 +138,41 @@ struct store_child {
 	TAILQ_ENTRY(store_child)	 entry;
 };
 
+/*
+ * One entry per connection spawn_connection() has forked a
+ * listener-worker (and, usually, a paired auth-worker) for, from the
+ * fork itself to reap_child() noticing the listener-worker exit.
+ * "authenticated" flips true the moment a matching IMSG_AUTH_CRED is
+ * accepted, so a second grant for the same session_id is refused as a
+ * duplicate rather than silently re-processed, and so count_startups()
+ * below can tell "counts against MaxStartups" apart from "doesn't".
+ * listener_iev/auth_iev are this session's own paired worker channels
+ * (not a global -- SS6.2 target 1's IMSG_AUTH_CRED gate below checks a
+ * claimed session_id's IMSG_AUTH_CRED came from THAT session's own
+ * auth_iev, not merely from some auth-worker somewhere); either
+ * becomes NULL when reap_child() sees that worker's pid exit.
+ * listener_pid/auth_pid are what let reap_child() find this entry from
+ * a bare pid in the first place.
+ */
+struct open_session {
+	uint32_t			 session_id;
+	int				 authenticated;
+	struct imsgev			*listener_iev;
+	struct imsgev			*auth_iev;
+	pid_t				 listener_pid;
+	pid_t				 auth_pid;	/* 0 if no auth-worker was spawned/is left */
+	pid_t				 search_pid;	/* 0 if no search-oracle was spawned/is left, see SS8.1 */
+	TAILQ_ENTRY(open_session)	 entry;
+};
+
 static TAILQ_HEAD(, child)		 children =
 				    TAILQ_HEAD_INITIALIZER(children);
 static TAILQ_HEAD(, store_child)	 store_children =
 				    TAILQ_HEAD_INITIALIZER(store_children);
+static TAILQ_HEAD(, open_session)	 open_sessions =
+				    TAILQ_HEAD_INITIALIZER(open_sessions);
 
-static struct imsgev	*iev_listener;
-static struct imsgev	*iev_auth;
+static struct imsgev	*iev_keymgr;
 static struct openimap_config *gconf;
 static char		 progpath[PATH_MAX];
 static char		**saved_argv;
@@ -75,12 +180,30 @@ static const char	*conf_path;	/* set once in parent_ma
 
 static struct event	 ev_sighup, ev_sigterm, ev_sigchld;
 
+/* the accept-loop events parent now owns directly, see this file's header comment */
+static struct event	 ev_accept_cleartext[LISTENER_MAX_ADDRS];
+static struct event	 ev_accept_tls[LISTENER_MAX_ADDRS];
+
+static uint32_t		 next_session_id = 1;	/* moved from listener.c's own counter, see spawn_connection() */
+
+static __dead void	 exec_self_as(const char *);
 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 pid_t	 fork_child_nonfatal(enum openimap_proc_type, struct imsgev **,
+		    void (*)(int, short, void *));
+static int	 setup_peer_send(struct imsgev *, struct imsgev *, uint32_t);
+static int	 setup_search_peer_send(struct imsgev *, struct imsgev *);
 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 *);
+static struct open_session *open_session_find(uint32_t);
+static unsigned int count_startups(void);
+static int	 startups_should_drop(unsigned int);
+static void	 parent_accept(int, short, void *);
+static void	 spawn_connection(int, int, const struct sockaddr_storage *,
+		    socklen_t);
+static void	 parent_handle_store_fork(struct open_session *, uid_t, gid_t,
+		    const char *);
+static void	 store_fork_failed(struct open_session *);
 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);
@@ -88,12 +211,9 @@ 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 int	 send_tls_cert(struct imsgev *, struct openimap_config *);
+static int	 send_keymgr_init(struct imsgev *, struct openimap_config *);
+static int	 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 *);
@@ -106,57 +226,105 @@ parent_main(const char *conffile, int argc, char *argv
 {
 	int	 cleartext_fds[LISTENER_MAX_ADDRS], tls_fds[LISTENER_MAX_ADDRS];
 	int	 n_cleartext, n_tls, i;
+	struct rlimit	 rl;
 
-	(void)argc;	/* fork_child()/parent_handle_store_fork() walk saved_argv, scanning for a NULL terminator */
+	(void)argc;	/* fork_child()/parent_handle_store_fork()/spawn_connection() walk saved_argv, scanning for a NULL terminator */
 
 	if (geteuid() != 0)
 		fatalx("parent must start as root");
 
+	/*
+	 * SS7 forks three children per connection and keeps one imsg channel
+	 * to each, so this process holds 3 descriptors per open session (4
+	 * once a store child exists) for that session's whole life.
+	 * rc.subr(8) starts a daemon under the login.conf(5) class named
+	 * after it if one exists, else "daemon" -- and "daemon" is
+	 * openfiles-cur=128, i.e. about 40 connections. That is well under
+	 * the 100 concurrent unauthenticated connections
+	 * "startups begin 10 rate 30 full 100" permits by default, so the
+	 * shipped configuration contradicts its own descriptor budget.
+	 *
+	 * Raise the soft limit to the hard one (openfiles-max, 1024 in that
+	 * class) here: still root, before pledge(2), before anything is
+	 * bound or forked. An operator who needs more can add an "imapd"
+	 * login class; this only stops the default from being
+	 * self-contradictory.
+	 */
+	if (getrlimit(RLIMIT_NOFILE, &rl) == -1)
+		log_warn("getrlimit RLIMIT_NOFILE");
+	else if (rl.rlim_cur < rl.rlim_max) {
+		rl.rlim_cur = rl.rlim_max;
+		if (setrlimit(RLIMIT_NOFILE, &rl) == -1)
+			log_warn("setrlimit RLIMIT_NOFILE");
+		else
+			log_debug("raised RLIMIT_NOFILE soft limit to %llu",
+			    (unsigned long long)rl.rlim_cur);
+	}
+
 	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);
+	/* bind(2) on <1024 needs root, done here, before any privdrop. Kept open for the daemon's whole lifetime now -- see this file's header comment -- not handed off and closed. */
+	n_cleartext = conf->port_cleartext == 0 ? 0 :
+	    bind_listen_socket(conf->listen_addr, conf->port_cleartext,
+	    cleartext_fds);
+	n_tls = conf->port_implicit_tls == 0 ? 0 :
+	    bind_listen_socket(conf->listen_addr, conf->port_implicit_tls,
+	    tls_fds);
+	if (n_cleartext == 0 && n_tls == 0)
+		fatalx("no listener configured: imapd.conf named no usable "
+		    "\"listen on\" address/port");
 
 	event_init();
 
-	/* boot-time children: listener and auth only, store is spawned per-session, see parent_handle_store_fork() */
-	fork_child(PROC_LISTENER, &iev_listener, parent_dispatch_child);
-	fork_child(PROC_AUTH, &iev_auth, parent_dispatch_child);
+	/*
+	 * keymgr is the one remaining boot-time, daemon-lifetime child;
+	 * listener and auth are spawned per-connection by
+	 * spawn_connection() below instead. IMSG_KEYMGR_INIT before the
+	 * peer handshake: keymgr_main() needs real key material in
+	 * place before its SS6.1 permission gate can open (see
+	 * keymgr.c's boot comment).
+	 */
+	fork_child(PROC_KEYMGR, &iev_keymgr, parent_dispatch_child);
+	/* Boot: a keymgr that cannot be initialized is not a daemon worth
+	 * starting, so this one caller keeps the old fatal behaviour. */
+	if (send_keymgr_init(iev_keymgr, conf) == -1)
+		fatalx("send_keymgr_init: could not initialize keymgr at boot");
 
-	/* IMSG_AUTH_INIT before the peer handshake, auth_main() derives its chroot dir from cred_file */
-	send_auth_init(iev_auth, conf);
+	/*
+	 * IMSG_SETUP_DONE with no preceding IMSG_SETUP_PEER: keymgr
+	 * gets its first peer only once spawn_connection() wires the
+	 * first accepted connection's listener-worker to it
+	 * (setup_peer_send() below), so there is nothing to wire at
+	 * boot -- this is purely the same boot-failure-detection
+	 * handshake every boot-time child gets (see setup_done_send()'s
+	 * own comment), just with zero peers instead of one.
+	 */
+	setup_done_send(iev_keymgr);
 
-	/* IMSG_SETUP_PEER / IMSG_SETUP_DONE, sourced from smtpd.c's setup_peers()/setup_done(); listener<->auth only */
-	setup_peer_send(iev_listener, iev_auth, 0);
-	setup_done_send(iev_listener);
-	setup_done_send(iev_auth);
-
-	/* fd-passes the listen sockets via SCM_RIGHTS; our own copies closed right after */
-	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);
-	/* SIGPIPE is ignored process-wide in main.c, before fork_child(), too late here to reach listener/auth, which are already spawned above */
+	/* SIGPIPE is ignored process-wide in main.c, before fork_child(), too late here to reach keymgr, which is already spawned above */
 
-	/* proc/exec/sendfd stay for the process lifetime, not narrowed post-boot, since store is fork-per-session */
+	for (i = 0; i < n_cleartext; i++) {
+		event_set(&ev_accept_cleartext[i], cleartext_fds[i],
+		    EV_READ | EV_PERSIST, parent_accept, (void *)0);
+		event_add(&ev_accept_cleartext[i], NULL);
+	}
+	for (i = 0; i < n_tls; i++) {
+		event_set(&ev_accept_tls[i], tls_fds[i],
+		    EV_READ | EV_PERSIST, parent_accept, (void *)1);
+		event_add(&ev_accept_tls[i], NULL);
+	}
+
+	/* proc/exec/sendfd stay for the process lifetime: spawn_connection() forks a fresh pair, fd-passing the client_fd, for as long as the daemon runs, not just at boot */
 #ifdef __OpenBSD__
 	if (pledge("stdio rpath inet proc exec sendfd", NULL) == -1)
 		fatal("pledge");
@@ -166,22 +334,113 @@ parent_main(const char *conffile, int argc, char *argv
 	fatalx("parent: exited event loop");
 }
 
-/* re-exec mechanism (smtpd.c's start_child()): socketpair/fork/dup2 onto fd 3/closefrom/execvp -x <role> */
+/* shared re-exec argv builder for fork_child()/parent_handle_store_fork()'s child-side fork: progpath, "-x", role, then saved_argv with any pre-existing "-x <role>" pair skipped */
+static __dead void
+exec_self_as(const char *role)
+{
+	char	*nargv[16];
+	int	 i, n;
+
+	n = 0;
+	nargv[n++] = progpath;
+	nargv[n++] = "-x";
+	/* role is const char *; execv(3) requires char *const argv[] for
+	 * historical reasons predating C const-correctness, and never
+	 * writes through argv's pointers, so this cast is the standard,
+	 * unavoidable idiom for building an exec() argv array.
+	 */
+	nargv[n++] = (char *)(uintptr_t)role;
+	for (i = 1; saved_argv[i] != NULL; i++) {
+		/* skip a pre-existing -x <role> from our own argv, everything else passes through */
+		if (strcmp(saved_argv[i], "-x") == 0) {
+			/*
+			 * A trailing "-x" with no value: the body's i++ plus
+			 * the loop's own would step past argv's NULL
+			 * terminator, and the next test would read one
+			 * element beyond the array -- in practice environ[0],
+			 * which then gets copied into the child's argv.
+			 * getopt(3) rejects a bare -x ("x:" takes an
+			 * argument), but an operand after "--" reaches here
+			 * unexamined because main() never checks optind.
+			 */
+			if (saved_argv[i + 1] == NULL)
+				break;
+			i++;
+			continue;
+		}
+		if (n >= (int)(sizeof(nargv) / sizeof(nargv[0])) - 1) {
+			/*
+			 * Refuse rather than truncate. Silently dropping the
+			 * tail can split an option from its value ("-f" with
+			 * no path), and the child's own getopt(3) then calls
+			 * usage() and exits -- surfacing as every connection
+			 * failing, with a stray "usage:" line as the only
+			 * clue. This runs in the freshly forked child, so the
+			 * parent simply sees the channel close.
+			 */
+			log_warnx("exec_self_as: argv too long to pass to the "
+			    "%s child, refusing to exec a truncated command "
+			    "line", role);
+			_exit(1);
+		}
+		nargv[n++] = saved_argv[i];
+	}
+	nargv[n] = NULL;
+
+	/*
+	 * execv(3), not execvp(3). nargv[0] is progpath, which realpath(3)
+	 * guarantees is absolute, so execvp() would never have searched PATH
+	 * -- it only does so for names containing no slash. But that safety
+	 * sat 120 lines away in parent_main(), and the OpenBSD ports guide
+	 * says plainly to avoid execvp. execv() cannot consult PATH at all,
+	 * which makes the property local and true by construction rather than
+	 * by an argument about a caller. (smtpd.c:856 still uses execvp for
+	 * the same re-exec; this is not a bug there either, for the same
+	 * reason.)
+	 */
+	execv(nargv[0], nargv);
+	_exit(1);
+}
+
+/*
+ * re-exec mechanism (smtpd.c's start_child()): socketpair/fork/dup2
+ * onto fd 3/closefrom/execv -x <role>. fatal()s on failure, the
+ * right behavior for a boot-time child the daemon cannot run without
+ * (keymgr is now the only caller). fork_child_nonfatal() below is the
+ * identical mechanism for spawn_connection()'s per-connection forks,
+ * which must degrade only the one connection instead.
+ */
 static pid_t
 fork_child(enum openimap_proc_type type, struct imsgev **ievp,
     void (*handler)(int, short, void *))
 {
+	pid_t	pid;
+
+	if ((pid = fork_child_nonfatal(type, ievp, handler)) == -1)
+		fatal("fork_child_nonfatal");
+	return (pid);
+}
+
+/* fork_child()'s non-fatal twin; see fork_child()'s own comment. Returns -1 (nothing forked, *ievp untouched) on failure, logging via log_warn(x) rather than fatal(ing) the daemon. */
+static pid_t
+fork_child_nonfatal(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 (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, sp) == -1) {
+		log_warn("socketpair");
+		return (-1);
+	}
 
-	if ((pid = fork()) == -1)
-		fatal("fork");
+	if ((pid = fork()) == -1) {
+		log_warn("fork");
+		close(sp[0]);
+		close(sp[1]);
+		return (-1);
+	}
 
 	if (pid == 0) {
 		/* child */
@@ -190,36 +449,19 @@ fork_child(enum openimap_proc_type type, struct imsgev
 			_exit(1);
 		closefrom(4);
 
-		n = 0;
-		nargv[n++] = progpath;
-		nargv[n++] = "-x";
-		/* log_procname() returns const char *; execvp(3) requires
-		 * char *const argv[] for historical reasons predating C
-		 * const-correctness, and never writes through argv's
-		 * pointers, so this cast is the standard, unavoidable idiom
-		 * for building an exec() argv array.
-		 */
-		nargv[n++] = (char *)(uintptr_t)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 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);
+		exec_self_as(log_procname(type));
 	}
 
 	/* parent */
 	close(sp[1]);
 
 	c = calloc(1, sizeof(*c));
-	if (c == NULL)
-		fatal("calloc");
+	if (c == NULL) {
+		log_warn("calloc");
+		kill(pid, SIGKILL);
+		close(sp[0]);
+		return (-1);
+	}
 	c->pid = pid;
 	c->type = type;
 	/* NULL here, not "c", handlers expect arg == &iev; imsgev_init()'s own fallback self-references &c->iev */
@@ -230,29 +472,104 @@ fork_child(enum openimap_proc_type type, struct imsgev
 	return (pid);
 }
 
-/* IMSG_SETUP_PEER (smtpd.c's setup_peers()): fresh socketpair, fd-passed to "a"/"b"; id is 0 at boot, else session_id */
-static void
+/* IMSG_SETUP_PEER (smtpd.c's setup_peers()): fresh socketpair, fd-passed to "a"/"b"; id is 0 at boot or when the receiving side has exactly one peer for its whole life, else session_id (keymgr's multi-peer case, store's own case below) */
+static int
 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");
+	/*
+	 * Returns -1 rather than fatal()ing: every caller is now a
+	 * per-connection path (spawn_connection(),
+	 * parent_handle_store_fork()), where the rule is "fail the session,
+	 * not the daemon". A socketpair(2) that fails with EMFILE must
+	 * refuse one connection, not exit the root process and take every
+	 * established session with it.
+	 *
+	 * Descriptor ownership on the error paths below follows
+	 * imsg_compose(3): it takes ownership of the fd only once it
+	 * succeeds (ibuf_fd_set()), and libutil then closes it after
+	 * sendmsg(2) -- so an fd handed to a compose that RETURNED
+	 * SUCCESS must not be closed here, and one whose compose failed
+	 * must be.
+	 */
+	if (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, sp) == -1) {
+		log_warn("setup_peer_send: socketpair");
+		return (-1);
+	}
 
 	if (imsg_compose(&a->ibuf, IMSG_SETUP_PEER, id, 0, sp[0], NULL, 0)
-	    == -1)
-		fatal("imsg_compose IMSG_SETUP_PEER (a)");
+	    == -1) {
+		log_warn("setup_peer_send: imsg_compose (a)");
+		close(sp[0]);	/* neither end was taken */
+		close(sp[1]);
+		return (-1);
+	}
 	if (imsg_compose(&b->ibuf, IMSG_SETUP_PEER, id, 0, sp[1], NULL, 0)
-	    == -1)
-		fatal("imsg_compose IMSG_SETUP_PEER (b)");
+	    == -1) {
+		log_warn("setup_peer_send: imsg_compose (b)");
+		close(sp[1]);	/* sp[0] now belongs to a's msgbuf */
+		return (-1);
+	}
 
-	if (imsgbuf_flush(&a->ibuf) == -1)
-		fatal("imsgbuf_flush");
-	if (imsgbuf_flush(&b->ibuf) == -1)
-		fatal("imsgbuf_flush");
+	if (imsgbuf_flush(&a->ibuf) == -1) {
+		log_warn("setup_peer_send: imsgbuf_flush (a)");
+		return (-1);
+	}
+	if (imsgbuf_flush(&b->ibuf) == -1) {
+		log_warn("setup_peer_send: imsgbuf_flush (b)");
+		return (-1);
+	}
+	return (0);
 }
 
-/* IMSG_SETUP_DONE: tell a child no more peers are coming, block for its ack (smtpd.c's setup_done()); boot-time only */
+/*
+ * SS8.1: same wiring dance as setup_peer_send() above, but hardcoded to
+ * IMSG_SETUP_SEARCH_PEER with id always 0 -- listener-worker's boot-drain
+ * expects exactly one of these (search-oracle is a short-lived
+ * per-connection peer, not a long-lived multi-peer one like keymgr, so it
+ * needs no session_id discriminator; see imapd.h's IMSG_SETUP_SEARCH_PEER
+ * comment for why this got its own imsg type instead of reusing
+ * IMSG_SETUP_PEER's id field as a third discriminator value).
+ */
+static int
+setup_search_peer_send(struct imsgev *a, struct imsgev *b)
+{
+	int sp[2];
+
+	/* Non-fatal for the same reason as setup_peer_send() above, and
+	 * with the same descriptor-ownership rules on the error paths. */
+	if (socketpair(AF_UNIX, SOCK_STREAM, PF_UNSPEC, sp) == -1) {
+		log_warn("setup_search_peer_send: socketpair");
+		return (-1);
+	}
+
+	if (imsg_compose(&a->ibuf, IMSG_SETUP_SEARCH_PEER, 0, 0, sp[0], NULL, 0)
+	    == -1) {
+		log_warn("setup_search_peer_send: imsg_compose (a)");
+		close(sp[0]);
+		close(sp[1]);
+		return (-1);
+	}
+	if (imsg_compose(&b->ibuf, IMSG_SETUP_SEARCH_PEER, 0, 0, sp[1], NULL, 0)
+	    == -1) {
+		log_warn("setup_search_peer_send: imsg_compose (b)");
+		close(sp[1]);
+		return (-1);
+	}
+
+	if (imsgbuf_flush(&a->ibuf) == -1) {
+		log_warn("setup_search_peer_send: imsgbuf_flush (a)");
+		return (-1);
+	}
+	if (imsgbuf_flush(&b->ibuf) == -1) {
+		log_warn("setup_search_peer_send: imsgbuf_flush (b)");
+		return (-1);
+	}
+	return (0);
+}
+
+/* IMSG_SETUP_DONE: tell a child no more peers are coming, block for its ack (smtpd.c's setup_done()); boot-time only -- see this file's header comment on why spawn_connection() does not use this for per-connection peer wiring */
 static void
 setup_done_send(struct imsgev *iev)
 {
@@ -264,10 +581,10 @@ setup_done_send(struct imsgev *iev)
 	if (imsgbuf_flush(&iev->ibuf) == -1)
 		fatal("imsgbuf_flush");
 
-	/* imsg_get() before imsgbuf_read(): kernel may coalesce the setup_peer_send() reply with this ack already */
+	/* imsgbuf_get() before imsgbuf_read(): kernel may coalesce the setup_peer_send() reply with this ack already */
 	for (;;) {
-		if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n != 0)
 			break;
 		if ((n = imsgbuf_read(&iev->ibuf)) == -1)
@@ -281,7 +598,15 @@ setup_done_send(struct imsgev *iev)
 	imsg_free(&imsg);
 }
 
-/* dispatch for boot-time listener/auth channels post-boot; IMSG_AUTH_CRED arrives here from auth, triggering a store spawn directly (no longer relayed via listener's old IMSG_STORE_FORK request) */
+/*
+ * dispatch for every non-boot child channel: keymgr (the one
+ * remaining boot-time singleton) and every spawn_connection()-forked
+ * listener-worker/auth-worker. Demuxes by imsg type only, not by
+ * sender -- IMSG_AUTH_CRED's case below is the one that needs to
+ * verify sender identity per session_id (SS6.2 target 1); the others
+ * are either sender-agnostic (IMSG_KEYMGR_*) or unreachable from a
+ * child at all.
+ */
 static void
 parent_dispatch_child(int fd, short event, void *arg)
 {
@@ -306,21 +631,86 @@ parent_dispatch_child(int fd, short event, void *arg)
 	}
 
 	for (;;) {
-		if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n == 0)
 			break;
 
 		switch (imsg_get_type(&imsg)) {
 		case IMSG_AUTH_CRED: {
 			struct imsg_auth_cred	 req;
+			struct open_session	*os;
 
 			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
 				log_warnx("bad IMSG_AUTH_CRED");
 				break;
 			}
-			parent_handle_store_fork(req.session_id, req.uid,
-			    req.gid, req.maildir);
+			/*
+			 * imsg_get_data() guarantees size, not NUL termination
+			 * (auth.c forces it on its own inbound imsgs for the same
+			 * reason). Everything downstream treats this as a C
+			 * string -- maildir_path_is_safe() walks it with strchr(3),
+			 * and strlcpy(3) reads the WHOLE source to compute its
+			 * return value -- so an unterminated field is an unbounded
+			 * read past a stack array, in the root process, driven by
+			 * the one child privsep exists to contain.
+			 */
+			req.maildir[sizeof(req.maildir) - 1] = '\0';
+
+			/*
+			 * SS6.2's retrofit target 1: verify session_id against a
+			 * session parent independently knows is open (tracked
+			 * since spawn_connection() forked this session's pair),
+			 * rather than trusting whatever auth claims.
+			 */
+			if ((os = open_session_find(req.session_id)) == NULL) {
+				log_warnx("refusing IMSG_AUTH_CRED for session "
+				    "%u: not a session parent knows is open "
+				    "(never opened, already closed, or evicted "
+				    "by OPEN_SESSION_MAX), refusing (SS6.2)",
+				    req.session_id);
+				break;
+			}
+			/*
+			 * Per-connection auth-workers (SS7) replace the old
+			 * single global iev_auth sender check (SS6.2 target
+			 * 1's original fix): each open_session remembers
+			 * exactly which auth-worker channel spawn_connection()
+			 * paired it with, and only that channel's
+			 * IMSG_AUTH_CRED is honored for it. A forged claim
+			 * from a different session's auth-worker is refused
+			 * exactly like the old global check refused a forgery
+			 * from listener or keymgr.
+			 */
+			if (iev != os->auth_iev) {
+				log_warnx("refusing IMSG_AUTH_CRED for "
+				    "session %u: not from that session's own "
+				    "auth-worker channel, refusing (SS6.2)",
+				    req.session_id);
+				break;
+			}
+			if (os->authenticated) {
+				log_warnx("refusing IMSG_AUTH_CRED for session "
+				    "%u: already authenticated, refusing "
+				    "duplicate grant (SS6.2)", req.session_id);
+				/*
+				 * Refuse the grant, but do not go silent: that
+				 * session's listener-worker is sitting in
+				 * SESSION_STORE_PENDING waiting for a store
+				 * peer it is never going to get, and nothing
+				 * times it out. This is reachable without any
+				 * misbehaviour -- a store spawn that failed
+				 * (STORE_CHILD_MAX, fork, socketpair) puts the
+				 * client back in SESSION_NOT_AUTH, and an
+				 * ordinary client retry lands right here.
+				 */
+				store_fork_failed(os);
+				break;
+			}
+			os->authenticated = 1;
+
+			parent_handle_store_fork(os, req.uid, req.gid,
+			    req.maildir);
 			break;
 		}
 		default:
@@ -330,11 +720,302 @@ parent_dispatch_child(int fd, short event, void *arg)
 		}
 		imsg_free(&imsg);
 	}
-	/* unconditional re-arm: without it, a pure EV_WRITE firing would let this channel's event lapse */
-	imsgev_add(iev);
+	imsgev_rearm_read(iev);
 	(void)fd;
 }
 
+/* SS6.2's open_sessions lookup; see struct open_session's own comment. */
+static struct open_session *
+open_session_find(uint32_t session_id)
+{
+	struct open_session	*os;
+
+	TAILQ_FOREACH(os, &open_sessions, entry) {
+		if (os->session_id == session_id)
+			return (os);
+	}
+	return (NULL);
+}
+
+/*
+ * SS7: count of open_sessions entries not yet authenticated --
+ * imapd's equivalent of sshd's "concurrent unauthenticated
+ * connections", moved here from listener.c's identical function of
+ * the same name now that parent tracks every open session itself
+ * (struct open_session's own "authenticated" field, flipped the
+ * moment IMSG_AUTH_CRED is accepted above, is the exact same boundary
+ * listener.c's SESSION_NOT_AUTH state check was -- see struct
+ * open_session's comment). O(n) over open_sessions, same tradeoff as
+ * before: n is bounded by max_startups_full by construction, so a
+ * live walk is simpler than a separately-synchronized counter and
+ * just as cheap at this scale.
+ */
+static unsigned int
+count_startups(void)
+{
+	struct open_session	*os;
+	unsigned int		 n = 0;
+
+	TAILQ_FOREACH(os, &open_sessions, entry) {
+		if (!os->authenticated)
+			n++;
+	}
+	return (n);
+}
+
+/*
+ * SS7: sshd_config(5)'s MaxStartups algorithm, per its own
+ * documentation -- below max_startups_begin, always accept; between
+ * begin and full, refuse with probability rising linearly from
+ * max_startups_rate% (at begin) to 100% (at full); at or above full,
+ * always refuse. max_startups_full == 0 disables the throttle
+ * entirely (accept unconditionally) -- an explicit opt-out, needed
+ * because the check below would otherwise treat max_startups_full ==
+ * 0 as "already at capacity" and refuse every connection. Moved here
+ * verbatim from listener.c; now reads gconf->max_startups_* directly
+ * (see this file's header comment -- SIGHUP already updates gconf in
+ * place, so there's no more separate push/reload imsg to keep in
+ * sync).
+ */
+static int
+startups_should_drop(unsigned int nstartups)
+{
+	double	p;
+
+	if (gconf->max_startups_full == 0)
+		return (0);
+	if (nstartups < gconf->max_startups_begin)
+		return (0);
+	if (nstartups >= gconf->max_startups_full)
+		return (1);
+	if (gconf->max_startups_rate >= 100)
+		return (1);
+	/* Misconfigured (full <= begin, shouldn't happen -- parse.y
+	 * validates this at config-load time): treat as "no ramp
+	 * region", fail toward refusing rather than silently
+	 * accepting past what the operator called "full". */
+	if (gconf->max_startups_full <= gconf->max_startups_begin)
+		return (1);
+
+	p = gconf->max_startups_rate + ((100.0 - gconf->max_startups_rate) *
+	    (nstartups - gconf->max_startups_begin)) /
+	    (gconf->max_startups_full - gconf->max_startups_begin);
+	return (arc4random_uniform(100) < (uint32_t)p);
+}
+
+/*
+ * parent's own accept loop (SS7, moved from listener.c's
+ * listener_accept()); arg is (void *)0 for the cleartext/STARTTLS
+ * socket, (void *)1 for the implicit-TLS one, same convention
+ * listener_accept() used. MaxStartups is checked here, before any
+ * fork -- no session_id minted, no open_session calloc'd -- so a
+ * refused connection costs as little as possible, same intent as the
+ * original listener.c placement.
+ */
+static void
+parent_accept(int fd, short event, void *arg)
+{
+	struct sockaddr_storage	 ss;
+	socklen_t			 sslen = sizeof(ss);
+	int				 client_fd, flags, implicit_tls;
+	unsigned int			 nstartups;
+
+	(void)event;
+
+	if ((client_fd = accept(fd, (struct sockaddr *)&ss, &sslen)) == -1) {
+		log_warn("accept");
+		return;
+	}
+
+	nstartups = count_startups();
+	if (startups_should_drop(nstartups)) {
+		log_debug("MaxStartups: refusing connection (%u "
+		    "unauthenticated already open)", nstartups);
+		close(client_fd);
+		return;
+	}
+
+	/*
+	 * accept(2) does not inherit O_NONBLOCK from the listening socket.
+	 * The listener-worker this fd is about to be handed to assumes a
+	 * non-blocking fd throughout (tls_handshake()'s TLS_WANT_POLL*
+	 * handling, session_write()'s EAGAIN/poll retry); O_NONBLOCK is a
+	 * file-status flag on the underlying open file description, not
+	 * per-descriptor, so setting it here before the fd-pass is
+	 * equivalent to the listener-worker setting it itself after.
+	 */
+	if ((flags = fcntl(client_fd, F_GETFL)) == -1 ||
+	    fcntl(client_fd, F_SETFL, flags | O_NONBLOCK) == -1) {
+		log_warn("fcntl O_NONBLOCK");
+		close(client_fd);
+		return;
+	}
+
+	implicit_tls = (arg != (void *)0);	/* (void *)1 == port-993 listener */
+	spawn_connection(client_fd, implicit_tls, &ss, sslen);
+}
+
+/*
+ * SS7's replicated-listener model: forks a paired listener-worker (+,
+ * usually, an auth-worker) for one newly-accepted connection, wires
+ * them to each other and to keymgr, and hands the listener-worker its
+ * one session via IMSG_LISTENER_SESSION_INIT. Never fatal()s -- every
+ * failure here degrades to closing this one client_fd or leaving this
+ * one session without an auth-worker, never the daemon; see this
+ * file's header comment for why no setup-done ack is used.
+ *
+ * client_fd is always either consumed (fd-passed to the new
+ * listener-worker) or closed before this function returns.
+ */
+static void
+spawn_connection(int client_fd, int implicit_tls,
+    const struct sockaddr_storage *ss, socklen_t sslen)
+{
+	struct open_session			*os, *it;
+	struct imsgev				*new_listener_iev;
+	struct imsgev				*new_auth_iev;
+	struct imsgev				*new_search_iev;
+	struct imsg_listener_session_init	 init;
+	uint32_t				 session_id;
+	pid_t					 listener_pid, auth_pid, search_pid;
+	unsigned int				 nopen = 0;
+
+	session_id = next_session_id++;
+	/*
+	 * 0 is not an ordinary id: listener.c's boot-drain loop uses it as
+	 * a hard discriminator ("0 == the auth peer, anything else == the
+	 * keymgr peer", see imapd.h's IMSG_SETUP_PEER comment), so a
+	 * session whose id wrapped to 0 would have its keymgr descriptor
+	 * filed as its auth peer and would block in that loop forever.
+	 */
+	if (next_session_id == 0)
+		next_session_id = 1;
+
+	TAILQ_FOREACH(it, &open_sessions, entry)
+		nopen++;
+	if (nopen >= OPEN_SESSION_MAX) {
+		log_warnx("refusing connection: %u open sessions already "
+		    "tracked (OPEN_SESSION_MAX)", nopen);
+		close(client_fd);
+		return;
+	}
+
+	if ((os = calloc(1, sizeof(*os))) == NULL) {
+		log_warn("calloc open_session (session %u)", session_id);
+		close(client_fd);
+		return;
+	}
+	os->session_id = session_id;
+
+	if ((listener_pid = fork_child_nonfatal(PROC_LISTENER,
+	    &new_listener_iev, parent_dispatch_child)) == -1) {
+		log_warnx("session %u: refusing connection: could not spawn "
+		    "a listener-worker", session_id);
+		free(os);
+		close(client_fd);
+		return;
+	}
+	os->listener_pid = listener_pid;
+	os->listener_iev = new_listener_iev;
+	/*
+	 * Inserted as soon as the listener-worker exists, even though
+	 * the rest of this function can still fail below: from here on
+	 * a live process is tracked against this session_id, and
+	 * reap_child() needs a matching open_session to find when that
+	 * process eventually exits, however incompletely it was wired
+	 * up. Simpler than unwinding a half-spawned session on every
+	 * later failure path.
+	 */
+	TAILQ_INSERT_TAIL(&open_sessions, os, entry);
+
+	if ((auth_pid = fork_child_nonfatal(PROC_AUTH, &new_auth_iev,
+	    parent_dispatch_child)) == -1) {
+		log_warnx("session %u: no auth-worker available, this "
+		    "session's first AUTHENTICATE will fail instead of the "
+		    "connection itself (listener-worker detects a missing "
+		    "auth peer the same way it detects one dying later)",
+		    session_id);
+		/* os->auth_iev/auth_pid stay 0/NULL; nothing to wire below. */
+	} else {
+		os->auth_pid = auth_pid;
+		os->auth_iev = new_auth_iev;
+		/* id 0: listener-worker and auth-worker have exactly one peer each for their whole (short) life, no discriminator needed on either side -- see setup_peer_send()'s own comment */
+		if (send_auth_init(new_auth_iev, gconf) == -1 ||
+		    setup_peer_send(new_listener_iev, new_auth_iev, 0) == -1)
+			goto fail_close;
+	}
+
+	/*
+	 * SS8.1: search-oracle, forked and wired the same fail-soft way as
+	 * auth-worker just above -- a missing oracle degrades this session's
+	 * SEARCH to "NO [UNAVAILABLE]" rather than the connection itself. No
+	 * config to send it (unlike send_auth_init() above): search_oracle.c
+	 * needs none, see its own file header comment.
+	 */
+	if ((search_pid = fork_child_nonfatal(PROC_SEARCH, &new_search_iev,
+	    parent_dispatch_child)) == -1) {
+		log_warnx("session %u: no search-oracle available, this "
+		    "session's SEARCH will fail until a new connection gets "
+		    "one (listener-worker detects a missing search peer the "
+		    "same way it detects one dying later)", session_id);
+		/* os->search_pid stays 0; nothing to wire below. */
+	} else {
+		os->search_pid = search_pid;
+		if (setup_search_peer_send(new_listener_iev, new_search_iev)
+		    == -1)
+			goto fail_close;
+	}
+
+	/* id session_id: keymgr is a long-lived, multi-peer process now (keymgr.c), and needs it to know which peer entry this is */
+	if (setup_peer_send(new_listener_iev, iev_keymgr, session_id) == -1)
+		goto fail_close;
+
+	memset(&init, 0, sizeof(init));
+	init.session_id = session_id;
+	init.implicit_tls = implicit_tls;
+	init.remote_ss = *ss;
+	init.remote_sslen = sslen;
+	/* read fresh from gconf per connection, so a SIGHUP that changes
+	 * "idle poll" reaches every later connection -- see sighup_handler() */
+	init.idle_poll_secs = gconf->idle_poll_secs;
+	if (imsg_compose(&new_listener_iev->ibuf, IMSG_LISTENER_SESSION_INIT,
+	    0, 0, client_fd, &init, sizeof(init)) == -1) {
+		log_warnx("session %u: imsg_compose "
+		    "IMSG_LISTENER_SESSION_INIT failed, connection lost",
+		    session_id);
+		close(client_fd);
+		goto fail_workers;
+	}
+	/*
+	 * From here on client_fd belongs to imsg (ibuf_fd_set(3) took it on
+	 * the successful compose above, and libutil closes it after
+	 * sendmsg(2)) -- so the unwind below must NOT close it. Hence two
+	 * labels rather than one.
+	 */
+	if (send_tls_cert(new_listener_iev, gconf) == -1)
+		goto fail_workers;
+	return;
+
+fail_close:
+	close(client_fd);	/* not yet handed to imsg; still ours */
+fail_workers:
+	/*
+	 * Fail this connection, not the daemon. The workers forked above
+	 * are killed; everything else is left to reap_child(), which
+	 * removes and frees this open_session when the listener-worker's
+	 * pid exits -- exactly what the TAILQ_INSERT_TAIL comment above
+	 * says it is there for. Do NOT free(os) here: reap_child() owns
+	 * it from the moment it was inserted.
+	 */
+	log_warnx("session %u: refusing connection: worker wiring failed",
+	    session_id);
+	kill(os->listener_pid, SIGKILL);
+	if (os->auth_pid != 0)
+		kill(os->auth_pid, SIGKILL);
+	if (os->search_pid != 0)
+		kill(os->search_pid, SIGKILL);
+}
+
 /* rejects a maildir that could escape the spool subtree: NULL/empty, absolute paths, "." / ".." components */
 static int
 maildir_path_is_safe(const char *p)
@@ -358,23 +1039,35 @@ maildir_path_is_safe(const char *p)
 	return (1);
 }
 
-/* per-session store spawn triggered directly by auth's IMSG_AUTH_CRED; mirrors fork_child() but with a timeout, not fatal() */
+/* per-session store spawn triggered directly by auth's IMSG_AUTH_CRED; mirrors fork_child_nonfatal() but with a timeout, not a bare failure return */
 static void
-parent_handle_store_fork(uint32_t session_id, uid_t uid, gid_t gid,
+parent_handle_store_fork(struct open_session *os, uid_t uid, gid_t gid,
     const char *maildir)
 {
 	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;
+	uint32_t		 session_id = os->session_id;
 
-	if (uid == 0 || gid == 0) {
+	/*
+	 * os->listener_iev is who the fail: path reports to, and who the
+	 * new child's fd gets passed to. A session's listener-worker is
+	 * never restarted when it dies (see reap_child()), so this can
+	 * legitimately be NULL if it died in the narrow window between
+	 * auth accepting credentials and this call running.
+	 */
+	if (os->listener_iev == NULL) {
+		log_warnx("refusing store spawn for session %u: "
+		    "listener-worker is gone", session_id);
+		return;
+	}
+
+	if (uid == 0 || gid == 0 ||
+	    uid == (uid_t)-1 || gid == (gid_t)-1) {
 		log_warnx("refusing store spawn: privileged uid=%u gid=%u "
 		    "for session %u", (unsigned)uid, (unsigned)gid,
 		    session_id);
@@ -386,8 +1079,21 @@ parent_handle_store_fork(uint32_t session_id, uid_t ui
 		goto fail;
 	}
 
-	TAILQ_FOREACH(it, &store_children, entry)
+	TAILQ_FOREACH(it, &store_children, entry) {
+		/*
+		 * A second spawn for a live session would hand the listener
+		 * a second IMSG_SETUP_PEER with the same id, and listener.c's
+		 * handler overwrites s->store_iev with a fresh calloc(3) --
+		 * leaking the old struct and its fd and orphaning a store
+		 * child. Only a broken or compromised auth can send one.
+		 */
+		if (it->session_id == session_id) {
+			log_warnx("refusing store spawn: session %u already "
+			    "has a store child", session_id);
+			goto fail;
+		}
 		nchildren++;
+	}
 	if (nchildren >= STORE_CHILD_MAX) {
 		log_warnx("refusing store spawn: %u store children active "
 		    "(session %u)", nchildren, session_id);
@@ -412,36 +1118,42 @@ parent_handle_store_fork(uint32_t session_id, uid_t ui
 			_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);
+		exec_self_as("store");
 	}
 
 	close(pair[1]);
 
 	/* allocated once, in place, sc->iev is never copied afterward (same reason as struct store_child) */
 	sc = calloc(1, sizeof(*sc));
-	if (sc == NULL)
-		fatal("calloc");
+	if (sc == NULL) {
+		/*
+		 * Fail this session, not the daemon: every other failure in
+		 * this function degrades to one NO reply, and a transient
+		 * allocation failure should not be the exception that takes
+		 * the whole service down with it.
+		 */
+		log_warn("calloc store child (session %u)", session_id);
+		kill(pid, SIGKILL);
+		close(pair[0]);
+		goto fail;
+	}
 	sc->session_id = session_id;
 	sc->pid = pid;
 	sc->pending = 1;
-	sc->listener_iev = iev_listener;
+	sc->listener_iev = os->listener_iev;
 	imsgev_init(&sc->iev, pair[0], store_child_dispatch, sc);
 
 	/* IMSG_STORE_INIT: the one message a store child needs that boot-time children don't, runtime privilege target */
+	/*
+	 * memset first: strlcpy(3) writes only up to its NUL, so without
+	 * this the unused tails of spool_root[1024] and maildir[] -- plus
+	 * every byte of inter-field padding -- travel to the store child as
+	 * whatever was on the root parent's stack, roughly a kilobyte per
+	 * session. Every other imsg payload in this file is zeroed first
+	 * (fail_payload below, the sockaddrs in bind_listen_socket()); this
+	 * one was the exception.
+	 */
+	memset(&init_payload, 0, sizeof(init_payload));
 	init_payload.session_id = session_id;
 	init_payload.uid = uid;
 	init_payload.gid = gid;
@@ -469,14 +1181,14 @@ parent_handle_store_fork(uint32_t session_id, uid_t ui
 	}
 
 	/* session_id rides as the imsg "id" field so listener.c can tell which in-flight handshake this fd belongs to */
-	setup_peer_send(&sc->iev, iev_listener, session_id);
+	if (setup_peer_send(&sc->iev, os->listener_iev, session_id) == -1)
+		goto fail_kill;
 
 	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;
@@ -490,18 +1202,40 @@ fail_kill:
 	kill(pid, SIGKILL);
 	event_del(&sc->iev.ev);
 	close(sc->iev.ibuf.fd);	/* match store_child_teardown(); else a setup failure leaks the fd */
+	imsgbuf_clear(&sc->iev.ibuf);	/* imsgbuf_init() allocates; close(2) alone leaks it */
 	free(sc);
 fail:
-	/* fail only this one session: IMSG_STORE_FORK is now solely this failure-reply type, to listener */
+	store_fork_failed(os);
+}
+
+/*
+ * Tell one session's listener-worker that no store child is coming, so it
+ * answers its client instead of waiting in SESSION_STORE_PENDING forever
+ * (there is no inactivity timeout anywhere in a session's life).
+ *
+ * Lifted out of parent_handle_store_fork()'s own fail: tail so that
+ * parent_dispatch_child()'s duplicate-IMSG_AUTH_CRED refusal can reach it
+ * too: that path marks the session authenticated, refuses to spawn a
+ * second store child (correctly -- it is a deliberate SS6.2 control), and
+ * used to return without replying, which hung the waiting listener-worker
+ * for good.
+ */
+static void
+store_fork_failed(struct open_session *os)
+{
+	struct imsg_store_fork	 fail_payload;
+
+	/* fail only this one session: IMSG_STORE_FORK is now solely this failure-reply type, to the session's own listener-worker */
+	if (os->listener_iev == NULL)
+		return;
 	memset(&fail_payload, 0, sizeof(fail_payload));
-	fail_payload.session_id = session_id;
-	if (imsg_compose(&iev_listener->ibuf, IMSG_STORE_FORK, 0, 0, -1,
+	fail_payload.session_id = os->session_id;
+	if (imsg_compose(&os->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(iev_listener);
 }
 
-/* imsgev_init() is EV_READ not EV_PERSIST; unconditional imsgev_add() at the end re-arms unless torn down first */
+/* the trailing imsgev_rearm_read() is not optional; see its definition */
 static void
 store_child_dispatch(int fd, short event, void *arg)
 {
@@ -525,7 +1259,7 @@ store_child_dispatch(int fd, short event, void *arg)
 	}
 
 	for (;;) {
-		if ((n = imsg_get(&sc->iev.ibuf, &imsg)) == -1) {
+		if ((n = imsgbuf_get(&sc->iev.ibuf, &imsg)) == -1) {
 			store_child_fail(sc);
 			return;
 		}
@@ -543,7 +1277,7 @@ store_child_dispatch(int fd, short event, void *arg)
 		    imsg_get_type(&imsg), sc->session_id);
 		imsg_free(&imsg);
 	}
-	imsgev_add(&sc->iev);	/* re-arm, see this function's header comment */
+	imsgev_rearm_read(&sc->iev);
 	(void)fd;
 }
 
@@ -559,25 +1293,33 @@ store_child_timeout(int fd, short event, void *arg)
 	store_child_fail(sc);
 }
 
-/* shared teardown for a store_child, alive or already SIGCHLD-reaped; if sc->pending, tells listener the fork failed */
+/* shared teardown for a store_child, alive or already SIGCHLD-reaped; if sc->pending, tells the store child's own session's listener-worker the fork failed */
 static void
 store_child_teardown(struct store_child *sc, int already_dead)
 {
-	if (sc->pending) {
+	if (sc->pending && sc->listener_iev != NULL) {
 		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);
 	}
+	/*
+	 * Unconditional: pending is cleared in store_child_dispatch() right
+	 * after its own evtimer_del(), so this is a no-op on that path, but
+	 * it stops a future clear-without-del from arming a timer on freed
+	 * memory. Also moved out of the "pending" branch above: sc is torn
+	 * down either way, so the timer must be disarmed either way, not
+	 * only when the listener-worker is reachable to notify.
+	 */
+	evtimer_del(&sc->timeout_ev);
 	if (!already_dead)
 		kill(sc->pid, SIGKILL);
 	event_del(&sc->iev.ev);
 	close(sc->iev.ibuf.fd);
+	imsgbuf_clear(&sc->iev.ibuf);	/* imsgbuf_init() allocates; close(2) alone leaks it */
 	TAILQ_REMOVE(&store_children, sc, entry);
 	free(sc);
 }
@@ -663,34 +1405,21 @@ bind_listen_socket(const char *addr, uint16_t port, in
 	    "IPv4/IPv6 address", addr);
 }
 
-static void
-send_listener_sockets(struct imsgev *iev, int cleartext_fds[],
-    int n_cleartext, int tls_fds[], int n_tls)
+/*
+ * Sends IMSG_TLS_CERT; used for both a new listener-worker (which
+ * needs the certificate for its own tls_config, at spawn_connection()
+ * time) and keymgr (which needs it to compute the certificate's
+ * pubkey hash, see keymgr.c's keymgr_pubkey_hash(), at boot and on
+ * SIGHUP reload). A listener-worker gets a fresh read of the cert
+ * file on every spawn -- see this file's header comment -- so unlike
+ * keymgr there is no separate reload push for it to receive.
+ */
+static int
+send_tls_cert(struct imsgev *iev, struct openimap_config *conf)
 {
-	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");
-}
-
-/* sends IMSG_TLS_CERT/IMSG_TLS_KEY, always both even on failure (zero-length = unusable), so listener never hangs */
-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;
@@ -698,14 +1427,70 @@ send_tls_certs(struct imsgev *iev, struct openimap_con
 		log_warn("fopen %s", conf->tls_cert_file);
 	} else {
 		n = fread(buf, 1, sizeof(buf), fp);
+		/*
+		 * A short buffer used to be sent as if it were the whole
+		 * file, which then failed to parse in the listener with a
+		 * misleading error; listener.c's own size check can never
+		 * fire because the truncation happens here. feof(3) alone
+		 * is not sufficient: if the file is EXACTLY sizeof(buf)
+		 * bytes, fread(3) satisfies the request in a single read
+		 * and never attempts to read past the end, so feof() comes
+		 * back false even though nothing was actually truncated --
+		 * an extra one-byte probe is the only reliable way to tell
+		 * "exactly full" apart from "more data follows".
+		 */
+		if (n == sizeof(buf)) {
+			if (fgetc(fp) != EOF) {
+				log_warnx("%s: larger than %zu bytes, "
+				    "refusing to send a truncated "
+				    "certificate (TLS will be disabled)",
+				    conf->tls_cert_file, sizeof(buf));
+				n = 0;
+			}
+		} else if (!feof(fp)) {
+			log_warnx("%s: short read that wasn't a clean EOF, "
+			    "refusing to send a possibly-truncated "
+			    "certificate (TLS will be disabled)",
+			    conf->tls_cert_file);
+			n = 0;
+		}
 		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");
+	/* Non-fatal: spawn_connection() calls this per connection. */
+	if (imsg_compose(&iev->ibuf, IMSG_TLS_CERT, 0, 0, -1, buf, n) == -1) {
+		log_warn("imsg_compose IMSG_TLS_CERT");
+		return (-1);
+	}
+	if (imsgbuf_flush(&iev->ibuf) == -1) {
+		log_warn("imsgbuf_flush IMSG_TLS_CERT");
+		return (-1);
+	}
+	return (0);
+}
 
+/*
+ * Sends keymgr its copy of IMSG_TLS_CERT (see send_tls_cert() above)
+ * followed by IMSG_KEYMGR_INIT, the real private key. Two composes
+ * rather than one combined blob: cert and key are each capped at
+ * sizeof(buf) (8192) below, and packing both into a single imsg
+ * would leave uncomfortably little headroom under imsg's
+ * MAX_IMSGSIZE (16384) if both were near that cap at once. Always
+ * sends both, even on failure (zero-length = unusable), so keymgr
+ * never hangs waiting -- same convention the old send_tls_certs()
+ * used for listener.
+ */
+static int
+send_keymgr_init(struct imsgev *iev, struct openimap_config *conf)
+{
+	FILE		*fp;
+	char		 buf[8192];
+	size_t		 n;
+	struct stat	 st;
+
+	if (send_tls_cert(iev, conf) == -1)
+		return (-1);
+
 	/* key is private material, permission-checked (uid 0, mode <= 0740, per smtpd's ssl_load_key()) */
 	n = 0;
 	if ((fp = fopen(conf->tls_key_file, "r")) == NULL) {
@@ -723,63 +1508,76 @@ send_tls_certs(struct imsgev *iev, struct openimap_con
 		fclose(fp);
 	} else {
 		n = fread(buf, 1, sizeof(buf), fp);
+		/* see the matching comment in send_tls_cert() -- feof(3)
+		 * alone cannot detect an exactly-sizeof(buf)-byte file */
+		if (n == sizeof(buf)) {
+			if (fgetc(fp) != EOF) {
+				log_warnx("%s: larger than %zu bytes, "
+				    "refusing to send a truncated key "
+				    "(TLS will be disabled)",
+				    conf->tls_key_file, sizeof(buf));
+				n = 0;
+			}
+		} else if (!feof(fp)) {
+			log_warnx("%s: short read that wasn't a clean EOF, "
+			    "refusing to send a possibly-truncated key "
+			    "(TLS will be disabled)", conf->tls_key_file);
+			n = 0;
+		}
 		fclose(fp);
 	}
 
 	/* imsg_compose() copies buf immediately, so it's safe to scrub our stack copy right after this call */
-	if (imsg_compose(&iev->ibuf, IMSG_TLS_KEY, 0, 0, -1, buf, n) == -1) {
+	/*
+	 * Non-fatal: this runs at boot (where parent_main() turns -1 into a
+	 * fatalx(), the old behaviour) and from sighup_handler(), where a
+	 * failed reload push must NOT kill a daemon that is otherwise
+	 * serving fine.
+	 */
+	if (imsg_compose(&iev->ibuf, IMSG_KEYMGR_INIT, 0, 0, -1, buf, n) == -1) {
 		explicit_bzero(buf, sizeof(buf));
-		fatal("imsg_compose IMSG_TLS_KEY");
+		log_warn("imsg_compose IMSG_KEYMGR_INIT");
+		return (-1);
 	}
 	explicit_bzero(buf, sizeof(buf));
-	if (imsgbuf_flush(&iev->ibuf) == -1)
-		fatal("imsgbuf_flush");
+	if (imsgbuf_flush(&iev->ibuf) == -1) {
+		log_warn("imsgbuf_flush IMSG_KEYMGR_INIT");
+		return (-1);
+	}
+	return (0);
 }
 
-/* IMSG_LISTENER_INIT: n_cleartext_addrs/n_tls_addrs tell listener's boot-time drain loop how many sockets to expect */
-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));
-	if (strlcpy(init.listen_addr, conf->listen_addr,
-	    sizeof(init.listen_addr)) >= sizeof(init.listen_addr))
-		fatalx("listen_addr truncated sending IMSG_LISTENER_INIT, "
-		    "config value too long for the wire struct's field");
-	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, to derive its chroot dir and unveil() path */
-static void
+/* IMSG_AUTH_INIT: auth's slice of config, just cred_file, to derive its chroot dir and unveil() path; sent once per auth-worker spawn now (spawn_connection()), not just once at boot -- cred_file doesn't vary per connection, so the payload itself is unchanged */
+static int
 send_auth_init(struct imsgev *iev, struct openimap_config *conf)
 {
 	struct imsg_auth_init	 init;
 
+	/* Non-fatal: spawn_connection() is now the only caller, once per
+	 * connection. (The truncation check below cannot fire today --
+	 * both fields are 1024 bytes -- but it stays as the wire-struct
+	 * invariant it was written to be.) */
 	memset(&init, 0, sizeof(init));
 	if (strlcpy(init.cred_file, conf->cred_file, sizeof(init.cred_file))
-	    >= sizeof(init.cred_file))
-		fatalx("cred_file truncated sending IMSG_AUTH_INIT, config "
+	    >= sizeof(init.cred_file)) {
+		log_warnx("cred_file truncated sending IMSG_AUTH_INIT, config "
 		    "value too long for the wire struct's field");
+		return (-1);
+	}
 
 	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");
+	    &init, sizeof(init)) == -1) {
+		log_warn("imsg_compose IMSG_AUTH_INIT");
+		return (-1);
+	}
+	if (imsgbuf_flush(&iev->ibuf) == -1) {
+		log_warn("imsgbuf_flush IMSG_AUTH_INIT");
+		return (-1);
+	}
+	return (0);
 }
 
-/* SIGHUP: reloads imapd.conf; listen_addr/port/cred_file can't be swapped live (sockets bound, auth chrooted) and warn instead */
+/* SIGHUP: reloads imapd.conf; listen_addr/port/cred_file can't be swapped live (sockets bound, auth chrooted per-connection but chroot dir is still derived from cred_file) and warn instead */
 static void
 sighup_handler(int fd, short event, void *arg)
 {
@@ -806,9 +1604,9 @@ sighup_handler(int fd, short event, void *arg)
 		    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);
+		log_warnx("SIGHUP: %s: \"credentials\" changed but each "
+		    "auth-worker chroots to the old path's directory, a "
+		    "restart is required for this to take effect", conf_path);
 	}
 
 	/* checked before writing into gconf: fields are overwritten in place with no saved-old-value to restore */
@@ -825,13 +1623,34 @@ sighup_handler(int fd, short event, void *arg)
 	(void)strlcpy(gconf->spool_root, newconf.spool_root,
 	    sizeof(gconf->spool_root));
 	gconf->bodystructure_read_max = newconf.bodystructure_read_max;
+	gconf->idle_poll_secs = newconf.idle_poll_secs;
+	/*
+	 * No corresponding push needed for either of these any more
+	 * (see this file's header comment): startups_should_drop()
+	 * reads gconf->max_startups_* directly on every parent_accept(),
+	 * and spawn_connection() reads gconf->tls_cert_file fresh via
+	 * send_tls_cert() on every spawn -- both already pick up
+	 * whatever was just written here on the very next connection.
+	 */
+	gconf->max_startups_begin = newconf.max_startups_begin;
+	gconf->max_startups_rate = newconf.max_startups_rate;
+	gconf->max_startups_full = newconf.max_startups_full;
 
 	(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);
 
+	/* keymgr is still the one long-lived child that needs an explicit reload push -- it holds the loaded private key for every existing and future connection's private-key ops, unlike a listener-worker's one-shot cert read above. */
+	if (iev_keymgr != NULL) {
+		if (send_keymgr_init(iev_keymgr, gconf) == -1)
+			log_warnx("SIGHUP: pushing the reloaded certificate "
+			    "and key to keymgr failed; it is still using the "
+			    "previously loaded material");
+	} else
+		log_warnx("SIGHUP: keymgr is gone, TLS private key not "
+		    "reloaded");
+
 	log_info("SIGHUP: reload complete");
 }
 
@@ -863,7 +1682,17 @@ sigchld_handler(int fd, short event, void *arg)
 		reap_child(pid, status);
 }
 
-/* children (listener/auth) reaching here always means an unexpected exit; deliberately not auto-restarted, log and degrade */
+/*
+ * children reaching here always means an unexpected exit. keymgr
+ * (the one remaining boot-time, daemon-lifetime child) is
+ * deliberately not auto-restarted, same as before SS7: log and
+ * degrade daemon-wide. A per-connection listener-worker, auth-worker,
+ * or search-oracle (SS8.1) exiting is the ordinary, expected way one
+ * connection's resources get reclaimed (a clean session end included,
+ * not just a crash) -- see this file's header comment -- so those are
+ * logged at debug level and only ever affect their own open_session
+ * entry.
+ */
 static void
 reap_child(pid_t pid, int status)
 {
@@ -872,30 +1701,98 @@ reap_child(pid_t pid, int status)
 
 	TAILQ_FOREACH(c, &children, entry) {
 		if (c->pid == pid) {
-			const char	*what;
+			struct open_session	*os = NULL;
 
-			/* listener's death takes down IMAP entirely; auth's death only breaks new logins */
-			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);
+			event_del(&c->iev.ev);
+			close(c->iev.ibuf.fd);
+			imsgbuf_clear(&c->iev.ibuf);
+
+			if (c->type == PROC_KEYMGR) {
+				if (WIFSIGNALED(status))
+					log_warnx("keymgr[%d] terminated by "
+					    "signal %d, new TLS handshakes "
+					    "will now fail (already-"
+					    "negotiated TLS sessions are "
+					    "unaffected); run \"rcctl "
+					    "restart imapd\" to recover",
+					    pid, WTERMSIG(status));
+				else if (WIFEXITED(status))
+					log_warnx("keymgr[%d] exited "
+					    "unexpectedly, status %d, new "
+					    "TLS handshakes will now fail "
+					    "(already-negotiated TLS "
+					    "sessions are unaffected); run "
+					    "\"rcctl restart imapd\" to "
+					    "recover", pid,
+					    WEXITSTATUS(status));
+				else
+					log_warnx("keymgr[%d] exited "
+					    "unexpectedly, new TLS "
+					    "handshakes will now fail "
+					    "(already-negotiated TLS "
+					    "sessions are unaffected); run "
+					    "\"rcctl restart imapd\" to "
+					    "recover", pid);
+				iev_keymgr = NULL;
+				free(c);
+				return;
+			}
+
+			/* PROC_LISTENER, PROC_AUTH, or PROC_SEARCH: one of spawn_connection()'s per-connection group (SS8.1 added search-oracle as a third member). Find the open_session it belonged to, if it's still tracked. */
+			TAILQ_FOREACH(os, &open_sessions, entry) {
+				if ((c->type == PROC_LISTENER &&
+				    os->listener_pid == pid) ||
+				    (c->type == PROC_AUTH &&
+				    os->auth_pid == pid) ||
+				    (c->type == PROC_SEARCH &&
+				    os->search_pid == pid))
+					break;
+			}
+			if (os == NULL) {
+				log_debug("%s[%d] exited, no matching "
+				    "open_session (already torn down)",
+				    log_procname(c->type), pid);
+				free(c);
+				return;
+			}
+
+			if (c->type == PROC_LISTENER) {
+				struct store_child	*s;
+
+				log_debug("session %u: listener-worker[%d] "
+				    "exited (status %d), session closed",
+				    os->session_id, pid, status);
+				/* the paired auth-worker and search-oracle, if
+				 * still alive, are each left to notice their
+				 * own peer channel EOF and exit on their own --
+				 * parent isn't in that data path
+				 * (setup_peer_send()/setup_search_peer_send()
+				 * wired them directly to listener-worker). */
+				os->listener_iev = NULL;
+				TAILQ_FOREACH(s, &store_children, entry) {
+					if (s->session_id == os->session_id)
+						s->listener_iev = NULL;
+				}
+				TAILQ_REMOVE(&open_sessions, os, entry);
+				free(os);
+			} else if (c->type == PROC_AUTH) {
+				log_debug("session %u: auth-worker[%d] "
+				    "exited (status %d); this session's "
+				    "listener-worker will detect this on "
+				    "its own auth channel", os->session_id,
+				    pid, status);
+				os->auth_iev = NULL;
+				os->auth_pid = 0;
+			} else {
+				log_debug("session %u: search-oracle[%d] "
+				    "exited (status %d); this session's "
+				    "listener-worker will detect this on "
+				    "its own search channel", os->session_id,
+				    pid, status);
+				os->search_pid = 0;
+			}
+
 			free(c);
 			return;
 		}
blob - 3833ac0b7d22c93dcfaa39efaa5c0ea8d2b8894f
blob + 14149db07500dc2c1e59623b7d51d131ead3a318
--- src/parse.y
+++ src/parse.y
@@ -102,6 +102,8 @@ typedef struct {
 %token	LISTEN ON TLS PORT
 %token	SPOOL CREDENTIALS CERTIFICATE KEY
 %token	ATTACHMENT MAX
+%token	STARTUPS BEGIN RATE FULL
+%token	IDLE POLL
 %token	INCLUDE
 %token	ERROR
 %token	<v.string>	STRING
@@ -121,7 +123,16 @@ grammar		: /* empty */
 include		: INCLUDE STRING		{
 			struct file	*nfile;
 
-			if ((nfile = pushfile($2, 0)) == NULL) {
+			/*
+			 * secret=1, matching config_load()'s own pushfile():
+			 * an included file can set "tls key" and
+			 * "credentials", so it gets the same ownership and
+			 * permission check the main config gets. iked and
+			 * ldapd -- the two base parsers whose configs also
+			 * hold key material -- pass 1 here for the same
+			 * reason; httpd and smtpd check neither file.
+			 */
+			if ((nfile = pushfile($2, 1)) == NULL) {
 				yyerror("failed to include file %s", $2);
 				free($2);
 				YYERROR;
@@ -256,6 +267,24 @@ main		: LISTEN ON STRING opttls PORT NUMBER	{
 			}
 			free($3);
 		}
+		| IDLE POLL NUMBER		{
+			/*
+			 * 0 is legal and means "do not poll": that restores
+			 * the behaviour this directive replaced, where an
+			 * IDLEing session saw nothing until it sent DONE. It
+			 * is here so an operator who dislikes the polling can
+			 * turn it off without patching, not because it is a
+			 * sensible thing to want.
+			 */
+			if ($3 < 0 || $3 > IDLE_POLL_MAX) {
+				yyerror("idle poll out of range "
+				    "(0 to disable, otherwise 1-%d "
+				    "seconds): %lld", IDLE_POLL_MAX,
+				    (long long)$3);
+				YYERROR;
+			}
+			conf->idle_poll_secs = (uint32_t)$3;
+		}
 		| ATTACHMENT MAX NUMBER	{
 			/*
 			 * Range-validated
@@ -268,6 +297,43 @@ main		: LISTEN ON STRING opttls PORT NUMBER	{
 			}
 			conf->bodystructure_read_max = (uint32_t)$3;
 		}
+		| STARTUPS BEGIN NUMBER RATE NUMBER FULL NUMBER {
+			/*
+			 * SS7's admission-control throttle, modeled on
+			 * sshd_config(5)'s MaxStartups: below "begin" open
+			 * connections, every new one is accepted; between
+			 * "begin" and "full", new ones are refused with
+			 * linearly increasing probability starting at
+			 * "rate" percent; at or above "full", every new one
+			 * is refused. See parent.c's startups_should_drop()
+			 * (moved there with the accept loop; see parent.c's
+			 * header comment).
+			 */
+			if ($3 < 0 || $3 > 1000000) {
+				yyerror("startups begin out of range "
+				    "(0-1000000): %lld", (long long)$3);
+				YYERROR;
+			}
+			if ($5 < 0 || $5 > 100) {
+				yyerror("startups rate must be a percentage "
+				    "(0-100): %lld", (long long)$5);
+				YYERROR;
+			}
+			if ($7 < 0 || $7 > 1000000) {
+				yyerror("startups full out of range "
+				    "(0-1000000): %lld", (long long)$7);
+				YYERROR;
+			}
+			if ($7 < $3) {
+				yyerror("startups full (%lld) must be >= "
+				    "begin (%lld)", (long long)$7,
+				    (long long)$3);
+				YYERROR;
+			}
+			conf->max_startups_begin = (uint32_t)$3;
+			conf->max_startups_rate = (uint32_t)$5;
+			conf->max_startups_full = (uint32_t)$7;
+		}
 		;
 
 %%
@@ -305,15 +371,21 @@ lookup(char *s)
 	/* this has to be sorted always */
 	static const struct keywords keywords[] = {
 	    {"attachment",		ATTACHMENT},
+	    {"begin",			BEGIN},
 	    {"certificate",		CERTIFICATE},
 	    {"credentials",		CREDENTIALS},
+	    {"full",			FULL},
+	    {"idle",			IDLE},
 	    {"include",			INCLUDE},
 	    {"key",			KEY},
 	    {"listen",			LISTEN},
 	    {"max",			MAX},
 	    {"on",			ON},
+	    {"poll",			POLL},
 	    {"port",			PORT},
+	    {"rate",			RATE},
 	    {"spool",			SPOOL},
+	    {"startups",		STARTUPS},
 	    {"tls",			TLS},
 	};
 	const struct keywords	*p;
@@ -534,6 +606,29 @@ top:
 			return (NUMBER);
 		} else {
 nodigits:
+			/*
+			 * This un-reads the whole token so the string scanner
+			 * below can re-lex it, but lungetc() drops characters
+			 * silently once pushback_buffer fills (MAXPUSHBACK is
+			 * 128, buf is sizeof(buf)) -- and pushback is LIFO, so
+			 * the loss excises the MIDDLE of the token and the
+			 * lexer proceeds on input the file does not contain.
+			 * Refuse it instead, with the same error the two
+			 * length checks above use. (Newer revisions of the
+			 * shared OpenBSD parse.y skeleton grow a per-file
+			 * ungetbuf instead of dropping; this file predates
+			 * that.)
+			 *
+			 * The loop below pushes (p - buf) - 1 characters and
+			 * lungetc() accepts MAXPUSHBACK - 1 of them, so the
+			 * exact bound is "p - buf > MAXPUSHBACK"; this is
+			 * deliberately one stricter, which costs nothing and
+			 * removes an off-by-one to get wrong.
+			 */
+			if ((size_t)(p - buf) >= MAXPUSHBACK) {
+				yyerror("string too long");
+				return (findeol());
+			}
 			while (p > buf + 1)
 				lungetc((unsigned char)*--p);
 			c = (unsigned char)*--p;
@@ -660,6 +755,7 @@ config_load(const char *path, struct openimap_config *
 	(void)strlcpy(conf->listen_addr, "0.0.0.0", sizeof(conf->listen_addr));
 	conf->port_cleartext = 143;
 	conf->port_implicit_tls = 993;
+	conf->idle_poll_secs = IDLE_POLL_DEFAULT;
 	(void)strlcpy(conf->spool_root, "/var/mail/imapd",
 	    sizeof(conf->spool_root));
 	(void)strlcpy(conf->cred_file, "/etc/imapd/credentials",
@@ -670,6 +766,11 @@ config_load(const char *path, struct openimap_config *
 	    sizeof(conf->tls_key_file));
 	conf->bodystructure_read_max = BODYSTRUCTURE_READ_DEFAULT;
 
+	/* sshd_config(5)'s own MaxStartups default is "10:30:100". */
+	conf->max_startups_begin = 10;
+	conf->max_startups_rate = 30;
+	conf->max_startups_full = 100;
+
 	have_cleartext = 0;
 	have_tls_listen = 0;
 	errors = 0;
@@ -682,6 +783,23 @@ config_load(const char *path, struct openimap_config *
 	errors = file->errors;
 	popfile();
 
+	/*
+	 * Carry the listener selection into the config. Both ports were
+	 * seeded with their defaults above; clear the one this config did not
+	 * ask for, so parent.c does not bind it.
+	 *
+	 * The guard matters: a config with NO "listen" line at all keeps both
+	 * defaults, which is what such a config has always produced. Only a
+	 * config that names a listener is taken to be selecting listeners --
+	 * which is what imapd.conf's syntax reads as, and previously was not.
+	 */
+	if (have_cleartext || have_tls_listen) {
+		if (!have_cleartext)
+			conf->port_cleartext = 0;
+		if (!have_tls_listen)
+			conf->port_implicit_tls = 0;
+	}
+
 	/* Free macros and warn about any that were never referenced. */
 	TAILQ_FOREACH_SAFE(sym, &symhead, entry, next) {
 		if (!sym->used)
blob - b3a1b82e5edd90490b6a9157af573b2455daa5eb
blob + 4b33680e9f912e510aa49c9027ffdd3f57f6b973
--- src/rc.d/imapd
+++ src/rc.d/imapd
@@ -17,22 +17,38 @@ rc_pre() {
 
 	[[ -f ${_relink_tar} ]] || return 0
 
+	# mktemp -d is safe on its own: it creates the directory atomically,
+	# 0700 and root-owned, under a name nobody can predict. Everything this
+	# function writes goes INSIDE it for that reason.
+	#
+	# This used to redirect to a fixed /tmp/imapd-relink.log, which was a
+	# symlink target: rc_pre() runs as root, and on every "rcctl start" or
+	# "rcctl restart" -- not only at boot -- so any local user could plant
+	# that name as a link to /etc/master.passwd or anything else and have
+	# ">" truncate it. The sticky bit on /tmp does not prevent creating a
+	# name that does not exist yet, and an unlink first would only narrow
+	# the window (OpenBSD ports guide, "Security Recommendations").
 	_tmp=$(mktemp -d /tmp/imapd-relink.XXXXXXXXXX) || {
 		logger -t imapd -p daemon.err \
-		    "boot-time relink: mktemp failed, skipping (${_relink_tar} left in place)"
+		    "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
+	    >"${_tmp}/relink.log" 2>&1; then
 		rm -f "${_relink_tar}"
+		rm -rf "${_tmp}"
 		logger -t imapd -p daemon.info \
-		    "boot-time relink applied successfully"
+		    "relink applied successfully"
 	else
+		# Deliberately NOT cleaned up: the log is the only diagnostic,
+		# and ${_tmp} is the one place it can be left that an attacker
+		# can neither read nor pre-create. One directory per failed
+		# relink, alongside the ${_relink_tar} that is also kept.
 		logger -t imapd -p daemon.err \
-		    "boot-time relink failed -- starting previously installed binary; see /tmp/imapd-relink.log and ${_relink_tar}"
+		    "relink failed -- starting previously installed binary;" \
+		    "output in ${_tmp}/relink.log, ${_relink_tar} left in place"
 	fi
-	rm -rf "${_tmp}"
 	return 0
 }
 
blob - 21cf4cd6b50c99435f432d649865f16d0c19b57b
blob + 178c548bc9999be2067d74a90196fc79526e4f7b
--- src/search_cmd.c
+++ src/search_cmd.c
@@ -101,13 +101,36 @@ search_push(struct search_parse_ctx *ctx, const struct
 	return (0);
 }
 
-/* reads one sequence-set token, rejects an internal comma, via parse_seq_range() */
+/*
+ * Reads one sequence-set token -- RFC 9051 SS9's full grammar, a
+ * comma-separated list of ranges/bare numbers, not just one -- and
+ * pushes it as one or more SEARCH_OP_SEQSET/SEARCH_OP_UIDSET leaf
+ * nodes OR'd together left to right, the same postfix-combinator
+ * technique parse_search_key_list() below already uses to AND
+ * multiple space-separated keys together. No new size cap is needed
+ * for the range count: SEARCH_PROGRAM_MAX_NODES (already enforced by
+ * search_push()) bounds the whole program long before
+ * parse_sequence_set()'s own SEQSET_MAX_RANGES (500) would matter.
+ *
+ * The token is copied out first rather than NUL-terminated in place
+ * the way FETCH/STORE terminate their own sequence-set token: here it
+ * can sit right before a ')' that parse_search_key_list()'s caller
+ * still needs to see (e.g. "SEARCH (1,3,5)"), and clobbering that
+ * with a NUL would misparse the list as unterminated. The copy buffer
+ * is sized off SESSION_INBUF_MAX (listener.h) -- the existing hard
+ * cap on an entire command line -- so it can never truncate a token a
+ * client could actually send.
+ */
 static int
-parse_search_seqset_token(char **pp, uint32_t *lo, uint32_t *hi,
-    int *lo_star, int *hi_star, const char **errmsg)
+parse_search_seqset(char **pp, struct search_parse_ctx *ctx, int op,
+    const char **errmsg)
 {
-	char		*p = *pp;
-	const char	*start = p;
+	char			*p = *pp;
+	const char		*start = p;
+	char			 tok[SESSION_INBUF_MAX];
+	size_t			 len;
+	struct seq_range	 ranges[SEQSET_MAX_RANGES];
+	uint32_t		 nranges, i;
 
 	while (*p != '\0' && *p != ' ' && *p != ')')
 		p++;
@@ -117,27 +140,36 @@ parse_search_seqset_token(char **pp, uint32_t *lo, uin
 		return (-1);
 	}
 
-	{
-		char	tok[32];
-		size_t	len = (size_t)(p - start);
+	len = (size_t)(p - start);
+	if (len >= sizeof(tok)) {
+		*errmsg = "sequence set too long";
+		return (-1);
+	}
+	memcpy(tok, start, len);
+	tok[len] = '\0';
 
-		if (len >= sizeof(tok)) {
-			*errmsg = "sequence set too long";
-			return (-1);
-		}
-		memcpy(tok, start, len);
-		tok[len] = '\0';
+	if (parse_sequence_set(tok, ranges, &nranges, errmsg) == -1)
+		return (-1);
 
-		if (strchr(tok, ',') != NULL) {
-			*errmsg = "comma-separated sequence sets not "
-			    "supported, issue separate SEARCH "
-			    "commands";
-			return (-1);
-		}
+	for (i = 0; i < nranges; i++) {
+		struct search_node	node;
 
-		if (parse_seq_range(tok, lo, hi, lo_star, hi_star) == -1) {
-			*errmsg = "invalid sequence set";
+		memset(&node, 0, sizeof(node));
+		node.op = op;
+		node.seq_lo = ranges[i].lo;
+		node.seq_hi = ranges[i].hi;
+		node.lo_is_star = ranges[i].lo_is_star;
+		node.hi_is_star = ranges[i].hi_is_star;
+		if (search_push(ctx, &node, errmsg) == -1)
 			return (-1);
+
+		if (i > 0) {
+			struct search_node	combine;
+
+			memset(&combine, 0, sizeof(combine));
+			combine.op = SEARCH_OP_OR;
+			if (search_push(ctx, &combine, errmsg) == -1)
+				return (-1);
 		}
 	}
 
@@ -163,6 +195,29 @@ parse_search_key(char **pp, struct search_parse_ctx *c
 }
 
 
+/* Skips leading spaces, then reads one space/')'-terminated token from *pp into buf (bounded, NUL-terminated); shared "empty or too long" bounds check for KEYWORD/UNKEYWORD, BEFORE/ON/SINCE's unquoted date, LARGER/SMALLER, and MODSEQ's value below. Returns 0 on success, -1 (with *errmsg set to errtext) if the token is empty or doesn't fit in buf. */
+static int
+read_search_token(char **pp, char *buf, size_t bufsize, const char *errtext,
+    const char **errmsg)
+{
+	char		*p = *pp;
+	const char	*start;
+
+	while (*p == ' ')
+		p++;
+	start = p;
+	while (*p != '\0' && *p != ' ' && *p != ')')
+		p++;
+	if (p == start || (size_t)(p - start) >= bufsize) {
+		*errmsg = errtext;
+		return (-1);
+	}
+	memcpy(buf, start, (size_t)(p - start));
+	buf[p - start] = '\0';
+	*pp = p;
+	return (0);
+}
+
 int
 parse_search_key_inner(char **pp, struct search_parse_ctx *ctx,
     const char **errmsg)
@@ -198,15 +253,9 @@ parse_search_key_inner(char **pp, struct search_parse_
 	}
 
 	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)
+		if (parse_search_seqset(&p, ctx, SEARCH_OP_SEQSET, errmsg) ==
+		    -1)
 			return (-1);
-		if (search_push(ctx, &node, errmsg) == -1)
-			return (-1);
 		*pp = p;
 		return (0);
 	}
@@ -271,24 +320,15 @@ parse_search_key_inner(char **pp, struct search_parse_
 	if (strcasecmp(word, "KEYWORD") == 0 ||
 	    strcasecmp(word, "UNKEYWORD") == 0) {
 		struct search_node	node;
-		const 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";
+		if (read_search_token(&p, node.keyword, sizeof(node.keyword),
+		    "missing or too-long KEYWORD/UNKEYWORD argument",
+		    errmsg) == -1)
 			return (-1);
-		}
-		memcpy(node.keyword, start, (size_t)(p - start));
-		node.keyword[p - start] = '\0';
 
 		if (search_push(ctx, &node, errmsg) == -1)
 			return (-1);
@@ -329,18 +369,9 @@ parse_search_key_inner(char **pp, struct search_parse_
 			datebuf[len] = '\0';
 			p = end + 1;
 		} else {
-			const 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";
+			if (read_search_token(&p, datebuf, sizeof(datebuf),
+			    "malformed date", errmsg) == -1)
 				return (-1);
-			}
-			memcpy(datebuf, start, len);
-			datebuf[len] = '\0';
 		}
 
 		if (parse_search_date(datebuf, &node.num) == -1) {
@@ -358,7 +389,6 @@ parse_search_key_inner(char **pp, struct search_parse_
 	    strcasecmp(word, "SMALLER") == 0) {
 		struct search_node	node;
 		char			numbuf[24];
-		const char		*start;
 		char			*numend;
 		unsigned long long	 v;
 
@@ -366,17 +396,9 @@ parse_search_key_inner(char **pp, struct search_parse_
 		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";
+		if (read_search_token(&p, numbuf, sizeof(numbuf),
+		    "missing or malformed octet count", errmsg) == -1)
 			return (-1);
-		}
-		memcpy(numbuf, start, (size_t)(p - start));
-		numbuf[p - start] = '\0';
 
 		errno = 0;
 		v = strtoull(numbuf, &numend, 10);
@@ -384,6 +406,16 @@ parse_search_key_inner(char **pp, struct search_parse_
 			*errmsg = "malformed octet count";
 			return (-1);
 		}
+		/*
+		 * node.num is int64_t and mbox_search.c compares it signed, so
+		 * an unchecked cast turns "LARGER 18446744073709551615" into
+		 * "size > -1", i.e. every message matches.  numbuf holds 23
+		 * digits and ULLONG_MAX is 20, so this is reachable.
+		 */
+		if (v > (unsigned long long)INT64_MAX) {
+			*errmsg = "octet count out of range";
+			return (-1);
+		}
 		node.num = (int64_t)v;
 
 		if (search_push(ctx, &node, errmsg) == -1)
@@ -393,19 +425,11 @@ parse_search_key_inner(char **pp, struct search_parse_
 	}
 
 	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)
+		if (parse_search_seqset(&p, ctx, SEARCH_OP_UIDSET, errmsg) ==
+		    -1)
 			return (-1);
-
-		if (search_push(ctx, &node, errmsg) == -1)
-			return (-1);
 		*pp = p;
 		return (0);
 	}
@@ -413,7 +437,6 @@ parse_search_key_inner(char **pp, struct search_parse_
 	if (strcasecmp(word, "MODSEQ") == 0) {
 		struct search_node	node;
 		char			numbuf[24];
-		const char		*start;
 		char			*numend;
 		unsigned long long	 v;
 
@@ -459,15 +482,9 @@ parse_search_key_inner(char **pp, struct search_parse_
 				p++;
 		}
 
-		start = p;
-		while (*p != '\0' && *p != ' ' && *p != ')')
-			p++;
-		if (p == start || (size_t)(p - start) >= sizeof(numbuf)) {
-			*errmsg = "missing or malformed MODSEQ value";
+		if (read_search_token(&p, numbuf, sizeof(numbuf),
+		    "missing or malformed MODSEQ value", errmsg) == -1)
 			return (-1);
-		}
-		memcpy(numbuf, start, (size_t)(p - start));
-		numbuf[p - start] = '\0';
 
 		errno = 0;
 		v = strtoull(numbuf, &numend, 10);
@@ -475,6 +492,11 @@ parse_search_key_inner(char **pp, struct search_parse_
 			*errmsg = "malformed MODSEQ value";
 			return (-1);
 		}
+		/* same signed-cast trap as LARGER/SMALLER above */
+		if (v > (unsigned long long)INT64_MAX) {
+			*errmsg = "MODSEQ value out of range";
+			return (-1);
+		}
 		node.num = (int64_t)v;
 
 		if (search_push(ctx, &node, errmsg) == -1)
@@ -592,6 +614,51 @@ parse_search_key_list(char **pp, struct search_parse_c
 	}
 }
 
+/*
+ * search_oracle.c's one entry point into this file's otherwise-
+ * private struct search_parse_ctx (docs/openimap-tls-privsep-
+ * design.md SS8.1's per-connection SEARCH-parsing oracle):
+ * parses already-buffered SEARCH argument text (RETURN/CHARSET
+ * already stripped by search_dispatch()) into nodes_out, a
+ * caller-supplied buffer of at least SEARCH_PROGRAM_MAX_NODES
+ * struct search_node elements -- struct search_parse_ctx itself
+ * never crosses this boundary, staying private to this file
+ * exactly as before this split. Returns parse_search_key_list()'s
+ * own rc (0 ok, -1 BAD, -2 NO), copying its errmsg into
+ * errmsg_out/errmsg_outsize whenever rc != 0. The "SEARCH
+ * requires search criteria" empty-result check that used to
+ * live in search_dispatch() moves here too: it's grammar
+ * validation like everything else in this function, not
+ * protocol/session handling.
+ */
+int
+search_oracle_parse(char *args, struct search_node *nodes_out,
+    uint32_t *nnodes_out, int *uses_modseq_out, char *errmsg_out,
+    size_t errmsg_outsize)
+{
+	struct search_parse_ctx	 ctx;
+	char			*p = args;
+	const char		*errmsg = NULL;
+	int			 rc;
+
+	memset(&ctx, 0, sizeof(ctx));
+	rc = parse_search_key_list(&p, &ctx, &errmsg, 0);
+	if (rc == 0 && ctx.n == 0) {
+		errmsg = "SEARCH requires search criteria";
+		rc = -1;
+	}
+	if (rc != 0) {
+		strlcpy(errmsg_out, errmsg != NULL ? errmsg : "",
+		    errmsg_outsize);
+		return (rc);
+	}
+
+	memcpy(nodes_out, ctx.nodes, ctx.n * sizeof(*nodes_out));
+	*nnodes_out = ctx.n;
+	*uses_modseq_out = ctx.uses_modseq;
+	return (0);
+}
+
 /* RFC 9051 SS6.4.4 search-return-opts; SAVE ("$" result variable) recognized but rejected -2/NO, needs cross-cutting support elsewhere */
 int
 parse_search_return_opts(char **pp, uint32_t *opts_out, const char **errmsg)
@@ -665,7 +732,6 @@ cmd_search(struct session *s, const char *tag, char *a
 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;
@@ -739,22 +805,6 @@ search_dispatch(struct session *s, const char *tag, ch
 			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 invariant check as cmd_fetch()/cmd_store_cmd() */
 		log_warnx("session %u: %s with no store channel wired",
@@ -763,6 +813,28 @@ search_dispatch(struct session *s, const char *tag, ch
 		return (1);
 	}
 
+	/*
+	 * SS8.1: grammar parsing itself no longer happens in this process --
+	 * p (whatever's left after RETURN/CHARSET, the highest-risk part of
+	 * the SEARCH grammar) is handed to the per-connection search-oracle
+	 * instead, same fail-soft "unwired channel" check auth_cmd.c's
+	 * sasl_plain_finish() already uses for iev_auth. The old inline
+	 * parse_search_key_list() call and everything that used to run
+	 * right after it now live in search_dispatch_finish() (below),
+	 * called from listener_dispatch_search() (listener.c) once
+	 * IMSG_SEARCH_PARSE_RESULT arrives.
+	 */
+	if (strlen(p) >= SEARCH_ORACLE_ARGS_MAX) {
+		session_reply(s, tag, "BAD", "SEARCH criteria too long");
+		return (1);
+	}
+
+	if (iev_search.ibuf.fd == -1) {
+		session_reply(s, tag, "NO",
+		    "[UNAVAILABLE] search temporarily unavailable");
+		return (1);
+	}
+
 	/* defensive cleanup of a previous SEARCH's leftovers; shouldn't actually find anything here */
 	free(s->search_matches);
 	s->search_matches = NULL;
@@ -770,50 +842,85 @@ search_dispatch(struct session *s, const char *tag, ch
 	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 SEARCH including the MODSEQ data item is a CONDSTORE enabling command */
-	if (ctx.uses_modseq)
-		session_condstore_enable(s);
-
 	if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag)) {
 		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
 		return (1);
 	}
+	s->state = SESSION_SEARCH_PARSING;
+
+	if (imsg_compose(&iev_search.ibuf, IMSG_SEARCH_PARSE_REQUEST, 0, 0,
+	    -1, p, strlen(p)) == -1) {
+		/*
+		 * s->state is already SESSION_SEARCH_PARSING, and nothing
+		 * will ever answer a request that was never sent -- the
+		 * oracle has not heard of it. Without this the session
+		 * waits in that state for good (no inactivity timeout
+		 * anywhere), queueing every later command until
+		 * SESSION_CMD_QUEUE_MAX drops the connection.
+		 */
+		log_warn("session %u: imsg_compose IMSG_SEARCH_PARSE_REQUEST",
+		    s->id);
+		session_reply(s, tag, "NO",
+		    "[UNAVAILABLE] search temporarily unavailable");
+		s->state = SESSION_SELECTED;
+		return (1);
+	}
+
+	return (1);
+}
+
+/*
+ * SS8.1: completes search_dispatch() once listener_dispatch_search()
+ * (listener.c) gets this session's IMSG_SEARCH_PARSE_RESULT -- BAD/NO
+ * straight from the oracle's (rc, errmsg) on a rejected parse, or (on
+ * rc == 0) exactly what search_dispatch() itself used to do right after
+ * a successful parse_search_key_list() call, now fed from the oracle's
+ * reply instead of a local struct search_parse_ctx. nodes is non-NULL
+ * only when res->rc == 0 and res->nnodes > 0; the caller (listener.c)
+ * owns freeing it either way, this function only reads it.
+ */
+void
+search_dispatch_finish(struct session *s,
+    const struct imsg_search_parse_result *res, struct search_node *nodes)
+{
+	if (res->rc == -1) {
+		session_reply(s, s->pending_tag, "BAD", res->errmsg);
+		s->state = SESSION_SELECTED;
+		return;
+	}
+	if (res->rc == -2) {
+		session_reply(s, s->pending_tag, "NO", res->errmsg);
+		s->state = SESSION_SELECTED;
+		return;
+	}
+
+	s->search_used_modseq = res->uses_modseq;
+	s->search_max_modseq = 0;
+
+	/* RFC 7162 SS3.1: a SEARCH including the MODSEQ data item is a CONDSTORE enabling command */
+	if (res->uses_modseq)
+		session_condstore_enable(s);
+
 	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;
+		req.nnodes = res->nnodes;
 
-		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");
+		if (!send_mbox_request(s, IMSG_MBOX_SEARCH, "SEARCH",
+		    "IMSG_MBOX_SEARCH", &req, sizeof(req), nodes, res->nnodes,
+		    sizeof(struct search_node))) {
+			session_reply(s, s->pending_tag, "NO",
+			    "[SERVERBUG] internal error");
 			s->state = SESSION_SELECTED;
-			return (1);
+			return;
 		}
-		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);
 }
 
 void
@@ -848,86 +955,134 @@ session_handle_mbox_search_match(struct session *s,
 		s->search_max_modseq = m->modseq;
 }
 
+/*
+ * Fixed part of an ESEARCH line: correlator + tag + " UID" + MIN/MAX/
+ * COUNT/MODSEQ items + CRLF, with headroom. The variable part is the ALL
+ * list, budgeted separately at SEARCH_ALL_PER_MATCH below.
+ */
+#define SEARCH_RESP_PREFIX_MAX	256
+#define SEARCH_ALL_PER_MATCH	11	/* "4294967295" + one separator */
+
 /* builds and sends the ESEARCH response once store.c's SEARCH pass completes (RFC 9051 SS6.4.4/SS9) */
 void
 session_finish_search(struct session *s, struct imsg_mbox_result *res)
 {
-	char	buf[8192];
-	size_t	len;
-	int	truncated = 0;
+	char		*buf = NULL;
+	size_t		 bufsize, len;
+	int		 truncated = 0;
+	int		 n;
 
 	s->state = SESSION_SELECTED;
 
-	log_debug("session %u: SEARCH done, error=%d, %u match(es)", s->id,
-	    res->error, res->count);
+	/*
+	 * res->error is enum mbox_op_error (imapd.h), not a boolean --
+	 * MBOX_OP_OK is 1, not 0 (MBOX_ERR_UNSET occupies 0), so printing
+	 * it raw under an "error=" label reads as a fault even on
+	 * success. mbox_search.c:374 only ever sets this to MBOX_OP_OK or
+	 * MBOX_OP_ERR_GENERIC for a SEARCH result, so a plain two-way
+	 * label is complete here.
+	 */
+	log_debug("session %u: SEARCH done, status=%s, %u match(es)", s->id,
+	    res->error == MBOX_OP_OK ? "OK" : "ERROR", res->count);
 
-	if (res->error != MBOX_OP_OK || s->search_alloc_failed) {
-		session_reply(s, s->pending_tag, "NO",
-		    s->cmd_by_uid ? "UID SEARCH failed" : "SEARCH failed");
-		goto cleanup;
+	if (res->error != MBOX_OP_OK || s->search_alloc_failed)
+		goto fail;
+
+	/*
+	 * Heap-allocated and sized for the worst case, rather than a fixed
+	 * 8KB stack buffer: format_seq_list() truncates on a token boundary,
+	 * so an over-long ALL list used to produce a well-formed but SHORT
+	 * ESEARCH -- the client was told those messages simply did not
+	 * match, with only a server-side log line to say otherwise. A
+	 * client driving a bulk MOVE/STORE/EXPUNGE off SEARCH results would
+	 * then quietly operate on a subset.
+	 */
+	bufsize = SEARCH_RESP_PREFIX_MAX +
+	    (size_t)s->search_nmatches * SEARCH_ALL_PER_MATCH + 1;
+	if ((buf = malloc(bufsize)) == NULL) {
+		log_warn("session %u: malloc SEARCH response buffer", s->id);
+		goto fail;
 	}
 
-	len = (size_t)snprintf(buf, sizeof(buf), "* ESEARCH (TAG \"%s\")",
-	    s->pending_tag);
+	n = snprintf(buf, bufsize, "* ESEARCH (TAG \"%s\")", s->pending_tag);
+	if (n < 0 || (size_t)n >= bufsize) {
+		/* pending_tag is IMAP_TAG_MAX-bounded and tag_is_valid()-checked
+		 * by session_handle_line(), so it holds no quote or backslash to
+		 * break the quoting above; checked rather than assumed. */
+		log_warnx("session %u: SEARCH response prefix did not fit",
+		    s->id);
+		goto fail;
+	}
+	len = (size_t)n;
 
 	/* RFC 9051 SS9: "UID" indicator comes right after the correlator, before MIN/MAX/ALL/COUNT/MODSEQ */
-	if (s->cmd_by_uid && len < sizeof(buf))
-		len += (size_t)snprintf(buf + len, sizeof(buf) - len, " UID");
+	if (s->cmd_by_uid && len < bufsize)
+		len += (size_t)snprintf(buf + len, bufsize - 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,
+	    s->search_nmatches > 0 && len < bufsize)
+		len += (size_t)snprintf(buf + len, bufsize - 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,
+	    s->search_nmatches > 0 && len < bufsize)
+		len += (size_t)snprintf(buf + len, bufsize - 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)) {
+	    s->search_nmatches > 0 && len < bufsize) {
 		size_t	listlen;
 
-		len += (size_t)snprintf(buf + len, sizeof(buf) - len,
-		    " ALL ");
-		if (len < sizeof(buf)) {
+		len += (size_t)snprintf(buf + len, bufsize - len, " ALL ");
+		if (len < bufsize) {
 			listlen = format_seq_list(buf + len,
-			    sizeof(buf) - len, s->search_matches,
+			    bufsize - 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)", s->id, sizeof(buf),
-				    s->search_nmatches);
+				    "matches) -- can't happen, bufsize is "
+				    "sized for the worst case", s->id,
+				    bufsize, s->search_nmatches);
 		}
 	}
 
-	if ((s->search_return_opts & SEARCH_RETURN_COUNT) && len < sizeof(buf))
-		len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+	if ((s->search_return_opts & SEARCH_RETURN_COUNT) && len < bufsize)
+		len += (size_t)snprintf(buf + len, bufsize - len,
 		    " COUNT %u", s->search_nmatches);
 
 	/* RFC 7162 SS3.1.10: non-empty MODSEQ result gets "MODSEQ n" appended with the highest mod-sequence among matches */
 	if (s->search_used_modseq && s->search_nmatches > 0 &&
-	    len < sizeof(buf))
-		len += (size_t)snprintf(buf + len, sizeof(buf) - len,
+	    len < bufsize)
+		len += (size_t)snprintf(buf + len, bufsize - len,
 		    " MODSEQ %llu",
 		    (unsigned long long)s->search_max_modseq);
 
 	/*
-	 * len holds snprintf(3)'s "would-be" length, which can run past sizeof(buf).
+	 * len holds snprintf(3)'s "would-be" length. bufsize is sized for
+	 * the worst case so this can't actually fire, but keep the same
+	 * defensive clamp the fixed-buffer version had rather than trust
+	 * that reasoning blindly.
 	 */
-	if (len >= sizeof(buf) - 1)
-		len = sizeof(buf) - 2;
+	if (len >= bufsize - 1)
+		len = bufsize - 2;
 
 	buf[len++] = '\r';
 	buf[len++] = '\n';
 	session_write(s, buf, len);
+	free(buf);
 
 	/* "UID SEARCH completed" follows SS6.4.9's "UID <cmd> completed" pattern */
 	session_reply(s, s->pending_tag, "OK",
 	    s->cmd_by_uid ? "UID SEARCH completed" : "SEARCH completed");
+	goto cleanup;
 
+fail:
+	free(buf);
+	session_reply(s, s->pending_tag, "NO",
+	    s->cmd_by_uid ? "UID SEARCH failed" : "SEARCH failed");
+
 cleanup:
 	free(s->search_matches);
 	s->search_matches = NULL;
blob - 913c52e22655c40876409ed6770e1b31da4e9cef
blob + c74ccc0319f36edbb70800cd2d9bdaec395e94fc
--- src/store.c
+++ src/store.c
@@ -36,13 +36,20 @@
 #include "imapd.h"
 #include "log.h"
 #include "store_internal.h"
+#include "utf8.h"
 
 static struct imsgev	 iev_listener;
 
+/* Defined below store_main(); declared here so the boot sequence can apply
+ * the same NUL-termination rule to IMSG_STORE_INIT that every runtime
+ * message already gets. */
+static int	 imsg_field_valid(const char *, size_t, const char *);
+
 /* definitions for store_internal.h's extern globals, shared across the split store_*.c units */
 uint32_t		 session_id;
 uint32_t		 append_counter;
 char			 current_mailbox_dir[MBOX_NAME_MAX];
+int			 mailbox_selected;
 uint32_t		 bodystructure_read_max;
 
 __dead void
@@ -57,14 +64,13 @@ store_main(void)
 
 	/* store children take no imapd.conf; uid/gid/spool_root arrive via IMSG_STORE_INIT below instead */
 
-	if (imsgbuf_init(&ibuf3, 3) == -1)
-		fatal("imsgbuf_init");
-	imsgbuf_allow_fdpass(&ibuf3);	/* for the SETUP_PEER peer fd below */
+	/* fd-passing is allowed on this channel for the SETUP_PEER peer fd below; see imsgev_ibuf_init()'s own comment */
+	imsgev_ibuf_init(&ibuf3, 3);
 
 	/* IMSG_STORE_INIT must be read first: the privilege target is a runtime value, needed before chroot() */
 	for (;;) {
-		if ((n = imsg_get(&ibuf3, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n != 0)
 			break;
 		if ((n = imsgbuf_read(&ibuf3)) == -1)
@@ -82,6 +88,28 @@ store_main(void)
 	session_id = init.session_id;
 	bodystructure_read_max = init.bodystructure_read_max;
 
+	/*
+	 * imsg_get_data() guarantees the payload's size, not that the
+	 * strings inside it are terminated -- and both of these are used as
+	 * C strings immediately below, by chroot(2) and by snprintf("%s").
+	 * struct imsg_store_init puts maildir[] straight after
+	 * spool_root[], so an unterminated spool_root would read on into
+	 * it.
+	 *
+	 * This is the one message that skipped the check this file applies
+	 * to every runtime message (imsg_field_valid() below,
+	 * mailbox_name_valid()'s own memchr, search_nodes_valid()) -- and
+	 * it is the one that picks this process's chroot(2) target.
+	 * fatalx() rather than a graceful refusal: this is boot-time
+	 * plumbing from the trusted parent, same as the malformed-payload
+	 * case two lines above.
+	 */
+	if (!imsg_field_valid(init.spool_root, sizeof(init.spool_root),
+	    "IMSG_STORE_INIT.spool_root") ||
+	    !imsg_field_valid(init.maildir, sizeof(init.maildir),
+	    "IMSG_STORE_INIT.maildir"))
+		fatalx("store: malformed IMSG_STORE_INIT payload");
+
 	if (chroot(init.spool_root) == -1)
 		fatal("chroot %s", init.spool_root);
 	if (chdir("/") == -1)
@@ -95,13 +123,29 @@ store_main(void)
 		fatal("session %u: cannot drop privileges to uid %u gid %u",
 		    session_id, init.uid, init.gid);
 
-	/* privilege-dropped now; finish handshake like a boot-time child (one peer, then SETUP_DONE+ack) */
-	peer_fd = setup_recv_one_peer(&ibuf3);
-	setup_recv_done_and_ack(&ibuf3);
+	/*
+	 * Confinement BEFORE the handshake ack below, not after.
+	 *
+	 * setup_recv_done_and_ack()'s IMSG_SETUP_DONE is what parent takes
+	 * as this child's readiness signal: parent_handle_store_fork()
+	 * holds sc->pending until it arrives, and store_child_teardown()
+	 * only tells the listener-worker the spawn failed while pending is
+	 * still set (parent.c). Acking first meant a failure in the two
+	 * steps below -- the plausible ones, since they are the only ones
+	 * that touch the filesystem -- produced no failure reply at all:
+	 * the client had already been told "OK Success", the listener saw
+	 * the store channel close and tore the session down, and the client
+	 * got an unexplained disconnect instead of "authentication
+	 * succeeded but mailbox store unavailable".
+	 *
+	 * With the ack last, a maildir that does not exist leaves pending
+	 * set, parent's STORE_SETUP_TIMEOUT_SEC timer fires, and the
+	 * designed failure path runs.
+	 *
+	 * unveil() only needs the chroot() and the privilege drop above; it
+	 * has no dependency on the peer fd or the event loop.
+	 */
 
-	event_init();
-	imsgev_init(&iev_listener, peer_fd, store_dispatch, NULL);
-
 	/* unveil() scoped to THIS session's own mailbox subdir, narrowing the view past chroot alone */
 	{
 		char	unveil_path[sizeof(init.maildir) + 1];
@@ -120,10 +164,30 @@ store_main(void)
 	if (unveil(NULL, NULL) == -1)
 		fatal("unveil lock");
 
-	/* flock for index r-m-w, rpath/wpath/cpath for delivery+renames; no fattr (no chmod/utimes here) */
+	/* privilege-dropped and confined now; finish handshake like a boot-time child (one peer, 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);
+
+	/*
+	 * flock for index r-m-w, rpath/wpath/cpath for delivery+renames; no
+	 * fattr (no chmod/utimes here).
+	 *
+	 * No recvfd, no sendfd. This process receives exactly one descriptor
+	 * in its life -- the peer fd from setup_recv_one_peer() above, which
+	 * has already arrived by the time this line runs -- and it never
+	 * sends one: the parent is the only process in the tree that attaches
+	 * a descriptor to an imsg. SYS_sendmsg and SYS_recvmsg are
+	 * PLEDGE_STDIO (sys/kern/kern_pledge.c); the two promises are checked
+	 * in unp_internalize()/unp_externalize() (sys/kern/uipc_usrreq.c),
+	 * which the kernel reaches only when SCM_RIGHTS is actually attached,
+	 * so an imsgbuf_allow_fdpass() channel carrying only fd == -1
+	 * messages does not need either.
+	 */
 #ifdef __OpenBSD__
-	if (pledge("stdio rpath wpath cpath flock recvfd sendfd", NULL)
-	    == -1)
+	if (pledge("stdio rpath wpath cpath flock", NULL) == -1)
 		fatal("pledge");
 #endif
 
@@ -140,9 +204,25 @@ mailbox_name_valid(const char *name)
 {
 	size_t	i, len;
 
+	if (memchr(name, '\0', MBOX_NAME_MAX) == NULL) {
+		log_warnx("session %u: mailbox name field is not "
+		    "NUL-terminated, refusing", session_id);
+		return (0);
+	}
+
 	len = strlen(name);
 	if (len == 0 || len >= MBOX_NAME_MAX)
 		return (0);
+
+	/*
+	 * RFC 9051 SS5.1: 8-bit mailbox names MUST comply with Net-Unicode.
+	 * The listener checks this too; that is the point of having two
+	 * validators, and the shared predicate is why they cannot drift.
+	 * See utf8.c for what is and is not enforced.
+	 */
+	if (!utf8_mailbox_ok(name))
+		return (0);
+
 	for (i = 0; i < len; i++) {
 		unsigned char	c = (unsigned char)name[i];
 
@@ -163,7 +243,9 @@ mailbox_name_valid(const char *name)
 	if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0)
 		return (0);
 	if (strcmp(name, STORE_INDEX_NAME) == 0 ||
-	    strcmp(name, STORE_INDEX_TMP_NAME) == 0)
+	    strcmp(name, STORE_INDEX_TMP_NAME) == 0 ||
+	    strcmp(name, STORE_INDEX_LOCK_NAME) == 0 ||
+	    strcmp(name, STORE_UIDVALIDITY_NAME) == 0)
 		return (0);
 
 	return (1);
@@ -193,7 +275,7 @@ select_mailbox_dir(const char *target)
 		struct stat	st;
 		int		exists;
 
-		exists = (stat(target, &st) == 0 && S_ISDIR(st.st_mode));
+		exists = (lstat(target, &st) == 0 && S_ISDIR(st.st_mode));
 		if (!exists || chdir(target) == -1) {
 			/* missing/non-directory is the expected "no such mailbox" case; existing-but-failed is not */
 			if (exists)
@@ -208,6 +290,15 @@ select_mailbox_dir(const char *target)
 		}
 	}
 
+	/*
+	 * cwd is about to become a different mailbox, so index.c's IDLE probe
+	 * is now holding another mailbox's stat(2) sample. Invalidate it here
+	 * rather than in the IDLE path: this is the only place the mailbox
+	 * can change, and a stale sample would make the first poll after a
+	 * SELECT report "unchanged" about a directory it has never looked at.
+	 */
+	idle_probe_reset();
+
 	if (strlcpy(current_mailbox_dir, target, sizeof(current_mailbox_dir))
 	    >= sizeof(current_mailbox_dir)) {
 		/* can't happen (callers bound target under MBOX_NAME_MAX); mirror the restore-cwd failure path anyway */
@@ -229,6 +320,99 @@ select_mailbox_dir(const char *target)
 }
 
 
+/*
+ * NUL-termination check for an imsg-carried field that isn't a mailbox name.
+ */
+static int
+imsg_field_valid(const char *field, size_t size, const char *what)
+{
+	if (memchr(field, '\0', size) != NULL)
+		return (1);
+	log_warnx("session %u: %s is not NUL-terminated, refusing the "
+	    "request", session_id, what);
+	return (0);
+}
+
+/*
+ * Same check applied to every node's keyword field of a SEARCH program.
+ */
+static int
+search_nodes_valid(const struct search_node *nodes, uint32_t nnodes)
+{
+	uint32_t	i;
+
+	for (i = 0; i < nnodes; i++) {
+		if (memchr(nodes[i].keyword, '\0', sizeof(nodes[i].keyword))
+		    == NULL) {
+			log_warnx("session %u: SEARCH node %u keyword is not "
+			    "NUL-terminated, refusing the request",
+			    session_id, i);
+			return (0);
+		}
+	}
+	return (1);
+}
+
+/* Shared receive-side unpack for store_dispatch()'s "fixed header + trailing array of `count` `elemsize`-sized elements" imsg shape (SELECT/FETCH/STORE/EXPUNGE/COPY/MOVE's seq_range[], SEARCH's search_node[], APPEND's raw body bytes with elemsize 1); *ok_out tells a legitimate 0-element buffer (NULL) apart from failure (also NULL, already logged). */
+static void *
+recv_trailing_array(struct imsg *imsg, const char *what, uint32_t count,
+    uint32_t maxcount, size_t elemsize, int *ok_out)
+{
+	size_t	 bodylen;
+	void	*out;
+
+	*ok_out = 0;
+
+	if (count > maxcount) {
+		log_warnx("session %u: %s count %u over the allowed maximum "
+		    "%u", session_id, what, count, maxcount);
+		return (NULL);
+	}
+	bodylen = imsg_get_len(imsg);
+	if (bodylen != (size_t)count * elemsize) {
+		log_warnx("session %u: %s length mismatch (header says %u "
+		    "elements, imsg has %zu bytes)", session_id, what, count,
+		    bodylen);
+		return (NULL);
+	}
+	*ok_out = 1;
+	if (bodylen == 0)
+		return (NULL);
+	if ((out = malloc(bodylen)) == NULL) {
+		log_warn("session %u: malloc %s elements", session_id, what);
+		*ok_out = 0;
+		return (NULL);
+	}
+	if (imsg_get_buf(imsg, out, bodylen) == -1) {
+		log_warnx("bad %s (elements)", what);
+		free(out);
+		*ok_out = 0;
+		return (NULL);
+	}
+	return (out);
+}
+
+/*
+ * SS6.2's retrofit: does IMSG_MBOX_FETCH/STORE/EXPUNGE/SEARCH/COPY/
+ * MOVE/IDLE_REFRESH actually arrive only after this session's own
+ * IMSG_MBOX_SELECT succeeded, verified by this process's own state --
+ * not merely inferred from listener.c's ST_SELECTED command-table
+ * gating, which is listener's own state machine, not a guarantee
+ * store itself checks anything. A compromised listener could send
+ * these out of order; mailbox_selected makes the refusal explicit
+ * and independently checkable here, the same shape as keymgr.c's
+ * SS6.1 keymgr_got_init gate.
+ */
+static int
+require_mailbox_selected(const char *what)
+{
+	if (mailbox_selected)
+		return (1);
+	log_warnx("session %u: %s before a successful IMSG_MBOX_SELECT, "
+	    "refusing (SS6.2)", session_id, what);
+	return (0);
+}
+
 void
 store_dispatch(int fd, short event, void *arg)
 {
@@ -253,8 +437,8 @@ store_dispatch(int fd, short event, void *arg)
 	}
 
 	for (;;) {
-		if ((n = imsg_get(&iev->ibuf, &imsg)) == -1)
-			fatal("imsg_get");
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
 		if (n == 0)
 			break;
 
@@ -266,116 +450,190 @@ store_dispatch(int fd, short event, void *arg)
 			break;
 		case IMSG_MBOX_SELECT: {
 			struct imsg_mbox_select		 req;
+			struct seq_range		*ranges;
+			int				 ok;
 
-			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
-				log_warnx("bad IMSG_MBOX_SELECT");
+			/*
+			 * Same header-plus-variable-body shape as
+			 * STORE/FETCH/SEARCH, but like EXPUNGE, nranges may
+			 * legitimately be 0: a plain SELECT/EXAMINE (no
+			 * QRESYNC), or QRESYNC without known-uids
+			 * (qresync_has_uids == 0, store.c defaults to the
+			 * full UID range), both carry no trailing sequence-
+			 * set at all.
+			 */
+			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_SELECT (header)");
 				break;
 			}
-			/* composes its own reply stream and calls imsgev_add(iev), like every handle_mbox_*() below */
-			handle_mbox_select(&req, iev);
+			ranges = recv_trailing_array(&imsg, "IMSG_MBOX_SELECT",
+			    req.qresync_nranges, SEQSET_MAX_RANGES,
+			    sizeof(struct seq_range), &ok);
+			if (!ok)
+				break;
+			/*
+			 * RFC 9051 SS6.3.2: a failed SELECT leaves NO mailbox
+			 * selected -- which is what listener does on its own
+			 * side (store_ipc.c's session_handle_mbox_selected()
+			 * sets SESSION_AUTHENTICATED on any error). Clear the
+			 * SS6.2 gate here, before dispatching, so a SELECT
+			 * that fails after an earlier one succeeded does not
+			 * leave this process answering "selected" for a
+			 * session the protocol says has nothing selected.
+			 * handle_mbox_select() sets it again only on success.
+			 */
+			mailbox_selected = 0;
+
+			/* composes its own reply stream, like every handle_mbox_*() below; the EV_WRITE arm comes from imsgev_on_compose() */
+			handle_mbox_select(&req, ranges, req.qresync_nranges,
+			    iev);
+			free(ranges);
 			break;
 		}
 		case IMSG_MBOX_FETCH: {
 			struct imsg_mbox_fetch	 req;
+			struct seq_range	*ranges;
+			int			 ok;
 
-			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
-				log_warnx("bad IMSG_MBOX_FETCH");
+			if (!require_mailbox_selected("IMSG_MBOX_FETCH"))
 				break;
+
+			/* same header-plus-variable-body shape as SEARCH, trailing struct seq_range[] not raw bytes */
+			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_FETCH (header)");
+				break;
 			}
-			handle_mbox_fetch(&req, iev);
+			if (!imsg_field_valid(req.header_fields,
+			    sizeof(req.header_fields),
+			    "IMSG_MBOX_FETCH.header_fields") ||
+			    !imsg_field_valid(req.section_part,
+			    sizeof(req.section_part),
+			    "IMSG_MBOX_FETCH.section_part"))
+				break;
+			if (req.nranges == 0) {
+				log_warnx("session %u: IMSG_MBOX_FETCH "
+				    "requires at least one range", session_id);
+				break;
+			}
+			ranges = recv_trailing_array(&imsg, "IMSG_MBOX_FETCH",
+			    req.nranges, SEQSET_MAX_RANGES,
+			    sizeof(struct seq_range), &ok);
+			if (!ok)
+				break;
+			handle_mbox_fetch(&req, ranges, req.nranges, iev);
+			free(ranges);
 			break;
 		}
 		case IMSG_MBOX_STORE: {
 			struct imsg_mbox_store	 req;
+			struct seq_range	*ranges;
+			int			 ok;
 
-			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
-				log_warnx("bad IMSG_MBOX_STORE");
+			if (!require_mailbox_selected("IMSG_MBOX_STORE"))
 				break;
+
+			/* same header-plus-variable-body shape as SEARCH/FETCH, trailing struct seq_range[] not raw bytes */
+			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_STORE (header)");
+				break;
 			}
-			handle_mbox_store(&req, iev);
+			if (!imsg_field_valid(req.keywords,
+			    sizeof(req.keywords), "IMSG_MBOX_STORE.keywords"))
+				break;
+			if (req.nranges == 0) {
+				log_warnx("session %u: IMSG_MBOX_STORE "
+				    "requires at least one range", session_id);
+				break;
+			}
+			ranges = recv_trailing_array(&imsg, "IMSG_MBOX_STORE",
+			    req.nranges, SEQSET_MAX_RANGES,
+			    sizeof(struct seq_range), &ok);
+			if (!ok)
+				break;
+			handle_mbox_store(&req, ranges, req.nranges, iev);
+			free(ranges);
 			break;
 		}
 		case IMSG_MBOX_EXPUNGE: {
 			struct imsg_mbox_expunge	 req;
+			struct seq_range		*ranges;
+			int				 ok;
 
-			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
-				log_warnx("bad IMSG_MBOX_EXPUNGE");
+			/*
+			 * Same header-plus-variable-body shape as
+			 * STORE/FETCH/SEARCH, but unlike those, nranges
+			 * may legitimately be 0 here: plain EXPUNGE and
+			 * CLOSE (also sent through this case, with
+			 * silent=1 -- see cmd_close()/cmd_expunge() in
+			 * store_cmd.c) take no sequence-set at all, only
+			 * UID EXPUNGE (by_uid=1) carries a real one.
+			 */
+			if (!require_mailbox_selected("IMSG_MBOX_EXPUNGE"))
 				break;
+
+			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_EXPUNGE (header)");
+				break;
 			}
-			/* also what CLOSE (listener.c's cmd_close()) sends, with silent=1 */
-			handle_mbox_expunge(&req, iev);
+			ranges = recv_trailing_array(&imsg,
+			    "IMSG_MBOX_EXPUNGE", req.nranges,
+			    SEQSET_MAX_RANGES, sizeof(struct seq_range), &ok);
+			if (!ok)
+				break;
+			handle_mbox_expunge(&req, ranges, req.nranges, iev);
+			free(ranges);
 			break;
 		}
 		case IMSG_MBOX_IDLE_REFRESH:
 			/* no request payload, see imapd.h's imsg_mbox_idle_uid comment */
+			if (!require_mailbox_selected("IMSG_MBOX_IDLE_REFRESH"))
+				break;
 			handle_mbox_idle_refresh(iev);
 			break;
 		case IMSG_MBOX_APPEND: {
 			struct imsg_mbox_append	 req;
-			size_t			 bodylen;
-			char			*body = NULL;
+			char			*body;
+			int			 ok;
 
 			/* header-plus-variable-body: imsg_get_buf()+imsg_get_len(), see imapd.h's imsg_mbox_append */
 			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);
+			if (!imsg_field_valid(req.keywords,
+			    sizeof(req.keywords), "IMSG_MBOX_APPEND.keywords"))
 				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);
+			body = recv_trailing_array(&imsg, "IMSG_MBOX_APPEND",
+			    req.msglen, APPEND_LITERAL_MAX, 1, &ok);
+			if (!ok)
+				break;
+			handle_mbox_append(&req, body, req.msglen, iev);
 			free(body);
 			break;
 		}
 		case IMSG_MBOX_SEARCH: {
 			struct imsg_mbox_search	 req;
-			size_t			 bodylen;
-			struct search_node	*nodes = NULL;
+			struct search_node	*nodes;
+			int			 ok;
 
+			if (!require_mailbox_selected("IMSG_MBOX_SEARCH"))
+				break;
+
 			/* same header-plus-variable-body shape as APPEND, trailing struct search_node[] not raw bytes */
 			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);
+			nodes = recv_trailing_array(&imsg, "IMSG_MBOX_SEARCH",
+			    req.nnodes, SEARCH_PROGRAM_MAX_NODES,
+			    sizeof(struct search_node), &ok);
+			if (!ok)
 				break;
+			if (nodes != NULL &&
+			    !search_nodes_valid(nodes, req.nnodes)) {
+				free(nodes);
+				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;
@@ -392,29 +650,58 @@ store_dispatch(int fd, short event, void *arg)
 		}
 		case IMSG_MBOX_COPY: {
 			struct imsg_mbox_copy	 req;
+			struct seq_range	*ranges;
+			int			 ok;
 
-			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
-				log_warnx("bad IMSG_MBOX_COPY");
+			if (!require_mailbox_selected("IMSG_MBOX_COPY"))
 				break;
+
+			/* same header-plus-variable-body shape as STORE/FETCH/SEARCH, trailing struct seq_range[] not raw bytes */
+			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_COPY (header)");
+				break;
 			}
-			handle_mbox_copy(&req, iev);
+			if (req.nranges == 0) {
+				log_warnx("session %u: IMSG_MBOX_COPY "
+				    "requires at least one range", session_id);
+				break;
+			}
+			ranges = recv_trailing_array(&imsg, "IMSG_MBOX_COPY",
+			    req.nranges, SEQSET_MAX_RANGES,
+			    sizeof(struct seq_range), &ok);
+			if (!ok)
+				break;
+			handle_mbox_copy(&req, ranges, req.nranges, iev);
+			free(ranges);
 			break;
 		}
 		case IMSG_MBOX_MOVE: {
 			struct imsg_mbox_copy	 req;
+			struct seq_range	*ranges;
+			int			 ok;
 
-			if (imsg_get_data(&imsg, &req, sizeof(req)) == -1) {
-				log_warnx("bad IMSG_MBOX_MOVE");
+			if (!require_mailbox_selected("IMSG_MBOX_MOVE"))
 				break;
+
+			/* same header-plus-variable-body shape as COPY above */
+			if (imsg_get_buf(&imsg, &req, sizeof(req)) == -1) {
+				log_warnx("bad IMSG_MBOX_MOVE (header)");
+				break;
 			}
-			handle_mbox_move(&req, iev);
+			if (req.nranges == 0) {
+				log_warnx("session %u: IMSG_MBOX_MOVE "
+				    "requires at least one range", session_id);
+				break;
+			}
+			ranges = recv_trailing_array(&imsg, "IMSG_MBOX_MOVE",
+			    req.nranges, SEQSET_MAX_RANGES,
+			    sizeof(struct seq_range), &ok);
+			if (!ok)
+				break;
+			handle_mbox_move(&req, ranges, req.nranges, iev);
+			free(ranges);
 			break;
 		}
-		case IMSG_MBOX_EXAMINE:
-			/* unreachable: EXAMINE reuses IMSG_MBOX_SELECT's "readonly" field, see listener.c */
-			log_debug("session %u: unimplemented mbox op %d",
-			    session_id, imsg_get_type(&imsg));
-			break;
 		case IMSG_MBOX_LIST:
 			handle_mbox_list(iev);
 			break;
@@ -455,8 +742,7 @@ store_dispatch(int fd, short event, void *arg)
 		}
 		imsg_free(&imsg);
 	}
-	/* unconditional re-arm: imsgev_init() is EV_READ not EV_PERSIST, so a pure EV_WRITE call would drop the channel */
-	imsgev_add(iev);
+	imsgev_rearm_read(iev);
 	(void)fd;
 }
 
blob - 90e9426c1da3331eb9f0a61f8a8db04c5a1f0a38
blob + 72282279d87c0f1a0aa54e4cc828ee746a225594
--- src/store_cmd.c
+++ src/store_cmd.c
@@ -55,8 +55,10 @@ cmd_idle(struct session *s, const char *tag, char *arg
 	s->idling = 1;
 	session_write(s, "+ idling\r\n", 10);
 
-	if (s->state == SESSION_SELECTED)
-		session_request_idle_refresh(s);
+	if (s->state == SESSION_SELECTED) {
+		session_request_idle_refresh(s);	/* seeds the baseline */
+		session_idle_poll_arm(s);		/* and keeps it current */
+	}
 
 	return (1);
 }
@@ -66,7 +68,7 @@ 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);
+	return session_request_expunge(s, tag, 1, 0, NULL, 0);
 }
 
 /* RFC 9051 SS6.4.2: like CLOSE but removes nothing, a purely local state change. */
@@ -76,6 +78,7 @@ cmd_unselect(struct session *s, const char *tag, char 
 	(void)args;
 
 	s->state = SESSION_AUTHENTICATED;
+	session_reset_idle_baseline(s);	/* no mailbox selected; the snapshot describes one that no longer applies */
 	session_reply(s, tag, "OK", "Unselect completed");
 	return (1);
 }
@@ -85,7 +88,7 @@ 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);
+	return session_request_expunge(s, tag, 0, 0, NULL, 0);
 }
 
 /* Parses a store-att-flags list (parenthesized or bare) into a sysflags bitmap plus comma-joined keywords. */
@@ -198,9 +201,28 @@ parse_store_modifiers(char *modspec, struct imsg_mbox_
 				    "mod-sequence value";
 				return (-1);
 			}
+			/*
+			 * RFC 7162 SS7: store-modifier takes a
+			 * mod-sequence-valzer, so 0 is legal here (unlike
+			 * CHANGEDSINCE), but the value is still "1*DIGIT"
+			 * bounded by 9,223,372,036,854,775,807.
+			 *
+			 * This one matters most of the three: strtoull(3)
+			 * accepts a leading sign, so "UNCHANGEDSINCE -1"
+			 * arrived as ULLONG_MAX, every message compared below
+			 * it, and the conditional STORE silently became an
+			 * UNCONDITIONAL one -- with a tagged OK and no
+			 * MODIFIED list to say otherwise. RFC 7162 SS3.1.3
+			 * exists precisely to stop that write.
+			 */
+			if (*valtok < '0' || *valtok > '9') {
+				*errmsg = "invalid UNCHANGEDSINCE mod-sequence";
+				return (-1);
+			}
 			errno = 0;
 			req->unchangedsince = strtoull(valtok, &ep, 10);
-			if (*ep != '\0' || errno != 0) {
+			if (*ep != '\0' || errno != 0 ||
+			    req->unchangedsince > MODSEQ_MAX) {
 				*errmsg = "invalid UNCHANGEDSINCE mod-sequence";
 				return (-1);
 			}
@@ -228,8 +250,9 @@ store_do(struct session *s, const char *tag, char *arg
 	struct imsg_mbox_store	 req;
 	const char		*seqtok;
 	char			*modetok, *flagspec, *modspec = NULL;
-	uint32_t		 lo, hi, sysflags;
-	int			 lo_star, hi_star, mode, silent, rc;
+	struct seq_range	 ranges[SEQSET_MAX_RANGES];
+	uint32_t		 nranges, sysflags;
+	int			 mode, silent, rc;
 	char			 keywords[MBOX_FLAGS_MAX];
 	const char		*errmsg;
 	const char		*cmdname = by_uid ? "UID STORE" : "STORE";
@@ -301,16 +324,10 @@ store_do(struct session *s, const char *tag, char *arg
 		args++;
 	flagspec = args;
 
-	if (strchr(seqtok, ',') != NULL) {
-		session_reply(s, tag, "BAD",
-		    "comma-separated sequence sets not supported"
-		    "issue separate STORE commands");
+	if (parse_sequence_set(seqtok, ranges, &nranges, &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
 		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) {
@@ -367,10 +384,7 @@ store_do(struct session *s, const char *tag, char *arg
 	}
 
 	/* req already memset(3)'d and has_unchangedsince set earlier, not re-zeroed here, to avoid wiping it out. */
-	req.seq_lo = lo;
-	req.seq_hi = hi;
-	req.lo_is_star = lo_star;
-	req.hi_is_star = hi_star;
+	req.nranges = nranges;
 	req.mode = mode;
 	req.silent = silent;
 	req.sysflags = sysflags;
@@ -390,10 +404,12 @@ store_do(struct session *s, const char *tag, char *arg
 	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);
+	if (!send_mbox_request(s, IMSG_MBOX_STORE, cmdname, "IMSG_MBOX_STORE",
+	    &req, sizeof(req), ranges, nranges, sizeof(struct seq_range))) {
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		s->state = SESSION_SELECTED;
+		return (1);
+	}
 
 	return (1);
 }
@@ -406,8 +422,8 @@ copy_move_dispatch(struct session *s, const char *tag,
 	struct imsg_mbox_copy	 req;
 	char			 mailbox[MBOX_NAME_MAX];
 	char			*seqtok, *p;
-	uint32_t		 lo, hi;
-	int			 lo_star, hi_star;
+	struct seq_range	 ranges[SEQSET_MAX_RANGES];
+	uint32_t		 nranges;
 	const char		*errmsg = NULL;
 	const char		*cmdname = is_move ?
 	    (by_uid ? "UID MOVE" : "MOVE") : (by_uid ? "UID COPY" : "COPY");
@@ -437,18 +453,12 @@ copy_move_dispatch(struct session *s, const char *tag,
 	while (*p == ' ')
 		p++;
 
-	if (strchr(seqtok, ',') != NULL) {
-		session_reply(s, tag, "BAD",
-		    "comma-separated sequence sets not supported"
-		    "issue separate COPY/MOVE commands");
+	if (parse_sequence_set(seqtok, ranges, &nranges, &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
 		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) {
+	if (parse_mailbox_name(&p, mailbox, sizeof(mailbox), &errmsg) == -1) {
 		session_reply(s, tag, "BAD", errmsg);
 		return (1);
 	}
@@ -458,6 +468,8 @@ copy_move_dispatch(struct session *s, const char *tag,
 		return (1);
 	}
 
+	if (listener_reject_bad_utf8(s, tag, mailbox))
+		return (1);
 	if (!listener_mailbox_name_is_inbox(mailbox) && !listener_mailbox_name_valid(mailbox)) {
 		session_reply(s, tag, "BAD", "invalid mailbox name");
 		return (1);
@@ -480,10 +492,7 @@ copy_move_dispatch(struct session *s, const char *tag,
 
 	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;
+	req.nranges = nranges;
 	if (strlcpy(req.destname, mailbox, sizeof(req.destname)) >=
 	    sizeof(req.destname) ||
 	    strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
@@ -495,11 +504,13 @@ copy_move_dispatch(struct session *s, const char *tag,
 	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);
+	if (!send_mbox_request(s, is_move ? IMSG_MBOX_MOVE : IMSG_MBOX_COPY,
+	    cmdname, cmdname, &req, sizeof(req), ranges, nranges,
+	    sizeof(struct seq_range))) {
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		s->state = SESSION_SELECTED;
+		return (1);
+	}
 
 	return (1);
 }
@@ -590,7 +601,7 @@ session_handle_fetch_vanished(struct session *s,
 /* Shared by cmd_expunge()/cmd_close()/UID EXPUNGE; SS6.4.9: '*' number is always a seqno, even for UID commands. */
 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)
+    int by_uid, const struct seq_range *ranges, uint32_t nranges)
 {
 	struct imsg_mbox_expunge	 req;
 	const char			*cmdname = is_close ? "CLOSE" :
@@ -600,6 +611,7 @@ session_request_expunge(struct session *s, const char 
 		if (is_close) {
 			/* RFC 9051 SS6.4.1: EXAMINE'd, skip the round trip, just deselect and reply OK, no error. */
 			s->state = SESSION_AUTHENTICATED;
+			session_reset_idle_baseline(s);
 			session_reply(s, tag, "OK", "CLOSE completed");
 			return (1);
 		}
@@ -620,10 +632,7 @@ session_request_expunge(struct session *s, const char 
 	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;
+	req.nranges = nranges;	/* 0 for plain EXPUNGE/CLOSE (by_uid == 0) */
 
 	if (strlcpy(s->pending_tag, tag, sizeof(s->pending_tag)) >=
 	    sizeof(s->pending_tag)) {
@@ -634,10 +643,13 @@ session_request_expunge(struct session *s, const char 
 	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);
+	if (!send_mbox_request(s, IMSG_MBOX_EXPUNGE, cmdname,
+	    "IMSG_MBOX_EXPUNGE", &req, sizeof(req), ranges, nranges,
+	    sizeof(struct seq_range))) {
+		session_reply(s, tag, "NO", "[SERVERBUG] internal error");
+		s->state = SESSION_SELECTED;
+		return (1);
+	}
 
 	return (1);
 }
@@ -646,26 +658,21 @@ session_request_expunge(struct session *s, const char 
 int
 uid_expunge_dispatch(struct session *s, const char *tag, const char *args)
 {
-	uint32_t	lo, hi;
-	int		lo_star, hi_star;
+	struct seq_range	 ranges[SEQSET_MAX_RANGES];
+	uint32_t		 nranges;
+	const char		*errmsg;
 
 	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"
-		    "issue separate UID EXPUNGE commands");
+	if (parse_sequence_set(args, ranges, &nranges, &errmsg) == -1) {
+		session_reply(s, tag, "BAD", errmsg);
 		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);
+	return session_request_expunge(s, tag, 0, 1, ranges, nranges);
 }
 
 /* Appends one MODIFIED entry (RFC 7162 SS3.1.3) to s->store_modified, growing by doubling from 16. */
@@ -782,6 +789,16 @@ format_range_list(char *buf, size_t bufsize, const str
 	return (written);
 }
 
+/*
+ * Worst-case sizing for each of COPYUID's two UID sets, matching
+ * store_ipc.c's MODIFIED_PER_ENTRY and search_cmd.c's
+ * SEARCH_ALL_PER_MATCH: "4294967295" plus a separator. The wrapper covers
+ * the tag (IMAP_TAG_MAX), "OK [COPYUID ", the uidvalidity, cmdname and
+ * CRLF.
+ */
+#define COPYUID_PER_ENTRY	11
+#define COPYUID_WRAPPER_MAX	160
+
 /* Terminal COPY/MOVE reply; COPYUID via format_seq_list() (SS7.1); MOVE emits EXPUNGE/VANISHED before the tagged OK. */
 void
 session_finish_copy_or_move(struct session *s, const struct imsg_mbox_result *res)
@@ -810,41 +827,83 @@ session_finish_copy_or_move(struct session *s, const s
 	/* RFC 7162 SS3.1.2.1: cache post-op HIGHESTMODSEQ (as for STORE/EXPUNGE) for session_condstore_enable()'s later use. */
 	s->mbox_highestmodseq = res->highestmodseq;
 
-	/* RFC 9051 SS6.3.13: notifies idle peers on the dest mailbox too; gated on copy_n > 0 (zero-match sends nothing). */
-	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;
+		char	*srcbuf = NULL, *destbuf = NULL, *text = NULL;
+		size_t	 listsize, textsize;
+		int	 truncated, n, sent = 0;
 
-		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);
+		/*
+		 * Heap-allocated and sized for the worst case, as
+		 * session_finish_search() does for the ESEARCH ALL list. The
+		 * two fixed buffers were the wrong shape twice over. They
+		 * truncated INDEPENDENTLY, so the source and destination
+		 * lists could stop at different entry counts and silently
+		 * misalign the positional mapping RFC 9051 SS7.1 defines --
+		 * a client resolving its own UIDs through a misaligned
+		 * COPYUID lands on the wrong messages. And neither was the
+		 * real bound: session_reply() and session_untagged() compose
+		 * into 512 bytes and force the last two back to CRLF on
+		 * overflow, amputating the closing "]" of the response code.
+		 * Composed here in full and handed to session_write().
+		 */
+		listsize = (size_t)s->copy_n * COPYUID_PER_ENTRY + 1;
+		textsize = 2 * listsize + COPYUID_WRAPPER_MAX;
 
+		if ((srcbuf = malloc(listsize)) != NULL &&
+		    (destbuf = malloc(listsize)) != NULL &&
+		    (text = malloc(textsize)) != NULL) {
+			format_seq_list(srcbuf, listsize, s->copy_src_uids,
+			    s->copy_n, &truncated);
+			if (truncated)
+				log_warnx("session %u: COPYUID source list "
+				    "truncated", s->id);
+			format_seq_list(destbuf, listsize, s->copy_dest_uids,
+			    s->copy_n, &truncated);
+			if (truncated)
+				log_warnx("session %u: COPYUID dest list "
+				    "truncated", s->id);
+
+			/* MOVE's COPYUID is untagged (its tagged OK follows the
+			 * EXPUNGEs); COPY's rides on the tagged OK itself. */
+			if (s->cmd_is_move)
+				n = snprintf(text, textsize,
+				    "* OK [COPYUID %u %s %s]\r\n",
+				    res->uidvalidity, srcbuf, destbuf);
+			else
+				n = snprintf(text, textsize,
+				    "%s OK [COPYUID %u %s %s] %s completed\r\n",
+				    s->pending_tag, res->uidvalidity, srcbuf,
+				    destbuf, cmdname);
+			if (n < 0 || (size_t)n >= textsize)
+				log_warnx("session %u: COPYUID response text "
+				    "truncated", s->id);
+			else {
+				session_write(s, text, (size_t)n);
+				sent = 1;
+			}
+		} else
+			log_warn("session %u: malloc COPYUID response", s->id);
+
+		free(srcbuf);
+		free(destbuf);
+		free(text);
+
 		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 if (!sent) {
+			/* RFC 9051 SS6.4.7 makes COPYUID a SHOULD, so dropping
+			 * the response code is a legal degradation; emitting a
+			 * truncated or half-empty one is not. */
+			char	fallback[64];
+
+			snprintf(fallback, sizeof(fallback), "%s completed",
+			    cmdname);
+			session_reply(s, s->pending_tag, "OK", fallback);
 		}
 	} else {
 		/* RFC 9051 SS6.4.7's own worked example, verbatim. */
blob - /dev/null
blob + e62225ce861ded6292483b35dbe047249efbd577 (mode 644)
--- /dev/null
+++ src/search_oracle.c
@@ -0,0 +1,341 @@
+/*
+ * 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.
+ */
+
+/*
+ * search_oracle.c, SEARCH-grammar parsing process (docs/openimap-tls-
+ * privsep-design.md SS8.1): a per-connection worker, forked alongside
+ * listener/auth by parent.c's spawn_connection(), that does nothing but
+ * turn already-buffered SEARCH argument text into a parsed struct
+ * search_node[] array or an error. The parsing logic itself is
+ * unchanged and stays in search_cmd.c (search_oracle_parse(), this
+ * file's one call into it) -- SS8's narrow variant only moves *where*
+ * that already-existing, already-fuzzed-in-isolation grammar runs, not
+ * what it does.
+ *
+ * Deliberately holds nothing else: no TLS context, no client fd, no
+ * credentials, no channel to store/auth/keymgr, just its one peer
+ * channel to the listener-worker it's paired with for that one
+ * connection's whole life. A memory-safety bug in the SEARCH grammar
+ * (the reason this process exists at all, see SS8.1's addendum) can
+ * therefore reach none of that. Exits when its one peer's channel
+ * closes, same as auth.c (SS7's per-connection exit-on-EOF discipline).
+ */
+
+#include <sys/types.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"
+
+static struct imsgev	 iev_listener;
+static struct imsgev	 iev_parent;	/* fd 3; nothing more ever arrives on it, see search_oracle_dispatch_parent()'s own comment */
+
+static void	 search_oracle_dispatch(int, short, void *);
+static void	 search_oracle_dispatch_parent(int, short, void *);
+static void	 search_oracle_fail(struct imsgev *, const char *);
+
+__dead void
+search_oracle_main(void)
+{
+	struct imsgbuf	 ibuf3;
+	struct imsg	 imsg;
+	struct passwd	*pw;
+	int		 peer_fd;
+	ssize_t		 n;
+
+	/* fd-passing is allowed on this channel for the IMSG_SETUP_SEARCH_PEER peer fd below; see imsgev_ibuf_init()'s own comment */
+	imsgev_ibuf_init(&ibuf3, 3);
+
+	/*
+	 * Unlike auth.c's IMSG_AUTH_INIT or listener.c's IMSG_TLS_CERT/
+	 * IMSG_LISTENER_SESSION_INIT, this process needs no config at
+	 * all -- no cred file, no TLS material, nothing session-
+	 * specific beyond the one peer fd itself. Its whole boot
+	 * sequence is draining this one message.
+	 */
+	for (;;) {
+		if ((n = imsgbuf_get(&ibuf3, &imsg)) == -1)
+			fatal("imsgbuf_get");
+		if (n != 0)
+			break;
+		if ((n = imsgbuf_read(&ibuf3)) == -1)
+			fatal("imsgbuf_read");
+		if (n == 0)
+			fatalx("search-oracle: parent closed channel before "
+			    "IMSG_SETUP_SEARCH_PEER");
+	}
+	if (imsg_get_type(&imsg) != IMSG_SETUP_SEARCH_PEER)
+		fatalx("search-oracle: expected IMSG_SETUP_SEARCH_PEER, got "
+		    "%d", imsg_get_type(&imsg));
+	peer_fd = imsg_get_fd(&imsg);
+	imsg_free(&imsg);
+	if (peer_fd == -1)
+		fatalx("search-oracle: IMSG_SETUP_SEARCH_PEER carried no fd");
+
+	/*
+	 * search-oracle's own daemon-user identity, distinct from
+	 * listener's _imapd and auth's _imapauth. It holds no secret of
+	 * its own -- the whole point is that it holds nothing worth
+	 * taking -- but a dedicated uid still keeps its failure/
+	 * compromise domain visibly separate, matching every other
+	 * role's convention in this project.
+	 */
+	if ((pw = getpwnam("_imapsearch")) == NULL)
+		fatalx("getpwnam _imapsearch: 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 /");
+
+	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 _imapsearch");
+
+	event_init();
+	imsgev_init(&iev_listener, peer_fd, search_oracle_dispatch, NULL);
+	imsgev_init_from_ibuf(&iev_parent, &ibuf3, search_oracle_dispatch_parent,
+	    NULL);
+
+	/*
+	 * No rpath: this process touches no file, ever -- unlike auth.c
+	 * (the credential file) or listener.c (the TLS cert), SEARCH
+	 * parsing is a pure function over already-buffered text. No
+	 * inet: no socket beyond its one already-open peer channel, no
+	 * accept/connect of any kind, unlike listener's carried-over
+	 * "inet" promise (SS8.1's separate finding). That's the whole
+	 * point of this process existing (SS8.1's addendum). No recvfd or
+	 * sendfd either: the one descriptor this process ever receives is
+	 * the peer fd drained above, before this line, and it never sends
+	 * one -- the parent is the only process in the tree that attaches a
+	 * descriptor to an imsg (parent.c's setup_peer_send(),
+	 * setup_search_peer_send() and IMSG_LISTENER_SESSION_INIT are the
+	 * only five such call sites). So "stdio" alone, matching auth.c's
+	 * own final pledge minus its rpath.
+	 *
+	 * An earlier version of this comment kept both promises on the theory
+	 * that an imsgbuf configured with imsgbuf_allow_fdpass() uses
+	 * sendmsg(2)/recvmsg(2) for all of its traffic, not only fd-carrying
+	 * messages. It does -- but that is not what the two promises gate.
+	 * SYS_sendmsg and SYS_recvmsg are PLEDGE_STDIO
+	 * (sys/kern/kern_pledge.c); "sendfd"/"recvfd" are checked in
+	 * unp_internalize()/unp_externalize() (sys/kern/uipc_usrreq.c), which
+	 * the kernel reaches only when SCM_RIGHTS is actually attached to the
+	 * message. A plain imsg with fd == -1 needs neither promise.
+	 */
+#ifdef __OpenBSD__
+	if (pledge("stdio", NULL) == -1)
+		fatal("pledge");
+#endif
+
+	event_dispatch();
+	fatalx("search-oracle: exited event loop");
+}
+
+/* EV_WRITE must be handled: imsg_compose() only queues, imsgbuf_write() puts it on the wire */
+static void
+search_oracle_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) {
+			/*
+			 * SS7/SS8: this process was spawned to serve
+			 * exactly this one connection's listener-worker
+			 * and will never serve another -- exit now rather
+			 * than sit in event_dispatch() forever with
+			 * nothing left to do. Matches auth.c's
+			 * auth_dispatch()/listener.c's session_teardown()
+			 * (see either's own comment for the full SS7
+			 * reasoning); parent.c's reap_child() already
+			 * treats this as the ordinary, expected end of a
+			 * session, not something to warn about.
+			 */
+			log_debug("search-oracle: listener closed channel, "
+			    "exiting");
+			exit(0);
+		}
+	}
+
+	for (;;) {
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
+		if (n == 0)
+			break;
+
+		switch (imsg_get_type(&imsg)) {
+		case IMSG_SEARCH_PARSE_REQUEST: {
+			char				 args[SEARCH_ORACLE_ARGS_MAX + 1];
+			struct search_node		 nodes[SEARCH_PROGRAM_MAX_NODES];
+			struct imsg_search_parse_result res;
+			size_t				 len, bodylen;
+			void				*combined;
+
+			len = imsg_get_len(&imsg);
+			if (len > (size_t)SEARCH_ORACLE_ARGS_MAX) {
+				log_warnx("IMSG_SEARCH_PARSE_REQUEST too "
+				    "large (%zu > %u)", len,
+				    (unsigned)SEARCH_ORACLE_ARGS_MAX);
+				search_oracle_fail(iev,
+				    "SEARCH criteria too long");
+				break;
+			}
+			if (imsg_get_data(&imsg, args, len) == -1) {
+				log_warnx("bad IMSG_SEARCH_PARSE_REQUEST");
+				search_oracle_fail(iev,
+				    "malformed SEARCH request");
+				break;
+			}
+			/* imsg_get_data() guarantees size, not NUL termination -- same discipline as auth.c's own inbound imsgs */
+			args[len] = '\0';
+
+			memset(&res, 0, sizeof(res));
+			res.rc = search_oracle_parse(args, nodes, &res.nnodes,
+			    &res.uses_modseq, res.errmsg, sizeof(res.errmsg));
+
+			bodylen = (res.rc == 0) ?
+			    (size_t)res.nnodes * sizeof(nodes[0]) : 0;
+			if ((combined = malloc(sizeof(res) + bodylen)) ==
+			    NULL) {
+				log_warn("malloc IMSG_SEARCH_PARSE_RESULT "
+				    "buffer");
+				search_oracle_fail(iev,
+				    "SEARCH temporarily unavailable");
+				break;
+			}
+			memcpy(combined, &res, sizeof(res));
+			if (bodylen > 0)
+				memcpy((char *)combined + sizeof(res), nodes,
+				    bodylen);
+
+			if (imsg_compose(&iev->ibuf, IMSG_SEARCH_PARSE_RESULT,
+			    0, 0, -1, combined, sizeof(res) + bodylen) == -1) {
+				log_warn("imsg_compose "
+				    "IMSG_SEARCH_PARSE_RESULT");
+				/* the small reply may still fit where the
+				 * full one did not */
+				search_oracle_fail(iev,
+				    "SEARCH temporarily unavailable");
+			}
+			free(combined);
+			break;
+		}
+		default:
+			log_debug("search_oracle_dispatch: unhandled %d",
+			    imsg_get_type(&imsg));
+			break;
+		}
+		imsg_free(&imsg);
+	}
+	imsgev_rearm_read(iev);
+	(void)fd;
+}
+
+/*
+ * Answers a parse request this process could not carry out, so the
+ * listener-worker is never left waiting.
+ *
+ * A SEARCH is a two-hop async round trip: search_dispatch() puts the
+ * session in SESSION_SEARCH_PARSING and it leaves that state only when
+ * IMSG_SEARCH_PARSE_RESULT arrives. listener.c handles this process
+ * DYING (it synthesizes a NO), but has nothing for this process staying
+ * alive and simply not answering -- and there is no inactivity timeout
+ * anywhere in a session's life, so a missing reply wedges that session
+ * for good.
+ *
+ * So every path out of the request handler answers, even the ones that
+ * "cannot happen". Same discipline as keymgr.c, where a refused or
+ * failed private-key operation is a reply with ok = 0 rather than
+ * silence, and for the same reason: the requester is synchronous.
+ *
+ * rc = -2 (NO, not BAD): these are local failures of this process, not
+ * anything wrong with the client's search criteria.
+ */
+static void
+search_oracle_fail(struct imsgev *iev, const char *errmsg)
+{
+	struct imsg_search_parse_result	 res;
+
+	memset(&res, 0, sizeof(res));
+	res.rc = -2;
+	(void)strlcpy(res.errmsg, errmsg, sizeof(res.errmsg));
+
+	if (imsg_compose(&iev->ibuf, IMSG_SEARCH_PARSE_RESULT, 0, 0, -1,
+	    &res, sizeof(res)) == -1)
+		log_warn("imsg_compose IMSG_SEARCH_PARSE_RESULT (failure)");
+}
+
+/*
+ * parent never sends search-oracle anything post-boot (it needs no
+ * config, no reload -- see search_oracle_main()'s own comment), so this
+ * exists only to notice if parent's end closes, same as auth.c's
+ * auth_dispatch_parent().
+ */
+static void
+search_oracle_dispatch_parent(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("parent closed channel");
+			event_del(&iev->ev);
+			return;
+		}
+	}
+
+	for (;;) {
+		if ((n = imsgbuf_get(&iev->ibuf, &imsg)) == -1)
+			fatal("imsgbuf_get");
+		if (n == 0)
+			break;
+
+		log_debug("search_oracle_dispatch_parent: unhandled %d",
+		    imsg_get_type(&imsg));
+		imsg_free(&imsg);
+	}
+	imsgev_rearm_read(iev);
+	(void)fd;
+}
blob - db2690adc00fb3bbeeb0b7eceb072944d0994ccf
blob + 10e04f6f4773e0e729225108f31543c6b930635a
--- src/store_internal.h
+++ src/store_internal.h
@@ -34,6 +34,22 @@ struct mbox_index {
 					 * index_load()/index_save()). Always
 					 * supported, so NOMODSEQ (RFC 7162
 					 * SS3.1.2.2) is unreachable here. */
+	/*
+	 * Set by index_load() when it found no index on disk and invented a
+	 * header. It means "this mailbox's UIDVALIDITY has just been issued
+	 * and is not yet written down anywhere", and every caller holding an
+	 * exclusive lock must persist it before returning.
+	 *
+	 * Nothing used to persist it, which was a bug of its own: an empty
+	 * mailbox has no new/ entries, so refresh_index() found nothing to
+	 * add and saved nothing, and each SELECT invented a fresh
+	 * time(NULL). Two SELECTs of the same empty mailbox a second apart
+	 * reported two different UIDVALIDITY values, which RFC 9051
+	 * SS2.3.1.1 permits only when the UIDs have actually been
+	 * invalidated. It also means uidvalidity_next() would consume a
+	 * floor value on every SELECT rather than once per mailbox.
+	 */
+	int		  fresh;
 	char		**lines;	/* raw "UID:basename:keywords:MODSEQ"
 					 * lines, no trailing newline, one
 					 * malloc(3) each; see
@@ -92,6 +108,29 @@ extern char	 current_mailbox_dir[MBOX_NAME_MAX]; /* na
 				 * into, relative to the maildir root; ""
 				 * means INBOX/nothing selected.
 				 * handle_mbox_select() is the only writer */
+extern int	 mailbox_selected; /* SS6.2's retrofit gate: 1 iff
+				 * this session's own most recent
+				 * IMSG_MBOX_SELECT succeeded and no CLOSE has
+				 * followed it. store_dispatch() clears it
+				 * before dispatching each SELECT, so a failed
+				 * SELECT leaves nothing selected (RFC 9051
+				 * SS6.3.2), matching listener's own
+				 * post-failure transition to
+				 * SESSION_AUTHENTICATED; independent of
+				 * current_mailbox_dir,
+				 * whose "" is ambiguous between "nothing selected
+				 * yet" and "INBOX selected". handle_mbox_select()
+				 * sets it 1 on success; handle_mbox_expunge()
+				 * (mbox_store.c) resets it 0 when req->silent
+				 * (CLOSE), mirroring listener's own post-CLOSE
+				 * state transition, and handle_mbox_delete()
+				 * (mbox_manage.c) resets it 0 when the mailbox
+				 * it just removed was this session's own
+				 * selection -- cwd is back at the maildir root
+				 * by then, so anything still "selected" would
+				 * silently be INBOX. Checked by store.c's
+				 * require_mailbox_selected() before dispatching
+				 * any op that presumes a selected mailbox. */
 
 /* Cross-file entry points: forward declarations for store.c (core) +
  * index.c + mime.c + envelope.c + mbox_fetch.c + mbox_search.c +
@@ -104,17 +143,23 @@ extern uint32_t	 bodystructure_read_max; /* from IMSG_
 
 void	 store_dispatch(int, short, void *);
 void	 store_shutdown(void);
-void	 handle_mbox_select(struct imsg_mbox_select *, struct imsgev *);
-void	 handle_mbox_fetch(struct imsg_mbox_fetch *, struct imsgev *);
-void	 handle_mbox_store(struct imsg_mbox_store *, struct imsgev *);
-void	 handle_mbox_expunge(struct imsg_mbox_expunge *, struct imsgev *);
+void	 handle_mbox_select(struct imsg_mbox_select *,
+		    const struct seq_range *, uint32_t, struct imsgev *);
+void	 handle_mbox_fetch(struct imsg_mbox_fetch *,
+		    const struct seq_range *, uint32_t, struct imsgev *);
+void	 handle_mbox_store(struct imsg_mbox_store *,
+		    const struct seq_range *, uint32_t, struct imsgev *);
+void	 handle_mbox_expunge(struct imsg_mbox_expunge *,
+		    const struct seq_range *, uint32_t, struct imsgev *);
 void	 handle_mbox_append(struct imsg_mbox_append *, const char *,
 		    size_t, struct imsgev *);
 void	 handle_mbox_search(struct imsg_mbox_search *,
 		    struct search_node *, uint32_t, struct imsgev *);
 void	 handle_mbox_status(struct imsg_mbox_status *, struct imsgev *);
-void	 handle_mbox_copy(struct imsg_mbox_copy *, struct imsgev *);
-void	 handle_mbox_move(struct imsg_mbox_copy *, struct imsgev *);
+void	 handle_mbox_copy(struct imsg_mbox_copy *,
+		    const struct seq_range *, uint32_t, struct imsgev *);
+void	 handle_mbox_move(struct imsg_mbox_copy *,
+		    const struct seq_range *, uint32_t, struct imsgev *);
 void	 handle_mbox_create(struct imsg_mbox_create *, struct imsgev *);
 void	 handle_mbox_delete(struct imsg_mbox_delete *, struct imsgev *);
 void	 handle_mbox_rename(struct imsg_mbox_rename *, struct imsgev *);
@@ -122,6 +167,7 @@ void	 handle_mbox_list(struct imsgev *);
 int	 mailbox_name_valid(const char *);
 int	 mailbox_name_is_inbox(const char *);
 int	 select_mailbox_dir(const char *);
+int	 save_current_mailbox_dir(char *, size_t);
 int	 locate_message_file(const char *, off_t *, char *, size_t);
 int	 open_message_file(const char *);
 int	 read_message_header(const char *, char **, uint32_t *);
@@ -176,8 +222,70 @@ int	 extract_mime_part(const char *, const int *, int,
  */
 #define STORE_INDEX_NAME	"imapd.index"
 #define STORE_INDEX_TMP_NAME	"imapd.index.tmp"
+/*
+ * The index's lock is taken on this file, not on the index itself.
+ *
+ * index_save() commits by writing STORE_INDEX_TMP_NAME and rename(2)ing it
+ * over STORE_INDEX_NAME, which means the index's inode is REPLACED on every
+ * save. flock(2) locks an open file description, and that is bound to an
+ * inode -- so a lock taken on the index is, from the first save onward, a
+ * lock on a file that is no longer the index. Two things went wrong with
+ * that: a process opening the index after a save locked a different inode
+ * and got no exclusion at all, and a process that had blocked on the old
+ * inode's lock went on to read the superseded file through its pre-rename
+ * descriptor and save that, silently reverting the other process's work.
+ *
+ * This file is created once per mailbox and never renamed or replaced, so
+ * its inode is stable and a lock on it means what it says. It holds no
+ * content; only its existence and its inode matter. mailbox_name_valid()
+ * refuses it as a mailbox name, and remove_maildir_subtree() unlinks it
+ * when the mailbox is deleted.
+ */
+#define STORE_INDEX_LOCK_NAME	"imapd.index.lock"
 #define STORE_INDEX_LINE_MAX	1024
 
+/*
+ * Per-user UIDVALIDITY floor: the highest UIDVALIDITY ever issued to this
+ * user, as decimal digits. One file at the MAILDIR ROOT, beside INBOX's own
+ * index, not one per mailbox.
+ *
+ * It exists because index_load() used to seed a fresh index with
+ * time(NULL), following RFC 9051 SS2.3.1.1's own advice -- but that advice
+ * comes with the promise that such a value "is unique and always increases",
+ * and at one-second granularity it is not. A mailbox deleted and recreated
+ * quickly could be handed a UIDVALIDITY it had already used, which is
+ * precisely the signal the protocol gives a client that its cache is still
+ * good. It would then map stale entries onto different messages, with no way
+ * to detect it.
+ *
+ * Unlike the index this file is its own lock. The index needs a separate
+ * lock file because index_save() commits by rename(2) and so replaces the
+ * inode; this holds one short number, is rewritten in place under the
+ * exclusive lock, and keeps its inode forever.
+ *
+ * LOCK ORDERING, which a later edit must not break: this lock is an
+ * INNERMOST LEAF. uidvalidity_next() is called with a mailbox's index lock
+ * already held, and it must never itself acquire a mailbox lock. A leaf lock
+ * taken last and released before anything else cannot take part in a cycle,
+ * which is what keeps it clear of lock_copy_move_mailboxes()'s name-ordered
+ * two-mailbox acquisition in mbox_copy.c.
+ */
+#define STORE_UIDVALIDITY_NAME	"imapd.uidvalidity"
+
+/*
+ * A held index lock: the flock(2)ed lock file, plus the index descriptor,
+ * which index_lock_acquire() opens only AFTER the lock is held so that it
+ * cannot refer to an inode a concurrent index_save() has already replaced.
+ * Both members are -1 when nothing is held; declare with INDEX_LOCK_INIT
+ * rather than memset(3), since an all-zero struct would name descriptor 0.
+ */
+struct index_lock {
+	int	lockfd;
+	int	fd;
+};
+
+#define INDEX_LOCK_INIT	{ -1, -1 }
+
 /* In-memory copy of one mailbox's index while a single mutating mbox op
  * is handled. Built fresh from the on-disk file, mutated, and
  * rewritten in full (temp file + rename(2)) on every mutation, then
@@ -189,13 +297,28 @@ int	 index_append(struct mbox_index *, uint32_t, const
 int	 index_save(const struct mbox_index *);
 void	 index_free(struct mbox_index *);
 int	 index_parse_line(const char *, struct index_rec *);
+int	 index_field_valid(const char *);	/* no ':', CR or LF --
+					 * safe to write into a
+					 * colon-delimited index line */
+int	 index_basename_valid(const char *);	/* additionally no '/', no
+					 * leading '.', no control bytes --
+					 * safe to paste into "new/%s" */
 uint32_t	 index_max_uid(struct mbox_index *);
 void	 send_vanished_range(const struct mbox_index *, uint32_t, uint32_t,
 		    struct imsgev *);
+uint32_t	 seqset_resolve(const struct seq_range *, uint32_t, uint32_t,
+		    int, struct seq_range[SEQSET_MAX_RANGES]);
+int	 seqset_contains(const struct seq_range *, uint32_t, uint32_t);
+uint32_t	 seqset_max_hi(const struct seq_range *, uint32_t);
 int	 refresh_index(struct mbox_index *, int);
+uint32_t uidvalidity_next(void);
+void	 idle_probe_reset(void);
+int	 index_lock_acquire(struct index_lock *, int);
+void	 index_lock_release(struct index_lock *);
 void	 handle_mbox_idle_refresh(struct imsgev *);
 void	 qresync_send_resync(const struct imsg_mbox_select *,
-		    const struct mbox_index *, struct imsgev *);
+		    const struct seq_range *, uint32_t, struct mbox_index *,
+		    struct imsgev *);
 
 /*
  * The remaining six are single-purpose helpers that happen to be called
@@ -204,7 +327,7 @@ void	 qresync_send_resync(const struct imsg_mbox_selec
 const char	*append_hostname(void);
 int		 ensure_maildir_dirs(const char *);
 uint32_t	 letters_to_sysflags(const char *);
-void		 merge_keywords(int, const char *, const char *, char *,
+int		 merge_keywords(int, const char *, const char *, char *,
 		    size_t);
 int64_t		 parse_maildir_timestamp(const char *);
 void		 sysflags_to_letters(uint32_t, char *, size_t);
blob - 4e312adf98b142d52cacc81428dddf30a46cd113
blob + b3ed28e9b65a886593592c66f2b861e9b94573e2
--- src/store_ipc.c
+++ src/store_ipc.c
@@ -42,6 +42,50 @@
 #include "log.h"
 #include "listener.h"
 
+/*
+ * Shared receive-side handling for the four IMSG_MBOX_FETCH_{HEADER,BODY,
+ * ENVELOPE,BODYSTRUCTURE} messages.
+ */
+static void
+fetch_part_recv(struct session *s, struct imsg *imsg, const char *what,
+    int found, uint32_t declared_len, char **bufp, uint32_t *lenp,
+    int *foundp)
+{
+	size_t	 wirelen;
+
+	free(*bufp);
+	*bufp = NULL;
+	*lenp = 0;
+	*foundp = 0;
+
+	if (!found)
+		return;		/* store.c found/built nothing for this message */
+
+	wirelen = imsg_get_len(imsg);
+	if (wirelen != declared_len) {
+		log_warnx("session %u: %s length mismatch (header says %u, "
+		    "imsg has %zu)", s->id, what, declared_len, wirelen);
+		return;
+	}
+	if (wirelen == 0) {
+		*foundp = 1;	/* found but empty is legitimate, see callers */
+		return;
+	}
+	if ((*bufp = malloc(wirelen)) == NULL) {
+		log_warn("session %u: malloc pending buffer for %s", s->id,
+		    what);
+		return;
+	}
+	if (imsg_get_buf(imsg, *bufp, wirelen) == -1) {
+		log_warnx("bad %s (body)", what);
+		free(*bufp);
+		*bufp = NULL;
+		return;
+	}
+	*lenp = (uint32_t)wirelen;
+	*foundp = 1;
+}
+
 void
 session_store_dispatch(int fd, short event, void *arg)
 {
@@ -72,7 +116,7 @@ session_store_dispatch(int fd, short event, void *arg)
 	}
 
 	for (;;) {
-		if ((n = imsg_get(&s->store_iev->ibuf, &imsg)) == -1) {
+		if ((n = imsgbuf_get(&s->store_iev->ibuf, &imsg)) == -1) {
 			log_warnx("session %u: imsg_get (store)", s->id);
 			session_teardown(s);
 			return;
@@ -103,51 +147,19 @@ session_store_dispatch(int fd, short event, void *arg)
 		}
 		case IMSG_MBOX_FETCH_HEADER: {
 			struct imsg_mbox_fetch_header	 hdr;
-			size_t				 hdrlen;
 
 			/* Fixed-header-plus-variable-trailing-bytes, same technique as store.c's handle_mbox_append(). */
 			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 */
-			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 legitimate, if odd (a header-less file) */
-				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;
+			fetch_part_recv(s, &imsg, "IMSG_MBOX_FETCH_HEADER",
+			    hdr.found, hdr.hdrlen, &s->pending_header_buf,
+			    &s->pending_header_len, &s->pending_header_found);
 			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)) ==
@@ -155,46 +167,15 @@ session_store_dispatch(int fd, short event, void *arg)
 				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, set once per FETCH in fetch_dispatch(). */
-
-			bodylen = imsg_get_len(&imsg);
-			if (!bodyhdr.found)
-				break;	/* store.c found nothing for this message */
-			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 (zero-length msg, or BODY.PEEK[TEXT] on an all-header msg) */
-				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;
+			fetch_part_recv(s, &imsg, "IMSG_MBOX_FETCH_BODY",
+			    bodyhdr.found, bodyhdr.bodylen,
+			    &s->pending_body_buf, &s->pending_body_len,
+			    &s->pending_body_found);
 			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, see IMSG_MBOX_FETCH_HEADER's comment. */
 			if (imsg_get_buf(&imsg, &envhdr, sizeof(envhdr)) ==
@@ -202,44 +183,14 @@ session_store_dispatch(int fd, short event, void *arg)
 				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 */
-			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 all-NIL fields; handled defensively anyway */
-				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;
+			fetch_part_recv(s, &imsg, "IMSG_MBOX_FETCH_ENVELOPE",
+			    envhdr.found, envhdr.envlen,
+			    &s->pending_envelope_buf, &s->pending_envelope_len,
+			    &s->pending_envelope_found);
 			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, see IMSG_MBOX_FETCH_HEADER's comment. */
 			if (imsg_get_buf(&imsg, &bshdr, sizeof(bshdr)) ==
@@ -248,42 +199,11 @@ session_store_dispatch(int fd, short event, void *arg)
 				    "(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 */
-			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; handled anyway */
-				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;
+			fetch_part_recv(s, &imsg, "IMSG_MBOX_FETCH_BODYSTRUCTURE",
+			    bshdr.found, bshdr.bslen,
+			    &s->pending_bodystructure_buf,
+			    &s->pending_bodystructure_len,
+			    &s->pending_bodystructure_found);
 			break;
 		}
 		case IMSG_MBOX_FETCH_META: {
@@ -293,6 +213,26 @@ session_store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_FETCH_META");
 				break;
 			}
+			/*
+			 * imsg_get_data() guarantees size, not NUL
+			 * termination. flags is formatted with "%s" into a
+			 * client-visible response by three senders --
+			 * session_send_fetch_response(),
+			 * session_send_store_fetch_response() (both
+			 * fetch_cmd.c) and session_send_qresync_fetch_
+			 * response() below -- so an unterminated field would
+			 * read on past meta into this stack frame and put
+			 * the result on the wire.
+			 *
+			 * The sender is the store child: it runs as the
+			 * authenticated user and it is the process that
+			 * parses attacker-supplied message content, so it is
+			 * exactly the peer whose framing must not be taken
+			 * on trust. Same rule as auth.c's inbound username/
+			 * password, parent.c's maildir, keymgr.c's hash and
+			 * store.c's own imsg_field_valid().
+			 */
+			meta.flags[sizeof(meta.flags) - 1] = '\0';
 			/* Shared reply type for FETCH/STORE/QRESYNC resync; SELECTING buffers per RFC 7162 SS3.2.6's VANISHED-before-FETCH order. */
 			if (s->state == SESSION_SELECTING)
 				session_handle_select_fetch(s, &meta);
@@ -414,6 +354,15 @@ session_store_dispatch(int fd, short event, void *arg)
 				log_warnx("bad IMSG_MBOX_LIST_ITEM");
 				break;
 			}
+			/*
+			 * Same rule as IMSG_MBOX_FETCH_META's flags above.
+			 * mailbox is the whole of struct imsg_mbox_list_item,
+			 * so an unterminated one leaves the struct
+			 * immediately -- and it is both walked as a C string
+			 * by list_pattern_match() and formatted with "%s"
+			 * into the untagged LIST response.
+			 */
+			item.mailbox[sizeof(item.mailbox) - 1] = '\0';
 			session_handle_mbox_list_item(s, &item);
 			break;
 		}
@@ -434,7 +383,7 @@ session_store_dispatch(int fd, short event, void *arg)
 		}
 		imsg_free(&imsg);
 	}
-	imsgev_add(s->store_iev);	/* re-arm for the next round of IMSG_MBOX_* replies */
+	imsgev_rearm_read(s->store_iev);
 	(void)fd;
 
 	/* If the reply just processed above was the terminal one, s->state is idle again, drain anything pipelined behind it. */
@@ -448,11 +397,42 @@ session_handle_mbox_selected(struct session *s, const 
 {
 	char	buf[128];
 
-	if (res->error != MBOX_OP_OK) {
-		/* RFC 9051 SS6.3.2 failure -> authenticated (RFC 5530 NONEXISTENT text, any cause). */
+	if (res->error != MBOX_OP_OK || s->qresync_alloc_failed) {
+		/*
+		 * RFC 9051 SS6.3.2 failure -> authenticated (RFC 5530
+		 * NONEXISTENT text, any cause).
+		 *
+		 * A dropped QRESYNC resync entry lands here too and fails the
+		 * whole SELECT on purpose: the resync data is held back until
+		 * this terminal reply, so nothing has been emitted yet and a
+		 * NO is still available. Completing the SELECT would instead
+		 * advertise a fresh HIGHESTMODSEQ that the client adopts as
+		 * its sync anchor, permanently hiding the dropped updates.
+		 * RFC 5530 SS3 UNAVAILABLE is the transient-server-problem
+		 * code.
+		 */
 		s->state = SESSION_AUTHENTICATED;
 		session_reply(s, s->pending_tag, "NO",
+		    s->qresync_alloc_failed ? "[UNAVAILABLE] SELECT failed" :
 		    "[NONEXISTENT] no such mailbox");
+
+		/*
+		 * An honest store child never streams resync data ahead of a
+		 * failing IMSG_MBOX_SELECTED -- every error path in
+		 * handle_mbox_select() reaches "send:" before
+		 * qresync_send_resync() runs -- but a compromised one can,
+		 * and anything left behind here would be replayed into the
+		 * next SELECT's response.
+		 */
+		free(s->vanished_ranges);
+		s->vanished_ranges = NULL;
+		s->vanished_nranges = 0;
+		s->vanished_cap = 0;
+		free(s->qresync_fetches);
+		s->qresync_fetches = NULL;
+		s->qresync_nfetches = 0;
+		s->qresync_fetches_cap = 0;
+		s->qresync_alloc_failed = 0;
 		return;
 	}
 
@@ -488,13 +468,31 @@ session_handle_mbox_selected(struct session *s, const 
 		    "OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen "
 		    "\\Draft \\*)] System flags and keywords allowed");
 
-	/* RFC 9051 SS6.3.2 LIST; "/" matches cmd_namespace()'s delimiter; name quoted since it may contain a space. */
+	/*
+	 * RFC 9051 SS6.3.2 LIST; "/" matches cmd_namespace()'s delimiter.
+	 * The name goes through quote_mailbox() rather than straight into
+	 * "\"%s\"": it may contain a space, and it may also contain one of
+	 * SS4.3's quoted-specials, which raw substitution would emit
+	 * unescaped and so break the response boundary. Written with
+	 * session_write() for the same reason VANISHED (EARLIER) is above
+	 * -- the escaped form can exceed session_untagged()'s 512 bytes.
+	 */
 	{
-		char	listbuf[MBOX_NAME_MAX + 32];
+		char	qname[MBOX_QUOTED_MAX];
+		char	listbuf[MBOX_QUOTED_MAX + 64];
+		int	qlen;
 
-		snprintf(listbuf, sizeof(listbuf), "LIST () \"/\" \"%s\"",
-		    s->selected_mailbox);
-		session_untagged(s, listbuf);
+		if (quote_mailbox(qname, sizeof(qname),
+		    s->selected_mailbox) == -1)
+			log_warnx("session %u: SELECT LIST mailbox name "
+			    "truncated", s->id);
+		qlen = snprintf(listbuf, sizeof(listbuf),
+		    "* LIST () \"/\" %s\r\n", qname);
+		if (qlen > 0 && (size_t)qlen < sizeof(listbuf))
+			session_write(s, listbuf, (size_t)qlen);
+		else
+			log_warnx("session %u: SELECT LIST response did not "
+			    "fit", s->id);
 	}
 
 	if (s->vanished_nranges > 0) {
@@ -533,6 +531,7 @@ session_handle_mbox_selected(struct session *s, const 
 	s->qresync_fetches = NULL;
 	s->qresync_nfetches = 0;
 	s->qresync_fetches_cap = 0;
+	s->qresync_alloc_failed = 0;
 
 	/* RFC 9051 SS6.3.2/SS6.3.3: READ-WRITE for SELECT, READ-ONLY for EXAMINE, distinguished solely by s->mbox_readonly. */
 	if (s->mbox_readonly)
@@ -548,7 +547,14 @@ void
 session_handle_mbox_status_result(struct session *s,
     const struct imsg_mbox_status_result *res)
 {
-	char	buf[MBOX_NAME_MAX + 256];	/* F2 fix: buf[256] was too small for a near-max mailbox name + STATUS attrs */
+	char	qname[MBOX_QUOTED_MAX];
+	/*
+	 * F2 fix: buf[256] was too small for a near-max mailbox name +
+	 * STATUS attrs. Now sized for the SS4.3-escaped name as well, plus
+	 * the leading "* " and trailing CRLF, since this line is written
+	 * directly rather than through session_untagged()'s 512 bytes.
+	 */
+	char	buf[MBOX_QUOTED_MAX + 384];
 	size_t	len;
 	int	n, first = 1;
 
@@ -561,9 +567,17 @@ session_handle_mbox_status_result(struct session *s,
 		return;
 	}
 
-	/* Quoted, not bare, mailbox names can contain spaces; no backslash-escaping, same gap parse_list_token() has. */
-	len = (size_t)snprintf(buf, sizeof(buf), "STATUS \"%s\" (",
-	    s->status_mailbox);
+	/*
+	 * Quoted, not bare: mailbox names can contain spaces. The
+	 * backslash-escaping gap this comment used to record is closed --
+	 * quote_mailbox() renders the SS4.3 quoted string, quoted-specials
+	 * and all. The matching gap on the INPUT side -- finding #1 of the
+	 * mailbox_cmd.c review -- is closed too: parse_mailbox_arg() decodes
+	 * the escapes, so a name this line emits can be sent back.
+	 */
+	if (quote_mailbox(qname, sizeof(qname), s->status_mailbox) == -1)
+		log_warnx("session %u: STATUS mailbox name truncated", s->id);
+	len = (size_t)snprintf(buf, sizeof(buf), "* STATUS %s (", qname);
 
 #define STATUS_APPEND(fmt, val) do {					\
 	if (len < sizeof(buf)) {	/* F2 fix: never index past buf */	\
@@ -600,12 +614,25 @@ session_handle_mbox_status_result(struct session *s,
 
 #undef STATUS_APPEND
 
-	if (len < sizeof(buf) - 1) {
+	/*
+	 * Closing paren and CRLF written here rather than by
+	 * session_untagged(), whose 512-byte buffer the escaped name can
+	 * exceed. buf is sized so this always fits; the else is a guard,
+	 * not a reachable path, and it answers NO rather than sending a
+	 * tagged OK with no STATUS data behind it.
+	 */
+	if (len + 3 < sizeof(buf)) {
 		buf[len++] = ')';
+		buf[len++] = '\r';
+		buf[len++] = '\n';
 		buf[len] = '\0';
+		session_write(s, buf, len);
+	} else {
+		log_warnx("session %u: STATUS response did not fit", s->id);
+		session_reply(s, s->pending_tag, "NO",
+		    "[SERVERBUG] internal error");
+		return;
 	}
-
-	session_untagged(s, buf);
 	session_reply(s, s->pending_tag, "OK", "STATUS completed");
 }
 
@@ -645,13 +672,46 @@ session_finish_mbox_op(struct session *s, const struct
 		return;
 	}
 
-	/* If the renamed mailbox was SELECTed here, s->selected_mailbox must follow (s->state's already restored 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)) >= sizeof(s->selected_mailbox))
-		log_warnx("session %u: selected_mailbox truncated after "
-		    "RENAME, can't happen (both same size)", s->id);
+	/*
+	 * The op succeeded and it named this session's own selected mailbox,
+	 * so the selection has to follow. s->state was restored to
+	 * mbox_op_prev_state above, and it gates the comparison because
+	 * s->selected_mailbox is only meaningful while SESSION_SELECTED (see
+	 * its declaration in listener.h: it's written optimistically when the
+	 * SELECT round trip starts, so a failed SELECT leaves a stale name).
+	 */
+	if (s->state == SESSION_SELECTED &&
+	    strcmp(s->selected_mailbox, s->mbox_op_name) == 0) {
+		if (strcmp(cmdname, "RENAME") == 0) {
+			if (strlcpy(s->selected_mailbox, s->rename_newname,
+			    sizeof(s->selected_mailbox)) >=
+			    sizeof(s->selected_mailbox))
+				log_warnx("session %u: selected_mailbox "
+				    "truncated after RENAME, can't happen "
+				    "(both same size)", s->id);
+		} else if (strcmp(cmdname, "DELETE") == 0) {
+			/*
+			 * The selected mailbox is gone, so this session has
+			 * nothing selected: the same transition CLOSE makes
+			 * (session_handle_mbox_result() below), for a sharper
+			 * reason. RFC 9051 SS6.3.5 neither forbids deleting
+			 * the selected mailbox nor says what follows, but
+			 * staying SESSION_SELECTED would let the next FETCH/
+			 * STORE/SEARCH/COPY/MOVE/EXPUNGE through, and the
+			 * store child resolves every mailbox from its cwd --
+			 * which handle_mbox_delete() (mbox_manage.c) has
+			 * already returned to the maildir root, i.e. INBOX.
+			 * The client would silently operate on INBOX under a
+			 * name it believes it just deleted; STORE \Deleted +
+			 * EXPUNGE would destroy INBOX mail. The store child
+			 * clears its own SS6.2 gate independently, in
+			 * handle_mbox_delete().
+			 */
+			s->state = SESSION_AUTHENTICATED;
+			s->selected_mailbox[0] = '\0';
+			session_reset_idle_baseline(s);
+		}
+	}
 
 	snprintf(text, sizeof(text), "%s completed", cmdname);
 	session_reply(s, s->pending_tag, "OK", text);
@@ -662,15 +722,28 @@ void
 session_handle_mbox_list_item(struct session *s,
     const struct imsg_mbox_list_item *item)
 {
-	char		 buf[(2 * MBOX_NAME_MAX) + 32];
+	char		 qname[MBOX_QUOTED_MAX];
+	char		 buf[MBOX_QUOTED_MAX + 64];
 	const char	*kw = s->list_is_lsub ? "LSUB" : "LIST";
+	int		 n;
 
 	if (!list_pattern_match(s->list_pattern, item->mailbox, 0))
 		return;
 
-	/* Quoted, not bare, same reasoning as session_handle_mbox_status_result(); "()" = no attributes (SS7.3.1). */
-	snprintf(buf, sizeof(buf), "%s () \"/\" \"%s\"", kw, item->mailbox);
-	session_untagged(s, buf);
+	/*
+	 * Quoted, not bare, same reasoning as
+	 * session_handle_mbox_status_result(); "()" = no attributes
+	 * (SS7.3.1). This is the emission that made the escaping gap
+	 * client-visible: item->mailbox comes from readdir(2) in the store
+	 * child, so it carries whatever is actually on disk.
+	 */
+	if (quote_mailbox(qname, sizeof(qname), item->mailbox) == -1)
+		log_warnx("session %u: LIST mailbox name truncated", s->id);
+	n = snprintf(buf, sizeof(buf), "* %s () \"/\" %s\r\n", kw, qname);
+	if (n > 0 && (size_t)n < sizeof(buf))
+		session_write(s, buf, (size_t)n);
+	else
+		log_warnx("session %u: LIST response did not fit", s->id);
 }
 
 /* Terminal LIST/LSUB reply; a non-OK error means a real I/O error only, mismatches already produce no item, silently. */
@@ -729,7 +802,7 @@ session_handle_mbox_copy_mapping(struct session *s,
 	s->copy_n++;
 }
 
-/* Appends one SELECT_VANISHED range; a dropped range only delays resync (not a correctness issue), so no alloc-failure flag. */
+/* Appends one SELECT_VANISHED range; a dropped range would leave the client holding a phantom UID it can never be told about, so it fails the SELECT (see s->qresync_alloc_failed). */
 void
 session_handle_select_vanished(struct session *s,
     const struct imsg_mbox_select_vanished *v)
@@ -743,6 +816,7 @@ session_handle_select_vanished(struct session *s,
 		if (n == NULL) {
 			log_warn("session %u: realloc VANISHED range array",
 			    s->id);
+			s->qresync_alloc_failed = 1;
 			return;
 		}
 		s->vanished_ranges = n;
@@ -767,6 +841,7 @@ session_handle_select_fetch(struct session *s, const s
 		if (n == NULL) {
 			log_warn("session %u: realloc QRESYNC fetch array",
 			    s->id);
+			s->qresync_alloc_failed = 1;
 			return;
 		}
 		s->qresync_fetches = n;
@@ -788,6 +863,7 @@ session_handle_idle_uid(struct session *s, const struc
 
 		if (n == NULL) {
 			log_warn("session %u: realloc IDLE UID array", s->id);
+			s->idle_alloc_failed = 1;
 			return;
 		}
 		s->idle_incoming_uids = n;
@@ -824,6 +900,35 @@ session_push_idle_expunges(struct session *s, const ui
 	}
 }
 
+/*
+ * Discards this session's IDLE snapshot. Must be called whenever the mailbox
+ * the snapshot describes stops being the selected one.
+ *
+ * The snapshot is per-SESSION state describing a per-MAILBOX fact, and nothing
+ * used to reset it: a client that IDLEd on one mailbox, sent DONE, selected
+ * another and IDLEd again had the second mailbox's UID list diffed against the
+ * first's, so every UID present in the first and absent from the second was
+ * reported to it as an untagged EXPUNGE for a message that was never expunged.
+ * Two SELECTs and two IDLEs on one connection, no attacker and no concurrency
+ * required. See the group-13 review's finding #1.
+ */
+void
+session_reset_idle_baseline(struct session *s)
+{
+	free(s->idle_known_uids);
+	s->idle_known_uids = NULL;
+	s->idle_known_nuids = 0;
+	s->idle_known_cap = 0;
+	s->idle_baseline_valid = 0;
+
+	/* the in-flight accumulator describes the same stale mailbox */
+	free(s->idle_incoming_uids);
+	s->idle_incoming_uids = NULL;
+	s->idle_incoming_n = 0;
+	s->idle_incoming_cap = 0;
+	s->idle_alloc_failed = 0;
+}
+
 /* Terminal IDLE_REFRESH reply; push gated on s->idling && SELECTED, client may've sent DONE/a new SELECT meanwhile. */
 void
 session_handle_idle_refreshed(struct session *s,
@@ -831,20 +936,43 @@ session_handle_idle_refreshed(struct session *s,
 {
 	uint32_t	*newlist = s->idle_incoming_uids;
 	uint32_t	 newn = s->idle_incoming_n;
+	int		 incomplete = s->idle_alloc_failed;
 
 	s->idle_incoming_uids = NULL;
 	s->idle_incoming_n = 0;
 	s->idle_incoming_cap = 0;
+	s->idle_alloc_failed = 0;
 
 	s->idle_refresh_pending = 0;
 
-	if (!res->ok) {
-		log_warnx("session %u: IDLE refresh failed, keeping last "
-		    "known state", s->id);
+	/*
+	 * The store child's cheap probe found nothing touched, so it sent no
+	 * IMSG_MBOX_IDLE_UID messages at all. Discarding the empty list and
+	 * keeping the baseline is not an optimisation here but a correctness
+	 * requirement: diffing an empty list against the baseline would emit
+	 * an untagged EXPUNGE for every message in the mailbox.
+	 */
+	if (res->ok && res->unchanged) {
 		free(newlist);
 		goto maybe_again;
 	}
 
+	/*
+	 * An incomplete list is discarded rather than diffed: every UID that
+	 * session_handle_idle_uid() had to drop would otherwise be reported
+	 * as an untagged EXPUNGE for a message that still exists, and the
+	 * short list would then become the new baseline, making the error
+	 * permanent. Keeping the last known state costs one stale view until
+	 * the next refresh; diffing costs the client's correctness.
+	 */
+	if (!res->ok || incomplete) {
+		log_warnx("session %u: IDLE refresh %s, keeping last "
+		    "known state", s->id,
+		    res->ok ? "list incomplete" : "failed");
+		free(newlist);
+		goto maybe_again;
+	}
+
 	if (!s->idle_baseline_valid) {
 		free(s->idle_known_uids);
 		s->idle_known_uids = newlist;
@@ -893,26 +1021,85 @@ session_request_idle_refresh(struct session *s)
 	    -1, NULL, 0) == -1)
 		log_warn("session %u: imsg_compose IMSG_MBOX_IDLE_REFRESH",
 		    s->id);
-	imsgev_add(s->store_iev);
 }
 
-/* Notifies same-uid idling sessions after a UID-changing op (not plain STORE); no mailbox filter, harmless over-notify. */
-void
-session_notify_idle_peers(const struct session *s)
+/*
+ * The IDLE poll (RFC 9051 SS6.3.13), which is what makes IDLE push anything
+ * at all.
+ *
+ * It replaced session_notify_idle_peers(), which walked this process's
+ * "sessions" list asking OTHER sessions to recheck after an
+ * APPEND/EXPUNGE/COPY/MOVE. Under SS7 each listener process owns exactly one
+ * session, so that loop always skipped its only element -- and even had it
+ * worked, it would only ever have fired for changes made by another IMAP
+ * session, never for mail an MTA delivered, which is the case IDLE exists
+ * for. A poll covers both, and needs no notification path between processes.
+ *
+ * Most firings are cheap: the store child answers an untouched mailbox with
+ * two stat(2) calls, no lock and no UID stream (index.c's
+ * idle_probe_unchanged()).
+ */
+static void
+session_idle_poll(int fd, short event, void *arg)
 {
-	struct session	*other;
+	struct session	*s = arg;
 
-	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);
+	(void)fd;
+	(void)event;
+
+	/*
+	 * Both transitions out of IDLE disarm this timer, so neither test
+	 * should be able to fail. They are here because the cost of being
+	 * wrong is a refresh whose reply arrives with the session no longer
+	 * idling, and session_handle_idle_refreshed() would then adopt a
+	 * different mailbox's UID list as the baseline.
+	 */
+	if (!s->idling || s->state != SESSION_SELECTED) {
+		/*
+		 * Should be unreachable. Logged rather than returned
+		 * silently: if it ever does fire, the silent version leaves
+		 * no evidence, and the consequence (a reply landing after
+		 * the session left IDLE) is a wrong baseline rather than a
+		 * crash.
+		 */
+		log_debug("session %u: idle poll fired while not idling "
+		    "(idling=%d state=%d), ignored", s->id, s->idling,
+		    (int)s->state);
+		return;
 	}
+
+	session_request_idle_refresh(s);
+	session_idle_poll_arm(s);	/* an evtimer is one-shot */
 }
 
+/* Once per session, early enough that session_idle_poll_disarm() is always safe. */
+void
+session_idle_poll_init(struct session *s)
+{
+	evtimer_set(&s->idle_ev, session_idle_poll, s);
+}
+
+void
+session_idle_poll_arm(struct session *s)
+{
+	struct timeval	 tv;
+
+	if (listener_idle_poll_secs == 0)
+		return;		/* "idle poll 0": polling disabled by config */
+
+	tv.tv_sec = (time_t)listener_idle_poll_secs;
+	tv.tv_usec = 0;
+	if (evtimer_add(&s->idle_ev, &tv) == -1)
+		log_warnx("session %u: evtimer_add (idle poll); this IDLE "
+		    "will not be woken until the client sends DONE", s->id);
+}
+
+void
+session_idle_poll_disarm(struct session *s)
+{
+	evtimer_del(&s->idle_ev);
+}
+
 /* QRESYNC resync FETCH: always UID+FLAGS+MODSEQ (RFC 7162 SS3.2.5.1); no s->fetch_attrs, the RFC fixes the content. */
 void
 session_send_qresync_fetch_response(struct session *s,
@@ -926,6 +1113,15 @@ session_send_qresync_fetch_response(struct session *s,
 	session_untagged(s, buf);
 }
 
+/*
+ * Worst-case sizing for the RFC 7162 SS3.1.3 MODIFIED list, mirroring
+ * search_cmd.c's SEARCH_ALL_PER_MATCH/SEARCH_RESP_PREFIX_MAX pair: one
+ * entry is at most "4294967295" plus a separator, and the wrapper is the
+ * tag (IMAP_TAG_MAX), the fixed words, cmdname and CRLF.
+ */
+#define MODIFIED_PER_ENTRY	11
+#define MODIFIED_WRAPPER_MAX	160
+
 /* Terminal reply hub; SEARCH/COPY/MOVE/CREATE/DELETE/RENAME/LIST peel off first; FETCH/STORE/EXPUNGE share the rest. */
 void
 session_handle_mbox_result(struct session *s, struct imsg_mbox_result *res)
@@ -965,8 +1161,15 @@ session_handle_mbox_result(struct session *s, struct i
 	} else
 		cmdname = s->cmd_by_uid ? "UID FETCH" : "FETCH";
 
-	log_debug("session %u: %s done, error=%d, %u response(s) sent",
-	    s->id, cmdname, res->error, res->count);
+	/*
+	 * res->error is enum mbox_op_error, where MBOX_OP_OK is 1 (MBOX_ERR_
+	 * UNSET occupies 0), so printing it raw under an "error=" label made
+	 * every successful FETCH/STORE/EXPUNGE/CLOSE log "error=1". Same
+	 * two-way label session_finish_search() already prints.
+	 */
+	log_debug("session %u: %s done, status=%s, %u response(s) sent",
+	    s->id, cmdname, res->error == MBOX_OP_OK ? "OK" : "ERROR",
+	    res->count);
 
 	/* RFC 7162: cache post-op HIGHESTMODSEQ for session_condstore_enable(); only STORE/EXPUNGE change it, not FETCH. */
 	if (was_storing || was_expunging)
@@ -974,6 +1177,8 @@ session_handle_mbox_result(struct session *s, struct i
 
 	/* RFC 9051 SS6.4.1: CLOSE returns to authenticated state, unlike FETCH/STORE/a real EXPUNGE, which stay Selected. */
 	s->state = was_close ? SESSION_AUTHENTICATED : SESSION_SELECTED;
+	if (was_close)
+		session_reset_idle_baseline(s);
 
 	if (res->error != MBOX_OP_OK) {
 		/* only failure mode is an index I/O error; RFC 9051 has no specific code for it, so a plain NO is honest. */
@@ -991,27 +1196,62 @@ session_handle_mbox_result(struct session *s, struct i
 
 	/* RFC 7162 SS3.1.3: a failed-conditional STORE gets MODIFIED on its tagged OK */
 	if (was_storing && s->store_modified_n > 0) {
-		char	rbuf[2048];
-		char	text[2048 + 32];
-		int	truncated;
+		char	*rbuf = NULL, *text = NULL;
+		size_t	 rbufsize, textsize;
+		int	 truncated, n, sent = 0;
 
-		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);
 		/*
-		 * text's ~32-byte margin over rbuf's 2048 is enough for the
-		 * wrapper words but not always for wrapper + a near-max
-		 * rbuf + "UID STORE" together.
+		 * Heap-allocated and sized for the worst case, for the same
+		 * reason session_finish_search() does it for the ESEARCH ALL
+		 * list. The old fixed 2048-byte buffers were never the real
+		 * bound anyway: session_reply() composes into 512 bytes and,
+		 * on overflow, forces the last two back to CRLF -- which
+		 * amputates the closing "]" and ships a malformed
+		 * resp-text-code (RFC 9051 SS7.1). Composed here in full and
+		 * handed to session_write(), the same bypass ESEARCH and
+		 * VANISHED (EARLIER) already use.
 		 */
-		if (snprintf(text, sizeof(text), "[MODIFIED %s] Conditional "
-		    "%s failed for some messages", rbuf, cmdname) >=
-		    (int)sizeof(text))
-			log_warnx("session %u: MODIFIED response text "
-			    "truncated", s->id);
-		session_reply(s, s->pending_tag, "OK", text);
+		rbufsize = (size_t)s->store_modified_n * MODIFIED_PER_ENTRY + 1;
+		textsize = rbufsize + MODIFIED_WRAPPER_MAX;
 
+		if ((rbuf = malloc(rbufsize)) != NULL &&
+		    (text = malloc(textsize)) != NULL) {
+			format_seq_list(rbuf, rbufsize, s->store_modified,
+			    s->store_modified_n, &truncated);
+			if (truncated)
+				log_warnx("session %u: MODIFIED list "
+				    "truncated", s->id);
+
+			n = snprintf(text, textsize, "%s OK [MODIFIED %s] "
+			    "Conditional %s failed for some messages\r\n",
+			    s->pending_tag, rbuf, cmdname);
+			if (n < 0 || (size_t)n >= textsize)
+				log_warnx("session %u: MODIFIED response text "
+				    "truncated", s->id);
+			else {
+				session_write(s, text, (size_t)n);
+				sent = 1;
+			}
+		} else
+			log_warn("session %u: malloc MODIFIED response", s->id);
+
+		free(rbuf);
+		free(text);
+
+		/*
+		 * Fallback drops the response code rather than emit an empty
+		 * or truncated one: RFC 7162 SS3.1.3's MODIFIED takes a
+		 * non-empty sequence set, and a client is better served by a
+		 * plain tagged OK it can parse than by a malformed code.
+		 */
+		if (!sent) {
+			char	fallback[64];
+
+			snprintf(fallback, sizeof(fallback),
+			    "%s failed for some messages", cmdname);
+			session_reply(s, s->pending_tag, "OK", fallback);
+		}
+
 		free(s->store_modified);
 		s->store_modified = NULL;
 		s->store_modified_n = 0;
@@ -1023,9 +1263,6 @@ session_handle_mbox_result(struct session *s, struct i
 	s->store_modified_n = 0;
 	s->store_modified_cap = 0;
 
-	/* RFC 9051 SS6.3.13: gated on was_expunging not res->count (CLOSE's count is always 0), harmless extra refresh. */
-	if (was_expunging)
-		session_notify_idle_peers(s);
 
 	/* RFC 7162 SS3.2.7: real EXPUNGE (not CLOSE, SS3.2.8 forbids it) with count>0 gets HIGHESTMODSEQ, once CONDSTORE-aware. */
 	if (was_expunging && !was_close && s->condstore_enabled &&
blob - /dev/null
blob + 506ba984b2f719ebdb67c95201f3a938af8b72ed (mode 644)
--- /dev/null
+++ src/utf8.c
@@ -0,0 +1,132 @@
+/*
+ * 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.
+ */
+
+/*
+ * utf8.c, the mailbox-name UTF-8 predicate.
+ *
+ * RFC 9051 SS5.1: "Server implementations MUST prohibit the creation of
+ * 8-bit mailbox names that do not comply with Net-Unicode." Net-Unicode is
+ * RFC 5198 SS2, which is six requirements, not one. This file implements
+ * three of them and is explicit about the three it does not:
+ *
+ *   item 1  UTF-8 per RFC 3629                    ENFORCED (utf8_mailbox_ok)
+ *   item 2  CRLF line endings                     N/A -- CR and LF are
+ *                                                 already refused by both
+ *                                                 validators' c < 0x20 test
+ *   item 3  C1 controls U+0080-U+009F MUST NOT    ENFORCED
+ *           (C0 and DEL are refused by the same
+ *           c < 0x20 || c == 0x7f test, which
+ *           RFC 9051 SS5.1 point 2 sanctions)
+ *   item 4  NFC normalization (a SHOULD)          NOT ENFORCED
+ *   item 5  no leading BOM                        ENFORCED, and stricter:
+ *                                                 U+FEFF is refused anywhere
+ *   item 6  no unassigned code points             NOT ENFORCED
+ *
+ * Items 4 and 6 are left out deliberately, and imapd.8 says so rather than
+ * letting the omission pass as compliance. Both need a Unicode character
+ * database: item 6 needs the assigned-code-point set, item 4 needs canonical
+ * decomposition mappings, combining classes and composition exclusions. Each
+ * is tens of kilobytes of generated tables plus a standing commitment to
+ * regenerate them for every Unicode release, in a daemon whose case is its
+ * smallness. The visible cost of skipping item 4 is that two canonically
+ * equivalent spellings of the same name (U+00E9, versus U+0065 U+0301) are
+ * two different mailboxes here.
+ *
+ * There is no decoding to a code point and no table lookup below. The legal
+ * range of the SECOND byte depends on the first, and that dependency is
+ * exactly what excludes overlong encodings, the UTF-16 surrogates and
+ * everything above U+10FFFF -- so writing the ranges out is both the whole
+ * check and the clearest statement of RFC 3629 SS4's well-formed byte
+ * sequence table.
+ */
+
+#include "utf8.h"
+
+/*
+ * 1 if `name` is a NUL-terminated string this server will accept as a
+ * mailbox name's encoding, 0 otherwise. Says nothing about whether the name
+ * is otherwise acceptable -- "/", ".", "..", the reserved imapd.* names and
+ * the length bound are each validator's own business, and both apply them
+ * alongside this.
+ */
+int
+utf8_mailbox_ok(const char *name)
+{
+	const unsigned char	*p = (const unsigned char *)name;
+
+	while (*p != '\0') {
+		unsigned int	need, lo, hi, i;
+
+		if (p[0] < 0x80) {	/* ASCII, C0 and DEL included */
+			p++;
+			continue;
+		}
+
+		/* RFC 3629 SS4's table, one branch per lead-byte class. */
+		if (p[0] >= 0xc2 && p[0] <= 0xdf) {
+			need = 1; lo = 0x80; hi = 0xbf;
+		} else if (p[0] == 0xe0) {
+			need = 2; lo = 0xa0; hi = 0xbf;	/* not overlong */
+		} else if ((p[0] >= 0xe1 && p[0] <= 0xec) || p[0] == 0xee ||
+		    p[0] == 0xef) {
+			need = 2; lo = 0x80; hi = 0xbf;
+		} else if (p[0] == 0xed) {
+			need = 2; lo = 0x80; hi = 0x9f;	/* no surrogate */
+		} else if (p[0] == 0xf0) {
+			need = 3; lo = 0x90; hi = 0xbf;	/* not overlong */
+		} else if (p[0] >= 0xf1 && p[0] <= 0xf3) {
+			need = 3; lo = 0x80; hi = 0xbf;
+		} else if (p[0] == 0xf4) {
+			need = 3; lo = 0x80; hi = 0x8f;	/* <= U+10FFFF */
+		} else {
+			/* 0x80-0xc1 (continuation or overlong lead) and 0xf5-0xff */
+			return (0);
+		}
+
+		/*
+		 * Reading p[1] cannot run past the terminator: if p[0] is the
+		 * last byte then p[1] is the NUL, which is below every `lo`
+		 * and fails here. p[2] and p[3] are reached only after their
+		 * predecessor tested inside 0x80-0xbf, so that predecessor
+		 * was not the NUL either. The walk stops at the first bad
+		 * byte in every case, terminator included.
+		 */
+		if (p[1] < lo || p[1] > hi)
+			return (0);
+		for (i = 2; i <= need; i++)
+			if (p[i] < 0x80 || p[i] > 0xbf)
+				return (0);
+
+		/* RFC 5198 SS2 item 3: the C1 controls MUST NOT appear. */
+		if (p[0] == 0xc2 && p[1] <= 0x9f)
+			return (0);
+
+		/*
+		 * RFC 5198 SS2 item 5 forbids a BOM at the beginning.
+		 * U+FEFF is refused wherever it appears: a zero-width
+		 * no-break space in the middle of a mailbox name is a name
+		 * no user can tell apart from its neighbour, which is worth
+		 * more than matching the RFC's exact scope. imapd.8 records
+		 * that this is deliberately stricter.
+		 */
+		if (p[0] == 0xef && p[1] == 0xbb && p[2] == 0xbf)
+			return (0);
+
+		p += need + 1;
+	}
+
+	return (1);
+}
blob - /dev/null
blob + 4d1f898ee071c990e1381e335363f9234bcc238e (mode 644)
--- /dev/null
+++ src/utf8.h
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+
+#ifndef IMAPD_UTF8_H
+#define IMAPD_UTF8_H
+
+/*
+ * The one UTF-8 predicate, shared by the listener's
+ * listener_mailbox_name_valid() and the store's mailbox_name_valid().
+ *
+ * Those two validators stay separate on purpose -- the store does not trust
+ * the listener, and re-checking on the far side of the imsg boundary is the
+ * point. But the well-formedness test itself carries no policy: it is a pure
+ * function of bytes, so both call this rather than each keeping a copy. The
+ * mailbox_cmd.c review's finding #3 is the argument -- the two validators had
+ * already drifted apart once over a much smaller difference than a decoder.
+ *
+ * This header deliberately depends on nothing, so both sides can include it
+ * without dragging in listener.h or store_internal.h.
+ */
+
+int	 utf8_mailbox_ok(const char *);
+
+#endif /* IMAPD_UTF8_H */